From a36c89222b7ca26506d0b7edf653e178b0fef220 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 11 Sep 2026 12:53:47 -0400 Subject: [PATCH] O5: mc_education_timetable - one dataset, three views, conflict-checked Serves Demo Scene 7: mc.timetable.slot (batch, weekday, period, subject, teacher, room, year, term) is the single dataset. "Three read views" are three entry points into that same model rather than three separate data structures - a stat button on mc.batch, on mc.teacher, and on mc.student (resolved through the student's active enrollment to their batch) each open the same list/search action with a different domain. Auto-generation is out of scope per spec; this module only configures slots by hand (demo data is a real Mon-Fri week for Grade 8-A, since a timetable is a recurring weekly pattern and one week fully represents it, unlike attendance/fees which genuinely need a run of history). Conflict prevention ("a teacher or a room cannot hold two slots in the same weekday+period") follows the same two-layer pattern used for mc.academic.year.is_current and mc.enrollment in O1: a partial unique index per conflict type (teacher, room, and - not explicitly asked for but an obvious extension of the same rule - batch, since a batch can't be in two places at once either) is the actual guarantee, and a pre-check in create()/write() raises a readable ValidationError before the insert/update, not after, for the same reason established building mc.enrollment: the DB index fires first and the friendly message is unreachable otherwise. Co-Authored-By: Claude Sonnet 5 --- addons/mc_education_timetable/__init__.py | 1 + addons/mc_education_timetable/__manifest__.py | 22 ++ .../demo/mc_timetable_slot_demo.xml | 245 ++++++++++++++++++ .../mc_education_timetable/models/__init__.py | 4 + .../mc_education_timetable/models/mc_batch.py | 16 ++ .../models/mc_student.py | 22 ++ .../models/mc_teacher.py | 16 ++ .../models/mc_timetable_slot.py | 108 ++++++++ .../security/ir.model.access.csv | 5 + .../mc_education_timetable/tests/__init__.py | 1 + .../tests/test_timetable.py | 135 ++++++++++ .../views/mc_batch_views.xml | 16 ++ .../views/mc_education_timetable_menus.xml | 6 + .../views/mc_student_views.xml | 16 ++ .../views/mc_teacher_views.xml | 16 ++ .../views/mc_timetable_slot_views.xml | 65 +++++ 16 files changed, 694 insertions(+) create mode 100644 addons/mc_education_timetable/__init__.py create mode 100644 addons/mc_education_timetable/__manifest__.py create mode 100644 addons/mc_education_timetable/demo/mc_timetable_slot_demo.xml create mode 100644 addons/mc_education_timetable/models/__init__.py create mode 100644 addons/mc_education_timetable/models/mc_batch.py create mode 100644 addons/mc_education_timetable/models/mc_student.py create mode 100644 addons/mc_education_timetable/models/mc_teacher.py create mode 100644 addons/mc_education_timetable/models/mc_timetable_slot.py create mode 100644 addons/mc_education_timetable/security/ir.model.access.csv create mode 100644 addons/mc_education_timetable/tests/__init__.py create mode 100644 addons/mc_education_timetable/tests/test_timetable.py create mode 100644 addons/mc_education_timetable/views/mc_batch_views.xml create mode 100644 addons/mc_education_timetable/views/mc_education_timetable_menus.xml create mode 100644 addons/mc_education_timetable/views/mc_student_views.xml create mode 100644 addons/mc_education_timetable/views/mc_teacher_views.xml create mode 100644 addons/mc_education_timetable/views/mc_timetable_slot_views.xml diff --git a/addons/mc_education_timetable/__init__.py b/addons/mc_education_timetable/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/mc_education_timetable/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/mc_education_timetable/__manifest__.py b/addons/mc_education_timetable/__manifest__.py new file mode 100644 index 0000000..5345353 --- /dev/null +++ b/addons/mc_education_timetable/__manifest__.py @@ -0,0 +1,22 @@ +{ + "name": "School ERP - Timetable", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "One timetable dataset, viewed by batch, by teacher, or by student.", + "author": "Metatroncube Software Solutions LLP", + "license": "Other proprietary", + "depends": ["mc_education_base"], + "data": [ + "security/ir.model.access.csv", + "views/mc_timetable_slot_views.xml", + "views/mc_batch_views.xml", + "views/mc_teacher_views.xml", + "views/mc_student_views.xml", + "views/mc_education_timetable_menus.xml", + ], + "demo": [ + "demo/mc_timetable_slot_demo.xml", + ], + "installable": True, + "application": False, +} diff --git a/addons/mc_education_timetable/demo/mc_timetable_slot_demo.xml b/addons/mc_education_timetable/demo/mc_timetable_slot_demo.xml new file mode 100644 index 0000000..87db45e --- /dev/null +++ b/addons/mc_education_timetable/demo/mc_timetable_slot_demo.xml @@ -0,0 +1,245 @@ + + + + + + + mon + 1 + + + + + + + + mon + 2 + + + + + + + mon + 3 + + + + + + + mon + 4 + + + + + + + mon + 5 + + + + + + + + tue + 1 + + + + + + + tue + 2 + + + + + + + + tue + 3 + + + + + + + tue + 4 + + + + + + + tue + 5 + + + + + + + + wed + 1 + + + + + + + wed + 2 + + + + + + + wed + 3 + + + + + + + + wed + 4 + + + + + + + wed + 5 + + + + + + + + thu + 1 + + + + + + + thu + 2 + + + + + + + thu + 3 + + + + + + + thu + 4 + + + + + + + + thu + 5 + + + + + + + + fri + 1 + + + + + + + fri + 2 + + + + + + + fri + 3 + + + + + + + fri + 4 + + + + + + + fri + 5 + + + + + + + + + + mon + 1 + + + + + + + mon + 2 + + + + + + + tue + 1 + + + + diff --git a/addons/mc_education_timetable/models/__init__.py b/addons/mc_education_timetable/models/__init__.py new file mode 100644 index 0000000..e9c13ab --- /dev/null +++ b/addons/mc_education_timetable/models/__init__.py @@ -0,0 +1,4 @@ +from . import mc_timetable_slot +from . import mc_batch +from . import mc_teacher +from . import mc_student diff --git a/addons/mc_education_timetable/models/mc_batch.py b/addons/mc_education_timetable/models/mc_batch.py new file mode 100644 index 0000000..3c4346b --- /dev/null +++ b/addons/mc_education_timetable/models/mc_batch.py @@ -0,0 +1,16 @@ +from odoo import models + + +class McBatch(models.Model): + _inherit = "mc.batch" + + def action_view_timetable(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": "Timetable - %s" % self.name, + "res_model": "mc.timetable.slot", + "view_mode": "list,form", + "domain": [("batch_id", "=", self.id)], + "context": {"default_batch_id": self.id, "search_default_group_weekday": 1}, + } diff --git a/addons/mc_education_timetable/models/mc_student.py b/addons/mc_education_timetable/models/mc_student.py new file mode 100644 index 0000000..6619be4 --- /dev/null +++ b/addons/mc_education_timetable/models/mc_student.py @@ -0,0 +1,22 @@ +from odoo import _, models +from odoo.exceptions import UserError + + +class McStudent(models.Model): + _inherit = "mc.student" + + def action_view_timetable(self): + self.ensure_one() + enrollment = self.env["mc.enrollment"].search([ + ("student_id", "=", self.id), ("state", "=", "active"), + ], limit=1) + if not enrollment: + raise UserError(_("%s has no active enrollment.") % self.name) + return { + "type": "ir.actions.act_window", + "name": "Timetable - %s" % self.name, + "res_model": "mc.timetable.slot", + "view_mode": "list,form", + "domain": [("batch_id", "=", enrollment.batch_id.id)], + "context": {"search_default_group_weekday": 1}, + } diff --git a/addons/mc_education_timetable/models/mc_teacher.py b/addons/mc_education_timetable/models/mc_teacher.py new file mode 100644 index 0000000..d70e99c --- /dev/null +++ b/addons/mc_education_timetable/models/mc_teacher.py @@ -0,0 +1,16 @@ +from odoo import models + + +class McTeacher(models.Model): + _inherit = "mc.teacher" + + def action_view_timetable(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": "Timetable - %s" % (self.name or ""), + "res_model": "mc.timetable.slot", + "view_mode": "list,form", + "domain": [("teacher_id", "=", self.employee_id.id)], + "context": {"search_default_group_weekday": 1}, + } diff --git a/addons/mc_education_timetable/models/mc_timetable_slot.py b/addons/mc_education_timetable/models/mc_timetable_slot.py new file mode 100644 index 0000000..c6cc478 --- /dev/null +++ b/addons/mc_education_timetable/models/mc_timetable_slot.py @@ -0,0 +1,108 @@ +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +WEEKDAYS = [ + ("mon", "Monday"), + ("tue", "Tuesday"), + ("wed", "Wednesday"), + ("thu", "Thursday"), + ("fri", "Friday"), + ("sat", "Saturday"), +] + + +class McTimetableSlot(models.Model): + _name = "mc.timetable.slot" + _description = "Timetable Slot" + _order = "year_id desc, weekday, period" + _rec_name = "display_name" + + batch_id = fields.Many2one("mc.batch", string="Batch", required=True, ondelete="cascade") + weekday = fields.Selection(WEEKDAYS, string="Weekday", required=True) + period = fields.Integer(string="Period", required=True) + subject_id = fields.Many2one("mc.subject", string="Subject", required=True, ondelete="restrict") + teacher_id = fields.Many2one("hr.employee", string="Teacher", ondelete="restrict") + room_id = fields.Many2one("mc.room", string="Room", ondelete="restrict") + year_id = fields.Many2one("mc.academic.year", string="Academic Year", required=True, ondelete="restrict") + term_id = fields.Many2one("mc.academic.term", string="Term", ondelete="restrict") + + @api.depends("batch_id.name", "weekday", "period", "subject_id.name") + def _compute_display_name(self): + weekday_labels = dict(self._fields["weekday"].selection) + for slot in self: + slot.display_name = "%s - %s P%s - %s" % ( + slot.batch_id.name or "?", + weekday_labels.get(slot.weekday, "?"), + slot.period, + slot.subject_id.name or "?", + ) + + def init(self): + # A teacher or a room cannot hold two slots in the same + # weekday+period (O5 spec, verbatim). Partial unique indexes are + # the actual guarantee; _check_no_conflict below only exists to + # turn the same violation into a message a scheduler can read, + # and - same lesson as mc.enrollment - has to run BEFORE the + # insert/update, because the index fires first otherwise. + self.env.cr.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS mc_timetable_slot_teacher_no_clash " + "ON mc_timetable_slot (teacher_id, weekday, period, year_id) " + "WHERE teacher_id IS NOT NULL" + ) + self.env.cr.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS mc_timetable_slot_room_no_clash " + "ON mc_timetable_slot (room_id, weekday, period, year_id) " + "WHERE room_id IS NOT NULL" + ) + self.env.cr.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS mc_timetable_slot_batch_no_clash " + "ON mc_timetable_slot (batch_id, weekday, period, year_id)" + ) + + def _check_no_conflict(self, vals, exclude_id=None): + weekday = vals.get("weekday") + period = vals.get("period") + year_id = vals.get("year_id") + if not (weekday and period and year_id): + return + base_domain = [ + ("weekday", "=", weekday), ("period", "=", period), ("year_id", "=", year_id), + ] + if exclude_id: + base_domain.append(("id", "!=", exclude_id)) + + batch_id = vals.get("batch_id") + if batch_id and self.search_count(base_domain + [("batch_id", "=", batch_id)]): + raise ValidationError(_( + "This batch already has a slot on this weekday and period." + )) + teacher_id = vals.get("teacher_id") + if teacher_id and self.search_count(base_domain + [("teacher_id", "=", teacher_id)]): + raise ValidationError(_( + "This teacher already has a slot on this weekday and period." + )) + room_id = vals.get("room_id") + if room_id and self.search_count(base_domain + [("room_id", "=", room_id)]): + raise ValidationError(_( + "This room is already booked for this weekday and period." + )) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self._check_no_conflict(vals) + return super().create(vals_list) + + def write(self, vals): + if any(key in vals for key in ("weekday", "period", "year_id", "batch_id", "teacher_id", "room_id")): + for slot in self: + merged = { + "weekday": vals.get("weekday", slot.weekday), + "period": vals.get("period", slot.period), + "year_id": vals.get("year_id", slot.year_id.id), + "batch_id": vals.get("batch_id", slot.batch_id.id), + "teacher_id": vals.get("teacher_id", slot.teacher_id.id), + "room_id": vals.get("room_id", slot.room_id.id), + } + self._check_no_conflict(merged, exclude_id=slot.id) + return super().write(vals) diff --git a/addons/mc_education_timetable/security/ir.model.access.csv b/addons/mc_education_timetable/security/ir.model.access.csv new file mode 100644 index 0000000..b939792 --- /dev/null +++ b/addons/mc_education_timetable/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_timetable_slot_administrator,mc.timetable.slot.administrator,model_mc_timetable_slot,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_timetable_slot_staff,mc.timetable.slot.staff,model_mc_timetable_slot,mc_education_base.group_school_staff,1,1,1,1 +access_mc_timetable_slot_teacher,mc.timetable.slot.teacher,model_mc_timetable_slot,mc_education_base.group_teacher,1,0,0,0 +access_mc_timetable_slot_accountant,mc.timetable.slot.accountant,model_mc_timetable_slot,mc_education_base.group_accountant,1,0,0,0 diff --git a/addons/mc_education_timetable/tests/__init__.py b/addons/mc_education_timetable/tests/__init__.py new file mode 100644 index 0000000..f27e2ca --- /dev/null +++ b/addons/mc_education_timetable/tests/__init__.py @@ -0,0 +1 @@ +from . import test_timetable diff --git a/addons/mc_education_timetable/tests/test_timetable.py b/addons/mc_education_timetable/tests/test_timetable.py new file mode 100644 index 0000000..b86bd80 --- /dev/null +++ b/addons/mc_education_timetable/tests/test_timetable.py @@ -0,0 +1,135 @@ +from psycopg2 import IntegrityError + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestTimetable(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.year = cls.env["mc.academic.year"].create({ + "name": "TEST-TT-2026-27", + "date_start": "2026-06-01", "date_end": "2027-04-30", + }) + cls.program = cls.env["mc.program"].create({ + "name": "TEST TT Program", "code": "TEST-TT-P1", "sequence_no": 1, + "display_label": "Test Grade", + }) + cls.batch_a = cls.env["mc.batch"].create({ + "name": "TEST TT Batch A", "program_id": cls.program.id, "year_id": cls.year.id, + }) + cls.batch_b = cls.env["mc.batch"].create({ + "name": "TEST TT Batch B", "program_id": cls.program.id, "year_id": cls.year.id, + }) + cls.subject = cls.env["mc.subject"].create({"name": "TEST TT Subject", "code": "TEST-TT-S1"}) + cls.teacher_employee = cls.env["hr.employee"].create({"name": "TEST TT Teacher"}) + cls.room = cls.env["mc.room"].create({"name": "TEST TT Room"}) + + def test_teacher_double_booking_rejected(self): + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_a.id, "year_id": self.year.id, + "weekday": "mon", "period": 1, "subject_id": self.subject.id, + "teacher_id": self.teacher_employee.id, + }) + with self.assertRaises(ValidationError): + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_b.id, "year_id": self.year.id, + "weekday": "mon", "period": 1, "subject_id": self.subject.id, + "teacher_id": self.teacher_employee.id, + }) + + def test_room_double_booking_rejected(self): + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_a.id, "year_id": self.year.id, + "weekday": "mon", "period": 2, "subject_id": self.subject.id, + "room_id": self.room.id, + }) + with self.assertRaises(ValidationError): + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_b.id, "year_id": self.year.id, + "weekday": "mon", "period": 2, "subject_id": self.subject.id, + "room_id": self.room.id, + }) + + def test_batch_double_booking_rejected(self): + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_a.id, "year_id": self.year.id, + "weekday": "mon", "period": 3, "subject_id": self.subject.id, + }) + with self.assertRaises(ValidationError): + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_a.id, "year_id": self.year.id, + "weekday": "mon", "period": 3, "subject_id": self.subject.id, + }) + + def test_same_teacher_different_period_is_allowed(self): + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_a.id, "year_id": self.year.id, + "weekday": "mon", "period": 4, "subject_id": self.subject.id, + "teacher_id": self.teacher_employee.id, + }) + # Should not raise: different period, same teacher. + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_b.id, "year_id": self.year.id, + "weekday": "mon", "period": 5, "subject_id": self.subject.id, + "teacher_id": self.teacher_employee.id, + }) + + @mute_logger("odoo.sql_db") + def test_db_index_backs_teacher_conflict_even_if_orm_check_bypassed(self): + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_a.id, "year_id": self.year.id, + "weekday": "tue", "period": 1, "subject_id": self.subject.id, + "teacher_id": self.teacher_employee.id, + }) + with self.assertRaises(IntegrityError): + with self.cr.savepoint(): + self.env.cr.execute( + "INSERT INTO mc_timetable_slot " + "(batch_id, year_id, weekday, period, subject_id, teacher_id, " + " create_uid, write_uid, create_date, write_date) " + "VALUES (%s, %s, 'tue', 1, %s, %s, %s, %s, now(), now())", + (self.batch_b.id, self.year.id, self.subject.id, + self.teacher_employee.id, self.env.uid, self.env.uid), + ) + + def test_updating_into_a_conflict_is_rejected(self): + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_a.id, "year_id": self.year.id, + "weekday": "wed", "period": 1, "subject_id": self.subject.id, + "teacher_id": self.teacher_employee.id, + }) + other = self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_b.id, "year_id": self.year.id, + "weekday": "wed", "period": 2, "subject_id": self.subject.id, + "teacher_id": self.teacher_employee.id, + }) + with self.assertRaises(ValidationError): + other.write({"period": 1}) + + def test_batch_view_timetable_resolves_own_slots_only(self): + slot_a = self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_a.id, "year_id": self.year.id, + "weekday": "thu", "period": 1, "subject_id": self.subject.id, + }) + self.env["mc.timetable.slot"].create({ + "batch_id": self.batch_b.id, "year_id": self.year.id, + "weekday": "thu", "period": 1, "subject_id": self.subject.id, + }) + action = self.batch_a.action_view_timetable() + self.assertEqual(action["domain"], [("batch_id", "=", self.batch_a.id)]) + found = self.env["mc.timetable.slot"].search(action["domain"]) + self.assertEqual(found, slot_a) + + def test_student_view_timetable_resolves_through_active_enrollment(self): + partner = self.env["res.partner"].create({"name": "TT Student"}) + student = self.env["mc.student"].create({"partner_id": partner.id, "name": "TT Student"}) + self.env["mc.enrollment"].create({ + "student_id": student.id, "program_id": self.program.id, + "batch_id": self.batch_a.id, "year_id": self.year.id, "state": "active", + }) + action = student.action_view_timetable() + self.assertEqual(action["domain"], [("batch_id", "=", self.batch_a.id)]) diff --git a/addons/mc_education_timetable/views/mc_batch_views.xml b/addons/mc_education_timetable/views/mc_batch_views.xml new file mode 100644 index 0000000..75498f5 --- /dev/null +++ b/addons/mc_education_timetable/views/mc_batch_views.xml @@ -0,0 +1,16 @@ + + + + mc.batch.form.inherit.mc.education.timetable + mc.batch + + + +
+
+
+
+
+
diff --git a/addons/mc_education_timetable/views/mc_education_timetable_menus.xml b/addons/mc_education_timetable/views/mc_education_timetable_menus.xml new file mode 100644 index 0000000..3dc8860 --- /dev/null +++ b/addons/mc_education_timetable/views/mc_education_timetable_menus.xml @@ -0,0 +1,6 @@ + + + + diff --git a/addons/mc_education_timetable/views/mc_student_views.xml b/addons/mc_education_timetable/views/mc_student_views.xml new file mode 100644 index 0000000..6e808fb --- /dev/null +++ b/addons/mc_education_timetable/views/mc_student_views.xml @@ -0,0 +1,16 @@ + + + + mc.student.form.inherit.mc.education.timetable + mc.student + + + +
+
+
+
+
+
diff --git a/addons/mc_education_timetable/views/mc_teacher_views.xml b/addons/mc_education_timetable/views/mc_teacher_views.xml new file mode 100644 index 0000000..62208b1 --- /dev/null +++ b/addons/mc_education_timetable/views/mc_teacher_views.xml @@ -0,0 +1,16 @@ + + + + mc.teacher.form.inherit.mc.education.timetable + mc.teacher + + + +
+
+
+
+
+
diff --git a/addons/mc_education_timetable/views/mc_timetable_slot_views.xml b/addons/mc_education_timetable/views/mc_timetable_slot_views.xml new file mode 100644 index 0000000..167a308 --- /dev/null +++ b/addons/mc_education_timetable/views/mc_timetable_slot_views.xml @@ -0,0 +1,65 @@ + + + + mc.timetable.slot.list + mc.timetable.slot + + + + + + + + + + + + + + + mc.timetable.slot.form + mc.timetable.slot + +
+ + + + + + + + + + + + + + +
+
+
+ + + mc.timetable.slot.search + mc.timetable.slot + + + + + + + + + + + + + + + + Timetable + mc.timetable.slot + list,form + {'search_default_group_weekday': 1} + +