251 lines
12 KiB
Python
251 lines
12 KiB
Python
from odoo import models, fields, api
|
|
from odoo.exceptions import ValidationError
|
|
from datetime import datetime, timedelta
|
|
import pytz
|
|
|
|
class EducationExam(models.Model):
|
|
_name = 'education.exam'
|
|
_description = 'Examination'
|
|
|
|
name = fields.Char(string='Name', required=True)
|
|
code = fields.Char(string='Code', required=True)
|
|
academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True)
|
|
program_id = fields.Many2one('education.program', string='Program', required=True)
|
|
term_id = fields.Many2one('education.academic.term', string='Semester/Term', required=True)
|
|
exam_type_id = fields.Many2one('education.exam.type', string='Exam Type', required=True)
|
|
start_date = fields.Date(string='Start Date', required=True)
|
|
end_date = fields.Date(string='End Date', required=True)
|
|
grading_system_id = fields.Many2one('education.grading.system', string='Grading System', required=True)
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('scheduled', 'Scheduled'),
|
|
('ongoing', 'Ongoing'),
|
|
('completed', 'Completed'),
|
|
('results_published', 'Results Published')
|
|
], string='Status', required=True, default='draft')
|
|
result_published_date = fields.Date(string='Result Published Date')
|
|
active = fields.Boolean(default=True)
|
|
|
|
line_ids = fields.One2many('education.exam.line', 'exam_id', string='Schedule')
|
|
result_ids = fields.One2many('education.exam.result', 'exam_id', string='Results')
|
|
|
|
def action_schedule(self):
|
|
for rec in self:
|
|
if not rec.line_ids:
|
|
raise ValidationError("Please add at least one subject to the schedule.")
|
|
rec.state = 'scheduled'
|
|
|
|
def action_start(self):
|
|
for rec in self:
|
|
if rec.state != 'scheduled':
|
|
raise ValidationError("Only scheduled exams can be started.")
|
|
|
|
earliest_line = min(rec.line_ids, key=lambda l: (l.exam_date, l.start_time), default=None)
|
|
if earliest_line:
|
|
# Get the user's timezone, fallback to UTC
|
|
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
|
|
|
|
# Create naive datetime from the earliest exam_date and start_time (which is in hours)
|
|
earliest_dt_naive = datetime.combine(earliest_line.exam_date, datetime.min.time()) + timedelta(hours=earliest_line.start_time)
|
|
|
|
# Localize this naive datetime to the user's timezone
|
|
earliest_dt_user = user_tz.localize(earliest_dt_naive)
|
|
|
|
# Get current time in UTC, and convert to user's timezone for comparison
|
|
now_utc = fields.Datetime.now()
|
|
now_user = pytz.utc.localize(now_utc).astimezone(user_tz)
|
|
|
|
if now_user < earliest_dt_user:
|
|
raise ValidationError(f"This exam cannot be started yet. It is scheduled to start on {earliest_dt_user.strftime('%d/%m/%Y at %I:%M %p')}.")
|
|
|
|
rec.state = 'ongoing'
|
|
|
|
def action_complete(self):
|
|
for rec in self:
|
|
if rec.state != 'ongoing':
|
|
raise ValidationError("Only ongoing exams can be completed.")
|
|
rec.state = 'completed'
|
|
|
|
def action_generate_results(self):
|
|
"""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 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),
|
|
('academic_term_id', '=', rec.term_id.id),
|
|
('status', 'in', ['active', 'enrolled', 'admitted'])
|
|
])
|
|
|
|
if not students:
|
|
raise ValidationError("No active/enrolled students found for this Program, Academic Year, and Semester.")
|
|
|
|
for student in students:
|
|
existing = self.env['education.exam.result'].search([
|
|
('student_id', '=', student.id),
|
|
('exam_id', '=', rec.id)
|
|
], limit=1)
|
|
|
|
if not existing:
|
|
existing = self.env['education.exam.result'].create({
|
|
'student_id': student.id,
|
|
'exam_id': rec.id,
|
|
})
|
|
|
|
# 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': 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:
|
|
if not rec.result_ids:
|
|
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.full_name} has no subjects.")
|
|
for line in result.line_ids:
|
|
if line.obtained_marks < 0:
|
|
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 ({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()
|
|
})
|
|
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):
|
|
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):
|
|
for rec in self:
|
|
if rec.start_date and rec.end_date and rec.end_date < rec.start_date:
|
|
raise ValidationError("End date cannot be before the start date.")
|
|
|
|
|
|
class EducationExamLine(models.Model):
|
|
_name = 'education.exam.line'
|
|
_description = 'Exam Schedule Line'
|
|
|
|
exam_id = fields.Many2one('education.exam', string='Exam', required=True, ondelete='cascade')
|
|
subject_id = fields.Many2one('education.subject', string='Subject', required=True)
|
|
exam_date = fields.Date(string='Exam Date', required=True)
|
|
start_time = fields.Float(string='Start Time', required=True)
|
|
end_time = fields.Float(string='End Time', required=True)
|
|
max_marks = fields.Float(string='Maximum Marks', required=True)
|
|
pass_marks = fields.Float(string='Pass Marks', required=True)
|
|
room = fields.Char(string='Room')
|
|
venue = fields.Char(string='Venue')
|
|
faculty_id = fields.Many2one('res.users', string='Faculty/Invigilator')
|
|
|
|
@api.constrains('start_time', 'end_time')
|
|
def _check_times(self):
|
|
for record in self:
|
|
if record.end_time <= record.start_time:
|
|
raise ValidationError("End time must be greater than start time.")
|
|
|
|
@api.constrains('max_marks', 'pass_marks')
|
|
def _check_marks(self):
|
|
for record in self:
|
|
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 be greater than maximum marks.")
|
|
|
|
@api.constrains('exam_date')
|
|
def _check_date(self):
|
|
for record in self:
|
|
if record.exam_id and (record.exam_date < record.exam_id.start_date or record.exam_date > record.exam_id.end_date):
|
|
raise ValidationError("Exam date must fall within the exam start and end dates.")
|
|
|
|
@api.constrains('exam_date', 'start_time', 'end_time', 'exam_id')
|
|
def _check_schedule_conflict(self):
|
|
for rec in self:
|
|
domain = [
|
|
('id', '!=', rec.id),
|
|
('exam_date', '=', rec.exam_date),
|
|
('start_time', '<', rec.end_time),
|
|
('end_time', '>', rec.start_time),
|
|
('exam_id.program_id', '=', rec.exam_id.program_id.id),
|
|
('exam_id.academic_year_id', '=', rec.exam_id.academic_year_id.id),
|
|
('exam_id.term_id', '=', rec.exam_id.term_id.id),
|
|
]
|
|
conflicts = self.search(domain)
|
|
if conflicts:
|
|
conflict = conflicts[0]
|
|
raise ValidationError(f"Exam schedule conflict: '{rec.subject_id.name}' overlaps with '{conflict.subject_id.name}' for the same student group on {rec.exam_date}.")
|