feat(education_exam): implement marks entry, grading, and secure result publication
This commit is contained in:
parent
69d69f0420
commit
2b4b4aead1
@ -1,2 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizard
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
# 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):
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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!'
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
@ -17,14 +17,56 @@
|
||||
<field name="name">Administrator</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_education_exam_teacher'))]"/>
|
||||
</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>
|
||||
|
||||
1
addons/education_exam/tests/__init__.py
Normal file
1
addons/education_exam/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import test_exam_workflow
|
||||
439
addons/education_exam/tests/test_exam_workflow.py
Normal file
439
addons/education_exam/tests/test_exam_workflow.py
Normal 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()
|
||||
@ -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>
|
||||
|
||||
@ -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 -->
|
||||
|
||||
1
addons/education_exam/wizard/__init__.py
Normal file
1
addons/education_exam/wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import marks_entry_wizard
|
||||
152
addons/education_exam/wizard/marks_entry_wizard.py
Normal file
152
addons/education_exam/wizard/marks_entry_wizard.py
Normal 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')
|
||||
49
addons/education_exam/wizard/marks_entry_wizard_views.xml
Normal file
49
addons/education_exam/wizard/marks_entry_wizard_views.xml
Normal 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 & 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>
|
||||
Loading…
x
Reference in New Issue
Block a user