feat(education_exam): implement marks entry, grading, and secure result publication

This commit is contained in:
Vidhya 2026-10-13 19:50:25 +05:30
parent 69d69f0420
commit 2b4b4aead1
14 changed files with 851 additions and 37 deletions

View File

@ -1,2 +1,3 @@
from . import models
from . import controllers
from . import wizard

View File

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

View File

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

View File

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

View File

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

View File

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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_education_subject_admin education.subject.admin model_education_subject group_education_exam_administrator 1 1 1 1
3 access_education_subject_teacher education.subject.teacher model_education_subject group_education_exam_teacher 1 0 0 0
4 access_education_subject_portal education.subject.portal model_education_subject base.group_portal 1 0 0 0
5 access_education_exam_type_admin education.exam.type.admin model_education_exam_type group_education_exam_administrator 1 1 1 1
6 access_education_exam_type_teacher education.exam.type.teacher model_education_exam_type group_education_exam_teacher 1 0 0 0
7 access_education_exam_type_portal education.exam.type.portal model_education_exam_type base.group_portal 1 0 0 0
8 access_education_grading_system_admin education.grading.system.admin model_education_grading_system group_education_exam_administrator 1 1 1 1
9 access_education_grading_system_teacher education.grading.system.teacher model_education_grading_system group_education_exam_teacher 1 0 0 0
10 access_education_grading_system_portal education.grading.system.portal model_education_grading_system base.group_portal 1 0 0 0
11 access_education_grade_admin education.grade.admin model_education_grade group_education_exam_administrator 1 1 1 1
12 access_education_grade_teacher education.grade.teacher model_education_grade group_education_exam_teacher 1 0 0 0
13 access_education_grade_portal education.grade.portal model_education_grade base.group_portal 1 0 0 0
14 access_education_exam_admin education.exam.admin model_education_exam group_education_exam_administrator 1 1 1 1
15 access_education_exam_teacher education.exam.teacher model_education_exam group_education_exam_teacher 1 0 0 0
16 access_education_exam_portal education.exam.portal model_education_exam base.group_portal 1 0 0 0
17 access_education_exam_line_admin education.exam.line.admin model_education_exam_line group_education_exam_administrator 1 1 1 1
18 access_education_exam_line_teacher education.exam.line.teacher model_education_exam_line group_education_exam_teacher 1 0 0 0
19 access_education_exam_line_portal education.exam.line.portal model_education_exam_line base.group_portal 1 0 0 0
20 access_education_exam_result_admin education.exam.result.admin model_education_exam_result group_education_exam_administrator 1 1 1 1
21 access_education_exam_result_teacher education.exam.result.teacher model_education_exam_result group_education_exam_teacher 1 1 0 0
22 access_education_exam_result_portal education.exam.result.portal model_education_exam_result base.group_portal 1 0 0 0
23 access_education_exam_result_line_admin education.exam.result.line.admin model_education_exam_result_line group_education_exam_administrator 1 1 1 1
24 access_education_exam_result_line_teacher education.exam.result.line.teacher model_education_exam_result_line group_education_exam_teacher 1 1 0 0
25 access_education_exam_result_line_portal education.exam.result.line.portal model_education_exam_result_line base.group_portal 1 0 0 0
26 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
27 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
28 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
29 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

View File

@ -16,15 +16,57 @@
<record id="group_education_exam_administrator" model="res.groups">
<field name="name">Administrator</field>
<field name="implied_ids" eval="[(4, ref('group_education_exam_teacher'))]"/>
</record>
</record>
</data>
<!-- Record Rules -->
<!-- Teachers can see only exams and results where they are assigned (simplified for now, full read access to exams) -->
<data noupdate="0">
<!-- Record Rules: Teacher -->
<record id="rule_education_exam_result_teacher" model="ir.rule">
<field name="name">Teacher Exam Result Rule</field>
<field name="model_id" ref="model_education_exam_result"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_education_exam_teacher'))]"/>
</record>
<record id="rule_education_exam_result_line_teacher" model="ir.rule">
<field name="name">Teacher Exam Result Line Rule</field>
<field name="model_id" ref="model_education_exam_result_line"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_education_exam_teacher'))]"/>
</record>
<!-- Record Rules: Portal (Student Isolation & Publication Enforcement) -->
<record id="rule_education_exam_portal" model="ir.rule">
<field name="name">Portal Student Exam View</field>
<field name="model_id" ref="model_education_exam"/>
<field name="domain_force">[('state', 'in', ['scheduled', 'ongoing', 'completed', 'results_published'])]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<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_education_exam_result_portal" model="ir.rule">
<field name="name">Portal Student Exam Result Isolation</field>
<field name="model_id" ref="model_education_exam_result"/>
<field name="domain_force">[('student_id.portal_user_id', '=', user.id), ('published', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<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_education_exam_result_line_portal" model="ir.rule">
<field name="name">Portal Student Result Line Isolation</field>
<field name="model_id" ref="model_education_exam_result_line"/>
<field name="domain_force">[('result_id.student_id.portal_user_id', '=', user.id), ('result_id.published', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<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>
</data>
</odoo>

View File

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

View File

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

View File

@ -63,8 +63,10 @@
<button name="action_schedule" string="Schedule" type="object" invisible="state != 'draft'" class="oe_highlight"/>
<button name="action_start" string="Start Exam" type="object" invisible="state != 'scheduled'" class="oe_highlight"/>
<button name="action_complete" string="Complete" type="object" invisible="state != 'ongoing'" class="oe_highlight"/>
<button name="action_generate_results" string="Generate Results" type="object" invisible="state != 'completed'" class="oe_highlight"/>
<button name="action_generate_results" string="Generate Results" type="object" invisible="state not in ('ongoing', 'completed')" class="oe_highlight"/>
<button name="action_open_marks_entry" string="Enter Marks" type="object" invisible="state not in ('ongoing', 'completed')" class="oe_highlight"/>
<button name="action_publish_results" string="Publish Results" type="object" invisible="state != 'completed'" class="oe_highlight"/>
<button name="action_unpublish_results" string="Unpublish Results" type="object" groups="education_exam.group_education_exam_administrator" invisible="state != 'results_published'"/>
<button name="action_reset_draft" string="Reset to Draft" type="object" invisible="state not in ('scheduled', 'ongoing', 'completed')"/>
<field name="state" widget="statusbar" statusbar_visible="draft,scheduled,ongoing,completed,results_published"/>
</header>

View File

@ -5,7 +5,8 @@
<!-- Operations -->
<menuitem id="menu_education_exam_exams" name="Exams" parent="menu_education_examinations" action="action_education_exam" sequence="10"/>
<menuitem id="menu_education_marks_entry" name="Marks Entry" parent="menu_education_examinations" action="action_education_marks_entry" sequence="20"/>
<menuitem id="menu_education_marks_entry_wizard" name="Subject Marks Entry" parent="menu_education_examinations" action="action_education_marks_entry_wizard" sequence="20"/>
<menuitem id="menu_education_marks_entry" name="Student Results Entry" parent="menu_education_examinations" action="action_education_marks_entry" sequence="25"/>
<menuitem id="menu_education_exam_results" name="Results" parent="menu_education_examinations" action="action_education_exam_result" sequence="30"/>
<!-- Configuration -->

View File

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

View File

@ -0,0 +1,152 @@
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class EducationExamMarksEntryWizard(models.TransientModel):
_name = 'education.exam.marks.entry.wizard'
_description = 'Subject-wise Marks Entry Wizard'
exam_id = fields.Many2one(
'education.exam',
string='Exam',
required=True,
domain="[('state', 'in', ['ongoing', 'completed'])]"
)
subject_id = fields.Many2one(
'education.subject',
string='Subject',
required=True
)
max_marks = fields.Float(string='Maximum Marks', readonly=True)
pass_marks = fields.Float(string='Pass Marks', readonly=True)
faculty_id = fields.Many2one('res.users', string='Assigned Faculty', readonly=True)
line_ids = fields.One2many(
'education.exam.marks.entry.wizard.line',
'wizard_id',
string='Student Marks'
)
@api.onchange('exam_id')
def _onchange_exam_id(self):
self.subject_id = False
self.line_ids = [(5, 0, 0)]
if self.exam_id:
# Restrict subject domain to subjects scheduled in this exam
subject_ids = self.exam_id.line_ids.mapped('subject_id.id')
return {'domain': {'subject_id': [('id', 'in', subject_ids)]}}
return {'domain': {'subject_id': []}}
@api.onchange('exam_id', 'subject_id')
def _onchange_exam_and_subject(self):
if self.exam_id and self.subject_id:
self._check_teacher_authorization()
self._load_subject_details()
self._load_student_lines()
def _check_teacher_authorization(self):
self.ensure_one()
if not self.env.user.has_group('education_exam.group_education_exam_administrator'):
schedule_line = self.env['education.exam.line'].search([
('exam_id', '=', self.exam_id.id),
('subject_id', '=', self.subject_id.id),
], limit=1)
if schedule_line and schedule_line.faculty_id and schedule_line.faculty_id != self.env.user:
raise ValidationError(
f"Access Denied: You are not authorized to enter marks for subject '{self.subject_id.name}'. "
f"This subject is assigned to {schedule_line.faculty_id.name}."
)
def _load_subject_details(self):
schedule_line = self.env['education.exam.line'].search([
('exam_id', '=', self.exam_id.id),
('subject_id', '=', self.subject_id.id),
], limit=1)
if schedule_line:
self.max_marks = schedule_line.max_marks
self.pass_marks = schedule_line.pass_marks
self.faculty_id = schedule_line.faculty_id.id
else:
self.max_marks = 0.0
self.pass_marks = 0.0
self.faculty_id = False
def _load_student_lines(self):
self.line_ids = [(5, 0, 0)]
result_lines = self.env['education.exam.result.line'].sudo().search([
('result_id.exam_id', '=', self.exam_id.id),
('subject_id', '=', self.subject_id.id),
])
lines = []
for rl in result_lines:
student = rl.result_id.student_id.sudo()
lines.append((0, 0, {
'result_line_id': rl.id,
'student_id': student.id,
'roll_number': student.roll_number or '',
'admission_number': student.admission_number or '',
'obtained_marks': rl.obtained_marks,
'remarks': rl.remarks or '',
}))
self.line_ids = lines
def action_load_students(self):
"""Action button to reload student lines explicitly."""
self.ensure_one()
self._check_teacher_authorization()
self._load_subject_details()
self._load_student_lines()
return {'type': 'ir.actions.do_nothing'}
def action_save_marks(self):
self.ensure_one()
self._check_teacher_authorization()
if self.exam_id.state == 'results_published':
raise ValidationError("Cannot save marks for published exams. Results must be unpublished first.")
if not self.line_ids:
raise ValidationError("No student marks to save. Please select a valid exam and subject, or generate results first.")
for line in self.line_ids:
if line.obtained_marks < -1.0:
raise ValidationError(
f"Marks for student '{line.student_id.full_name}' cannot be negative (use -1 for pending)."
)
if line.obtained_marks > self.max_marks:
raise ValidationError(
f"Marks for student '{line.student_id.full_name}' ({line.obtained_marks}) "
f"cannot exceed maximum marks ({self.max_marks})."
)
if line.result_line_id:
line.result_line_id.write({
'obtained_marks': line.obtained_marks,
'remarks': line.remarks,
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Marks Saved',
'message': f"Marks successfully saved for '{self.subject_id.name}'.",
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
}
}
class EducationExamMarksEntryWizardLine(models.TransientModel):
_name = 'education.exam.marks.entry.wizard.line'
_description = 'Subject-wise Marks Entry Wizard Line'
wizard_id = fields.Many2one(
'education.exam.marks.entry.wizard',
string='Wizard',
required=True,
ondelete='cascade'
)
result_line_id = fields.Many2one('education.exam.result.line', string='Exam Result Line')
student_id = fields.Many2one('education.student', string='Student', required=True)
roll_number = fields.Char(string='Roll No.', readonly=True)
admission_number = fields.Char(string='Admission No.', readonly=True)
obtained_marks = fields.Float(string='Obtained Marks', default=-1.0, required=True)
remarks = fields.Char(string='Remarks')

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_exam_marks_entry_wizard_form" model="ir.ui.view">
<field name="name">education.exam.marks.entry.wizard.form</field>
<field name="model">education.exam.marks.entry.wizard</field>
<field name="arch" type="xml">
<form string="Subject-wise Marks Entry">
<sheet>
<div class="alert alert-info" role="alert">
<strong>Evaluation Instructions:</strong> Enter marks between 0 and the Maximum Marks. Use <strong>-1</strong> to keep a mark as Pending / Un-evaluated.
</div>
<group>
<group string="Exam &amp; Subject Selection">
<field name="exam_id" options="{'no_create': True}"/>
<field name="subject_id" options="{'no_create': True}"/>
</group>
<group string="Subject Benchmark">
<field name="max_marks"/>
<field name="pass_marks"/>
<field name="faculty_id" options="{'no_create': True}"/>
</group>
</group>
<div class="o_horizontal_separator mb-2">Student Mark List</div>
<field name="line_ids">
<list editable="bottom" create="0" delete="0">
<field name="student_id" readonly="1"/>
<field name="roll_number" readonly="1"/>
<field name="admission_number" readonly="1"/>
<field name="obtained_marks"/>
<field name="remarks"/>
</list>
</field>
</sheet>
<footer>
<button name="action_save_marks" string="Save Marks" type="object" class="btn-primary"/>
<button name="action_load_students" string="Reload Students" type="object" class="btn-secondary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_education_marks_entry_wizard" model="ir.actions.act_window">
<field name="name">Subject Marks Entry</field>
<field name="res_model">education.exam.marks.entry.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>