metatroncubeswdev 8b7bd91f74 O1: mc_education_base gate, first slice - academic calendar
mc.academic.year and mc.academic.term: the two models every other O1
model (program, batch, enrollment...) will hang off. Exactly one
current year is enforced two ways - the ORM toggles is_current off
the previous year on create/write, and a partial unique index on
(company_id) WHERE is_current backs it at the database level so the
rule holds even if something writes around the ORM.

Security groups for all six roles from the spec (Administrator,
Staff, Teacher, Accountant, Guardian, Student) are scaffolded now
since every later O1 model needs them, though only Administrator/
Staff have access rows on these two models so far.

Verified against a real odoo:19.0 container, not just read: module
installs clean with views, menus and demo data, and all 7 test
methods pass. That surfaced two things CLAUDE.md's Coding Standards
section didn't anticipate, since Odoo 19 moved past 17/18-era APIs
in ways not caught by an 18-era mental model:

  - `_sql_constraints` is gone; constraints are now per-attribute
    `models.Constraint(sql, message)`.
  - `res.groups.category_id` is gone; groups now hang off a new
    `res.groups.privilege` record, which carries the category.

Both addons/mc_education_base files already use the new APIs.
CLAUDE.md itself needs a note added in a follow-up so this isn't
rediscovered per-module - flagging here per its own closing
instruction ("say so and propose the correction... update this file
in the same PR") rather than leaving it implicit in this commit body.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 08:53:34 -04:00

80 lines
3.1 KiB
Python

from odoo import api, fields, models
from odoo.exceptions import ValidationError
class McAcademicYear(models.Model):
_name = "mc.academic.year"
_inherit = ["mail.thread"]
_description = "Academic Year"
_order = "date_start desc"
_rec_name = "name"
name = fields.Char(
string="Name", required=True, tracking=True,
help="School-facing label, e.g. 2026-27.",
)
date_start = fields.Date(string="Start Date", required=True, tracking=True)
date_end = fields.Date(string="End Date", required=True, tracking=True)
is_current = fields.Boolean(string="Current Year", default=False, tracking=True)
term_ids = fields.One2many("mc.academic.term", "year_id", string="Terms")
company_id = fields.Many2one(
"res.company", string="Company", required=True,
default=lambda self: self.env.company,
)
_name_company_uniq = models.Constraint(
"unique(name, company_id)",
"An academic year with this name already exists for this company.",
)
def init(self):
# Belt-and-suspenders on top of the create/write auto-toggle below:
# a partial unique index guarantees at most one current year per
# company even if a write bypasses the ORM (direct SQL, a future
# bug in the toggle logic, concurrent transactions).
self.env.cr.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS mc_academic_year_one_current_per_company "
"ON mc_academic_year (company_id) WHERE is_current = true"
)
@api.constrains("date_start", "date_end")
def _check_dates(self):
for year in self:
if year.date_start and year.date_end and year.date_end <= year.date_start:
raise ValidationError(
"Academic year '%s' end date must be after its start date." % year.name
)
def _unset_other_current_years(self):
for year in self:
others = self.search([
("id", "!=", year.id),
("company_id", "=", year.company_id.id),
("is_current", "=", True),
])
if others:
others.write({"is_current": False})
@api.model_create_multi
def create(self, vals_list):
# Unset the existing current year for each affected company BEFORE
# inserting the new one, and flush immediately: create() issues a
# direct SQL INSERT that does not wait for unrelated pending writes
# in the ORM cache, so without the flush here the old row is still
# True in the database when the new row is inserted, tripping the
# partial unique index mid-transaction.
for vals in vals_list:
if vals.get("is_current"):
company_id = vals.get("company_id", self.env.company.id)
self.search([
("company_id", "=", company_id),
("is_current", "=", True),
]).write({"is_current": False})
self.env.flush_all()
return super().create(vals_list)
def write(self, vals):
if vals.get("is_current"):
self._unset_other_current_years()
return super().write(vals)