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', tracking=True) 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 empty result records for all students enrolled in the program and term.""" for rec in self: if rec.state != 'completed': raise ValidationError("Results can only be generated for 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: # Check if result already exists existing = self.env['education.exam.result'].search([ ('student_id', '=', student.id), ('exam_id', '=', rec.id) ]) if not existing: result = 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: self.env['education.exam.result.line'].create({ 'result_id': result.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, }) def action_publish_results(self): for rec in self: # Validations before publishing if not rec.result_ids: raise ValidationError("No results generated yet.") 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.") 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}.") if line.obtained_marks > line.max_marks: raise ValidationError(f"Obtained marks cannot exceed maximum marks for {line.subject_id.name}.") 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_reset_draft(self): self.write({'state': 'draft'}) @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}.")