metatroncubeswdev 709bd2eca7 O1: complete the gate - program, subject, room, batch, people, enrollment
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>
2026-09-11 09:58:36 -04:00

132 lines
4.8 KiB
Python

from psycopg2 import IntegrityError
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase
from odoo.tools import mute_logger
# Names are deliberately distinct from the demo data's "2025-26"/"2026-27"
# (see demo/mc_academic_year_demo.xml) - these tests must pass whether or
# not demo data is loaded, and mc.academic.year.name is unique per company.
class TestAcademicCalendar(TransactionCase):
def test_setting_current_on_create_unsets_previous(self):
Year = self.env["mc.academic.year"]
year_a = Year.create({
"name": "TEST-2025-26",
"date_start": "2025-06-01",
"date_end": "2026-04-30",
"is_current": True,
})
self.assertTrue(year_a.is_current)
year_b = Year.create({
"name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
"is_current": True,
})
self.assertFalse(year_a.is_current, "Creating a new current year must unset the old one.")
self.assertTrue(year_b.is_current)
def test_setting_current_on_write_unsets_previous(self):
Year = self.env["mc.academic.year"]
year_a = Year.create({
"name": "TEST-2025-26",
"date_start": "2025-06-01",
"date_end": "2026-04-30",
"is_current": True,
})
year_b = Year.create({
"name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
"is_current": False,
})
year_b.write({"is_current": True})
self.assertFalse(year_a.is_current)
self.assertTrue(year_b.is_current)
@mute_logger("odoo.sql_db")
def test_two_current_years_violate_db_index_when_orm_bypassed(self):
# The write()/create() override auto-toggles is_current through the ORM.
# The partial unique index is the actual enforcement layer for anything
# that writes around it (direct SQL, a future bug in the toggle logic).
Year = self.env["mc.academic.year"]
Year.create({
"name": "TEST-2025-26",
"date_start": "2025-06-01",
"date_end": "2026-04-30",
"is_current": True,
})
with self.assertRaises(IntegrityError):
with self.cr.savepoint():
self.env.cr.execute(
"INSERT INTO mc_academic_year "
"(name, date_start, date_end, is_current, company_id, create_uid, write_uid, create_date, write_date) "
"VALUES ('TEST-2026-27', '2026-06-01', '2027-04-30', true, %s, %s, %s, now(), now())",
(self.env.company.id, self.env.uid, self.env.uid),
)
def test_year_end_before_start_raises(self):
with self.assertRaises(ValidationError):
self.env["mc.academic.year"].create({
"name": "TEST-Bad-Year",
"date_start": "2026-06-01",
"date_end": "2026-05-01",
})
def test_term_end_before_start_raises(self):
year = self.env["mc.academic.year"].create({
"name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
})
with self.assertRaises(ValidationError):
self.env["mc.academic.term"].create({
"name": "Term 1",
"year_id": year.id,
"date_start": "2026-09-30",
"date_end": "2026-06-01",
})
def test_term_must_fall_within_year(self):
year = self.env["mc.academic.year"].create({
"name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
})
with self.assertRaises(ValidationError):
self.env["mc.academic.term"].create({
"name": "Term 1",
"year_id": year.id,
"date_start": "2026-05-01", # before the year starts
"date_end": "2026-09-30",
})
@mute_logger("odoo.sql_db")
def test_duplicate_term_name_in_same_year_rejected(self):
year = self.env["mc.academic.year"].create({
"name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
})
self.env["mc.academic.term"].create({
"name": "Term 1",
"year_id": year.id,
"date_start": "2026-06-01",
"date_end": "2026-09-30",
})
with self.assertRaises(IntegrityError):
with self.cr.savepoint():
self.env["mc.academic.term"].create({
"name": "Term 1",
"year_id": year.id,
"date_start": "2026-10-01",
"date_end": "2026-12-31",
})