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