Rounds out mc_education_base with the remaining O1 models: mc.program,
mc.subject, mc.room, mc.batch, mc.teacher, mc.student, mc.guardian,
mc.student.guardian, and mc.enrollment - the spine everything else in
the O1 table (attendance, timetable, exam, portal) is built against.
Two rules get the same "DB constraint is the real guarantee, the ORM
does a friendly pre-check" treatment as academic.year.is_current:
- At most one primary guardian per student (mc.student.guardian):
partial unique index on student_id WHERE is_primary, plus a
create/write toggle.
- At most one Active enrollment per student per year
(mc.enrollment): partial unique index on (student_id, year_id)
WHERE state='active'. This one is CLAUDE.md's flagship rule
("Enforce as a database constraint, not application logic").
Two more real bugs surfaced by testing against a live odoo:19.0
container rather than trusting the code by inspection:
- The partial unique index fires at INSERT/UPDATE time, before
@api.constrains ever runs - so a naive "index + constrains for a
friendly message" design never reaches the friendly message, the
raw IntegrityError wins the race. Fixed by pre-checking for a
conflict in create()/write() before calling super(), with
@api.constrains kept only as a backstop for batch creates.
- create() issues a direct SQL INSERT that does not wait for
unrelated pending writes in the ORM cache (e.g. withdrawing one
enrollment right before creating its replacement, in the same
method) - needs an explicit self.env.flush_all() first, same
lesson as the is_current toggle.
Also caught before it became a permanent test flake: the enrollment
and academic-calendar tests originally hardcoded the same year names
("2025-26", "2026-27") and program code ("G8") as the demo data.
Passed in isolation, failed as soon as demo data was loaded first -
so verification here included a combined
`--without-demo=False --test-enable` run, matching what CI actually
does, not just an isolated test-tagged run. Renamed to TEST-prefixed
fixtures.
Security access rows added for all new models across the four
internal groups (Administrator: full CRUD everywhere; Staff: full
CRUD on the people/enrollment models that are front office's daily
job, read-only on academic structure; Teacher/Accountant: read-only
across the board, narrower record rules land with the modules that
need them - attendance, exam, portal). No portal-group access yet;
that is O7's job once explicit ownership-scoped record rules exist -
granting it now without those rules would be exactly the "identifier
supplied by the client" hole CLAUDE.md's standing security rule
warns about.
Demo data populates the shared/DEMO_SCRIPT.md cast: Meera Krishnan as
primary guardian of both Aditya (Grade 8-A) and Ananya (Grade 5-B) -
the multi-child guardian view the script calls "the single most
convincing portal feature" - plus Arun Prakash as Grade 8-A's class
teacher. Verified by querying the resulting database directly, not
just by the install succeeding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
113 lines
4.7 KiB
Python
113 lines
4.7 KiB
Python
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)
|