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

81 lines
2.9 KiB
Python

from odoo import api, fields, models
class McStudentGuardian(models.Model):
_name = "mc.student.guardian"
_description = "Student Guardian Link"
_order = "student_id, is_primary desc"
student_id = fields.Many2one(
"mc.student", string="Student", required=True, ondelete="cascade",
)
guardian_id = fields.Many2one(
"mc.guardian", string="Guardian", required=True, ondelete="cascade",
)
relationship = fields.Selection(
[
("father", "Father"),
("mother", "Mother"),
("legal_guardian", "Legal Guardian"),
("other", "Other"),
],
string="Relationship", required=True,
)
is_primary = fields.Boolean(
string="Primary", default=False,
help="Notices go to the primary guardian only.",
)
_student_guardian_uniq = models.Constraint(
"unique(student_id, guardian_id)",
"This guardian is already linked to this student.",
)
def init(self):
# At most one primary guardian per student, enforced at the database
# level - the same belt-and-suspenders pattern as
# mc.academic.year.is_current. See that model for why both the
# ORM toggle and the partial index exist.
self.env.cr.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS mc_student_guardian_one_primary_per_student "
"ON mc_student_guardian (student_id) WHERE is_primary = true"
)
@api.depends("student_id.name", "guardian_id.name", "relationship")
def _compute_display_name(self):
relationship_labels = dict(self._fields["relationship"].selection)
for link in self:
label = relationship_labels.get(link.relationship, "")
link.display_name = "%s -> %s (%s)" % (
link.guardian_id.name or "?", link.student_id.name or "?", label,
)
def _unset_other_primary_links(self):
for link in self:
others = self.search([
("id", "!=", link.id),
("student_id", "=", link.student_id.id),
("is_primary", "=", True),
])
if others:
others.write({"is_primary": False})
@api.model_create_multi
def create(self, vals_list):
# Unset the existing primary guardian for each affected student
# BEFORE inserting, and flush immediately - create() issues a
# direct SQL INSERT that will not wait for this pending write.
for vals in vals_list:
if vals.get("is_primary") and vals.get("student_id"):
self.search([
("student_id", "=", vals["student_id"]),
("is_primary", "=", True),
]).write({"is_primary": False})
self.env.flush_all()
return super().create(vals_list)
def write(self, vals):
if vals.get("is_primary"):
self._unset_other_primary_links()
return super().write(vals)