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>
87 lines
3.6 KiB
Python
87 lines
3.6 KiB
Python
from psycopg2 import IntegrityError
|
|
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.tests.common import TransactionCase
|
|
from odoo.tools import mute_logger
|
|
|
|
|
|
class TestEnrollment(TransactionCase):
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
# Names/codes are deliberately distinct from demo data (see
|
|
# demo/mc_program_demo.xml, demo/mc_academic_year_demo.xml) -
|
|
# these tests must pass whether or not demo data is loaded.
|
|
cls.year = cls.env["mc.academic.year"].create({
|
|
"name": "TEST-2026-27",
|
|
"date_start": "2026-06-01",
|
|
"date_end": "2027-04-30",
|
|
})
|
|
cls.other_year = cls.env["mc.academic.year"].create({
|
|
"name": "TEST-2027-28",
|
|
"date_start": "2027-06-01",
|
|
"date_end": "2028-04-30",
|
|
})
|
|
cls.program = cls.env["mc.program"].create({
|
|
"name": "Test Grade 8 CBSE", "code": "TEST-G8", "sequence_no": 8,
|
|
"display_label": "Test Grade 8",
|
|
})
|
|
cls.batch_a = cls.env["mc.batch"].create({
|
|
"name": "Grade 8-A", "program_id": cls.program.id, "year_id": cls.year.id,
|
|
})
|
|
cls.batch_b = cls.env["mc.batch"].create({
|
|
"name": "Grade 8-B", "program_id": cls.program.id, "year_id": cls.year.id,
|
|
})
|
|
partner = cls.env["res.partner"].create({"name": "Test Student"})
|
|
cls.student = cls.env["mc.student"].create({
|
|
"partner_id": partner.id, "name": "Test Student",
|
|
})
|
|
|
|
def _enroll(self, batch, year, state="active", roll_no="1"):
|
|
return self.env["mc.enrollment"].create({
|
|
"student_id": self.student.id,
|
|
"program_id": self.program.id,
|
|
"batch_id": batch.id,
|
|
"year_id": year.id,
|
|
"state": state,
|
|
"roll_no": roll_no,
|
|
})
|
|
|
|
def test_admission_no_auto_generated(self):
|
|
self.assertTrue(self.student.admission_no)
|
|
self.assertIn("ADM", self.student.admission_no)
|
|
|
|
def test_second_active_enrollment_same_year_rejected(self):
|
|
self._enroll(self.batch_a, self.year)
|
|
with self.assertRaises(ValidationError):
|
|
self._enroll(self.batch_b, self.year)
|
|
|
|
@mute_logger("odoo.sql_db")
|
|
def test_db_index_backs_the_rule_even_if_orm_check_is_bypassed(self):
|
|
# The @api.constrains gives a friendly message; the partial unique
|
|
# index is what actually guarantees the rule. Prove the index is
|
|
# there by writing around the ORM check.
|
|
self._enroll(self.batch_a, self.year)
|
|
with self.assertRaises(IntegrityError):
|
|
with self.cr.savepoint():
|
|
self.env.cr.execute(
|
|
"INSERT INTO mc_enrollment "
|
|
"(student_id, program_id, batch_id, year_id, state, create_uid, write_uid, create_date, write_date) "
|
|
"VALUES (%s, %s, %s, %s, 'active', %s, %s, now(), now())",
|
|
(self.student.id, self.program.id, self.batch_b.id, self.year.id,
|
|
self.env.uid, self.env.uid),
|
|
)
|
|
|
|
def test_active_enrollment_allowed_in_different_year(self):
|
|
self._enroll(self.batch_a, self.year)
|
|
# Should not raise: different academic year, no conflict.
|
|
self._enroll(self.batch_a, self.other_year)
|
|
|
|
def test_withdrawing_then_re_enrolling_active_is_allowed(self):
|
|
first = self._enroll(self.batch_a, self.year)
|
|
first.state = "withdrawn"
|
|
# Should not raise: the only active row for this student+year was
|
|
# just withdrawn, so a new active enrollment is legitimate.
|
|
self._enroll(self.batch_b, self.year)
|