changes on Exam, timetable and lms added
This commit is contained in:
parent
dccaef02f4
commit
6dd327ce3d
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
addons/education_lms/__init__.py
Normal file
2
addons/education_lms/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
31
addons/education_lms/__manifest__.py
Normal file
31
addons/education_lms/__manifest__.py
Normal 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,
|
||||
}
|
||||
BIN
addons/education_lms/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/education_lms/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
1
addons/education_lms/controllers/__init__.py
Normal file
1
addons/education_lms/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
Binary file not shown.
Binary file not shown.
376
addons/education_lms/controllers/main.py
Normal file
376
addons/education_lms/controllers/main.py
Normal 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)
|
||||
4
addons/education_lms/models/__init__.py
Normal file
4
addons/education_lms/models/__init__.py
Normal 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
|
||||
BIN
addons/education_lms/models/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/education_lms/models/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
104
addons/education_lms/models/education_lms_assignment.py
Normal file
104
addons/education_lms/models/education_lms_assignment.py
Normal 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()
|
||||
109
addons/education_lms/models/education_lms_content.py
Normal file
109
addons/education_lms/models/education_lms_content.py
Normal 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.")
|
||||
76
addons/education_lms/models/education_lms_course.py
Normal file
76
addons/education_lms/models/education_lms_course.py
Normal 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()
|
||||
|
||||
197
addons/education_lms/models/education_lms_submission.py
Normal file
197
addons/education_lms/models/education_lms_submission.py
Normal 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()
|
||||
19
addons/education_lms/security/ir.model.access.csv
Normal file
19
addons/education_lms/security/ir.model.access.csv
Normal 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
|
||||
|
257
addons/education_lms/security/security.xml
Normal file
257
addons/education_lms/security/security.xml
Normal 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>
|
||||
2
addons/education_lms/tests/__init__.py
Normal file
2
addons/education_lms/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import test_education_lms
|
||||
from . import test_education_lms_portal
|
||||
BIN
addons/education_lms/tests/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/education_lms/tests/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
1051
addons/education_lms/tests/test_education_lms.py
Normal file
1051
addons/education_lms/tests/test_education_lms.py
Normal file
File diff suppressed because it is too large
Load Diff
14
addons/education_lms/tests/test_education_lms_phase_h.py
Normal file
14
addons/education_lms/tests/test_education_lms_phase_h.py
Normal 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
|
||||
242
addons/education_lms/tests/test_education_lms_portal.py
Normal file
242
addons/education_lms/tests/test_education_lms_portal.py
Normal 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")
|
||||
@ -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>
|
||||
114
addons/education_lms/views/education_lms_content_views.xml
Normal file
114
addons/education_lms/views/education_lms_content_views.xml
Normal 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>
|
||||
120
addons/education_lms/views/education_lms_course_views.xml
Normal file
120
addons/education_lms/views/education_lms_course_views.xml
Normal 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>
|
||||
@ -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>
|
||||
31
addons/education_lms/views/menu_views.xml
Normal file
31
addons/education_lms/views/menu_views.xml
Normal 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>
|
||||
736
addons/education_lms/views/portal_lms_templates.xml
Normal file
736
addons/education_lms/views/portal_lms_templates.xml
Normal 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&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&id={{ student.campus_id.id }}&field=logo" style="max-height: 40px; width: auto;" alt="Logo"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<img t-attf-src="/web/image?model=res.company&id={{ student.company_id.id }}&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>></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>></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>></span>
|
||||
<a t-attf-href="/my/lms/course/{{ course.id }}"><t t-esc="course.name"/></a>
|
||||
<span>></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>></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>></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>></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>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
addons/education_timetable/__init__.py
Normal file
2
addons/education_timetable/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
30
addons/education_timetable/__manifest__.py
Normal file
30
addons/education_timetable/__manifest__.py
Normal 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,
|
||||
}
|
||||
BIN
addons/education_timetable/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/education_timetable/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
1
addons/education_timetable/controllers/__init__.py
Normal file
1
addons/education_timetable/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
Binary file not shown.
Binary file not shown.
113
addons/education_timetable/controllers/main.py
Normal file
113
addons/education_timetable/controllers/main.py
Normal 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)
|
||||
2
addons/education_timetable/models/__init__.py
Normal file
2
addons/education_timetable/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import education_room
|
||||
from . import education_timetable_slot
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
17
addons/education_timetable/models/education_room.py
Normal file
17
addons/education_timetable/models/education_room.py
Normal 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!')
|
||||
]
|
||||
@ -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}.")
|
||||
8
addons/education_timetable/security/ir.model.access.csv
Normal file
8
addons/education_timetable/security/ir.model.access.csv
Normal 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
|
||||
|
21
addons/education_timetable/security/security.xml
Normal file
21
addons/education_timetable/security/security.xml
Normal 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>
|
||||
52
addons/education_timetable/views/education_room_views.xml
Normal file
52
addons/education_timetable/views/education_room_views.xml
Normal 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>
|
||||
@ -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>
|
||||
20
addons/education_timetable/views/menu_views.xml
Normal file
20
addons/education_timetable/views/menu_views.xml
Normal 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>
|
||||
411
addons/education_timetable/views/portal_timetable_templates.xml
Normal file
411
addons/education_timetable/views/portal_timetable_templates.xml
Normal 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&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&id={{ student.campus_id.id }}&field=logo" style="max-height: 40px; width: auto;" alt="Logo"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<img t-attf-src="/web/image?model=res.company&id={{ student.campus_id.company_id.id or student.company_id.id }}&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 & 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>></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
259
append_tests 2.py
Normal 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
259
append_tests.py
Normal 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
16
debug_domain 2.py
Normal 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
16
debug_domain.py
Normal 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
48
fix_all_perms 2.py
Normal 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
48
fix_all_perms.py
Normal 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
13
fix_mapped 2.py
Normal 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
13
fix_mapped.py
Normal 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
33
fix_perms 2.py
Normal 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
33
fix_perms.py
Normal 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
24
fix_security 2.py
Normal 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
24
fix_security.py
Normal 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
18
fix_security2 2.py
Normal 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
18
fix_security2.py
Normal 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
10
fix_state 2.py
Normal 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
10
fix_state.py
Normal 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_student_user_type 2.py
Normal file
10
fix_student_user_type 2.py
Normal 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
10
fix_student_user_type.py
Normal 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
9
fix_test 2.py
Normal 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
9
fix_test.py
Normal 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
37
fix_test_users 2.py
Normal 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
37
fix_test_users.py
Normal 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
Loading…
x
Reference in New Issue
Block a user