From 8b7bd91f749e88d7192018da57c072b491a4716e Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 11 Sep 2026 08:53:34 -0400 Subject: [PATCH] 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 --- addons/mc_education_base/__init__.py | 1 + addons/mc_education_base/__manifest__.py | 22 +++ .../demo/mc_academic_term_demo.xml | 24 ++++ .../demo/mc_academic_year_demo.xml | 15 +++ addons/mc_education_base/models/__init__.py | 2 + .../models/mc_academic_term.py | 46 +++++++ .../models/mc_academic_year.py | 79 +++++++++++ .../security/ir.model.access.csv | 5 + .../security/mc_education_security.xml | 58 ++++++++ addons/mc_education_base/tests/__init__.py | 1 + .../tests/test_academic_calendar.py | 127 ++++++++++++++++++ .../views/mc_academic_term_views.xml | 44 ++++++ .../views/mc_academic_year_views.xml | 60 +++++++++ .../views/mc_education_menus.xml | 17 +++ 14 files changed, 501 insertions(+) create mode 100644 addons/mc_education_base/__init__.py create mode 100644 addons/mc_education_base/__manifest__.py create mode 100644 addons/mc_education_base/demo/mc_academic_term_demo.xml create mode 100644 addons/mc_education_base/demo/mc_academic_year_demo.xml create mode 100644 addons/mc_education_base/models/__init__.py create mode 100644 addons/mc_education_base/models/mc_academic_term.py create mode 100644 addons/mc_education_base/models/mc_academic_year.py create mode 100644 addons/mc_education_base/security/ir.model.access.csv create mode 100644 addons/mc_education_base/security/mc_education_security.xml create mode 100644 addons/mc_education_base/tests/__init__.py create mode 100644 addons/mc_education_base/tests/test_academic_calendar.py create mode 100644 addons/mc_education_base/views/mc_academic_term_views.xml create mode 100644 addons/mc_education_base/views/mc_academic_year_views.xml create mode 100644 addons/mc_education_base/views/mc_education_menus.xml diff --git a/addons/mc_education_base/__init__.py b/addons/mc_education_base/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/mc_education_base/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/mc_education_base/__manifest__.py b/addons/mc_education_base/__manifest__.py new file mode 100644 index 0000000..7ed5772 --- /dev/null +++ b/addons/mc_education_base/__manifest__.py @@ -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, +} diff --git a/addons/mc_education_base/demo/mc_academic_term_demo.xml b/addons/mc_education_base/demo/mc_academic_term_demo.xml new file mode 100644 index 0000000..4502892 --- /dev/null +++ b/addons/mc_education_base/demo/mc_academic_term_demo.xml @@ -0,0 +1,24 @@ + + + + Term 1 + + 2026-06-01 + 2026-09-30 + 10 + + + Term 2 + + 2026-10-01 + 2026-12-31 + 20 + + + Term 3 + + 2027-01-01 + 2027-04-30 + 30 + + diff --git a/addons/mc_education_base/demo/mc_academic_year_demo.xml b/addons/mc_education_base/demo/mc_academic_year_demo.xml new file mode 100644 index 0000000..2f6f9fe --- /dev/null +++ b/addons/mc_education_base/demo/mc_academic_year_demo.xml @@ -0,0 +1,15 @@ + + + + 2026-27 + 2026-06-01 + 2027-04-30 + True + + + 2025-26 + 2025-06-01 + 2026-04-30 + False + + diff --git a/addons/mc_education_base/models/__init__.py b/addons/mc_education_base/models/__init__.py new file mode 100644 index 0000000..39746e6 --- /dev/null +++ b/addons/mc_education_base/models/__init__.py @@ -0,0 +1,2 @@ +from . import mc_academic_year +from . import mc_academic_term diff --git a/addons/mc_education_base/models/mc_academic_term.py b/addons/mc_education_base/models/mc_academic_term.py new file mode 100644 index 0000000..cc894c0 --- /dev/null +++ b/addons/mc_education_base/models/mc_academic_term.py @@ -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) + ) diff --git a/addons/mc_education_base/models/mc_academic_year.py b/addons/mc_education_base/models/mc_academic_year.py new file mode 100644 index 0000000..5962137 --- /dev/null +++ b/addons/mc_education_base/models/mc_academic_year.py @@ -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) diff --git a/addons/mc_education_base/security/ir.model.access.csv b/addons/mc_education_base/security/ir.model.access.csv new file mode 100644 index 0000000..51adc27 --- /dev/null +++ b/addons/mc_education_base/security/ir.model.access.csv @@ -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 diff --git a/addons/mc_education_base/security/mc_education_security.xml b/addons/mc_education_base/security/mc_education_security.xml new file mode 100644 index 0000000..a91f7eb --- /dev/null +++ b/addons/mc_education_base/security/mc_education_security.xml @@ -0,0 +1,58 @@ + + + + School Management + 20 + + + + + School Management + + 20 + + + + + School Staff + + Front office and general staff: read access to academic structure and student records. + + + + Teacher + + + Teaching staff: their own batches only, enforced by record rules in the modules that need it (attendance, exam). + + + + Accountant + + + Fees, invoices and payments. + + + + School Administrator + + + Full read/write on the academic structure: years, terms, programs, batches. + + + + + Guardian + + + Portal access is scoped by mc.student.guardian links, never by holding this group alone. See CLAUDE.md sec 3. + + + + Student + + + Portal access to the student's own record only, enforced by record rules. + + diff --git a/addons/mc_education_base/tests/__init__.py b/addons/mc_education_base/tests/__init__.py new file mode 100644 index 0000000..be3fb1a --- /dev/null +++ b/addons/mc_education_base/tests/__init__.py @@ -0,0 +1 @@ +from . import test_academic_calendar diff --git a/addons/mc_education_base/tests/test_academic_calendar.py b/addons/mc_education_base/tests/test_academic_calendar.py new file mode 100644 index 0000000..219b640 --- /dev/null +++ b/addons/mc_education_base/tests/test_academic_calendar.py @@ -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", + }) diff --git a/addons/mc_education_base/views/mc_academic_term_views.xml b/addons/mc_education_base/views/mc_academic_term_views.xml new file mode 100644 index 0000000..cbae1e3 --- /dev/null +++ b/addons/mc_education_base/views/mc_academic_term_views.xml @@ -0,0 +1,44 @@ + + + + mc.academic.term.list + mc.academic.term + + + + + + + + + + + + + mc.academic.term.form + mc.academic.term + +
+ +
+
+ + + + + + +
+ + +
+
+ + + Academic Terms + mc.academic.term + list,form + +
diff --git a/addons/mc_education_base/views/mc_academic_year_views.xml b/addons/mc_education_base/views/mc_academic_year_views.xml new file mode 100644 index 0000000..71ef0ac --- /dev/null +++ b/addons/mc_education_base/views/mc_academic_year_views.xml @@ -0,0 +1,60 @@ + + + + mc.academic.year.list + mc.academic.year + + + + + + + + + + + + + mc.academic.year.form + mc.academic.year + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Academic Years + mc.academic.year + list,form + +
diff --git a/addons/mc_education_base/views/mc_education_menus.xml b/addons/mc_education_base/views/mc_education_menus.xml new file mode 100644 index 0000000..c6502e2 --- /dev/null +++ b/addons/mc_education_base/views/mc_education_menus.xml @@ -0,0 +1,17 @@ + + + + + + + + + +