diff --git a/addons/mc_education_base/__manifest__.py b/addons/mc_education_base/__manifest__.py
index 7ed5772..3f7b787 100644
--- a/addons/mc_education_base/__manifest__.py
+++ b/addons/mc_education_base/__manifest__.py
@@ -9,13 +9,29 @@
"data": [
"security/mc_education_security.xml",
"security/ir.model.access.csv",
+ "data/mc_sequence_data.xml",
"views/mc_academic_year_views.xml",
"views/mc_academic_term_views.xml",
+ "views/mc_room_views.xml",
+ "views/mc_program_views.xml",
+ "views/mc_subject_views.xml",
+ "views/mc_teacher_views.xml",
+ "views/mc_batch_views.xml",
+ "views/mc_student_views.xml",
+ "views/mc_guardian_views.xml",
+ "views/mc_enrollment_views.xml",
"views/mc_education_menus.xml",
],
"demo": [
"demo/mc_academic_year_demo.xml",
"demo/mc_academic_term_demo.xml",
+ "demo/mc_room_demo.xml",
+ "demo/mc_program_demo.xml",
+ "demo/mc_subject_demo.xml",
+ "demo/mc_teacher_demo.xml",
+ "demo/mc_batch_demo.xml",
+ "demo/mc_people_demo.xml",
+ "demo/mc_enrollment_demo.xml",
],
"installable": True,
"application": True,
diff --git a/addons/mc_education_base/data/mc_sequence_data.xml b/addons/mc_education_base/data/mc_sequence_data.xml
new file mode 100644
index 0000000..282394f
--- /dev/null
+++ b/addons/mc_education_base/data/mc_sequence_data.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ Student Admission Number
+ mc.student.admission_no
+ ADM%(year)s
+ 4
+
+
+
diff --git a/addons/mc_education_base/demo/mc_batch_demo.xml b/addons/mc_education_base/demo/mc_batch_demo.xml
new file mode 100644
index 0000000..4d4b8e8
--- /dev/null
+++ b/addons/mc_education_base/demo/mc_batch_demo.xml
@@ -0,0 +1,18 @@
+
+
+
+ Grade 8-A
+
+
+
+
+ 40
+
+
+ Grade 5-B
+
+
+
+ 35
+
+
diff --git a/addons/mc_education_base/demo/mc_enrollment_demo.xml b/addons/mc_education_base/demo/mc_enrollment_demo.xml
new file mode 100644
index 0000000..1859bb3
--- /dev/null
+++ b/addons/mc_education_base/demo/mc_enrollment_demo.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+ active
+ 12
+ 2026-06-01
+
+
+
+
+
+
+ active
+ 7
+ 2026-06-01
+
+
diff --git a/addons/mc_education_base/demo/mc_people_demo.xml b/addons/mc_education_base/demo/mc_people_demo.xml
new file mode 100644
index 0000000..93762d3
--- /dev/null
+++ b/addons/mc_education_base/demo/mc_people_demo.xml
@@ -0,0 +1,63 @@
+
+
+
+
+ Meera Krishnan
+ parent@demo.school
+ +91 98765 43210
+ Coimbatore
+
+
+
+ Meera Krishnan
+ Software Engineer
+ +91 98765 43210
+ parent@demo.school
+
+
+
+ Aditya Krishnan
+ student@demo.school
+ Coimbatore
+
+
+
+ Aditya Krishnan
+ 2013-04-12
+ male
+ 2020-06-01
+ active
+ o+
+
+
+
+ Ananya Krishnan
+ Coimbatore
+
+
+
+ Ananya Krishnan
+ 2016-09-23
+ female
+ 2022-06-01
+ active
+ o+
+
+
+
+
+
+ mother
+ True
+
+
+
+
+ mother
+ True
+
+
diff --git a/addons/mc_education_base/demo/mc_program_demo.xml b/addons/mc_education_base/demo/mc_program_demo.xml
new file mode 100644
index 0000000..4d04d70
--- /dev/null
+++ b/addons/mc_education_base/demo/mc_program_demo.xml
@@ -0,0 +1,17 @@
+
+
+
+ Grade 5 CBSE
+ G5
+ 5
+ Grade 5
+ CBSE
+
+
+ Grade 8 CBSE
+ G8
+ 8
+ Grade 8
+ CBSE
+
+
diff --git a/addons/mc_education_base/demo/mc_room_demo.xml b/addons/mc_education_base/demo/mc_room_demo.xml
new file mode 100644
index 0000000..1038711
--- /dev/null
+++ b/addons/mc_education_base/demo/mc_room_demo.xml
@@ -0,0 +1,15 @@
+
+
+
+ Room 101
+ Main Block
+ classroom
+ 40
+
+
+ Room 102
+ Main Block
+ classroom
+ 35
+
+
diff --git a/addons/mc_education_base/demo/mc_subject_demo.xml b/addons/mc_education_base/demo/mc_subject_demo.xml
new file mode 100644
index 0000000..522ba11
--- /dev/null
+++ b/addons/mc_education_base/demo/mc_subject_demo.xml
@@ -0,0 +1,29 @@
+
+
+
+ Mathematics
+ MATH
+
+
+
+ English
+ ENG
+
+
+
+ Science
+ SCI
+
+
+
+ Social Studies
+ SST
+
+
+
+ Hindi
+ HIN
+ True
+
+
+
diff --git a/addons/mc_education_base/demo/mc_teacher_demo.xml b/addons/mc_education_base/demo/mc_teacher_demo.xml
new file mode 100644
index 0000000..f1a0436
--- /dev/null
+++ b/addons/mc_education_base/demo/mc_teacher_demo.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ Arun Prakash
+ teacher@demo.school
+ Mathematics Teacher
+
+
+
+
+
+ 30
+
+
diff --git a/addons/mc_education_base/models/__init__.py b/addons/mc_education_base/models/__init__.py
index 39746e6..bf9fdf9 100644
--- a/addons/mc_education_base/models/__init__.py
+++ b/addons/mc_education_base/models/__init__.py
@@ -1,2 +1,11 @@
from . import mc_academic_year
from . import mc_academic_term
+from . import mc_room
+from . import mc_program
+from . import mc_subject
+from . import mc_teacher
+from . import mc_batch
+from . import mc_student
+from . import mc_guardian
+from . import mc_student_guardian
+from . import mc_enrollment
diff --git a/addons/mc_education_base/models/mc_batch.py b/addons/mc_education_base/models/mc_batch.py
new file mode 100644
index 0000000..9755f8d
--- /dev/null
+++ b/addons/mc_education_base/models/mc_batch.py
@@ -0,0 +1,24 @@
+from odoo import fields, models
+
+
+class McBatch(models.Model):
+ _name = "mc.batch"
+ _description = "Batch"
+ _order = "year_id desc, program_id, name"
+ _rec_name = "name"
+
+ name = fields.Char(string="Name", required=True, help='e.g. "Grade 8-A".')
+ program_id = fields.Many2one(
+ "mc.program", string="Program", required=True, ondelete="restrict",
+ )
+ year_id = fields.Many2one(
+ "mc.academic.year", string="Academic Year", required=True, ondelete="restrict",
+ )
+ class_teacher_id = fields.Many2one("hr.employee", string="Class Teacher", ondelete="restrict")
+ capacity = fields.Integer(string="Capacity")
+ room_id = fields.Many2one("mc.room", string="Room", ondelete="restrict")
+
+ _name_year_uniq = models.Constraint(
+ "unique(name, year_id)",
+ "A batch with this name already exists for this academic year.",
+ )
diff --git a/addons/mc_education_base/models/mc_enrollment.py b/addons/mc_education_base/models/mc_enrollment.py
new file mode 100644
index 0000000..23a09fd
--- /dev/null
+++ b/addons/mc_education_base/models/mc_enrollment.py
@@ -0,0 +1,112 @@
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class McEnrollment(models.Model):
+ _name = "mc.enrollment"
+ _inherit = ["mail.thread"]
+ _description = "Enrollment"
+ _order = "year_id desc, student_id"
+
+ student_id = fields.Many2one(
+ "mc.student", string="Student", required=True, ondelete="restrict",
+ )
+ program_id = fields.Many2one(
+ "mc.program", string="Program", required=True, ondelete="restrict",
+ )
+ batch_id = fields.Many2one(
+ "mc.batch", string="Batch", required=True, ondelete="restrict",
+ )
+ year_id = fields.Many2one(
+ "mc.academic.year", string="Academic Year", required=True, ondelete="restrict",
+ )
+ state = fields.Selection(
+ [
+ ("draft", "Draft"),
+ ("active", "Active"),
+ ("completed", "Completed"),
+ ("withdrawn", "Withdrawn"),
+ ("transferred", "Transferred"),
+ ],
+ string="Status", default="draft", required=True, tracking=True,
+ )
+ roll_no = fields.Char(string="Roll No.", tracking=True)
+ date_enrolled = fields.Date(string="Date Enrolled", default=fields.Date.context_today)
+
+ @api.depends("student_id.name", "year_id.name", "batch_id.name")
+ def _compute_display_name(self):
+ for enrollment in self:
+ enrollment.display_name = "%s - %s (%s)" % (
+ enrollment.student_id.name or "?",
+ enrollment.batch_id.name or "?",
+ enrollment.year_id.name or "?",
+ )
+
+ def init(self):
+ # The spine of the whole domain model: a student has at most one
+ # Active enrollment per academic year. This MUST be a database
+ # constraint, not application logic - CLAUDE.md is explicit about
+ # this one. This partial unique index is the actual guarantee.
+ # Because it fires synchronously at INSERT/UPDATE time, it runs
+ # BEFORE @api.constrains ever gets a chance to - a naive
+ # "index + constrains for a friendly message" design is not
+ # enough, the raw IntegrityError wins that race. create()/write()
+ # below pre-check and raise the friendly message first for the
+ # common single-record case; @api.constrains stays as a backstop
+ # for batch creates where sibling rows in the same vals_list
+ # can't see each other yet at pre-check time.
+ self.env.cr.execute(
+ "CREATE UNIQUE INDEX IF NOT EXISTS mc_enrollment_one_active_per_student_per_year "
+ "ON mc_enrollment (student_id, year_id) WHERE state = 'active'"
+ )
+
+ def _check_no_conflicting_active_enrollment(self, student_id, year_id, exclude_id=None):
+ domain = [
+ ("student_id", "=", student_id),
+ ("year_id", "=", year_id),
+ ("state", "=", "active"),
+ ]
+ if exclude_id:
+ domain.append(("id", "!=", exclude_id))
+ duplicate = self.search(domain, limit=1)
+ if duplicate:
+ raise ValidationError(_(
+ "%(student)s already has an active enrollment for %(year)s.",
+ student=duplicate.student_id.name,
+ year=duplicate.year_id.name,
+ ))
+
+ @api.constrains("student_id", "year_id", "state")
+ def _check_one_active_enrollment_per_year(self):
+ for enrollment in self:
+ if enrollment.state == "active":
+ self._check_no_conflicting_active_enrollment(
+ enrollment.student_id.id, enrollment.year_id.id,
+ exclude_id=enrollment.id,
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get("state") == "active":
+ self._check_no_conflicting_active_enrollment(
+ vals.get("student_id"), vals.get("year_id"),
+ )
+ # A state change on another enrollment earlier in the same
+ # transaction (e.g. withdrawing the old one before enrolling the
+ # new one) sits in the ORM cache until flushed. create() issues a
+ # direct SQL INSERT that will not wait for it, so without this
+ # flush the partial unique index can still see the old row as
+ # active and reject a perfectly legitimate re-enrollment.
+ self.env.flush_all()
+ return super().create(vals_list)
+
+ def write(self, vals):
+ if vals.get("state") == "active":
+ for enrollment in self:
+ self._check_no_conflicting_active_enrollment(
+ vals.get("student_id", enrollment.student_id.id),
+ vals.get("year_id", enrollment.year_id.id),
+ exclude_id=enrollment.id,
+ )
+ return super().write(vals)
diff --git a/addons/mc_education_base/models/mc_guardian.py b/addons/mc_education_base/models/mc_guardian.py
new file mode 100644
index 0000000..5cb79be
--- /dev/null
+++ b/addons/mc_education_base/models/mc_guardian.py
@@ -0,0 +1,25 @@
+from odoo import fields, models
+
+
+class McGuardian(models.Model):
+ _name = "mc.guardian"
+ _inherit = ["mail.thread"]
+ _description = "Guardian"
+ _order = "name"
+ _rec_name = "name"
+
+ partner_id = fields.Many2one(
+ "res.partner", string="Contact", required=True, ondelete="restrict",
+ )
+ name = fields.Char(string="Name", required=True, tracking=True)
+ occupation = fields.Char(string="Occupation")
+ phone = fields.Char(string="Phone")
+ email = fields.Char(string="Email")
+ student_link_ids = fields.One2many(
+ "mc.student.guardian", "guardian_id", string="Children",
+ )
+
+ _partner_uniq = models.Constraint(
+ "unique(partner_id)",
+ "This contact already has a guardian record.",
+ )
diff --git a/addons/mc_education_base/models/mc_program.py b/addons/mc_education_base/models/mc_program.py
new file mode 100644
index 0000000..5e5d5e4
--- /dev/null
+++ b/addons/mc_education_base/models/mc_program.py
@@ -0,0 +1,31 @@
+from odoo import fields, models
+
+
+class McProgram(models.Model):
+ _name = "mc.program"
+ _description = "Program"
+ # sequence_no drives sort order everywhere - never sort by display_label.
+ # "Class X" sorts alphabetically between "Class I" and "Class XI".
+ _order = "sequence_no, name"
+ _rec_name = "name"
+
+ name = fields.Char(string="Name", required=True, help='Full label, e.g. "Grade 8 CBSE".')
+ code = fields.Char(string="Code", required=True)
+ sequence_no = fields.Integer(
+ string="Sequence", required=True, default=10,
+ help="Drives sort order everywhere. Never sort by display label.",
+ )
+ display_label = fields.Char(
+ string="Display Label", required=True,
+ help='School-facing label, e.g. "Grade 8", "Class VIII", "Year 8".',
+ )
+ board = fields.Char(string="Board", help="e.g. CBSE, ICSE, IB, Cambridge, Ontario.")
+ company_id = fields.Many2one(
+ "res.company", string="Company", required=True,
+ default=lambda self: self.env.company,
+ )
+
+ _code_company_uniq = models.Constraint(
+ "unique(code, company_id)",
+ "A program with this code already exists for this company.",
+ )
diff --git a/addons/mc_education_base/models/mc_room.py b/addons/mc_education_base/models/mc_room.py
new file mode 100644
index 0000000..bc5e27c
--- /dev/null
+++ b/addons/mc_education_base/models/mc_room.py
@@ -0,0 +1,23 @@
+from odoo import fields, models
+
+
+class McRoom(models.Model):
+ _name = "mc.room"
+ _description = "Room"
+ _order = "building, name"
+ _rec_name = "name"
+
+ name = fields.Char(string="Name", required=True)
+ capacity = fields.Integer(string="Capacity")
+ building = fields.Char(string="Building")
+ type = fields.Selection(
+ [
+ ("classroom", "Classroom"),
+ ("lab", "Laboratory"),
+ ("library", "Library"),
+ ("auditorium", "Auditorium"),
+ ("sports", "Sports"),
+ ("other", "Other"),
+ ],
+ string="Type", default="classroom",
+ )
diff --git a/addons/mc_education_base/models/mc_student.py b/addons/mc_education_base/models/mc_student.py
new file mode 100644
index 0000000..c93207d
--- /dev/null
+++ b/addons/mc_education_base/models/mc_student.py
@@ -0,0 +1,66 @@
+from odoo import api, fields, models
+
+
+class McStudent(models.Model):
+ _name = "mc.student"
+ _inherit = ["mail.thread"]
+ _description = "Student"
+ _order = "name"
+ _rec_name = "name"
+
+ partner_id = fields.Many2one(
+ "res.partner", string="Contact", required=True, ondelete="restrict",
+ )
+ admission_no = fields.Char(string="Admission No.", copy=False, tracking=True)
+ name = fields.Char(string="Name", required=True, tracking=True)
+ dob = fields.Date(string="Date of Birth")
+ gender = fields.Selection(
+ [("male", "Male"), ("female", "Female"), ("other", "Other")],
+ string="Gender",
+ )
+ admission_date = fields.Date(string="Admission Date", default=fields.Date.context_today)
+ photo = fields.Binary(string="Photo", attachment=True)
+ status = fields.Selection(
+ [
+ ("active", "Active"),
+ ("inactive", "Inactive"),
+ ("alumni", "Alumni"),
+ ("withdrawn", "Withdrawn"),
+ ],
+ string="Status", default="active", required=True, tracking=True,
+ )
+ blood_group = fields.Selection(
+ [
+ ("a+", "A+"), ("a-", "A-"),
+ ("b+", "B+"), ("b-", "B-"),
+ ("ab+", "AB+"), ("ab-", "AB-"),
+ ("o+", "O+"), ("o-", "O-"),
+ ],
+ string="Blood Group",
+ )
+ address_id = fields.Many2one(
+ "res.partner", string="Address",
+ help="A specific address contact for this student, if different from the guardian's.",
+ )
+ guardian_link_ids = fields.One2many(
+ "mc.student.guardian", "student_id", string="Guardians",
+ )
+ enrollment_ids = fields.One2many("mc.enrollment", "student_id", string="Enrollments")
+
+ _partner_uniq = models.Constraint(
+ "unique(partner_id)",
+ "This contact already has a student record.",
+ )
+ _admission_no_uniq = models.Constraint(
+ "unique(admission_no)",
+ "A student with this admission number already exists.",
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if not vals.get("admission_no"):
+ vals["admission_no"] = self.env["ir.sequence"].next_by_code(
+ "mc.student.admission_no"
+ )
+ return super().create(vals_list)
diff --git a/addons/mc_education_base/models/mc_student_guardian.py b/addons/mc_education_base/models/mc_student_guardian.py
new file mode 100644
index 0000000..954c76c
--- /dev/null
+++ b/addons/mc_education_base/models/mc_student_guardian.py
@@ -0,0 +1,80 @@
+from odoo import api, fields, models
+
+
+class McStudentGuardian(models.Model):
+ _name = "mc.student.guardian"
+ _description = "Student Guardian Link"
+ _order = "student_id, is_primary desc"
+
+ student_id = fields.Many2one(
+ "mc.student", string="Student", required=True, ondelete="cascade",
+ )
+ guardian_id = fields.Many2one(
+ "mc.guardian", string="Guardian", required=True, ondelete="cascade",
+ )
+ relationship = fields.Selection(
+ [
+ ("father", "Father"),
+ ("mother", "Mother"),
+ ("legal_guardian", "Legal Guardian"),
+ ("other", "Other"),
+ ],
+ string="Relationship", required=True,
+ )
+ is_primary = fields.Boolean(
+ string="Primary", default=False,
+ help="Notices go to the primary guardian only.",
+ )
+
+ _student_guardian_uniq = models.Constraint(
+ "unique(student_id, guardian_id)",
+ "This guardian is already linked to this student.",
+ )
+
+ def init(self):
+ # At most one primary guardian per student, enforced at the database
+ # level - the same belt-and-suspenders pattern as
+ # mc.academic.year.is_current. See that model for why both the
+ # ORM toggle and the partial index exist.
+ self.env.cr.execute(
+ "CREATE UNIQUE INDEX IF NOT EXISTS mc_student_guardian_one_primary_per_student "
+ "ON mc_student_guardian (student_id) WHERE is_primary = true"
+ )
+
+ @api.depends("student_id.name", "guardian_id.name", "relationship")
+ def _compute_display_name(self):
+ relationship_labels = dict(self._fields["relationship"].selection)
+ for link in self:
+ label = relationship_labels.get(link.relationship, "")
+ link.display_name = "%s -> %s (%s)" % (
+ link.guardian_id.name or "?", link.student_id.name or "?", label,
+ )
+
+ def _unset_other_primary_links(self):
+ for link in self:
+ others = self.search([
+ ("id", "!=", link.id),
+ ("student_id", "=", link.student_id.id),
+ ("is_primary", "=", True),
+ ])
+ if others:
+ others.write({"is_primary": False})
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ # Unset the existing primary guardian for each affected student
+ # BEFORE inserting, and flush immediately - create() issues a
+ # direct SQL INSERT that will not wait for this pending write.
+ for vals in vals_list:
+ if vals.get("is_primary") and vals.get("student_id"):
+ self.search([
+ ("student_id", "=", vals["student_id"]),
+ ("is_primary", "=", True),
+ ]).write({"is_primary": False})
+ self.env.flush_all()
+ return super().create(vals_list)
+
+ def write(self, vals):
+ if vals.get("is_primary"):
+ self._unset_other_primary_links()
+ return super().write(vals)
diff --git a/addons/mc_education_base/models/mc_subject.py b/addons/mc_education_base/models/mc_subject.py
new file mode 100644
index 0000000..9024ce3
--- /dev/null
+++ b/addons/mc_education_base/models/mc_subject.py
@@ -0,0 +1,18 @@
+from odoo import fields, models
+
+
+class McSubject(models.Model):
+ _name = "mc.subject"
+ _description = "Subject"
+ _order = "name"
+ _rec_name = "name"
+
+ name = fields.Char(string="Name", required=True)
+ code = fields.Char(string="Code", required=True)
+ program_ids = fields.Many2many("mc.program", string="Programs")
+ is_elective = fields.Boolean(string="Elective", default=False)
+
+ _code_uniq = models.Constraint(
+ "unique(code)",
+ "A subject with this code already exists.",
+ )
diff --git a/addons/mc_education_base/models/mc_teacher.py b/addons/mc_education_base/models/mc_teacher.py
new file mode 100644
index 0000000..0e662c9
--- /dev/null
+++ b/addons/mc_education_base/models/mc_teacher.py
@@ -0,0 +1,21 @@
+from odoo import fields, models
+
+
+class McTeacher(models.Model):
+ _name = "mc.teacher"
+ _description = "Teacher"
+ _order = "name"
+ _rec_name = "name"
+
+ employee_id = fields.Many2one(
+ "hr.employee", string="Employee", required=True, ondelete="cascade",
+ help="A teacher is an employee. This record only carries academic attributes.",
+ )
+ name = fields.Char(related="employee_id.name", string="Name", store=True, readonly=True)
+ subject_ids = fields.Many2many("mc.subject", string="Subjects Qualified to Teach")
+ max_weekly_periods = fields.Integer(string="Max Weekly Periods", default=30)
+
+ _employee_uniq = models.Constraint(
+ "unique(employee_id)",
+ "This employee already has a teacher record.",
+ )
diff --git a/addons/mc_education_base/security/ir.model.access.csv b/addons/mc_education_base/security/ir.model.access.csv
index 51adc27..00a1f29 100644
--- a/addons/mc_education_base/security/ir.model.access.csv
+++ b/addons/mc_education_base/security/ir.model.access.csv
@@ -3,3 +3,39 @@ access_mc_academic_year_administrator,mc.academic.year.administrator,model_mc_ac
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
+access_mc_room_administrator,mc.room.administrator,model_mc_room,group_school_administrator,1,1,1,1
+access_mc_room_staff,mc.room.staff,model_mc_room,group_school_staff,1,0,0,0
+access_mc_room_teacher,mc.room.teacher,model_mc_room,group_teacher,1,0,0,0
+access_mc_room_accountant,mc.room.accountant,model_mc_room,group_accountant,1,0,0,0
+access_mc_program_administrator,mc.program.administrator,model_mc_program,group_school_administrator,1,1,1,1
+access_mc_program_staff,mc.program.staff,model_mc_program,group_school_staff,1,0,0,0
+access_mc_program_teacher,mc.program.teacher,model_mc_program,group_teacher,1,0,0,0
+access_mc_program_accountant,mc.program.accountant,model_mc_program,group_accountant,1,0,0,0
+access_mc_subject_administrator,mc.subject.administrator,model_mc_subject,group_school_administrator,1,1,1,1
+access_mc_subject_staff,mc.subject.staff,model_mc_subject,group_school_staff,1,0,0,0
+access_mc_subject_teacher,mc.subject.teacher,model_mc_subject,group_teacher,1,0,0,0
+access_mc_subject_accountant,mc.subject.accountant,model_mc_subject,group_accountant,1,0,0,0
+access_mc_teacher_administrator,mc.teacher.administrator,model_mc_teacher,group_school_administrator,1,1,1,1
+access_mc_teacher_staff,mc.teacher.staff,model_mc_teacher,group_school_staff,1,0,0,0
+access_mc_teacher_teacher,mc.teacher.teacher,model_mc_teacher,group_teacher,1,0,0,0
+access_mc_teacher_accountant,mc.teacher.accountant,model_mc_teacher,group_accountant,1,0,0,0
+access_mc_batch_administrator,mc.batch.administrator,model_mc_batch,group_school_administrator,1,1,1,1
+access_mc_batch_staff,mc.batch.staff,model_mc_batch,group_school_staff,1,1,1,0
+access_mc_batch_teacher,mc.batch.teacher,model_mc_batch,group_teacher,1,0,0,0
+access_mc_batch_accountant,mc.batch.accountant,model_mc_batch,group_accountant,1,0,0,0
+access_mc_student_administrator,mc.student.administrator,model_mc_student,group_school_administrator,1,1,1,1
+access_mc_student_staff,mc.student.staff,model_mc_student,group_school_staff,1,1,1,0
+access_mc_student_teacher,mc.student.teacher,model_mc_student,group_teacher,1,0,0,0
+access_mc_student_accountant,mc.student.accountant,model_mc_student,group_accountant,1,0,0,0
+access_mc_guardian_administrator,mc.guardian.administrator,model_mc_guardian,group_school_administrator,1,1,1,1
+access_mc_guardian_staff,mc.guardian.staff,model_mc_guardian,group_school_staff,1,1,1,0
+access_mc_guardian_teacher,mc.guardian.teacher,model_mc_guardian,group_teacher,1,0,0,0
+access_mc_guardian_accountant,mc.guardian.accountant,model_mc_guardian,group_accountant,1,0,0,0
+access_mc_student_guardian_administrator,mc.student.guardian.administrator,model_mc_student_guardian,group_school_administrator,1,1,1,1
+access_mc_student_guardian_staff,mc.student.guardian.staff,model_mc_student_guardian,group_school_staff,1,1,1,0
+access_mc_student_guardian_teacher,mc.student.guardian.teacher,model_mc_student_guardian,group_teacher,1,0,0,0
+access_mc_student_guardian_accountant,mc.student.guardian.accountant,model_mc_student_guardian,group_accountant,1,0,0,0
+access_mc_enrollment_administrator,mc.enrollment.administrator,model_mc_enrollment,group_school_administrator,1,1,1,1
+access_mc_enrollment_staff,mc.enrollment.staff,model_mc_enrollment,group_school_staff,1,1,1,0
+access_mc_enrollment_teacher,mc.enrollment.teacher,model_mc_enrollment,group_teacher,1,0,0,0
+access_mc_enrollment_accountant,mc.enrollment.accountant,model_mc_enrollment,group_accountant,1,0,0,0
diff --git a/addons/mc_education_base/tests/__init__.py b/addons/mc_education_base/tests/__init__.py
index be3fb1a..f9739a1 100644
--- a/addons/mc_education_base/tests/__init__.py
+++ b/addons/mc_education_base/tests/__init__.py
@@ -1 +1,3 @@
from . import test_academic_calendar
+from . import test_people
+from . import test_enrollment
diff --git a/addons/mc_education_base/tests/test_academic_calendar.py b/addons/mc_education_base/tests/test_academic_calendar.py
index 219b640..2e2dfc2 100644
--- a/addons/mc_education_base/tests/test_academic_calendar.py
+++ b/addons/mc_education_base/tests/test_academic_calendar.py
@@ -4,13 +4,17 @@ from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase
from odoo.tools import mute_logger
+# Names are deliberately distinct from the demo data's "2025-26"/"2026-27"
+# (see demo/mc_academic_year_demo.xml) - these tests must pass whether or
+# not demo data is loaded, and mc.academic.year.name is unique per company.
+
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",
+ "name": "TEST-2025-26",
"date_start": "2025-06-01",
"date_end": "2026-04-30",
"is_current": True,
@@ -18,7 +22,7 @@ class TestAcademicCalendar(TransactionCase):
self.assertTrue(year_a.is_current)
year_b = Year.create({
- "name": "2026-27",
+ "name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
"is_current": True,
@@ -30,13 +34,13 @@ class TestAcademicCalendar(TransactionCase):
def test_setting_current_on_write_unsets_previous(self):
Year = self.env["mc.academic.year"]
year_a = Year.create({
- "name": "2025-26",
+ "name": "TEST-2025-26",
"date_start": "2025-06-01",
"date_end": "2026-04-30",
"is_current": True,
})
year_b = Year.create({
- "name": "2026-27",
+ "name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
"is_current": False,
@@ -54,7 +58,7 @@ class TestAcademicCalendar(TransactionCase):
# that writes around it (direct SQL, a future bug in the toggle logic).
Year = self.env["mc.academic.year"]
Year.create({
- "name": "2025-26",
+ "name": "TEST-2025-26",
"date_start": "2025-06-01",
"date_end": "2026-04-30",
"is_current": True,
@@ -64,21 +68,21 @@ class TestAcademicCalendar(TransactionCase):
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())",
+ "VALUES ('TEST-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",
+ "name": "TEST-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",
+ "name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
})
@@ -92,7 +96,7 @@ class TestAcademicCalendar(TransactionCase):
def test_term_must_fall_within_year(self):
year = self.env["mc.academic.year"].create({
- "name": "2026-27",
+ "name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
})
@@ -107,7 +111,7 @@ class TestAcademicCalendar(TransactionCase):
@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",
+ "name": "TEST-2026-27",
"date_start": "2026-06-01",
"date_end": "2027-04-30",
})
diff --git a/addons/mc_education_base/tests/test_enrollment.py b/addons/mc_education_base/tests/test_enrollment.py
new file mode 100644
index 0000000..4b7a6ea
--- /dev/null
+++ b/addons/mc_education_base/tests/test_enrollment.py
@@ -0,0 +1,86 @@
+from psycopg2 import IntegrityError
+
+from odoo.exceptions import ValidationError
+from odoo.tests.common import TransactionCase
+from odoo.tools import mute_logger
+
+
+class TestEnrollment(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ # Names/codes are deliberately distinct from demo data (see
+ # demo/mc_program_demo.xml, demo/mc_academic_year_demo.xml) -
+ # these tests must pass whether or not demo data is loaded.
+ cls.year = cls.env["mc.academic.year"].create({
+ "name": "TEST-2026-27",
+ "date_start": "2026-06-01",
+ "date_end": "2027-04-30",
+ })
+ cls.other_year = cls.env["mc.academic.year"].create({
+ "name": "TEST-2027-28",
+ "date_start": "2027-06-01",
+ "date_end": "2028-04-30",
+ })
+ cls.program = cls.env["mc.program"].create({
+ "name": "Test Grade 8 CBSE", "code": "TEST-G8", "sequence_no": 8,
+ "display_label": "Test Grade 8",
+ })
+ cls.batch_a = cls.env["mc.batch"].create({
+ "name": "Grade 8-A", "program_id": cls.program.id, "year_id": cls.year.id,
+ })
+ cls.batch_b = cls.env["mc.batch"].create({
+ "name": "Grade 8-B", "program_id": cls.program.id, "year_id": cls.year.id,
+ })
+ partner = cls.env["res.partner"].create({"name": "Test Student"})
+ cls.student = cls.env["mc.student"].create({
+ "partner_id": partner.id, "name": "Test Student",
+ })
+
+ def _enroll(self, batch, year, state="active", roll_no="1"):
+ return self.env["mc.enrollment"].create({
+ "student_id": self.student.id,
+ "program_id": self.program.id,
+ "batch_id": batch.id,
+ "year_id": year.id,
+ "state": state,
+ "roll_no": roll_no,
+ })
+
+ def test_admission_no_auto_generated(self):
+ self.assertTrue(self.student.admission_no)
+ self.assertIn("ADM", self.student.admission_no)
+
+ def test_second_active_enrollment_same_year_rejected(self):
+ self._enroll(self.batch_a, self.year)
+ with self.assertRaises(ValidationError):
+ self._enroll(self.batch_b, self.year)
+
+ @mute_logger("odoo.sql_db")
+ def test_db_index_backs_the_rule_even_if_orm_check_is_bypassed(self):
+ # The @api.constrains gives a friendly message; the partial unique
+ # index is what actually guarantees the rule. Prove the index is
+ # there by writing around the ORM check.
+ self._enroll(self.batch_a, self.year)
+ with self.assertRaises(IntegrityError):
+ with self.cr.savepoint():
+ self.env.cr.execute(
+ "INSERT INTO mc_enrollment "
+ "(student_id, program_id, batch_id, year_id, state, create_uid, write_uid, create_date, write_date) "
+ "VALUES (%s, %s, %s, %s, 'active', %s, %s, now(), now())",
+ (self.student.id, self.program.id, self.batch_b.id, self.year.id,
+ self.env.uid, self.env.uid),
+ )
+
+ def test_active_enrollment_allowed_in_different_year(self):
+ self._enroll(self.batch_a, self.year)
+ # Should not raise: different academic year, no conflict.
+ self._enroll(self.batch_a, self.other_year)
+
+ def test_withdrawing_then_re_enrolling_active_is_allowed(self):
+ first = self._enroll(self.batch_a, self.year)
+ first.state = "withdrawn"
+ # Should not raise: the only active row for this student+year was
+ # just withdrawn, so a new active enrollment is legitimate.
+ self._enroll(self.batch_b, self.year)
diff --git a/addons/mc_education_base/tests/test_people.py b/addons/mc_education_base/tests/test_people.py
new file mode 100644
index 0000000..ca7daf6
--- /dev/null
+++ b/addons/mc_education_base/tests/test_people.py
@@ -0,0 +1,72 @@
+from psycopg2 import IntegrityError
+
+from odoo.tests.common import TransactionCase
+from odoo.tools import mute_logger
+
+
+class TestPeople(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.student_partner = cls.env["res.partner"].create({"name": "Test Student"})
+ cls.student = cls.env["mc.student"].create({
+ "partner_id": cls.student_partner.id, "name": "Test Student",
+ })
+ cls.guardian_a_partner = cls.env["res.partner"].create({"name": "Guardian A"})
+ cls.guardian_a = cls.env["mc.guardian"].create({
+ "partner_id": cls.guardian_a_partner.id, "name": "Guardian A",
+ })
+ cls.guardian_b_partner = cls.env["res.partner"].create({"name": "Guardian B"})
+ cls.guardian_b = cls.env["mc.guardian"].create({
+ "partner_id": cls.guardian_b_partner.id, "name": "Guardian B",
+ })
+
+ def test_setting_primary_on_create_unsets_previous(self):
+ Link = self.env["mc.student.guardian"]
+ link_a = Link.create({
+ "student_id": self.student.id, "guardian_id": self.guardian_a.id,
+ "relationship": "mother", "is_primary": True,
+ })
+ link_b = Link.create({
+ "student_id": self.student.id, "guardian_id": self.guardian_b.id,
+ "relationship": "father", "is_primary": True,
+ })
+ self.assertFalse(link_a.is_primary, "A second primary guardian must unset the first.")
+ self.assertTrue(link_b.is_primary)
+
+ def test_setting_primary_on_write_unsets_previous(self):
+ Link = self.env["mc.student.guardian"]
+ link_a = Link.create({
+ "student_id": self.student.id, "guardian_id": self.guardian_a.id,
+ "relationship": "mother", "is_primary": True,
+ })
+ link_b = Link.create({
+ "student_id": self.student.id, "guardian_id": self.guardian_b.id,
+ "relationship": "father", "is_primary": False,
+ })
+ link_b.write({"is_primary": True})
+ self.assertFalse(link_a.is_primary)
+ self.assertTrue(link_b.is_primary)
+
+ @mute_logger("odoo.sql_db")
+ def test_duplicate_student_guardian_link_rejected(self):
+ Link = self.env["mc.student.guardian"]
+ Link.create({
+ "student_id": self.student.id, "guardian_id": self.guardian_a.id,
+ "relationship": "mother",
+ })
+ with self.assertRaises(IntegrityError):
+ with self.cr.savepoint():
+ Link.create({
+ "student_id": self.student.id, "guardian_id": self.guardian_a.id,
+ "relationship": "father",
+ })
+
+ @mute_logger("odoo.sql_db")
+ def test_partner_cannot_back_two_student_records(self):
+ with self.assertRaises(IntegrityError):
+ with self.cr.savepoint():
+ self.env["mc.student"].create({
+ "partner_id": self.student_partner.id, "name": "Duplicate",
+ })
diff --git a/addons/mc_education_base/views/mc_batch_views.xml b/addons/mc_education_base/views/mc_batch_views.xml
new file mode 100644
index 0000000..c180b58
--- /dev/null
+++ b/addons/mc_education_base/views/mc_batch_views.xml
@@ -0,0 +1,49 @@
+
+
+
+ mc.batch.list
+ mc.batch
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mc.batch.form
+ mc.batch
+
+
+
+
+
+
+ Batches
+ mc.batch
+ list,form
+
+
diff --git a/addons/mc_education_base/views/mc_education_menus.xml b/addons/mc_education_base/views/mc_education_menus.xml
index c6502e2..8713d8a 100644
--- a/addons/mc_education_base/views/mc_education_menus.xml
+++ b/addons/mc_education_base/views/mc_education_menus.xml
@@ -3,6 +3,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -14,4 +35,20 @@
+
+
+
+
+
+
+
+
diff --git a/addons/mc_education_base/views/mc_enrollment_views.xml b/addons/mc_education_base/views/mc_enrollment_views.xml
new file mode 100644
index 0000000..0da7168
--- /dev/null
+++ b/addons/mc_education_base/views/mc_enrollment_views.xml
@@ -0,0 +1,50 @@
+
+
+
+ mc.enrollment.list
+ mc.enrollment
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mc.enrollment.form
+ mc.enrollment
+
+
+
+
+
+
+ Enrollments
+ mc.enrollment
+ list,form
+
+
diff --git a/addons/mc_education_base/views/mc_guardian_views.xml b/addons/mc_education_base/views/mc_guardian_views.xml
new file mode 100644
index 0000000..24fbca9
--- /dev/null
+++ b/addons/mc_education_base/views/mc_guardian_views.xml
@@ -0,0 +1,54 @@
+
+
+
+ mc.guardian.list
+ mc.guardian
+
+
+
+
+
+
+
+
+
+
+
+ mc.guardian.form
+ mc.guardian
+
+
+
+
+
+
+ Guardians
+ mc.guardian
+ list,form
+
+
diff --git a/addons/mc_education_base/views/mc_program_views.xml b/addons/mc_education_base/views/mc_program_views.xml
new file mode 100644
index 0000000..a140648
--- /dev/null
+++ b/addons/mc_education_base/views/mc_program_views.xml
@@ -0,0 +1,49 @@
+
+
+
+ mc.program.list
+ mc.program
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mc.program.form
+ mc.program
+
+
+
+
+
+
+ Programs
+ mc.program
+ list,form
+
+
diff --git a/addons/mc_education_base/views/mc_room_views.xml b/addons/mc_education_base/views/mc_room_views.xml
new file mode 100644
index 0000000..55955a7
--- /dev/null
+++ b/addons/mc_education_base/views/mc_room_views.xml
@@ -0,0 +1,41 @@
+
+
+
+ mc.room.list
+ mc.room
+
+
+
+
+
+
+
+
+
+
+
+ mc.room.form
+ mc.room
+
+
+
+
+
+
+ Rooms
+ mc.room
+ list,form
+
+
diff --git a/addons/mc_education_base/views/mc_student_views.xml b/addons/mc_education_base/views/mc_student_views.xml
new file mode 100644
index 0000000..f6b825f
--- /dev/null
+++ b/addons/mc_education_base/views/mc_student_views.xml
@@ -0,0 +1,75 @@
+
+
+
+ mc.student.list
+ mc.student
+
+
+
+
+
+
+
+
+
+
+
+
+ mc.student.form
+ mc.student
+
+
+
+
+
+
+ Students
+ mc.student
+ list,form
+
+
diff --git a/addons/mc_education_base/views/mc_subject_views.xml b/addons/mc_education_base/views/mc_subject_views.xml
new file mode 100644
index 0000000..9c9d86f
--- /dev/null
+++ b/addons/mc_education_base/views/mc_subject_views.xml
@@ -0,0 +1,41 @@
+
+
+
+ mc.subject.list
+ mc.subject
+
+
+
+
+
+
+
+
+
+
+
+ mc.subject.form
+ mc.subject
+
+
+
+
+
+
+ Subjects
+ mc.subject
+ list,form
+
+
diff --git a/addons/mc_education_base/views/mc_teacher_views.xml b/addons/mc_education_base/views/mc_teacher_views.xml
new file mode 100644
index 0000000..bcab9d0
--- /dev/null
+++ b/addons/mc_education_base/views/mc_teacher_views.xml
@@ -0,0 +1,39 @@
+
+
+
+ mc.teacher.list
+ mc.teacher
+
+
+
+
+
+
+
+
+
+
+ mc.teacher.form
+ mc.teacher
+
+
+
+
+
+
+ Teachers
+ mc.teacher
+ list,form
+
+