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}.")