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>
This commit is contained in:
metatroncubeswdev 2026-09-11 08:53:34 -04:00
parent 85f2b4b11a
commit 8b7bd91f74
14 changed files with 501 additions and 0 deletions

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,22 @@
{
"name": "School ERP - Base",
"version": "19.0.1.0.0",
"category": "Education",
"summary": "Core academic structure: years, terms, programs, batches, students, guardians, teachers, enrollment.",
"author": "Metatroncube Software Solutions LLP",
"license": "Other proprietary",
"depends": ["base", "mail", "contacts", "hr"],
"data": [
"security/mc_education_security.xml",
"security/ir.model.access.csv",
"views/mc_academic_year_views.xml",
"views/mc_academic_term_views.xml",
"views/mc_education_menus.xml",
],
"demo": [
"demo/mc_academic_year_demo.xml",
"demo/mc_academic_term_demo.xml",
],
"installable": True,
"application": True,
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_term_2026_27_t1" model="mc.academic.term">
<field name="name">Term 1</field>
<field name="year_id" ref="demo_year_2026_27"/>
<field name="date_start">2026-06-01</field>
<field name="date_end">2026-09-30</field>
<field name="sequence">10</field>
</record>
<record id="demo_term_2026_27_t2" model="mc.academic.term">
<field name="name">Term 2</field>
<field name="year_id" ref="demo_year_2026_27"/>
<field name="date_start">2026-10-01</field>
<field name="date_end">2026-12-31</field>
<field name="sequence">20</field>
</record>
<record id="demo_term_2026_27_t3" model="mc.academic.term">
<field name="name">Term 3</field>
<field name="year_id" ref="demo_year_2026_27"/>
<field name="date_start">2027-01-01</field>
<field name="date_end">2027-04-30</field>
<field name="sequence">30</field>
</record>
</odoo>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_year_2026_27" model="mc.academic.year">
<field name="name">2026-27</field>
<field name="date_start">2026-06-01</field>
<field name="date_end">2027-04-30</field>
<field name="is_current">True</field>
</record>
<record id="demo_year_2025_26" model="mc.academic.year">
<field name="name">2025-26</field>
<field name="date_start">2025-06-01</field>
<field name="date_end">2026-04-30</field>
<field name="is_current">False</field>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
from . import mc_academic_year
from . import mc_academic_term

View File

@ -0,0 +1,46 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class McAcademicTerm(models.Model):
_name = "mc.academic.term"
_inherit = ["mail.thread"]
_description = "Academic Term"
_order = "year_id, sequence, date_start"
_rec_name = "name"
name = fields.Char(string="Name", required=True, tracking=True)
year_id = fields.Many2one(
"mc.academic.year", string="Academic Year", required=True,
ondelete="cascade", tracking=True,
)
date_start = fields.Date(string="Start Date", required=True, tracking=True)
date_end = fields.Date(string="End Date", required=True, tracking=True)
sequence = fields.Integer(string="Sequence", default=10)
company_id = fields.Many2one(
related="year_id.company_id", string="Company", store=True, readonly=True,
)
_name_year_uniq = models.Constraint(
"unique(name, year_id)",
"A term with this name already exists for this academic year.",
)
@api.constrains("date_start", "date_end")
def _check_dates(self):
for term in self:
if term.date_start and term.date_end and term.date_end <= term.date_start:
raise ValidationError(
"Term '%s' end date must be after its start date." % term.name
)
@api.constrains("date_start", "date_end", "year_id")
def _check_within_year(self):
for term in self:
year = term.year_id
if not (year.date_start and year.date_end and term.date_start and term.date_end):
continue
if term.date_start < year.date_start or term.date_end > year.date_end:
raise ValidationError(
"Term '%s' must fall within its academic year (%s)." % (term.name, year.name)
)

View File

@ -0,0 +1,79 @@
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)

View File

@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mc_academic_year_administrator,mc.academic.year.administrator,model_mc_academic_year,group_school_administrator,1,1,1,1
access_mc_academic_year_staff,mc.academic.year.staff,model_mc_academic_year,group_school_staff,1,0,0,0
access_mc_academic_term_administrator,mc.academic.term.administrator,model_mc_academic_term,group_school_administrator,1,1,1,1
access_mc_academic_term_staff,mc.academic.term.staff,model_mc_academic_term,group_school_staff,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mc_academic_year_administrator mc.academic.year.administrator model_mc_academic_year group_school_administrator 1 1 1 1
3 access_mc_academic_year_staff mc.academic.year.staff model_mc_academic_year group_school_staff 1 0 0 0
4 access_mc_academic_term_administrator mc.academic.term.administrator model_mc_academic_term group_school_administrator 1 1 1 1
5 access_mc_academic_term_staff mc.academic.term.staff model_mc_academic_term group_school_staff 1 0 0 0

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_school" model="ir.module.category">
<field name="name">School Management</field>
<field name="sequence">20</field>
</record>
<!-- Odoo 19: res.groups no longer has category_id directly; groups are
grouped under a res.groups.privilege, which carries the category. -->
<record id="privilege_school" model="res.groups.privilege">
<field name="name">School Management</field>
<field name="category_id" ref="module_category_school"/>
<field name="sequence">20</field>
</record>
<!-- Internal (backend) roles -->
<record id="group_school_staff" model="res.groups">
<field name="name">School Staff</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="comment">Front office and general staff: read access to academic structure and student records.</field>
</record>
<record id="group_teacher" model="res.groups">
<field name="name">Teacher</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('group_school_staff'))]"/>
<field name="comment">Teaching staff: their own batches only, enforced by record rules in the modules that need it (attendance, exam).</field>
</record>
<record id="group_accountant" model="res.groups">
<field name="name">Accountant</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('group_school_staff'))]"/>
<field name="comment">Fees, invoices and payments.</field>
</record>
<record id="group_school_administrator" model="res.groups">
<field name="name">School Administrator</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('group_school_staff'))]"/>
<field name="comment">Full read/write on the academic structure: years, terms, programs, batches.</field>
</record>
<!-- Portal roles -->
<record id="group_guardian" model="res.groups">
<field name="name">Guardian</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('base.group_portal'))]"/>
<field name="comment">Portal access is scoped by mc.student.guardian links, never by holding this group alone. See CLAUDE.md sec 3.</field>
</record>
<record id="group_student" model="res.groups">
<field name="name">Student</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('base.group_portal'))]"/>
<field name="comment">Portal access to the student's own record only, enforced by record rules.</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import test_academic_calendar

View File

@ -0,0 +1,127 @@
from psycopg2 import IntegrityError
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase
from odoo.tools import mute_logger
class TestAcademicCalendar(TransactionCase):
def test_setting_current_on_create_unsets_previous(self):
Year = self.env["mc.academic.year"]
year_a = Year.create({
"name": "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": "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": "2025-26",
"date_start": "2025-06-01",
"date_end": "2026-04-30",
"is_current": True,
})
year_b = Year.create({
"name": "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": "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 ('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": "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": "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": "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": "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",
})

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_academic_term_list" model="ir.ui.view">
<field name="name">mc.academic.term.list</field>
<field name="model">mc.academic.term</field>
<field name="arch" type="xml">
<list string="Academic Terms">
<field name="year_id"/>
<field name="name"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="sequence" widget="handle"/>
</list>
</field>
</record>
<record id="view_mc_academic_term_form" model="ir.ui.view">
<field name="name">mc.academic.term.form</field>
<field name="model">mc.academic.term</field>
<field name="arch" type="xml">
<form string="Academic Term">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="Term 1"/></h1>
</div>
<group>
<field name="year_id"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="sequence"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_mc_academic_term" model="ir.actions.act_window">
<field name="name">Academic Terms</field>
<field name="res_model">mc.academic.term</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_academic_year_list" model="ir.ui.view">
<field name="name">mc.academic.year.list</field>
<field name="model">mc.academic.year</field>
<field name="arch" type="xml">
<list string="Academic Years">
<field name="name"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="is_current" widget="boolean_toggle"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_mc_academic_year_form" model="ir.ui.view">
<field name="name">mc.academic.year.form</field>
<field name="model">mc.academic.year</field>
<field name="arch" type="xml">
<form string="Academic Year">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="2026-27"/></h1>
</div>
<group>
<group>
<field name="date_start"/>
<field name="date_end"/>
</group>
<group>
<field name="is_current"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Terms">
<field name="term_ids">
<list editable="bottom">
<field name="name"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="sequence" widget="handle"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_mc_academic_year" model="ir.actions.act_window">
<field name="name">Academic Years</field>
<field name="res_model">mc.academic.year</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_school_root" name="School" sequence="10"
groups="mc_education_base.group_school_staff"/>
<menuitem id="menu_school_configuration" name="Configuration"
parent="menu_school_root" sequence="100"
groups="mc_education_base.group_school_administrator"/>
<menuitem id="menu_mc_academic_year" name="Academic Years"
parent="menu_school_configuration"
action="action_mc_academic_year" sequence="10"/>
<menuitem id="menu_mc_academic_term" name="Academic Terms"
parent="menu_school_configuration"
action="action_mc_academic_term" sequence="20"/>
</odoo>