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