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