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 @@ + + + + + + + + + +