changes on Exam, timetable and lms added

This commit is contained in:
Vidhya 2026-09-17 17:14:59 +05:30
parent dccaef02f4
commit 6dd327ce3d
146 changed files with 7304 additions and 111 deletions

BIN
.DS_Store vendored

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@ -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,
}

View File

@ -0,0 +1 @@
from . import main

View File

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

View File

@ -0,0 +1,4 @@
from . import education_lms_course
from . import education_lms_content
from . import education_lms_assignment
from . import education_lms_submission

View File

@ -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()

View File

@ -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.")

View File

@ -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()

View File

@ -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()

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_education_lms_course_admin education.lms.course.admin model_education_lms_course group_education_lms_admin 1 1 1 1
3 access_education_lms_section_admin education.lms.section.admin model_education_lms_section group_education_lms_admin 1 1 1 1
4 access_education_lms_lesson_admin education.lms.lesson.admin model_education_lms_lesson group_education_lms_admin 1 1 1 1
5 access_education_lms_material_admin education.lms.material.admin model_education_lms_material group_education_lms_admin 1 1 1 1
6 access_education_lms_course_faculty education.lms.course.faculty model_education_lms_course group_education_lms_faculty 1 1 1 1
7 access_education_lms_section_faculty education.lms.section.faculty model_education_lms_section group_education_lms_faculty 1 1 1 1
8 access_education_lms_lesson_faculty education.lms.lesson.faculty model_education_lms_lesson group_education_lms_faculty 1 1 1 1
9 access_education_lms_material_faculty education.lms.material.faculty model_education_lms_material group_education_lms_faculty 1 1 1 1
10 access_education_lms_course_student education.lms.course.student model_education_lms_course group_education_lms_student 1 0 0 0
11 access_education_lms_section_student education.lms.section.student model_education_lms_section group_education_lms_student 1 0 0 0
12 access_education_lms_lesson_student education.lms.lesson.student model_education_lms_lesson group_education_lms_student 1 0 0 0
13 access_education_lms_material_student education.lms.material.student model_education_lms_material group_education_lms_student 1 0 0 0
14 access_education_lms_assignment_admin education.lms.assignment.admin model_education_lms_assignment group_education_lms_admin 1 1 1 1
15 access_education_lms_submission_admin education.lms.submission.admin model_education_lms_submission group_education_lms_admin 1 1 1 1
16 access_education_lms_assignment_faculty education.lms.assignment.faculty model_education_lms_assignment group_education_lms_faculty 1 1 1 1
17 access_education_lms_submission_faculty education.lms.submission.faculty model_education_lms_submission group_education_lms_faculty 1 1 1 1
18 access_education_lms_assignment_student education.lms.assignment.student model_education_lms_assignment group_education_lms_student 1 0 0 0
19 access_education_lms_submission_student education.lms.submission.student model_education_lms_submission group_education_lms_student 1 1 1 1

View File

@ -0,0 +1,257 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- LMS Categories -->
<record id="module_category_education_lms" model="ir.module.category">
<field name="name">Education LMS</field>
<field name="description">Helps you manage LMS courses and content.</field>
<field name="sequence">20</field>
</record>
<!-- Student Group -->
<record id="group_education_lms_student" model="res.groups">
<field name="name">LMS Student</field>
<field name="category_id" ref="module_category_education_lms"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Faculty Group -->
<record id="group_education_lms_faculty" model="res.groups">
<field name="name">LMS Faculty</field>
<field name="category_id" ref="module_category_education_lms"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Administrator Group -->
<record id="group_education_lms_admin" model="res.groups">
<field name="name">LMS Administrator</field>
<field name="category_id" ref="module_category_education_lms"/>
<field name="implied_ids" eval="[(4, ref('group_education_lms_faculty'))]"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
<data>
<!-- Faculty Record Rules: Strict Ownership -->
<record id="rule_lms_course_faculty" model="ir.rule">
<field name="name">Faculty Own Courses</field>
<field name="model_id" ref="model_education_lms_course"/>
<field name="domain_force">[('faculty_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_faculty'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_section_faculty" model="ir.rule">
<field name="name">Faculty Own Sections</field>
<field name="model_id" ref="model_education_lms_section"/>
<field name="domain_force">[('course_id.faculty_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_faculty'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_lesson_faculty" model="ir.rule">
<field name="name">Faculty Own Lessons</field>
<field name="model_id" ref="model_education_lms_lesson"/>
<field name="domain_force">[('course_id.faculty_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_faculty'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_material_faculty" model="ir.rule">
<field name="name">Faculty Own Materials</field>
<field name="model_id" ref="model_education_lms_material"/>
<field name="domain_force">[('lesson_id.course_id.faculty_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_faculty'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Admin Record Rules: Global Access -->
<record id="rule_lms_course_admin" model="ir.rule">
<field name="name">Admin All Courses</field>
<field name="model_id" ref="model_education_lms_course"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_admin'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_section_admin" model="ir.rule">
<field name="name">Admin All Sections</field>
<field name="model_id" ref="model_education_lms_section"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_admin'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_lesson_admin" model="ir.rule">
<field name="name">Admin All Lessons</field>
<field name="model_id" ref="model_education_lms_lesson"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_admin'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_material_admin" model="ir.rule">
<field name="name">Admin All Materials</field>
<field name="model_id" ref="model_education_lms_material"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_admin'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Student Record Rules: Restrict Backend -->
<record id="rule_lms_course_student" model="ir.rule">
<field name="name">Student Course Access</field>
<field name="model_id" ref="model_education_lms_course"/>
<field name="domain_force">[
('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),
]</field>
<field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_lms_section_student" model="ir.rule">
<field name="name">Student Section Access</field>
<field name="model_id" ref="model_education_lms_section"/>
<field name="domain_force">[
('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),
]</field>
<field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_lms_lesson_student" model="ir.rule">
<field name="name">Student Lesson Access</field>
<field name="model_id" ref="model_education_lms_lesson"/>
<field name="domain_force">[
('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),
]</field>
<field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_lms_material_student" model="ir.rule">
<field name="name">Student Material Access</field>
<field name="model_id" ref="model_education_lms_material"/>
<field name="domain_force">[
('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),
]</field>
<field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Assignment Rules -->
<record id="rule_lms_assignment_faculty" model="ir.rule">
<field name="name">Faculty Own Assignments</field>
<field name="model_id" ref="model_education_lms_assignment"/>
<field name="domain_force">[('course_id.faculty_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_faculty'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_assignment_admin" model="ir.rule">
<field name="name">Admin All Assignments</field>
<field name="model_id" ref="model_education_lms_assignment"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_admin'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_assignment_student" model="ir.rule">
<field name="name">Student Assignment Access</field>
<field name="model_id" ref="model_education_lms_assignment"/>
<field name="domain_force">[
('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),
]</field>
<field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Submission Rules -->
<record id="rule_lms_submission_faculty" model="ir.rule">
<field name="name">Faculty Own Submissions</field>
<field name="model_id" ref="model_education_lms_submission"/>
<field name="domain_force">[('assignment_id.course_id.faculty_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_faculty'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_submission_admin" model="ir.rule">
<field name="name">Admin All Submissions</field>
<field name="model_id" ref="model_education_lms_submission"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_admin'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_lms_submission_student" model="ir.rule">
<field name="name">Student Submission Sandbox</field>
<field name="model_id" ref="model_education_lms_submission"/>
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,2 @@
from . import test_education_lms
from . import test_education_lms_portal

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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")

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ASSIGNMENT FORM VIEW -->
<record id="view_education_lms_assignment_form" model="ir.ui.view">
<field name="name">education.lms.assignment.form</field>
<field name="model">education.lms.assignment</field>
<field name="arch" type="xml">
<form string="Assignment">
<header>
<button name="action_publish" type="object" string="Publish" class="oe_highlight" invisible="state != 'draft'" groups="education_lms.group_education_lms_faculty,education_lms.group_education_lms_admin"/>
<button name="action_close" type="object" string="Close" class="oe_highlight" invisible="state != 'published'" groups="education_lms.group_education_lms_faculty,education_lms.group_education_lms_admin"/>
<field name="state" widget="statusbar" statusbar_visible="draft,published,closed"/>
</header>
<sheet>
<div class="oe_title">
<label for="title" class="oe_edit_only"/>
<h1><field name="title" placeholder="Assignment Title"/></h1>
</div>
<group>
<group string="Course Information">
<field name="course_id"/>
<field name="section_id" domain="[('course_id', '=', course_id)]"/>
<field name="lesson_id" domain="[('section_id', '=', section_id)]"/>
<field name="subject_id"/>
<field name="faculty_id"/>
</group>
<group string="Assignment Details">
<field name="assigned_date"/>
<field name="due_date"/>
<field name="maximum_marks"/>
<field name="published"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="Instructions" name="instructions">
<field name="instructions" widget="html"/>
</page>
<page string="Attachments" name="attachments">
<field name="attachment_ids" widget="many2many_binary"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ASSIGNMENT TREE VIEW -->
<record id="view_education_lms_assignment_tree" model="ir.ui.view">
<field name="name">education.lms.assignment.tree</field>
<field name="model">education.lms.assignment</field>
<field name="arch" type="xml">
<tree string="Assignments">
<field name="title"/>
<field name="course_id"/>
<field name="due_date"/>
<field name="maximum_marks"/>
<field name="state"/>
</tree>
</field>
</record>
<!-- ASSIGNMENT SEARCH VIEW -->
<record id="view_education_lms_assignment_search" model="ir.ui.view">
<field name="name">education.lms.assignment.search</field>
<field name="model">education.lms.assignment</field>
<field name="arch" type="xml">
<search string="Assignments">
<field name="title"/>
<field name="course_id"/>
<field name="faculty_id"/>
<filter string="Published" name="published" domain="[('state','=','published')]"/>
<filter string="Draft" name="draft" domain="[('state','=','draft')]"/>
<group expand="0" string="Group By">
<filter string="Course" name="group_by_course" context="{'group_by':'course_id'}"/>
<filter string="State" name="group_by_state" context="{'group_by':'state'}"/>
</group>
</search>
</field>
</record>
<!-- ACTION -->
<record id="action_education_lms_assignment" model="ir.actions.act_window">
<field name="name">Assignments</field>
<field name="res_model">education.lms.assignment</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create the first Assignment!
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- SECTION VIEWS -->
<record id="view_education_lms_section_form" model="ir.ui.view">
<field name="name">education.lms.section.form</field>
<field name="model">education.lms.section</field>
<field name="arch" type="xml">
<form string="Section">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Section Name"/></h1>
</div>
<group>
<group>
<field name="sequence"/>
<field name="published"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="Lessons" name="lessons">
<field name="lesson_ids">
<tree string="Lessons">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="published"/>
</tree>
</field>
</page>
<page string="Description" name="description">
<field name="description"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- LESSON VIEWS -->
<record id="view_education_lms_lesson_form" model="ir.ui.view">
<field name="name">education.lms.lesson.form</field>
<field name="model">education.lms.lesson</field>
<field name="arch" type="xml">
<form string="Lesson">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Lesson Name"/></h1>
</div>
<group>
<group>
<field name="section_id" invisible="1"/>
<field name="sequence"/>
<field name="published"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="Materials" name="materials">
<field name="material_ids">
<tree string="Materials">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="material_type"/>
<field name="published"/>
</tree>
</field>
</page>
<page string="Content" name="content">
<field name="description" widget="html"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- MATERIAL VIEWS -->
<record id="view_education_lms_material_form" model="ir.ui.view">
<field name="name">education.lms.material.form</field>
<field name="model">education.lms.material</field>
<field name="arch" type="xml">
<form string="Material">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Material Name"/></h1>
</div>
<group>
<group>
<field name="lesson_id" invisible="1"/>
<field name="material_type"/>
<field name="sequence"/>
<field name="published"/>
<field name="active" invisible="1"/>
</group>
<group>
<field name="attachment_id" invisible="material_type != 'file'" required="material_type == 'file'"/>
<field name="url" invisible="material_type == 'file'" required="material_type in ['url', 'video']"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<field name="description"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- COURSE FORM VIEW -->
<record id="view_education_lms_course_form" model="ir.ui.view">
<field name="name">education.lms.course.form</field>
<field name="model">education.lms.course</field>
<field name="arch" type="xml">
<form string="Course">
<header>
<button name="action_publish" string="Publish Course" type="object" class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_draft" string="Set to Draft" type="object" invisible="state != 'published'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,published,archived" options="{'clickable': '1'}"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" type="action" name="%(action_education_lms_assignment)d"
icon="fa-tasks"
context="{'search_default_course_id': active_id, 'default_course_id': active_id}">
<div class="o_stat_info">
<field name="assignment_count" class="o_stat_value"/>
<span class="o_stat_text">Assignments</span>
</div>
</button>
<button class="oe_stat_button" type="action" name="%(action_education_lms_submission)d"
icon="fa-file-text-o"
context="{'search_default_course_id': active_id, 'default_course_id': active_id}">
<div class="o_stat_info">
<field name="submission_count" class="o_stat_value"/>
<span class="o_stat_text">Submissions</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Course Name"/></h1>
</div>
<group>
<group string="Basic Information">
<field name="subject_id"/>
<field name="faculty_id"/>
<field name="active" invisible="1"/>
</group>
<group string="Academic Information">
<field name="program_id"/>
<field name="academic_year_id"/>
<field name="term_id"/>
<field name="batch_id"/>
</group>
</group>
<notebook>
<page string="Content" name="content">
<field name="section_ids">
<tree string="Sections">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="published"/>
</tree>
</field>
</page>
<page string="Description" name="description">
<field name="description" widget="html"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- COURSE TREE VIEW -->
<record id="view_education_lms_course_tree" model="ir.ui.view">
<field name="name">education.lms.course.tree</field>
<field name="model">education.lms.course</field>
<field name="arch" type="xml">
<tree string="Courses">
<field name="name"/>
<field name="subject_id"/>
<field name="program_id"/>
<field name="faculty_id"/>
<field name="state"/>
</tree>
</field>
</record>
<!-- COURSE SEARCH VIEW -->
<record id="view_education_lms_course_search" model="ir.ui.view">
<field name="name">education.lms.course.search</field>
<field name="model">education.lms.course</field>
<field name="arch" type="xml">
<search string="Courses">
<field name="name"/>
<field name="subject_id"/>
<field name="faculty_id"/>
<field name="program_id"/>
<filter string="Published" name="published" domain="[('state','=','published')]"/>
<filter string="Draft" name="draft" domain="[('state','=','draft')]"/>
<group expand="0" string="Group By">
<filter string="Program" name="group_by_program" context="{'group_by':'program_id'}"/>
<filter string="Faculty" name="group_by_faculty" context="{'group_by':'faculty_id'}"/>
<filter string="State" name="group_by_state" context="{'group_by':'state'}"/>
</group>
</search>
</field>
</record>
<!-- ACTION -->
<record id="action_education_lms_course" model="ir.actions.act_window">
<field name="name">Courses</field>
<field name="res_model">education.lms.course</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first LMS Course!
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- SUBMISSION FORM VIEW -->
<record id="view_education_lms_submission_form" model="ir.ui.view">
<field name="name">education.lms.submission.form</field>
<field name="model">education.lms.submission</field>
<field name="arch" type="xml">
<form string="Submission">
<header>
<button name="action_submit" type="object" string="Submit" class="oe_highlight" invisible="status != 'draft'" groups="education_lms.group_education_lms_student,education_lms.group_education_lms_admin"/>
<button name="action_evaluate" type="object" string="Mark as Evaluated" class="oe_highlight" invisible="status not in ('submitted', 'late')" groups="education_lms.group_education_lms_faculty,education_lms.group_education_lms_admin"/>
<field name="status" widget="statusbar" statusbar_visible="draft,submitted,evaluated,late"/>
</header>
<sheet>
<group>
<group string="Submission Details">
<field name="assignment_id" readonly="status != 'draft'"/>
<field name="student_id" readonly="status != 'draft'"/>
<field name="submission_date"/>
<field name="is_late" invisible="1"/>
</group>
<group string="Evaluation" groups="education_lms.group_education_lms_faculty,education_lms.group_education_lms_admin">
<field name="marks"/>
<field name="evaluation_date"/>
<field name="evaluated_by"/>
</group>
</group>
<notebook>
<page string="Content" name="content">
<group>
<field name="text_submission" widget="html" readonly="status != 'draft'"/>
<field name="attachment_ids" widget="many2many_binary" readonly="status != 'draft'"/>
</group>
</page>
<page string="Feedback" name="feedback" groups="education_lms.group_education_lms_faculty,education_lms.group_education_lms_admin">
<field name="feedback" widget="html"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- SUBMISSION TREE VIEW -->
<record id="view_education_lms_submission_tree" model="ir.ui.view">
<field name="name">education.lms.submission.tree</field>
<field name="model">education.lms.submission</field>
<field name="arch" type="xml">
<tree string="Submissions" decoration-danger="status=='late'" decoration-success="status=='evaluated'">
<field name="assignment_id"/>
<field name="student_id"/>
<field name="submission_date"/>
<field name="status"/>
<field name="marks"/>
</tree>
</field>
</record>
<!-- SUBMISSION SEARCH VIEW -->
<record id="view_education_lms_submission_search" model="ir.ui.view">
<field name="name">education.lms.submission.search</field>
<field name="model">education.lms.submission</field>
<field name="arch" type="xml">
<search string="Submissions">
<field name="assignment_id"/>
<field name="student_id"/>
<filter string="Submitted" name="submitted" domain="[('status','=','submitted')]"/>
<filter string="Evaluated" name="evaluated" domain="[('status','=','evaluated')]"/>
<filter string="Late" name="late" domain="[('status','=','late')]"/>
<group expand="0" string="Group By">
<filter string="Assignment" name="group_by_assignment" context="{'group_by':'assignment_id'}"/>
<filter string="Student" name="group_by_student" context="{'group_by':'student_id'}"/>
<filter string="Status" name="group_by_status" context="{'group_by':'status'}"/>
</group>
</search>
</field>
</record>
<!-- ACTION -->
<record id="action_education_lms_submission" model="ir.actions.act_window">
<field name="name">Submissions</field>
<field name="res_model">education.lms.submission</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Manage Student Submissions!
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Main LMS Menu in Education Base -->
<menuitem id="menu_education_lms_root"
name="LMS"
parent="education_base.menu_education_base_root"
sequence="40"/>
<!-- Courses Menu -->
<menuitem id="menu_education_lms_courses"
name="Courses"
parent="menu_education_lms_root"
action="action_education_lms_course"
sequence="10"/>
<!-- Assignments Menu -->
<menuitem id="menu_education_lms_assignments"
name="Assignments"
parent="menu_education_lms_root"
action="action_education_lms_assignment"
sequence="20"/>
<!-- Submissions Menu -->
<menuitem id="menu_education_lms_submissions"
name="Submissions"
parent="menu_education_lms_root"
action="action_education_lms_submission"
sequence="30"/>
</odoo>

View File

@ -0,0 +1,736 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inject My LMS into the portal sidebar and dashboard quick links -->
<template id="portal_lms_dashboard_inject" inherit_id="education_portal.portal_student_dashboard" priority="50">
<!-- Sidebar injection -->
<xpath expr="//a[@href='/my/education/finance'][1]" position="before">
<a href="/my/lms" class="sidebar-link">
<i class="fa fa-book"/>
<span>My LMS</span>
</a>
<a href="/my/lms/assignments" class="sidebar-link">
<i class="fa fa-pencil-square-o"/>
<span>Assignments</span>
</a>
</xpath>
<!-- Dashboard quick links injection -->
<xpath expr="//div[hasclass('row', 'g-3', 'mb-4')]" position="inside">
<div class="col-md-4">
<a href="/my/lms" style="text-decoration: none;">
<div class="quick-card">
<div class="quick-icon-box" style="background: #e0f2fe; color: #0284c7;">
<i class="fa fa-book"/>
</div>
<div class="quick-title">My LMS</div>
<div class="quick-desc">Access your course materials and lessons</div>
<div class="quick-arrow"><i class="fa fa-arrow-right"/></div>
</div>
</a>
</div>
</xpath>
</template>
<!-- Base LMS Template wrapper -->
<template id="portal_my_lms_base" name="LMS Base Wrapper">
<t t-call="portal.portal_layout">
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&amp;display=swap" rel="stylesheet"/>
<style>
header, footer, .o_portal_navbar, .o_breadcrumb, #oe_structure_portal_footer_1, .o_portal_submenu, .breadcrumb {
display: none !important;
}
.container, .o_portal_container {
max-width: 100% !important;
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
body {
background-color: #eef9f9 !important;
font-family: 'Nunito', sans-serif !important;
color: #3b3b3b !important;
margin: 0;
padding: 0;
min-height: 100vh;
}
#wrap { background: transparent !important; }
/* Layout */
.portal-header {
height: 70px;
background: #eef9f9 !important;
border-bottom: 1px solid rgba(31, 143, 143, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px;
position: sticky;
top: 0;
z-index: 100;
}
.portal-logo-area {
display: flex;
align-items: center;
font-size: 1.35rem;
font-weight: 800;
color: #144d4d;
}
.portal-user-area {
display: flex;
align-items: center;
gap: 20px;
}
.user-profile-bubble {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: #144d4d;
}
.user-avatar-initial {
width: 38px;
height: 38px;
background: #1f8f8f;
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
}
.portal-layout-wrapper {
display: flex;
min-height: calc(100vh - 70px);
}
.portal-sidebar {
width: 280px;
background: #ffffff;
border-right: 1px solid rgba(31, 143, 143, 0.1);
padding: 30px 20px;
display: flex;
flex-direction: column;
gap: 10px;
position: sticky;
top: 70px;
align-self: flex-start;
max-height: calc(100vh - 70px);
overflow-y: auto;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 20px;
border-radius: 12px;
color: #4a5568;
font-weight: 700;
text-decoration: none !important;
}
.sidebar-link:hover {
background: rgba(31, 143, 143, 0.05);
color: #1f8f8f;
}
.sidebar-link.active {
background: #e6f7f7;
color: #1f8f8f;
}
.portal-content-area {
flex: 1;
padding: 24px 40px;
max-width: 1400px;
}
.breadcrumb-premium {
display: flex;
gap: 8px;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 16px;
}
.breadcrumb-premium a { color: #1f8f8f; text-decoration: none; }
.breadcrumb-premium span { color: #a0aec0; }
.breadcrumb-premium .active { color: #1f8f8f; }
/* LMS specific */
.lms-card {
background: #ffffff;
border: 1px solid rgba(31, 143, 143, 0.1);
border-radius: 16px;
padding: 20px;
transition: all 0.25s ease;
box-shadow: 0 4px 15px rgba(31, 143, 143, 0.02);
margin-bottom: 20px;
}
.lms-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(31, 143, 143, 0.06);
}
.lms-btn {
background: #1f8f8f;
color: #ffffff !important;
border-radius: 8px;
padding: 8px 16px;
font-weight: 700;
text-decoration: none;
display: inline-block;
border: none;
}
.lms-btn:hover { background: #144d4d; }
.lms-btn-outline {
background: transparent;
color: #1f8f8f !important;
border: 2px solid #1f8f8f;
border-radius: 8px;
padding: 6px 14px;
font-weight: 700;
text-decoration: none;
display: inline-block;
}
.lms-btn-outline:hover { background: #e6f7f7; }
.lms-badge {
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 700;
}
.lms-badge-green { background: #dcfce7; color: #15803d; }
.lms-badge-blue { background: #dbeafe; color: #1d4ed8; }
.lms-badge-red { background: #fee2e2; color: #b91c1c; }
.lms-badge-yellow { background: #fef9c3; color: #a16207; }
/* Course Details */
.lms-section-header {
background: #f8fafc;
padding: 12px 20px;
border-radius: 8px;
font-weight: 700;
margin-top: 20px;
margin-bottom: 10px;
color: #1e293b;
}
.lms-lesson-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid #e2e8f0;
}
.lms-lesson-item:last-child { border-bottom: none; }
.lms-lesson-link {
color: #1f8f8f;
font-weight: 600;
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
}
.lms-lesson-link:hover { color: #144d4d; text-decoration: underline; }
/* Video wrapper */
.video-wrapper {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
height: 0;
overflow: hidden;
border-radius: 12px;
margin-bottom: 20px;
}
.video-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
<!-- Header -->
<div class="portal-header">
<div class="portal-logo-area">
<t t-if="student.campus_id and student.campus_id.logo">
<img t-attf-src="/web/image?model=education.campus&amp;id={{ student.campus_id.id }}&amp;field=logo" style="max-height: 40px; width: auto;" alt="Logo"/>
</t>
<t t-else="">
<img t-attf-src="/web/image?model=res.company&amp;id={{ student.company_id.id }}&amp;field=logo" style="max-height: 40px; width: auto;" alt="Logo"/>
</t>
</div>
<div class="portal-user-area">
<div class="user-profile-bubble">
<span><t t-esc="student.full_name"/></span>
<div class="user-avatar-initial">
<t t-esc="student.full_name[0]"/>
</div>
</div>
</div>
</div>
<!-- Layout Wrapper -->
<div class="portal-layout-wrapper">
<!-- Sidebar -->
<div class="portal-sidebar">
<a href="/my/education/dashboard" class="sidebar-link">
<i class="fa fa-home"/>
<span>Dashboard</span>
</a>
<a href="/my/lms" t-attf-class="sidebar-link #{'active' if page_name in ['lms_courses', 'lms_course_detail', 'lms_lesson_detail'] else ''}">
<i class="fa fa-book"/>
<span>My LMS</span>
</a>
<a href="/my/lms/assignments" t-attf-class="sidebar-link #{'active' if page_name in ['lms_assignments', 'lms_assignment_detail', 'lms_submission_detail'] else ''}">
<i class="fa fa-pencil-square-o"/>
<span>Assignments</span>
</a>
<a href="/my/exams" class="sidebar-link">
<i class="fa fa-pencil-square-o"/>
<span>Exams</span>
</a>
<a href="/my/education/documents" class="sidebar-link">
<i class="fa fa-folder-open"/>
<span>Documents</span>
</a>
<a href="/web/session/logout?redirect=/" class="sidebar-link">
<i class="fa fa-sign-out"/>
<span>Logout</span>
</a>
</div>
<!-- Content Area -->
<div class="portal-content-area">
<t t-out="0"/>
</div>
</div>
</t>
</template>
<!-- My LMS Course List -->
<template id="portal_my_lms_course_list" name="My LMS Courses">
<t t-call="education_lms.portal_my_lms_base">
<div class="breadcrumb-premium">
<a href="/my/education/dashboard">Dashboard</a>
<span>&gt;</span>
<span class="active">My LMS</span>
</div>
<h3 class="mb-4 font-weight-bold" style="color: #144d4d;">My Courses</h3>
<t t-if="not courses">
<div class="alert alert-info" style="border-radius: 12px; border-color: rgba(31, 143, 143, 0.2); background: #e6f7f7; color: #144d4d;">
You don't have any available courses.
</div>
</t>
<t t-else="">
<div class="row">
<t t-foreach="courses" t-as="course">
<div class="col-md-4">
<div class="lms-card">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="font-weight-bold m-0" style="color: #144d4d;">
<t t-esc="course.name"/>
</h5>
<span t-attf-class="lms-badge #{'lms-badge-green' if course.state == 'published' else 'lms-badge-blue'}">
<t t-esc="dict(course._fields['state'].selection).get(course.state)"/>
</span>
</div>
<div class="text-muted small mb-3">
<i class="fa fa-book mr-1"/> <t t-esc="course.subject_id.name"/><br/>
<i class="fa fa-user mr-1"/> <t t-esc="course.faculty_id.name"/>
</div>
<div class="small mb-3" style="color: #64748b;">
<t t-esc="course.program_id.name"/> | <t t-esc="course.academic_year_id.name"/>
<t t-if="course.term_id"> | <t t-esc="course.term_id.name"/></t>
</div>
<a t-attf-href="/my/lms/course/{{ course.id }}" class="lms-btn w-100 text-center">
View Course
</a>
</div>
</div>
</t>
</div>
<t t-if="pager">
<div class="mt-4">
<t t-call="portal.pager"/>
</div>
</t>
</t>
</t>
</template>
<!-- Course Detail -->
<template id="portal_my_lms_course_detail" name="LMS Course Detail">
<t t-call="education_lms.portal_my_lms_base">
<div class="breadcrumb-premium">
<a href="/my/lms">My LMS</a>
<span>&gt;</span>
<span class="active"><t t-esc="course.name"/></span>
</div>
<div class="lms-card mb-4">
<h3 class="font-weight-bold" style="color: #144d4d;"><t t-esc="course.name"/></h3>
<div class="row mt-3">
<div class="col-md-6">
<p class="mb-1 text-muted"><strong>Subject:</strong> <t t-esc="course.subject_id.name"/></p>
<p class="mb-1 text-muted"><strong>Faculty:</strong> <t t-esc="course.faculty_id.name"/></p>
</div>
<div class="col-md-6">
<p class="mb-1 text-muted"><strong>Program:</strong> <t t-esc="course.program_id.name"/></p>
<p class="mb-1 text-muted"><strong>Year:</strong> <t t-esc="course.academic_year_id.name"/></p>
</div>
</div>
<t t-if="course.description">
<div class="mt-3 p-3 bg-light rounded">
<t t-out="course.description"/>
</div>
</t>
</div>
<h4 class="font-weight-bold mt-4 mb-3" style="color: #144d4d;">Course Content</h4>
<t t-if="not sections">
<div class="alert alert-info">No published content available yet.</div>
</t>
<t t-else="">
<t t-foreach="sections" t-as="section">
<div class="lms-card p-0 mb-3" style="overflow: hidden;">
<div class="lms-section-header m-0 rounded-0">
<t t-esc="section.name"/>
</div>
<t t-set="lessons" t-value="section.lesson_ids.filtered(lambda l: l.published)"/>
<t t-if="not lessons">
<div class="p-3 text-muted small">No published lessons in this section.</div>
</t>
<t t-else="">
<t t-foreach="lessons" t-as="lesson">
<div class="lms-lesson-item">
<a t-attf-href="/my/lms/course/{{ course.id }}/lesson/{{ lesson.id }}" class="lms-lesson-link">
<i class="fa fa-play-circle-o"/> <t t-esc="lesson.name"/>
</a>
</div>
</t>
</t>
</div>
</t>
</t>
</t>
</template>
<!-- Lesson Detail -->
<template id="portal_my_lms_lesson_detail" name="LMS Lesson Detail">
<t t-call="education_lms.portal_my_lms_base">
<div class="breadcrumb-premium">
<a href="/my/lms">My LMS</a>
<span>&gt;</span>
<a t-attf-href="/my/lms/course/{{ course.id }}"><t t-esc="course.name"/></a>
<span>&gt;</span>
<span class="active"><t t-esc="lesson.name"/></span>
</div>
<div class="row">
<div class="col-lg-8">
<div class="lms-card">
<h3 class="font-weight-bold" style="color: #144d4d;"><t t-esc="lesson.name"/></h3>
<p class="text-muted small mb-4">Section: <t t-esc="lesson.section_id.name"/></p>
<div class="lesson-content">
<t t-out="lesson.description"/>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<t t-if="prev_lesson">
<a t-attf-href="/my/lms/course/{{ course.id }}/lesson/{{ prev_lesson.id }}" class="lms-btn-outline">
<i class="fa fa-chevron-left mr-2"/> Previous
</a>
</t>
<t t-else="">
<div></div>
</t>
<t t-if="next_lesson">
<a t-attf-href="/my/lms/course/{{ course.id }}/lesson/{{ next_lesson.id }}" class="lms-btn">
Next <i class="fa fa-chevron-right ml-2"/>
</a>
</t>
</div>
</div>
<div class="col-lg-4">
<div class="lms-card">
<h5 class="font-weight-bold border-bottom pb-2 mb-3">Materials</h5>
<t t-set="materials" t-value="lesson.material_ids.filtered(lambda m: m.published)"/>
<t t-if="not materials">
<p class="text-muted small">No materials available.</p>
</t>
<t t-else="">
<ul class="list-unstyled m-0">
<t t-foreach="materials" t-as="material">
<li class="mb-3">
<t t-if="material.material_type == 'file'">
<a t-attf-href="/my/lms/material/{{ material.id }}/download" class="d-flex align-items-center" style="color: #1f8f8f; text-decoration: none;">
<i class="fa fa-file-pdf-o fa-2x mr-3"/>
<div>
<div class="font-weight-bold"><t t-esc="material.name"/></div>
<div class="small text-muted">Download File</div>
</div>
</a>
</t>
<t t-if="material.material_type == 'url'">
<a t-att-href="material.url" target="_blank" class="d-flex align-items-center" style="color: #0284c7; text-decoration: none;">
<i class="fa fa-link fa-2x mr-3"/>
<div>
<div class="font-weight-bold"><t t-esc="material.name"/></div>
<div class="small text-muted">External Link</div>
</div>
</a>
</t>
<t t-if="material.material_type == 'video'">
<a t-att-href="material.url" target="_blank" class="d-flex align-items-center" style="color: #e11d48; text-decoration: none;">
<i class="fa fa-play-circle fa-2x mr-3"/>
<div>
<div class="font-weight-bold"><t t-esc="material.name"/></div>
<div class="small text-muted">Watch Video</div>
</div>
</a>
</t>
</li>
</t>
</ul>
</t>
</div>
</div>
</div>
</t>
</template>
<!-- Assignments List -->
<template id="portal_my_lms_assignment_list" name="My Assignments">
<t t-call="education_lms.portal_my_lms_base">
<div class="breadcrumb-premium">
<a href="/my/education/dashboard">Dashboard</a>
<span>&gt;</span>
<span class="active">Assignments</span>
</div>
<h3 class="mb-4 font-weight-bold" style="color: #144d4d;">Assignments</h3>
<t t-if="not assignments">
<div class="alert alert-info" style="border-radius: 12px; border-color: rgba(31, 143, 143, 0.2); background: #e6f7f7; color: #144d4d;">
You don't have any assignments yet.
</div>
</t>
<t t-else="">
<div class="lms-card p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="bg-light">
<tr>
<th class="border-0">Assignment</th>
<th class="border-0">Course</th>
<th class="border-0">Due Date</th>
<th class="border-0">Status</th>
<th class="border-0">Marks</th>
<th class="border-0">Action</th>
</tr>
</thead>
<tbody>
<t t-foreach="assignments" t-as="assignment">
<t t-set="submission" t-value="submission_map.get(assignment.id)"/>
<tr>
<td class="align-middle font-weight-bold">
<t t-esc="assignment.name"/>
</td>
<td class="align-middle text-muted"><t t-esc="assignment.course_id.name"/></td>
<td class="align-middle">
<t t-esc="assignment.due_date" t-options='{"widget": "datetime"}'/>
</td>
<td class="align-middle">
<t t-if="not submission">
<span class="lms-badge lms-badge-red">Pending</span>
</t>
<t t-elif="submission.status == 'submitted'">
<span class="lms-badge lms-badge-blue">Submitted</span>
</t>
<t t-elif="submission.status == 'evaluated'">
<span class="lms-badge lms-badge-green">Evaluated</span>
</t>
</td>
<td class="align-middle font-weight-bold">
<t t-if="submission and submission.status == 'evaluated'">
<t t-esc="submission.marks"/> / <t t-esc="assignment.max_marks"/>
</t>
<t t-else="">
- / <t t-esc="assignment.max_marks"/>
</t>
</td>
<td class="align-middle">
<t t-if="submission">
<a t-attf-href="/my/lms/submission/{{ submission.id }}" class="lms-btn-outline btn-sm">View Submission</a>
</t>
<t t-else="">
<a t-attf-href="/my/lms/assignment/{{ assignment.id }}" class="lms-btn btn-sm">Submit</a>
</t>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
<t t-if="pager">
<div class="mt-4">
<t t-call="portal.pager"/>
</div>
</t>
</t>
</t>
</template>
<!-- Assignment Detail & Submit -->
<template id="portal_my_lms_assignment_detail" name="Assignment Submit">
<t t-call="education_lms.portal_my_lms_base">
<div class="breadcrumb-premium">
<a href="/my/lms/assignments">Assignments</a>
<span>&gt;</span>
<span class="active"><t t-esc="assignment.name"/></span>
</div>
<t t-if="error == 'closed'">
<div class="alert alert-danger">This assignment is closed and cannot accept new submissions.</div>
</t>
<t t-if="error == 'exists'">
<div class="alert alert-warning">You have already submitted this assignment.</div>
</t>
<t t-if="error == 'empty'">
<div class="alert alert-danger">You must provide either text content or an attachment.</div>
</t>
<t t-if="error == 'server'">
<div class="alert alert-danger">An error occurred while saving your submission. Please try again.</div>
</t>
<div class="row">
<div class="col-lg-7">
<div class="lms-card">
<h3 class="font-weight-bold" style="color: #144d4d;"><t t-esc="assignment.name"/></h3>
<p class="text-muted mb-4"><t t-esc="assignment.course_id.name"/> | <t t-esc="assignment.course_id.subject_id.name"/></p>
<div class="mb-4">
<span class="lms-badge lms-badge-yellow mr-2">Due: <t t-esc="assignment.due_date" t-options='{"widget": "datetime"}'/></span>
<span class="lms-badge lms-badge-blue">Max Marks: <t t-esc="assignment.max_marks"/></span>
</div>
<h5 class="font-weight-bold">Instructions</h5>
<div class="bg-light p-3 rounded">
<t t-if="assignment.instructions">
<t t-out="assignment.instructions"/>
</t>
<t t-else="">
<p class="text-muted mb-0">No specific instructions provided.</p>
</t>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="lms-card">
<h5 class="font-weight-bold border-bottom pb-2 mb-3">Your Submission</h5>
<t t-if="submission">
<div class="alert alert-success">You have already submitted this assignment.</div>
<a t-attf-href="/my/lms/submission/{{ submission.id }}" class="lms-btn w-100 text-center">View My Submission</a>
</t>
<t t-elif="assignment.state == 'closed'">
<div class="alert alert-danger">Assignment is closed.</div>
</t>
<t t-else="">
<form t-attf-action="/my/lms/assignment/{{ assignment.id }}/submit" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="form-group mb-3">
<label class="font-weight-bold">Text Submission (Optional if file attached)</label>
<textarea name="content" class="form-control" rows="5" placeholder="Enter your answer here..."></textarea>
</div>
<div class="form-group mb-4">
<label class="font-weight-bold">File Attachment (Optional if text provided)</label>
<input type="file" name="attachment" class="form-control-file"/>
</div>
<button type="submit" class="lms-btn w-100">Submit Assignment</button>
</form>
</t>
</div>
</div>
</div>
</t>
</template>
<!-- Submission Detail -->
<template id="portal_my_lms_submission_detail" name="Submission Detail">
<t t-call="education_lms.portal_my_lms_base">
<div class="breadcrumb-premium">
<a href="/my/lms/assignments">Assignments</a>
<span>&gt;</span>
<span class="active">My Submission</span>
</div>
<div class="row">
<div class="col-lg-8">
<div class="lms-card">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="font-weight-bold m-0" style="color: #144d4d;">Submission for <t t-esc="assignment.name"/></h3>
<t t-if="submission.is_late">
<span class="lms-badge lms-badge-red">LATE SUBMISSION</span>
</t>
</div>
<p class="text-muted">Submitted on: <t t-esc="submission.submission_date" t-options='{"widget": "datetime"}'/></p>
<h5 class="font-weight-bold mt-4">Submitted Content</h5>
<div class="bg-light p-3 rounded mb-4">
<t t-if="submission.content">
<div style="white-space: pre-wrap;"><t t-esc="submission.content"/></div>
</t>
<t t-else="">
<p class="text-muted mb-0">No text content provided.</p>
</t>
</div>
<t t-if="attachments">
<h5 class="font-weight-bold">Attachments</h5>
<ul class="list-unstyled">
<t t-foreach="attachments" t-as="attach">
<li class="mb-2">
<a t-attf-href="/my/lms/submission/attachment/{{ attach.id }}" class="lms-btn-outline btn-sm">
<i class="fa fa-download mr-1"/> <t t-esc="attach.name"/>
</a>
</li>
</t>
</ul>
</t>
</div>
</div>
<div class="col-lg-4">
<div class="lms-card">
<h5 class="font-weight-bold border-bottom pb-2 mb-3">Evaluation</h5>
<t t-if="submission.status == 'submitted'">
<div class="alert alert-info">Not yet evaluated</div>
</t>
<t t-elif="submission.status == 'evaluated'">
<div class="text-center mb-4">
<div style="font-size: 3rem; font-weight: 900; color: #144d4d;">
<t t-esc="submission.marks"/> <span style="font-size: 1.5rem; color: #64748b;">/ <t t-esc="assignment.max_marks"/></span>
</div>
<div class="text-muted small">Evaluated on <t t-esc="submission.evaluation_date" t-options='{"widget": "date"}'/></div>
</div>
<t t-if="submission.feedback">
<h6 class="font-weight-bold">Faculty Feedback</h6>
<div class="bg-light p-2 rounded small" style="white-space: pre-wrap;"><t t-esc="submission.feedback"/></div>
</t>
</t>
</div>
</div>
</div>
</t>
</template>
</odoo>

View File

@ -881,6 +881,14 @@
<i class="fa fa-file-text-o"/>
<span>Receipts</span>
</a>
<a href="/my/exams" class="sidebar-link">
<i class="fa fa-pencil-square-o"/>
<span>Exams</span>
</a>
<a href="/my/results" class="sidebar-link">
<i class="fa fa-graduation-cap"/>
<span>Results</span>
</a>
<a href="/my/education/dashboard" class="sidebar-link">
<i class="fa fa-bell-o"/>
<span>Notices</span>
@ -1725,6 +1733,14 @@
<i class="fa fa-file-text-o"/>
<span>Receipts</span>
</a>
<a href="/my/exams" class="sidebar-link">
<i class="fa fa-pencil-square-o"/>
<span>Exams</span>
</a>
<a href="/my/results" class="sidebar-link">
<i class="fa fa-graduation-cap"/>
<span>Results</span>
</a>
<a href="/my/education/dashboard" class="sidebar-link">
<i class="fa fa-bell-o"/>
<span>Notices</span>
@ -2421,6 +2437,14 @@
<i class="fa fa-file-text-o"/>
<span>Receipts</span>
</a>
<a href="/my/exams" class="sidebar-link">
<i class="fa fa-pencil-square-o"/>
<span>Exams</span>
</a>
<a href="/my/results" class="sidebar-link">
<i class="fa fa-graduation-cap"/>
<span>Results</span>
</a>
<a href="/my/education/dashboard" class="sidebar-link">
<i class="fa fa-bell-o"/>
<span>Notices</span>
@ -3323,6 +3347,14 @@
<i class="fa fa-file-text-o"/>
<span>Receipts</span>
</a>
<a href="/my/exams" class="sidebar-link">
<i class="fa fa-pencil-square-o"/>
<span>Exams</span>
</a>
<a href="/my/results" class="sidebar-link">
<i class="fa fa-graduation-cap"/>
<span>Results</span>
</a>
<a href="/my/education/dashboard" class="sidebar-link">
<i class="fa fa-bell-o"/>
<span>Notices</span>
@ -4036,6 +4068,14 @@
<i class="fa fa-file-text-o"/>
<span>Receipts</span>
</a>
<a href="/my/exams" class="sidebar-link">
<i class="fa fa-pencil-square-o"/>
<span>Exams</span>
</a>
<a href="/my/results" class="sidebar-link">
<i class="fa fa-graduation-cap"/>
<span>Results</span>
</a>
<a href="/my/education/dashboard" class="sidebar-link">
<i class="fa fa-bell-o"/>
<span>Notices</span>
@ -4189,6 +4229,12 @@
</div>
</div>
</div>
<!-- Bottom Footer Area -->
<div style="height: 60px; background: #ffffff; border-top: 1px solid rgba(31, 143, 143, 0.1); display: flex; align-items: center; justify-content: space-between; padding: 0 40px; font-size: 0.9rem; color: #718096; font-weight: 600;">
<span>© 2026 My Company. All rights reserved.</span>
<span>Powered by <span style="color: #714B67; font-weight: 700;">odoo</span></span>
</div>
</t>
</template>
</odoo>

View File

@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@ -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,
}

View File

@ -0,0 +1 @@
from . import main

View File

@ -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)

View File

@ -0,0 +1,2 @@
from . import education_room
from . import education_timetable_slot

View File

@ -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!')
]

View File

@ -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}.")

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_education_room_admin education.room.admin model_education_room group_education_timetable_admin 1 1 1 1
3 access_education_room_faculty education.room.faculty model_education_room group_education_timetable_faculty 1 0 0 0
4 access_education_room_student education.room.student model_education_room group_education_timetable_student 1 0 0 0
5 access_education_timetable_slot_admin education.timetable.slot.admin model_education_timetable_slot group_education_timetable_admin 1 1 1 1
6 access_education_timetable_slot_faculty education.timetable.slot.faculty model_education_timetable_slot group_education_timetable_faculty 1 0 0 0
7 access_education_timetable_slot_student education.timetable.slot.student model_education_timetable_slot group_education_timetable_student 1 0 0 0

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="group_education_timetable_student" model="res.groups">
<field name="name">Student / Portal</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="group_education_timetable_faculty" model="res.groups">
<field name="name">Faculty</field>
<field name="category_id" ref="base.module_category_hidden"/>
<field name="implied_ids" eval="[(4, ref('group_education_timetable_student'))]"/>
</record>
<record id="group_education_timetable_admin" model="res.groups">
<field name="name">Timetable Administrator</field>
<field name="category_id" ref="base.module_category_hidden"/>
<field name="implied_ids" eval="[(4, ref('group_education_timetable_faculty'))]"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_room_tree" model="ir.ui.view">
<field name="name">education.room.tree</field>
<field name="model">education.room</field>
<field name="arch" type="xml">
<tree string="Rooms">
<field name="name"/>
<field name="code"/>
<field name="capacity"/>
<field name="building"/>
</tree>
</field>
</record>
<record id="view_education_room_form" model="ir.ui.view">
<field name="name">education.room.form</field>
<field name="model">education.room</field>
<field name="arch" type="xml">
<form string="Room">
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="Room Name"/>
</h1>
</div>
<group>
<group>
<field name="code"/>
<field name="capacity"/>
</group>
<group>
<field name="building"/>
<field name="active"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_education_room" model="ir.actions.act_window">
<field name="name">Rooms / Venues</field>
<field name="res_model">education.room</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>

View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_timetable_slot_tree" model="ir.ui.view">
<field name="name">education.timetable.slot.tree</field>
<field name="model">education.timetable.slot</field>
<field name="arch" type="xml">
<tree string="Timetable Slots">
<field name="date"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="subject_id"/>
<field name="faculty_id"/>
<field name="room_id"/>
<field name="batch_id"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'confirmed'" decoration-danger="state == 'cancelled'"/>
</tree>
</field>
</record>
<record id="view_education_timetable_slot_form" model="ir.ui.view">
<field name="name">education.timetable.slot.form</field>
<field name="model">education.timetable.slot</field>
<field name="arch" type="xml">
<form string="Timetable Slot">
<header>
<field name="state" widget="statusbar" options="{'clickable': '1'}"/>
</header>
<sheet>
<group>
<group string="Schedule">
<field name="date"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="subject_id"/>
<field name="faculty_id"/>
<field name="room_id"/>
</group>
<group string="Academic Population">
<field name="program_id"/>
<field name="academic_year_id"/>
<field name="term_id"/>
<field name="batch_id"/>
<field name="active"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_education_timetable_slot_search" model="ir.ui.view">
<field name="name">education.timetable.slot.search</field>
<field name="model">education.timetable.slot</field>
<field name="arch" type="xml">
<search string="Search Timetable Slots">
<field name="subject_id"/>
<field name="faculty_id"/>
<field name="room_id"/>
<field name="program_id"/>
<field name="academic_year_id"/>
<field name="term_id"/>
<field name="batch_id"/>
<group expand="0" string="Group By">
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
<filter string="Faculty" name="group_faculty" context="{'group_by': 'faculty_id'}"/>
<filter string="Room" name="group_room" context="{'group_by': 'room_id'}"/>
<filter string="Program" name="group_program" context="{'group_by': 'program_id'}"/>
<filter string="Batch" name="group_batch" context="{'group_by': 'batch_id'}"/>
</group>
</search>
</field>
</record>
<record id="view_education_timetable_slot_calendar" model="ir.ui.view">
<field name="name">education.timetable.slot.calendar</field>
<field name="model">education.timetable.slot</field>
<field name="arch" type="xml">
<calendar string="Timetable" date_start="date" color="faculty_id" mode="week">
<field name="subject_id"/>
<field name="faculty_id"/>
<field name="room_id"/>
<field name="batch_id"/>
</calendar>
</field>
</record>
<record id="action_education_timetable_slot" model="ir.actions.act_window">
<field name="name">Timetable Slots</field>
<field name="res_model">education.timetable.slot</field>
<field name="view_mode">tree,calendar,form</field>
<field name="search_view_id" ref="view_education_timetable_slot_search"/>
</record>
</odoo>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Main Timetable Menu -->
<menuitem id="menu_education_timetable_root"
name="Timetable"
parent="education_base.menu_education_base_root"
sequence="30"/>
<menuitem id="menu_education_timetable_slots"
name="Timetable Slots"
parent="menu_education_timetable_root"
action="action_education_timetable_slot"
sequence="10"/>
<menuitem id="menu_education_room"
name="Rooms / Venues"
parent="education_base.menu_education_base_configuration"
action="action_education_room"
sequence="40"/>
</odoo>

View File

@ -0,0 +1,411 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Timetable to Portal Home -->
<template id="portal_my_home_timetable" name="Portal My Home: Timetable" inherit_id="portal.portal_my_home" priority="40">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="title">My Timetable</t>
<t t-set="url" t-value="'/my/timetable'"/>
<t t-set="placeholder_count" t-value="'timetable_count'"/>
<t t-set="icon" t-value="'/education_portal/static/description/icon.png'"/>
</t>
</xpath>
</template>
<!-- Main Timetable Template -->
<template id="portal_student_timetable" name="My Timetable">
<t t-call="portal.portal_layout">
<t t-call="education_portal.portal_student_styles"/>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&amp;display=swap" rel="stylesheet"/>
<style>
/* Absolute Reset and Layout overrides */
header, footer, .o_portal_navbar, .o_breadcrumb, #oe_structure_portal_footer_1, .o_portal_submenu, .breadcrumb {
display: none !important;
}
.container, .o_portal_container {
max-width: 100% !important;
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
body {
background-color: #eef9f9 !important;
font-family: 'Nunito', sans-serif !important;
color: #3b3b3b !important;
margin: 0;
padding: 0;
min-height: 100vh;
}
#wrap { background: transparent !important; }
/* Layout */
.portal-header {
height: 70px;
background: #eef9f9 !important;
border-bottom: 1px solid rgba(31, 143, 143, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px;
position: sticky;
top: 0;
z-index: 100;
}
.portal-logo-area {
display: flex;
align-items: center;
font-size: 1.35rem;
font-weight: 800;
color: #144d4d;
}
.portal-user-area {
display: flex;
align-items: center;
gap: 20px;
}
.notification-bell {
font-size: 1.35rem;
color: #4a5568;
position: relative;
cursor: pointer;
}
.notification-badge {
position: absolute;
top: -6px;
right: -6px;
background: #ff5e5e;
color: #ffffff;
font-size: 0.65rem;
font-weight: 800;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.user-profile-bubble {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: #144d4d;
cursor: pointer;
}
.dropdown-toggle::after {
display: none !important;
}
.user-avatar-initial {
width: 38px;
height: 38px;
background: #1f8f8f;
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
}
.portal-layout-wrapper {
display: flex;
min-height: calc(100vh - 70px);
}
.portal-sidebar {
width: 280px;
background: #ffffff;
border-right: 1px solid rgba(31, 143, 143, 0.1);
padding: 30px 20px;
display: flex;
flex-direction: column;
gap: 10px;
position: sticky;
top: 70px;
align-self: flex-start;
max-height: calc(100vh - 70px);
overflow-y: auto;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 20px;
border-radius: 12px;
color: #4a5568;
font-weight: 700;
text-decoration: none !important;
}
.sidebar-link:hover {
background: rgba(31, 143, 143, 0.05);
color: #1f8f8f;
}
.sidebar-link.active {
background: #e6f7f7;
color: #1f8f8f;
}
.portal-content-area {
flex: 1;
padding: 24px 40px;
max-width: 1400px;
}
.breadcrumb-premium {
display: flex;
gap: 8px;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 16px;
}
.breadcrumb-premium a { color: #1f8f8f; text-decoration: none; }
.breadcrumb-premium span { color: #a0aec0; }
.breadcrumb-premium .active { color: #1f8f8f; }
.timetable-card {
border-radius: 12px;
border: none;
background: #ffffff;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
margin-bottom: 1rem;
transition: all 0.3s ease;
border-left: 4px solid #4f46e5;
}
.timetable-card:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.timetable-date-header {
font-weight: 700;
color: #1e293b;
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.5rem;
}
.timetable-time {
font-weight: 600;
color: #4f46e5;
font-size: 0.95rem;
}
.timetable-subject {
font-weight: 700;
color: #0f172a;
font-size: 1.1rem;
}
.timetable-detail {
color: #64748b;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.timetable-empty {
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 16px;
padding: 3rem;
text-align: center;
margin-top: 2rem;
}
</style>
<!-- Top Header -->
<div class="portal-header">
<div class="portal-logo-area">
<t t-if="student.campus_id and student.campus_id.logo">
<img t-attf-src="/web/image?model=education.campus&amp;id={{ student.campus_id.id }}&amp;field=logo" style="max-height: 40px; width: auto;" alt="Logo"/>
</t>
<t t-else="">
<img t-attf-src="/web/image?model=res.company&amp;id={{ student.campus_id.company_id.id or student.company_id.id }}&amp;field=logo" style="max-height: 40px; width: auto;" alt="Logo"/>
</t>
</div>
<div class="portal-user-area">
<div class="notification-bell">
<i class="fa fa-bell-o"/>
<div class="notification-badge">3</div>
</div>
<div class="dropdown">
<div class="user-profile-bubble dropdown-toggle" data-toggle="dropdown" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" id="userMenuDropdown" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<span><t t-esc="student.full_name"/></span>
<div class="user-avatar-initial">
<t t-esc="student.full_name[0]"/>
</div>
<i class="fa fa-angle-down"/>
</div>
<div class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="userMenuDropdown" style="border-radius: 12px; border: 1px solid rgba(31, 143, 143, 0.1); margin-top: 10px;">
<a class="dropdown-item py-2" href="/my/education/profile" style="font-weight: 600; color: #4a5568;"><i class="fa fa-user-circle mr-2 text-muted"/> My Profile</a>
<a class="dropdown-item py-2" href="/my/education/dashboard" style="font-weight: 600; color: #4a5568;"><i class="fa fa-home mr-2 text-muted"/> Dashboard</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item py-2 text-danger" href="/web/session/logout?redirect=/" style="font-weight: 600;"><i class="fa fa-sign-out mr-2"/> Logout</a>
</div>
</div>
</div>
</div>
<div class="portal-layout-wrapper">
<div class="portal-sidebar">
<a href="/my/education/dashboard" class="sidebar-link">
<i class="fa fa-home"/><span>Dashboard</span>
</a>
<a href="/my/education/profile" class="sidebar-link">
<i class="fa fa-user-circle"/><span>My Profile</span>
</a>
<a href="/my/education/attendance" class="sidebar-link">
<i class="fa fa-calendar-check-o"/><span>Attendance</span>
</a>
<a href="/my/education/finance" class="sidebar-link">
<i class="fa fa-credit-card"/><span>Fee &amp; Payments</span>
</a>
<a href="/my/education/finance" class="sidebar-link">
<i class="fa fa-file-text-o"/><span>Receipts</span>
</a>
<a href="/my/lms" class="sidebar-link">
<i class="fa fa-book"/><span>My LMS</span>
</a>
<a href="/my/lms/assignments" class="sidebar-link">
<i class="fa fa-pencil-square-o"/><span>Assignments</span>
</a>
<a href="/my/exams" class="sidebar-link">
<i class="fa fa-pencil-square-o"/><span>Exams</span>
</a>
<a href="/my/results" class="sidebar-link">
<i class="fa fa-graduation-cap"/><span>Results</span>
</a>
<a href="/my/timetable" class="sidebar-link active">
<i class="fa fa-calendar"/><span>My Timetable</span>
</a>
<a href="/my/education/dashboard" class="sidebar-link">
<i class="fa fa-bell-o"/><span>Notices</span>
</a>
<a href="/my/education/documents" class="sidebar-link">
<i class="fa fa-download"/><span>Downloads</span>
</a>
</div>
<div class="portal-content-area">
<!-- Breadcrumbs -->
<div class="breadcrumb-premium">
<a href="/my/home">Home</a>
<span>&gt;</span>
<span class="active">My Timetable</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="font-weight-bold m-0" style="color: #0f172a;">My Timetable</h2>
</div>
<t t-if="error">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle mr-2"/> <t t-esc="error"/>
</div>
</t>
<t t-elif="not grouped_slots">
<div class="timetable-empty">
<i class="fa fa-calendar-times-o mb-3" style="font-size: 3rem; color: #94a3b8;"/>
<h4 style="color: #475569; font-weight: 600;">No Classes Scheduled</h4>
<p class="text-muted">No timetable has been published for you yet.</p>
</div>
</t>
<t t-else="">
<t t-foreach="grouped_slots.keys()" t-as="tdate">
<h4 class="timetable-date-header">
<i class="fa fa-calendar-day mr-2 text-muted"/>
<t t-esc="tdate.strftime('%A, %d %B %Y')"/>
</h4>
<div class="row">
<t t-foreach="grouped_slots[tdate]" t-as="slot">
<div class="col-md-6 col-lg-6 mb-3">
<div class="card timetable-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="timetable-subject"><t t-esc="slot['subject']"/></div>
<span class="badge badge-pill badge-light-primary" style="background-color: #e0e7ff; color: #4338ca;">
<i class="fa fa-clock-o mr-1"/>
<t t-esc="slot['start_time']"/> - <t t-esc="slot['end_time']"/>
</span>
</div>
<div class="timetable-detail mt-3">
<div class="w-50">
<i class="fa fa-user-circle-o"/> <t t-esc="slot['faculty']"/>
</div>
<div class="w-50">
<i class="fa fa-map-marker"/> <t t-esc="slot['room']"/>
</div>
</div>
</div>
</div>
</div>
</t>
</div>
</t>
</t>
</div>
</div>
</t>
</template>
<template id="portal_sidebar_add_timetable_education_portal_portal_student_dashboard" inherit_id="education_portal.portal_student_dashboard">
<xpath expr="//a[@href='/my/results']" position="after">
<a href="/my/timetable" class="sidebar-link">
<i class="fa fa-calendar"/><span>My Timetable</span>
</a>
</xpath>
</template>
<template id="portal_sidebar_add_timetable_education_portal_portal_student_profile" inherit_id="education_portal.portal_student_profile">
<xpath expr="//a[@href='/my/results']" position="after">
<a href="/my/timetable" class="sidebar-link">
<i class="fa fa-calendar"/><span>My Timetable</span>
</a>
</xpath>
</template>
<template id="portal_sidebar_add_timetable_education_portal_portal_student_finance" inherit_id="education_portal.portal_student_finance">
<xpath expr="//a[@href='/my/results']" position="after">
<a href="/my/timetable" class="sidebar-link">
<i class="fa fa-calendar"/><span>My Timetable</span>
</a>
</xpath>
</template>
<template id="portal_sidebar_add_timetable_education_portal_portal_student_documents" inherit_id="education_portal.portal_student_documents">
<xpath expr="//a[@href='/my/results']" position="after">
<a href="/my/timetable" class="sidebar-link">
<i class="fa fa-calendar"/><span>My Timetable</span>
</a>
</xpath>
</template>
<template id="portal_sidebar_add_timetable_education_portal_portal_student_attendance" inherit_id="education_portal.portal_student_attendance">
<xpath expr="//a[@href='/my/results']" position="after">
<a href="/my/timetable" class="sidebar-link">
<i class="fa fa-calendar"/><span>My Timetable</span>
</a>
</xpath>
</template>
<template id="portal_sidebar_add_timetable_education_exam_portal_my_exams" inherit_id="education_exam.portal_my_exams">
<xpath expr="//a[@href='/my/results']" position="after">
<a href="/my/timetable" class="sidebar-link">
<i class="fa fa-calendar"/><span>My Timetable</span>
</a>
</xpath>
</template>
<template id="portal_sidebar_add_timetable_education_exam_portal_my_results" inherit_id="education_exam.portal_my_results">
<xpath expr="//a[@href='/my/results']" position="after">
<a href="/my/timetable" class="sidebar-link">
<i class="fa fa-calendar"/><span>My Timetable</span>
</a>
</xpath>
</template>
<template id="portal_sidebar_add_timetable_education_exam_portal_result_detail" inherit_id="education_exam.portal_result_detail">
<xpath expr="//a[@href='/my/results']" position="after">
<a href="/my/timetable" class="sidebar-link">
<i class="fa fa-calendar"/><span>My Timetable</span>
</a>
</xpath>
</template>
</odoo>

259
append_tests 2.py Normal file
View File

@ -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'])
''')

259
append_tests.py Normal file
View File

@ -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'])
''')

16
debug_domain 2.py Normal file
View File

@ -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)

16
debug_domain.py Normal file
View File

@ -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)

48
fix_all_perms 2.py Normal file
View File

@ -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 </field>\n <field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
# and adding perm_read="True" after it.
def replace_perms(match):
return match.group(0) + '\n <field name="perm_read" eval="True"/>\n <field name="perm_write" eval="False"/>\n <field name="perm_create" eval="False"/>\n <field name="perm_unlink" eval="False"/>'
# First, remove existing perms to avoid duplicates
text = re.sub(r' *<field name="perm_(read|write|create|unlink)".*?/>\n', '', text)
# Now add default perms back to Faculty
text = re.sub(
r'(<field name="groups" eval="\[\(4, ref\(\'group_education_lms_faculty\'\)\)\]"/>)',
r'\1\n <field name="perm_read" eval="True"/>\n <field name="perm_write" eval="True"/>\n <field name="perm_create" eval="True"/>\n <field name="perm_unlink" eval="True"/>',
text
)
# And Admin
text = re.sub(
r'(<field name="groups" eval="\[\(4, ref\(\'group_education_lms_admin\'\)\)\]"/>)',
r'\1\n <field name="perm_read" eval="True"/>\n <field name="perm_write" eval="True"/>\n <field name="perm_create" eval="True"/>\n <field name="perm_unlink" eval="True"/>',
text
)
# And Student (Read Only except for submission)
text = re.sub(
r'(<record id="rule_lms_.*?_student" model="ir.rule">.*?<field name="groups" eval="\[\(4, ref\(\'group_education_lms_student\'\)\)\]"/>)',
r'\1\n <field name="perm_read" eval="True"/>\n <field name="perm_write" eval="False"/>\n <field name="perm_create" eval="False"/>\n <field name="perm_unlink" eval="False"/>',
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'(<record id="rule_lms_submission_student" model="ir.rule">.*?)<field name="perm_write" eval="False"/>\n <field name="perm_create" eval="False"/>\n <field name="perm_unlink" eval="False"/>',
r'\1<field name="perm_write" eval="True"/>\n <field name="perm_create" eval="True"/>\n <field name="perm_unlink" eval="False"/>',
text,
flags=re.DOTALL
)
with open('addons/education_lms/security/security.xml', 'w') as f:
f.write(text)

48
fix_all_perms.py Normal file
View File

@ -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 </field>\n <field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
# and adding perm_read="True" after it.
def replace_perms(match):
return match.group(0) + '\n <field name="perm_read" eval="True"/>\n <field name="perm_write" eval="False"/>\n <field name="perm_create" eval="False"/>\n <field name="perm_unlink" eval="False"/>'
# First, remove existing perms to avoid duplicates
text = re.sub(r' *<field name="perm_(read|write|create|unlink)".*?/>\n', '', text)
# Now add default perms back to Faculty
text = re.sub(
r'(<field name="groups" eval="\[\(4, ref\(\'group_education_lms_faculty\'\)\)\]"/>)',
r'\1\n <field name="perm_read" eval="True"/>\n <field name="perm_write" eval="True"/>\n <field name="perm_create" eval="True"/>\n <field name="perm_unlink" eval="True"/>',
text
)
# And Admin
text = re.sub(
r'(<field name="groups" eval="\[\(4, ref\(\'group_education_lms_admin\'\)\)\]"/>)',
r'\1\n <field name="perm_read" eval="True"/>\n <field name="perm_write" eval="True"/>\n <field name="perm_create" eval="True"/>\n <field name="perm_unlink" eval="True"/>',
text
)
# And Student (Read Only except for submission)
text = re.sub(
r'(<record id="rule_lms_.*?_student" model="ir.rule">.*?<field name="groups" eval="\[\(4, ref\(\'group_education_lms_student\'\)\)\]"/>)',
r'\1\n <field name="perm_read" eval="True"/>\n <field name="perm_write" eval="False"/>\n <field name="perm_create" eval="False"/>\n <field name="perm_unlink" eval="False"/>',
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'(<record id="rule_lms_submission_student" model="ir.rule">.*?)<field name="perm_write" eval="False"/>\n <field name="perm_create" eval="False"/>\n <field name="perm_unlink" eval="False"/>',
r'\1<field name="perm_write" eval="True"/>\n <field name="perm_create" eval="True"/>\n <field name="perm_unlink" eval="False"/>',
text,
flags=re.DOTALL
)
with open('addons/education_lms/security/security.xml', 'w') as f:
f.write(text)

13
fix_mapped 2.py Normal file
View File

@ -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)

13
fix_mapped.py Normal file
View File

@ -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)

33
fix_perms 2.py Normal file
View File

@ -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 = """<record id="rule_lms_course_student" model="ir.rule">
<field name="name">Student Course Access</field>
<field name="model_id" ref="model_education_lms_course"/>
<field name="domain_force">[
('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),
]</field>
<field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>"""
text = re.sub(
r'<record id="rule_lms_course_student" model="ir.rule">.*?</record>',
new_course_rule,
text,
flags=re.DOTALL
)
with open('addons/education_lms/security/security.xml', 'w') as f:
f.write(text)

33
fix_perms.py Normal file
View File

@ -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 = """<record id="rule_lms_course_student" model="ir.rule">
<field name="name">Student Course Access</field>
<field name="model_id" ref="model_education_lms_course"/>
<field name="domain_force">[
('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),
]</field>
<field name="groups" eval="[(4, ref('group_education_lms_student'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>"""
text = re.sub(
r'<record id="rule_lms_course_student" model="ir.rule">.*?</record>',
new_course_rule,
text,
flags=re.DOTALL
)
with open('addons/education_lms/security/security.xml', 'w') as f:
f.write(text)

24
fix_security 2.py Normal file
View File

@ -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'<record id="rule_lms_course_student" model="ir.rule">.*?<field name="domain_force">\[.*?\]</field>',
"""<record id="rule_lms_course_student" model="ir.rule">
<field name="name">Student Course Access</field>
<field name="model_id" ref="model_education_lms_course"/>
<field name="domain_force">[
('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),
]</field>""",
text,
flags=re.DOTALL
)
with open('addons/education_lms/security/security.xml', 'w') as f:
f.write(text)

24
fix_security.py Normal file
View File

@ -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'<record id="rule_lms_course_student" model="ir.rule">.*?<field name="domain_force">\[.*?\]</field>',
"""<record id="rule_lms_course_student" model="ir.rule">
<field name="name">Student Course Access</field>
<field name="model_id" ref="model_education_lms_course"/>
<field name="domain_force">[
('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),
]</field>""",
text,
flags=re.DOTALL
)
with open('addons/education_lms/security/security.xml', 'w') as f:
f.write(text)

18
fix_security2 2.py Normal file
View File

@ -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)

18
fix_security2.py Normal file
View File

@ -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)

10
fix_state 2.py Normal file
View File

@ -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)

10
fix_state.py Normal file
View File

@ -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)

View File

@ -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)

10
fix_student_user_type.py Normal file
View File

@ -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)

9
fix_test 2.py Normal file
View File

@ -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)

9
fix_test.py Normal file
View File

@ -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)

37
fix_test_users 2.py Normal file
View File

@ -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)

37
fix_test_users.py Normal file
View File

@ -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)

Some files were not shown because too many files have changed in this diff Show More