From 709bd2eca7b53f9af9bfed329115bf8b47802706 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 11 Sep 2026 09:58:36 -0400 Subject: [PATCH] O1: complete the gate - program, subject, room, batch, people, enrollment Rounds out mc_education_base with the remaining O1 models: mc.program, mc.subject, mc.room, mc.batch, mc.teacher, mc.student, mc.guardian, mc.student.guardian, and mc.enrollment - the spine everything else in the O1 table (attendance, timetable, exam, portal) is built against. Two rules get the same "DB constraint is the real guarantee, the ORM does a friendly pre-check" treatment as academic.year.is_current: - At most one primary guardian per student (mc.student.guardian): partial unique index on student_id WHERE is_primary, plus a create/write toggle. - At most one Active enrollment per student per year (mc.enrollment): partial unique index on (student_id, year_id) WHERE state='active'. This one is CLAUDE.md's flagship rule ("Enforce as a database constraint, not application logic"). Two more real bugs surfaced by testing against a live odoo:19.0 container rather than trusting the code by inspection: - The partial unique index fires at INSERT/UPDATE time, before @api.constrains ever runs - so a naive "index + constrains for a friendly message" design never reaches the friendly message, the raw IntegrityError wins the race. Fixed by pre-checking for a conflict in create()/write() before calling super(), with @api.constrains kept only as a backstop for batch creates. - create() issues a direct SQL INSERT that does not wait for unrelated pending writes in the ORM cache (e.g. withdrawing one enrollment right before creating its replacement, in the same method) - needs an explicit self.env.flush_all() first, same lesson as the is_current toggle. Also caught before it became a permanent test flake: the enrollment and academic-calendar tests originally hardcoded the same year names ("2025-26", "2026-27") and program code ("G8") as the demo data. Passed in isolation, failed as soon as demo data was loaded first - so verification here included a combined `--without-demo=False --test-enable` run, matching what CI actually does, not just an isolated test-tagged run. Renamed to TEST-prefixed fixtures. Security access rows added for all new models across the four internal groups (Administrator: full CRUD everywhere; Staff: full CRUD on the people/enrollment models that are front office's daily job, read-only on academic structure; Teacher/Accountant: read-only across the board, narrower record rules land with the modules that need them - attendance, exam, portal). No portal-group access yet; that is O7's job once explicit ownership-scoped record rules exist - granting it now without those rules would be exactly the "identifier supplied by the client" hole CLAUDE.md's standing security rule warns about. Demo data populates the shared/DEMO_SCRIPT.md cast: Meera Krishnan as primary guardian of both Aditya (Grade 8-A) and Ananya (Grade 5-B) - the multi-child guardian view the script calls "the single most convincing portal feature" - plus Arun Prakash as Grade 8-A's class teacher. Verified by querying the resulting database directly, not just by the install succeeding. Co-Authored-By: Claude Sonnet 5 --- addons/mc_education_base/__manifest__.py | 16 +++ .../data/mc_sequence_data.xml | 13 ++ .../mc_education_base/demo/mc_batch_demo.xml | 18 +++ .../demo/mc_enrollment_demo.xml | 21 ++++ .../mc_education_base/demo/mc_people_demo.xml | 63 ++++++++++ .../demo/mc_program_demo.xml | 17 +++ .../mc_education_base/demo/mc_room_demo.xml | 15 +++ .../demo/mc_subject_demo.xml | 29 +++++ .../demo/mc_teacher_demo.xml | 17 +++ addons/mc_education_base/models/__init__.py | 9 ++ addons/mc_education_base/models/mc_batch.py | 24 ++++ .../mc_education_base/models/mc_enrollment.py | 112 ++++++++++++++++++ .../mc_education_base/models/mc_guardian.py | 25 ++++ addons/mc_education_base/models/mc_program.py | 31 +++++ addons/mc_education_base/models/mc_room.py | 23 ++++ addons/mc_education_base/models/mc_student.py | 66 +++++++++++ .../models/mc_student_guardian.py | 80 +++++++++++++ addons/mc_education_base/models/mc_subject.py | 18 +++ addons/mc_education_base/models/mc_teacher.py | 21 ++++ .../security/ir.model.access.csv | 36 ++++++ addons/mc_education_base/tests/__init__.py | 2 + .../tests/test_academic_calendar.py | 24 ++-- .../tests/test_enrollment.py | 86 ++++++++++++++ addons/mc_education_base/tests/test_people.py | 72 +++++++++++ .../views/mc_batch_views.xml | 49 ++++++++ .../views/mc_education_menus.xml | 37 ++++++ .../views/mc_enrollment_views.xml | 50 ++++++++ .../views/mc_guardian_views.xml | 54 +++++++++ .../views/mc_program_views.xml | 49 ++++++++ .../mc_education_base/views/mc_room_views.xml | 41 +++++++ .../views/mc_student_views.xml | 75 ++++++++++++ .../views/mc_subject_views.xml | 41 +++++++ .../views/mc_teacher_views.xml | 39 ++++++ 33 files changed, 1263 insertions(+), 10 deletions(-) create mode 100644 addons/mc_education_base/data/mc_sequence_data.xml create mode 100644 addons/mc_education_base/demo/mc_batch_demo.xml create mode 100644 addons/mc_education_base/demo/mc_enrollment_demo.xml create mode 100644 addons/mc_education_base/demo/mc_people_demo.xml create mode 100644 addons/mc_education_base/demo/mc_program_demo.xml create mode 100644 addons/mc_education_base/demo/mc_room_demo.xml create mode 100644 addons/mc_education_base/demo/mc_subject_demo.xml create mode 100644 addons/mc_education_base/demo/mc_teacher_demo.xml create mode 100644 addons/mc_education_base/models/mc_batch.py create mode 100644 addons/mc_education_base/models/mc_enrollment.py create mode 100644 addons/mc_education_base/models/mc_guardian.py create mode 100644 addons/mc_education_base/models/mc_program.py create mode 100644 addons/mc_education_base/models/mc_room.py create mode 100644 addons/mc_education_base/models/mc_student.py create mode 100644 addons/mc_education_base/models/mc_student_guardian.py create mode 100644 addons/mc_education_base/models/mc_subject.py create mode 100644 addons/mc_education_base/models/mc_teacher.py create mode 100644 addons/mc_education_base/tests/test_enrollment.py create mode 100644 addons/mc_education_base/tests/test_people.py create mode 100644 addons/mc_education_base/views/mc_batch_views.xml create mode 100644 addons/mc_education_base/views/mc_enrollment_views.xml create mode 100644 addons/mc_education_base/views/mc_guardian_views.xml create mode 100644 addons/mc_education_base/views/mc_program_views.xml create mode 100644 addons/mc_education_base/views/mc_room_views.xml create mode 100644 addons/mc_education_base/views/mc_student_views.xml create mode 100644 addons/mc_education_base/views/mc_subject_views.xml create mode 100644 addons/mc_education_base/views/mc_teacher_views.xml 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 + +