from odoo import _, api, fields, models from odoo.exceptions import ValidationError class McEnrollment(models.Model): _name = "mc.enrollment" _inherit = ["mail.thread"] _description = "Enrollment" _order = "year_id desc, student_id" student_id = fields.Many2one( "mc.student", string="Student", required=True, ondelete="restrict", ) program_id = fields.Many2one( "mc.program", string="Program", required=True, ondelete="restrict", ) batch_id = fields.Many2one( "mc.batch", string="Batch", required=True, ondelete="restrict", ) year_id = fields.Many2one( "mc.academic.year", string="Academic Year", required=True, ondelete="restrict", ) state = fields.Selection( [ ("draft", "Draft"), ("active", "Active"), ("completed", "Completed"), ("withdrawn", "Withdrawn"), ("transferred", "Transferred"), ], string="Status", default="draft", required=True, tracking=True, ) roll_no = fields.Char(string="Roll No.", tracking=True) date_enrolled = fields.Date(string="Date Enrolled", default=fields.Date.context_today) @api.depends("student_id.name", "year_id.name", "batch_id.name") def _compute_display_name(self): for enrollment in self: enrollment.display_name = "%s - %s (%s)" % ( enrollment.student_id.name or "?", enrollment.batch_id.name or "?", enrollment.year_id.name or "?", ) def init(self): # The spine of the whole domain model: a student has at most one # Active enrollment per academic year. This MUST be a database # constraint, not application logic - CLAUDE.md is explicit about # this one. This partial unique index is the actual guarantee. # Because it fires synchronously at INSERT/UPDATE time, it runs # BEFORE @api.constrains ever gets a chance to - a naive # "index + constrains for a friendly message" design is not # enough, the raw IntegrityError wins that race. create()/write() # below pre-check and raise the friendly message first for the # common single-record case; @api.constrains stays as a backstop # for batch creates where sibling rows in the same vals_list # can't see each other yet at pre-check time. self.env.cr.execute( "CREATE UNIQUE INDEX IF NOT EXISTS mc_enrollment_one_active_per_student_per_year " "ON mc_enrollment (student_id, year_id) WHERE state = 'active'" ) def _check_no_conflicting_active_enrollment(self, student_id, year_id, exclude_id=None): domain = [ ("student_id", "=", student_id), ("year_id", "=", year_id), ("state", "=", "active"), ] if exclude_id: domain.append(("id", "!=", exclude_id)) duplicate = self.search(domain, limit=1) if duplicate: raise ValidationError(_( "%(student)s already has an active enrollment for %(year)s.", student=duplicate.student_id.name, year=duplicate.year_id.name, )) @api.constrains("student_id", "year_id", "state") def _check_one_active_enrollment_per_year(self): for enrollment in self: if enrollment.state == "active": self._check_no_conflicting_active_enrollment( enrollment.student_id.id, enrollment.year_id.id, exclude_id=enrollment.id, ) @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get("state") == "active": self._check_no_conflicting_active_enrollment( vals.get("student_id"), vals.get("year_id"), ) # A state change on another enrollment earlier in the same # transaction (e.g. withdrawing the old one before enrolling the # new one) sits in the ORM cache until flushed. create() issues a # direct SQL INSERT that will not wait for it, so without this # flush the partial unique index can still see the old row as # active and reject a perfectly legitimate re-enrollment. self.env.flush_all() return super().create(vals_list) def write(self, vals): if vals.get("state") == "active": for enrollment in self: self._check_no_conflicting_active_enrollment( vals.get("student_id", enrollment.student_id.id), vals.get("year_id", enrollment.year_id.id), exclude_id=enrollment.id, ) return super().write(vals)