diff --git a/addons/education_exam/__init__.py b/addons/education_exam/__init__.py index f7209b1..48d9904 100644 --- a/addons/education_exam/__init__.py +++ b/addons/education_exam/__init__.py @@ -1,2 +1,3 @@ from . import models from . import controllers +from . import wizard diff --git a/addons/education_exam/__manifest__.py b/addons/education_exam/__manifest__.py index f45b400..95ae249 100644 --- a/addons/education_exam/__manifest__.py +++ b/addons/education_exam/__manifest__.py @@ -24,6 +24,7 @@ 'views/education_exam_result_views.xml', 'views/education_student_inherit_views.xml', 'views/portal_exam_templates.xml', + 'wizard/marks_entry_wizard_views.xml', 'views/menu_views.xml', ], 'installable': True, diff --git a/addons/education_exam/models/education_exam.py b/addons/education_exam/models/education_exam.py index 5b9d157..6c44713 100644 --- a/addons/education_exam/models/education_exam.py +++ b/addons/education_exam/models/education_exam.py @@ -22,7 +22,7 @@ class EducationExam(models.Model): ('ongoing', 'Ongoing'), ('completed', 'Completed'), ('results_published', 'Results Published') - ], string='Status', required=True, default='draft', tracking=True) + ], string='Status', required=True, default='draft') result_published_date = fields.Date(string='Result Published Date') active = fields.Boolean(default=True) @@ -67,10 +67,11 @@ class EducationExam(models.Model): rec.state = 'completed' def action_generate_results(self): - """Generates empty result records for all students enrolled in the program and term.""" + """Generates result records idempotently for all students enrolled in the program and term. + If results already exist, preserves previously entered marks and only creates missing lines.""" for rec in self: - if rec.state != 'completed': - raise ValidationError("Results can only be generated for completed exams.") + if rec.state not in ('ongoing', 'completed'): + raise ValidationError("Results can only be generated for ongoing or completed exams.") students = self.env['education.student'].search([ ('program_id', '=', rec.program_id.id), ('academic_year_id', '=', rec.academic_year_id.id), @@ -82,54 +83,110 @@ class EducationExam(models.Model): raise ValidationError("No active/enrolled students found for this Program, Academic Year, and Semester.") for student in students: - # Check if result already exists existing = self.env['education.exam.result'].search([ ('student_id', '=', student.id), ('exam_id', '=', rec.id) - ]) + ], limit=1) + if not existing: - result = self.env['education.exam.result'].create({ + existing = self.env['education.exam.result'].create({ 'student_id': student.id, 'exam_id': rec.id, }) - # Create result lines based on schedule - for line in rec.line_ids: + + # Idempotently ensure all scheduled subjects exist as result lines without overwriting existing marks + existing_subjects = existing.line_ids.mapped('subject_id.id') + for line in rec.line_ids: + if line.subject_id.id not in existing_subjects: self.env['education.exam.result.line'].create({ - 'result_id': result.id, + 'result_id': existing.id, 'subject_id': line.subject_id.id, 'exam_type_id': rec.exam_type_id.id, 'max_marks': line.max_marks, 'pass_marks': line.pass_marks, + 'obtained_marks': -1.0, }) def action_publish_results(self): + """Publishes results after verifying all marks are entered and valid.""" for rec in self: - # Validations before publishing if not rec.result_ids: - raise ValidationError("No results generated yet.") + raise ValidationError("No results generated yet. Please generate results first.") for result in rec.result_ids: if not result.line_ids: - raise ValidationError(f"Result for student {result.student_id.name} has no subjects.") - if result.result_status == 'pending': - raise ValidationError(f"Cannot publish results: Student {result.student_id.name} has pending evaluations.") + raise ValidationError(f"Result for student {result.student_id.full_name} has no subjects.") for line in result.line_ids: if line.obtained_marks < 0: - raise ValidationError(f"Marks not entered for {line.subject_id.name} for student {result.student_id.name}.") + raise ValidationError( + f"Marks not entered for subject '{line.subject_id.name}' for student '{result.student_id.full_name}'. " + "All student marks must be entered before results can be published." + ) if line.obtained_marks > line.max_marks: - raise ValidationError(f"Obtained marks cannot exceed maximum marks for {line.subject_id.name}.") + raise ValidationError( + f"Obtained marks ({line.obtained_marks}) cannot exceed maximum marks ({line.max_marks}) " + f"for '{line.subject_id.name}' for student '{result.student_id.full_name}'." + ) + if result.result_status == 'pending': + raise ValidationError( + f"Cannot publish results: Student '{result.student_id.full_name}' has pending evaluations." + ) rec.write({ 'state': 'results_published', 'result_published_date': fields.Date.today() }) - # Update publication status on results rec.result_ids.write({ 'published': True, 'published_date': fields.Date.today() }) + def action_unpublish_results(self): + """Allows Exam Administrator to unpublish results for audited corrections.""" + if not self.env.user.has_group('education_exam.group_education_exam_administrator'): + raise ValidationError("Only an Examination Administrator can unpublish exam results.") + for rec in self: + rec.write({'state': 'completed'}) + rec.result_ids.write({'published': False}) + + def action_open_marks_entry(self): + """Opens the subject-wise marks entry wizard for this exam.""" + self.ensure_one() + return { + 'name': 'Subject-wise Marks Entry', + 'type': 'ir.actions.act_window', + 'res_model': 'education.exam.marks.entry.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_exam_id': self.id, + }, + } + def action_reset_draft(self): - self.write({'state': 'draft'}) + for rec in self: + if rec.state == 'results_published': + raise ValidationError("Cannot reset published exams to draft. Unpublish results first.") + rec.write({'state': 'draft'}) + + def write(self, vals): + for rec in self: + if rec.state == 'results_published': + locked_fields = {'program_id', 'term_id', 'academic_year_id', 'grading_system_id', 'line_ids'} + if any(f in vals for f in locked_fields): + raise ValidationError( + f"Exam '{rec.name}' has published results and cannot be modified. " + "An Examination Administrator must unpublish results first." + ) + return super().write(vals) + + def unlink(self): + for rec in self: + if rec.state == 'results_published': + raise ValidationError( + f"Cannot delete exam '{rec.name}' because its results are published. " + "Unpublish results first." + ) + return super().unlink() @api.constrains('start_date', 'end_date') def _check_exam_dates(self): diff --git a/addons/education_exam/models/education_exam_result.py b/addons/education_exam/models/education_exam_result.py index 81e6f60..cfe3c72 100644 --- a/addons/education_exam/models/education_exam_result.py +++ b/addons/education_exam/models/education_exam_result.py @@ -20,14 +20,39 @@ class EducationExamResult(models.Model): ('fail', 'Fail') ], string='Result Status', compute='_compute_totals', store=True, default='pending') - published = fields.Boolean(string='Published', default=False, tracking=True) + published = fields.Boolean(string='Published', default=False) published_date = fields.Date(string='Published Date') line_ids = fields.One2many('education.exam.result.line', 'result_id', string='Subject Marks') - _sql_constraints = [ - ('unique_student_exam', 'unique(student_id, exam_id)', 'A result already exists for this student for this exam!') - ] + _unique_student_exam = models.Constraint( + 'unique(student_id, exam_id)', + 'A result already exists for this student for this exam!' + ) + + def write(self, vals): + for rec in self: + if rec.published: + if 'published' in vals and not vals['published']: + if not self.env.user.has_group('education_exam.group_education_exam_administrator'): + raise ValidationError("Only an Examination Administrator can unpublish exam results.") + else: + locked_fields = {'student_id', 'exam_id', 'line_ids'} + if any(f in vals for f in locked_fields): + raise ValidationError( + f"Results for student '{rec.student_id.full_name}' are published and locked. " + "An Examination Administrator must unpublish results first." + ) + return super().write(vals) + + def unlink(self): + for rec in self: + if rec.published: + raise ValidationError( + f"Cannot delete published results for student '{rec.student_id.full_name}'. " + "Unpublish results first." + ) + return super().unlink() @api.depends('line_ids.max_marks', 'line_ids.obtained_marks', 'line_ids.status', 'exam_id.grading_system_id') def _compute_totals(self): @@ -86,14 +111,45 @@ class EducationExamResultLine(models.Model): remarks = fields.Char(string='Remarks') - @api.constrains('obtained_marks', 'max_marks') + _unique_result_subject = models.Constraint( + 'unique(result_id, subject_id)', + 'A mark entry for this subject already exists in this exam result!' + ) + + def write(self, vals): + for rec in self: + if rec.result_id.published: + raise ValidationError( + f"Cannot modify marks for subject '{rec.subject_id.name}' because the exam results are published and locked. " + "An Examination Administrator must unpublish results first." + ) + return super().write(vals) + + def unlink(self): + for rec in self: + if rec.result_id.published: + raise ValidationError( + f"Cannot delete marks for subject '{rec.subject_id.name}' because the exam results are published." + ) + return super().unlink() + + @api.constrains('obtained_marks', 'max_marks', 'pass_marks') def _check_marks(self): for record in self: - if record.obtained_marks > record.max_marks: - raise ValidationError(f"Obtained marks cannot exceed maximum marks for {record.subject_id.name}.") + if record.max_marks <= 0: + raise ValidationError("Maximum marks must be greater than 0.") + if record.pass_marks < 0: + raise ValidationError("Pass marks cannot be negative.") + if record.pass_marks > record.max_marks: + raise ValidationError("Pass marks cannot exceed maximum marks.") # Using -1 to indicate not entered yet. So < -1 is invalid. if record.obtained_marks < -1.0: - raise ValidationError("Obtained marks cannot be negative.") + raise ValidationError("Obtained marks cannot be negative (use -1 for pending).") + if record.obtained_marks > record.max_marks: + raise ValidationError( + f"Obtained marks ({record.obtained_marks}) cannot exceed maximum marks ({record.max_marks}) " + f"for {record.subject_id.name}." + ) @api.depends('obtained_marks', 'max_marks', 'pass_marks', 'result_id.exam_id.grading_system_id') def _compute_grade_details(self): @@ -113,7 +169,6 @@ class EducationExamResultLine(models.Model): if grade: rec.grade_id = grade.id rec.grade_point = grade.grade_point - # Could override status based on grade if required, but usually marks determine subject pass/fail else: rec.grade_id = False rec.grade_point = 0.0 @@ -124,4 +179,4 @@ class EducationExamResultLine(models.Model): rec.percentage = 0.0 rec.grade_id = False rec.grade_point = 0.0 - rec.status = 'pending' # default to pending until marks are entered + rec.status = 'pending' diff --git a/addons/education_exam/models/education_subject.py b/addons/education_exam/models/education_subject.py index f957059..e3b969a 100644 --- a/addons/education_exam/models/education_subject.py +++ b/addons/education_exam/models/education_subject.py @@ -17,6 +17,7 @@ class EducationSubject(models.Model): ], string='Subject Type', required=True, default='theory') active = fields.Boolean(default=True) - _sql_constraints = [ - ('code_unique', 'unique(code)', 'Subject code must be unique!') - ] + _code_unique = models.Constraint( + 'unique(code)', + 'Subject code must be unique!' + ) diff --git a/addons/education_exam/security/ir.model.access.csv b/addons/education_exam/security/ir.model.access.csv index 2fb0de6..2179403 100644 --- a/addons/education_exam/security/ir.model.access.csv +++ b/addons/education_exam/security/ir.model.access.csv @@ -1,17 +1,29 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_education_subject_admin,education.subject.admin,model_education_subject,group_education_exam_administrator,1,1,1,1 access_education_subject_teacher,education.subject.teacher,model_education_subject,group_education_exam_teacher,1,0,0,0 +access_education_subject_portal,education.subject.portal,model_education_subject,base.group_portal,1,0,0,0 access_education_exam_type_admin,education.exam.type.admin,model_education_exam_type,group_education_exam_administrator,1,1,1,1 access_education_exam_type_teacher,education.exam.type.teacher,model_education_exam_type,group_education_exam_teacher,1,0,0,0 +access_education_exam_type_portal,education.exam.type.portal,model_education_exam_type,base.group_portal,1,0,0,0 access_education_grading_system_admin,education.grading.system.admin,model_education_grading_system,group_education_exam_administrator,1,1,1,1 access_education_grading_system_teacher,education.grading.system.teacher,model_education_grading_system,group_education_exam_teacher,1,0,0,0 +access_education_grading_system_portal,education.grading.system.portal,model_education_grading_system,base.group_portal,1,0,0,0 access_education_grade_admin,education.grade.admin,model_education_grade,group_education_exam_administrator,1,1,1,1 access_education_grade_teacher,education.grade.teacher,model_education_grade,group_education_exam_teacher,1,0,0,0 +access_education_grade_portal,education.grade.portal,model_education_grade,base.group_portal,1,0,0,0 access_education_exam_admin,education.exam.admin,model_education_exam,group_education_exam_administrator,1,1,1,1 access_education_exam_teacher,education.exam.teacher,model_education_exam,group_education_exam_teacher,1,0,0,0 +access_education_exam_portal,education.exam.portal,model_education_exam,base.group_portal,1,0,0,0 access_education_exam_line_admin,education.exam.line.admin,model_education_exam_line,group_education_exam_administrator,1,1,1,1 access_education_exam_line_teacher,education.exam.line.teacher,model_education_exam_line,group_education_exam_teacher,1,0,0,0 +access_education_exam_line_portal,education.exam.line.portal,model_education_exam_line,base.group_portal,1,0,0,0 access_education_exam_result_admin,education.exam.result.admin,model_education_exam_result,group_education_exam_administrator,1,1,1,1 access_education_exam_result_teacher,education.exam.result.teacher,model_education_exam_result,group_education_exam_teacher,1,1,0,0 +access_education_exam_result_portal,education.exam.result.portal,model_education_exam_result,base.group_portal,1,0,0,0 access_education_exam_result_line_admin,education.exam.result.line.admin,model_education_exam_result_line,group_education_exam_administrator,1,1,1,1 access_education_exam_result_line_teacher,education.exam.result.line.teacher,model_education_exam_result_line,group_education_exam_teacher,1,1,0,0 +access_education_exam_result_line_portal,education.exam.result.line.portal,model_education_exam_result_line,base.group_portal,1,0,0,0 +access_education_exam_marks_entry_wizard_admin,education.exam.marks.entry.wizard.admin,model_education_exam_marks_entry_wizard,group_education_exam_administrator,1,1,1,1 +access_education_exam_marks_entry_wizard_teacher,education.exam.marks.entry.wizard.teacher,model_education_exam_marks_entry_wizard,group_education_exam_teacher,1,1,1,1 +access_education_exam_marks_entry_wizard_line_admin,education.exam.marks.entry.wizard.line.admin,model_education_exam_marks_entry_wizard_line,group_education_exam_administrator,1,1,1,1 +access_education_exam_marks_entry_wizard_line_teacher,education.exam.marks.entry.wizard.line.teacher,model_education_exam_marks_entry_wizard_line,group_education_exam_teacher,1,1,1,1 diff --git a/addons/education_exam/security/security.xml b/addons/education_exam/security/security.xml index ef88408..7977e49 100644 --- a/addons/education_exam/security/security.xml +++ b/addons/education_exam/security/security.xml @@ -16,15 +16,57 @@ Administrator - + + - - + + Teacher Exam Result Rule [(1, '=', 1)] + + + Teacher Exam Result Line Rule + + [(1, '=', 1)] + + + + + + Portal Student Exam View + + [('state', 'in', ['scheduled', 'ongoing', 'completed', 'results_published'])] + + + + + + + + + Portal Student Exam Result Isolation + + [('student_id.portal_user_id', '=', user.id), ('published', '=', True)] + + + + + + + + + Portal Student Result Line Isolation + + [('result_id.student_id.portal_user_id', '=', user.id), ('result_id.published', '=', True)] + + + + + + diff --git a/addons/education_exam/tests/__init__.py b/addons/education_exam/tests/__init__.py new file mode 100644 index 0000000..7061cc2 --- /dev/null +++ b/addons/education_exam/tests/__init__.py @@ -0,0 +1 @@ +from . import test_exam_workflow diff --git a/addons/education_exam/tests/test_exam_workflow.py b/addons/education_exam/tests/test_exam_workflow.py new file mode 100644 index 0000000..a60494d --- /dev/null +++ b/addons/education_exam/tests/test_exam_workflow.py @@ -0,0 +1,439 @@ +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.exceptions import ValidationError, AccessError + + +class TestEducationExamWorkflow(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + + # Setup Academic Foundation + cls.campus = cls.env['education.campus'].create({ + 'name': 'Exam Test Campus', + 'code': 'EX-CAMP', + 'company_id': cls.company.id, + }) + cls.level = cls.env['education.academic.level'].create({ + 'name': 'Undergraduate', + 'code': 'UG-EX', + 'company_id': cls.company.id, + }) + cls.department = cls.env['education.department'].create({ + 'name': 'Engineering', + 'code': 'ENG-EX', + 'campus_id': cls.campus.id, + }) + cls.academic_year = cls.env['education.academic.year'].create({ + 'name': '2026-2027', + 'code': 'AY2026', + 'start_date': '2026-06-01', + 'end_date': '2027-05-31', + 'company_id': cls.company.id, + }) + cls.academic_term = cls.env['education.academic.term'].create({ + 'name': 'Term 1', + 'code': 'T1-2026', + 'academic_year_id': cls.academic_year.id, + 'start_date': '2026-06-01', + 'end_date': '2026-10-31', + 'sequence': 1, + 'company_id': cls.company.id, + }) + cls.program = cls.env['education.program'].create({ + 'name': 'B.E. Computer Engineering', + 'code': 'BE-CE', + 'level_id': cls.level.id, + 'department_id': cls.department.id, + 'duration': 4, + 'duration_unit': 'years', + 'company_id': cls.company.id, + }) + cls.batch = cls.env['education.batch'].create({ + 'name': 'Exam Test Batch 2026', + 'code': 'EXT-B2026', + }) + cls.semester = cls.env['education.semester'].create({ + 'name': 'Exam Test Semester 1', + 'code': 'EXT-SEM1', + }) + cls.section = cls.env['education.section'].create({ + 'name': 'Exam Test Section A', + 'code': 'EXT-SEC-A', + }) + + # Users and Security Groups + cls.group_teacher = cls.env.ref('education_exam.group_education_exam_teacher') + cls.group_admin = cls.env.ref('education_exam.group_education_exam_administrator') + cls.group_portal = cls.env.ref('base.group_portal') + + cls.user_teacher_1 = cls.env['res.users'].create({ + 'name': 'Teacher Math', + 'login': 'teacher.math', + 'email': 'teacher.math@example.com', + 'group_ids': [(6, 0, [cls.group_teacher.id])], + }) + cls.user_teacher_2 = cls.env['res.users'].create({ + 'name': 'Teacher Physics', + 'login': 'teacher.physics', + 'email': 'teacher.physics@example.com', + 'group_ids': [(6, 0, [cls.group_teacher.id])], + }) + cls.user_admin = cls.env['res.users'].create({ + 'name': 'Exam Controller Admin', + 'login': 'exam.admin', + 'email': 'exam.admin@example.com', + 'group_ids': [(6, 0, [cls.group_admin.id])], + }) + + cls.user_portal_1 = cls.env['res.users'].create({ + 'name': 'Student One Portal User', + 'login': 'student1.portal', + 'email': 'student1@example.com', + 'group_ids': [(6, 0, [cls.group_portal.id])], + }) + cls.user_portal_2 = cls.env['res.users'].create({ + 'name': 'Student Two Portal User', + 'login': 'student2.portal', + 'email': 'student2@example.com', + 'group_ids': [(6, 0, [cls.group_portal.id])], + }) + + # Students (Active) + cls.student_1 = cls.env['education.student'].create({ + 'first_name': 'Aarav', + 'last_name': 'Kumar', + 'student_id': 'STD-001', + '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.academic_term.id, + 'batch_id': cls.batch.id, + 'semester_id': cls.semester.id, + 'section_id': cls.section.id, + 'company_id': cls.company.id, + 'portal_user_id': cls.user_portal_1.id, + }) + cls.student_1.action_admit() + cls.student_1.action_activate() + + cls.student_2 = cls.env['education.student'].create({ + 'first_name': 'Diya', + 'last_name': 'Patel', + 'student_id': 'STD-002', + '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.academic_term.id, + 'batch_id': cls.batch.id, + 'semester_id': cls.semester.id, + 'section_id': cls.section.id, + 'company_id': cls.company.id, + 'portal_user_id': cls.user_portal_2.id, + }) + cls.student_2.action_admit() + cls.student_2.action_activate() + + # Subjects + cls.subject_math = cls.env['education.subject'].create({ + 'name': 'Mathematics I', + 'code': 'MATH101-EXT', + 'program_id': cls.program.id, + 'academic_year_id': cls.academic_year.id, + 'term_id': cls.academic_term.id, + 'credits': 4.0, + 'subject_type': 'theory', + }) + cls.subject_physics = cls.env['education.subject'].create({ + 'name': 'Physics I', + 'code': 'PHYS101-EXT', + 'program_id': cls.program.id, + 'academic_year_id': cls.academic_year.id, + 'term_id': cls.academic_term.id, + 'credits': 4.0, + 'subject_type': 'both', + }) + + # Grading System and Exam Type + cls.grading_system = cls.env.ref('education_exam.default_grading_system') + cls.exam_type = cls.env['education.exam.type'].create({ + 'name': 'Semester Final Examination Test', + 'code': 'SEM_FINAL_EXT', + 'weightage': 100.0, + }) + + def _create_standard_exam(self): + """Helper to create an ongoing exam with 2 subjects.""" + today = date.today() + exam = self.env['education.exam'].create({ + 'name': 'Fall 2026 Semester Exams', + 'code': 'EX-FALL-2026', + 'exam_type_id': self.exam_type.id, + 'academic_year_id': self.academic_year.id, + 'program_id': self.program.id, + 'term_id': self.academic_term.id, + 'grading_system_id': self.grading_system.id, + 'start_date': today - timedelta(days=5), + 'end_date': today + timedelta(days=5), + 'line_ids': [ + (0, 0, { + 'subject_id': self.subject_math.id, + 'exam_date': today - timedelta(days=1), + 'start_time': 9.0, + 'end_time': 12.0, + 'max_marks': 100.0, + 'pass_marks': 40.0, + 'faculty_id': self.user_teacher_1.id, + }), + (0, 0, { + 'subject_id': self.subject_physics.id, + 'exam_date': today, + 'start_time': 0.0, + 'end_time': 1.0, + 'max_marks': 100.0, + 'pass_marks': 40.0, + 'faculty_id': self.user_teacher_2.id, + }), + ] + }) + exam.action_schedule() + exam.action_start() + return exam + + def test_01_lifecycle_transitions(self): + """Test full exam lifecycle transitions: draft -> scheduled -> ongoing -> completed.""" + today = date.today() + exam = self.env['education.exam'].create({ + 'name': 'Lifecycle Exam', + 'code': 'EX-LIFECYCLE', + 'exam_type_id': self.exam_type.id, + 'academic_year_id': self.academic_year.id, + 'program_id': self.program.id, + 'term_id': self.academic_term.id, + 'grading_system_id': self.grading_system.id, + 'start_date': today - timedelta(days=5), + 'end_date': today + timedelta(days=5), + 'line_ids': [ + (0, 0, { + 'subject_id': self.subject_math.id, + 'exam_date': today - timedelta(days=1), + 'start_time': 9.0, + 'end_time': 12.0, + 'max_marks': 100.0, + 'pass_marks': 40.0, + 'faculty_id': self.user_teacher_1.id, + }), + ] + }) + self.assertEqual(exam.state, 'draft') + exam.action_schedule() + self.assertEqual(exam.state, 'scheduled') + exam.action_start() + self.assertEqual(exam.state, 'ongoing') + exam.action_complete() + self.assertEqual(exam.state, 'completed') + + def test_02_idempotent_result_generation(self): + """Calling action_generate_results multiple times must not duplicate results or wipe marks.""" + exam = self._create_standard_exam() + exam.action_complete() + + # First run: generates results for student_1 and student_2 + exam.action_generate_results() + self.assertEqual(len(exam.result_ids), 2) + res_s1 = exam.result_ids.filtered(lambda r: r.student_id == self.student_1) + self.assertEqual(len(res_s1.line_ids), 2) + + # Enter mark for Math for Student 1 + math_line = res_s1.line_ids.filtered(lambda l: l.subject_id == self.subject_math) + math_line.obtained_marks = 85.0 + self.assertEqual(math_line.obtained_marks, 85.0) + + # Second run: must preserve the 85.0 marks and not create duplicate results or lines + exam.action_generate_results() + self.assertEqual(len(exam.result_ids), 2) + res_s1_check = exam.result_ids.filtered(lambda r: r.student_id == self.student_1) + self.assertEqual(len(res_s1_check.line_ids), 2) + math_line_check = res_s1_check.line_ids.filtered(lambda l: l.subject_id == self.subject_math) + self.assertEqual(math_line_check.obtained_marks, 85.0) + + def test_03_marks_validation(self): + """Marks must be validated against boundaries (-1 for pending, 0 <= marks <= max_marks).""" + exam = self._create_standard_exam() + exam.action_generate_results() + result_line = exam.result_ids[0].line_ids[0] + + # Negative mark < -1 should raise ValidationError + with self.assertRaises(ValidationError): + result_line.obtained_marks = -5.0 + + # Mark > max_marks should raise ValidationError + with self.assertRaises(ValidationError): + result_line.obtained_marks = 105.0 + + # Valid pending mark (-1.0) and valid mark (75.0) should succeed + result_line.obtained_marks = -1.0 + self.assertEqual(result_line.obtained_marks, -1.0) + result_line.obtained_marks = 75.0 + self.assertEqual(result_line.obtained_marks, 75.0) + + def test_04_grade_calculation(self): + """Verify grade and percentage calculation using the configured grading system.""" + exam = self._create_standard_exam() + exam.action_generate_results() + res_s1 = exam.result_ids.filtered(lambda r: r.student_id == self.student_1) + + line_math = res_s1.line_ids.filtered(lambda l: l.subject_id == self.subject_math) + line_physics = res_s1.line_ids.filtered(lambda l: l.subject_id == self.subject_physics) + + # Math: 92/100 -> Grade O (Pass) + line_math.obtained_marks = 92.0 + self.assertEqual(line_math.status, 'pass') + self.assertEqual(line_math.grade_id.name, 'O') + self.assertEqual(line_math.grade_point, 10.0) + + # Physics: 35/100 -> Grade F / Fail (< 40 pass marks) + line_physics.obtained_marks = 35.0 + self.assertEqual(line_physics.status, 'fail') + + # Overall Result Status should be Fail since one subject failed + self.assertEqual(res_s1.result_status, 'fail') + self.assertAlmostEqual(res_s1.total_obtained_marks, 127.0) + self.assertAlmostEqual(res_s1.percentage, 63.5) + + # Now pass Physics with 85/100 -> Grade A+ + line_physics.obtained_marks = 85.0 + self.assertEqual(line_physics.status, 'pass') + self.assertEqual(res_s1.result_status, 'pass') + self.assertAlmostEqual(res_s1.percentage, 88.5) + self.assertEqual(res_s1.grade_id.name, 'A+') + + def test_05_duplicate_prevention(self): + """Prevent duplicate exam results for the same student and duplicate subject lines.""" + exam = self._create_standard_exam() + exam.action_generate_results() + + # Duplicate exam result for student 1 + with self.assertRaises(Exception): + self.env['education.exam.result'].create({ + 'student_id': self.student_1.id, + 'exam_id': exam.id, + }) + self.env.flush_all() + + def test_06_publication_locking_and_unpublish(self): + """Results cannot be published with pending marks, and published results are locked against edits.""" + exam = self._create_standard_exam() + exam.action_complete() + exam.action_generate_results() + + # Attempt to publish while marks are pending (-1.0) must fail + with self.assertRaises(ValidationError): + exam.action_publish_results() + + # Fill in all marks + for result in exam.result_ids: + for line in result.line_ids: + line.obtained_marks = 80.0 + + # Now publish should succeed + exam.action_publish_results() + self.assertEqual(exam.state, 'results_published') + self.assertTrue(exam.result_ids[0].published) + + # Direct write to published result line must be blocked + with self.assertRaises(ValidationError): + exam.result_ids[0].line_ids[0].write({'obtained_marks': 95.0}) + + # Direct deletion of published result line must be blocked + with self.assertRaises(ValidationError): + exam.result_ids[0].line_ids[0].unlink() + + # Direct deletion of published exam result must be blocked + with self.assertRaises(ValidationError): + exam.result_ids[0].unlink() + + # Non-admin user cannot unpublish + exam_as_teacher = exam.with_user(self.user_teacher_1) + with self.assertRaises(ValidationError): + exam_as_teacher.action_unpublish_results() + + # Exam Administrator can unpublish for audited corrections + exam_as_admin = exam.with_user(self.user_admin) + exam_as_admin.action_unpublish_results() + self.assertEqual(exam.state, 'completed') + self.assertFalse(exam.result_ids[0].published) + + # Now authorized mark corrections can be made + exam.result_ids[0].line_ids[0].write({'obtained_marks': 95.0}) + self.assertEqual(exam.result_ids[0].line_ids[0].obtained_marks, 95.0) + + def test_07_student_portal_isolation(self): + """Portal student user can ONLY read their own published results.""" + exam = self._create_standard_exam() + exam.action_complete() + exam.action_generate_results() + + for result in exam.result_ids: + for line in result.line_ids: + line.obtained_marks = 78.0 + + # Before publishing, portal user 1 sees NO results + results_p1_before = self.env['education.exam.result'].with_user(self.user_portal_1).search([]) + self.assertEqual(len(results_p1_before), 0) + + # Publish results + exam.action_publish_results() + + # After publishing, portal user 1 sees ONLY their own result + results_p1_after = self.env['education.exam.result'].with_user(self.user_portal_1).search([]) + self.assertEqual(len(results_p1_after), 1) + self.assertEqual(results_p1_after.student_id, self.student_1) + + # Portal user 2 sees ONLY their own result + results_p2_after = self.env['education.exam.result'].with_user(self.user_portal_2).search([]) + self.assertEqual(len(results_p2_after), 1) + self.assertEqual(results_p2_after.student_id, self.student_2) + + def test_08_marks_entry_wizard(self): + """Subject-wise marks entry wizard populates students and enforces teacher assignment.""" + exam = self._create_standard_exam() + exam.action_complete() + exam.action_generate_results() + + # Teacher 1 is assigned to Mathematics + wizard = self.env['education.exam.marks.entry.wizard'].with_user(self.user_teacher_1).create({ + 'exam_id': exam.id, + 'subject_id': self.subject_math.id, + }) + wizard.action_load_students() + self.assertEqual(len(wizard.line_ids), 2) + self.assertEqual(wizard.max_marks, 100.0) + + # Teacher 1 enters marks + for line in wizard.line_ids: + line.obtained_marks = 88.0 + wizard.action_save_marks() + + # Check marks saved in result lines + math_lines = self.env['education.exam.result.line'].search([ + ('result_id.exam_id', '=', exam.id), + ('subject_id', '=', self.subject_math.id), + ]) + for ml in math_lines: + self.assertEqual(ml.obtained_marks, 88.0) + self.assertEqual(ml.status, 'pass') + + # Teacher 1 attempts to enter marks for Physics (assigned to Teacher 2) -> Access Denied + wizard_physics = self.env['education.exam.marks.entry.wizard'].with_user(self.user_teacher_1).create({ + 'exam_id': exam.id, + 'subject_id': self.subject_physics.id, + }) + with self.assertRaises(ValidationError): + wizard_physics.action_save_marks() diff --git a/addons/education_exam/views/education_exam_views.xml b/addons/education_exam/views/education_exam_views.xml index 6ae079c..9edb81d 100644 --- a/addons/education_exam/views/education_exam_views.xml +++ b/addons/education_exam/views/education_exam_views.xml @@ -63,8 +63,10 @@