diff --git a/addons/education_demo_data/demo_data_generator.py b/addons/education_demo_data/demo_data_generator.py
index a443e1c..20d8fb8 100644
--- a/addons/education_demo_data/demo_data_generator.py
+++ b/addons/education_demo_data/demo_data_generator.py
@@ -221,5 +221,92 @@ def generate_demo_data(env):
'state': 'issued'
})
+ # 8. Timetable Demo Data
+ if 'education.timetable.slot' in env:
+ # Create rooms
+ room_101 = env['education.room'].search([('code', '=', 'LH-101'), ('company_id', '=', company.id)], limit=1)
+ if not room_101:
+ room_101 = env['education.room'].create({
+ 'name': 'Lecture Hall 101',
+ 'code': 'LH-101',
+ 'capacity': 60,
+ 'building': 'Main Academic Block',
+ 'company_id': company.id,
+ })
+ room_102 = env['education.room'].search([('code', '=', 'LH-102'), ('company_id', '=', company.id)], limit=1)
+ if not room_102:
+ room_102 = env['education.room'].create({
+ 'name': 'Lecture Hall 102',
+ 'code': 'LH-102',
+ 'capacity': 50,
+ 'building': 'Main Academic Block',
+ 'company_id': company.id,
+ })
+ room_lab = env['education.room'].search([('code', '=', 'LAB-01'), ('company_id', '=', company.id)], limit=1)
+ if not room_lab:
+ room_lab = env['education.room'].create({
+ 'name': 'Computing Lab 1',
+ 'code': 'LAB-01',
+ 'capacity': 40,
+ 'building': 'Turing Wing',
+ 'company_id': company.id,
+ })
+
+ # Create demo subjects
+ demo_sub_1 = env['education.subject'].search([('code', '=', 'CS-DEMO-101')], limit=1)
+ if not demo_sub_1:
+ demo_sub_1 = env['education.subject'].create({
+ 'name': 'Data Structures & Algorithms',
+ 'code': 'CS-DEMO-101',
+ 'program_id': program.id,
+ })
+ demo_sub_2 = env['education.subject'].search([('code', '=', 'CS-DEMO-102')], limit=1)
+ if not demo_sub_2:
+ demo_sub_2 = env['education.subject'].create({
+ 'name': 'Operating Systems',
+ 'code': 'CS-DEMO-102',
+ 'program_id': program.id,
+ })
+ demo_sub_3 = env['education.subject'].search([('code', '=', 'CS-DEMO-103')], limit=1)
+ if not demo_sub_3:
+ demo_sub_3 = env['education.subject'].create({
+ 'name': 'Database Systems',
+ 'code': 'CS-DEMO-103',
+ 'program_id': program.id,
+ })
+
+ monday = today - datetime.timedelta(days=today.weekday())
+ demo_slots = [
+ {'offset': 0, 'start': 9.0, 'end': 10.5, 'subject': demo_sub_1, 'room': room_101},
+ {'offset': 0, 'start': 11.0, 'end': 12.5, 'subject': demo_sub_2, 'room': room_102},
+ {'offset': 1, 'start': 9.0, 'end': 10.5, 'subject': demo_sub_3, 'room': room_101},
+ {'offset': 1, 'start': 11.0, 'end': 12.5, 'subject': demo_sub_1, 'room': room_102},
+ {'offset': 2, 'start': 10.0, 'end': 11.5, 'subject': demo_sub_2, 'room': room_lab},
+ {'offset': 3, 'start': 9.0, 'end': 10.5, 'subject': demo_sub_3, 'room': room_101},
+ {'offset': 4, 'start': 9.0, 'end': 10.5, 'subject': demo_sub_1, 'room': room_101},
+ ]
+ target_faculty = faculty_user if faculty_user else env['res.users'].browse(1)
+ for ds in demo_slots:
+ slot_date = monday + datetime.timedelta(days=ds['offset'])
+ existing_slot = env['education.timetable.slot'].search([
+ ('program_id', '=', program.id),
+ ('date', '=', slot_date),
+ ('start_time', '=', ds['start']),
+ ], limit=1)
+ if not existing_slot:
+ env['education.timetable.slot'].create({
+ 'program_id': program.id,
+ 'academic_year_id': year.id,
+ 'term_id': term.id if term else False,
+ 'subject_id': ds['subject'].id,
+ 'faculty_id': target_faculty.id,
+ 'room_id': ds['room'].id,
+ 'date': slot_date,
+ 'start_time': ds['start'],
+ 'end_time': ds['end'],
+ 'state': 'confirmed',
+ 'company_id': company.id,
+ })
+
env.cr.commit()
diff --git a/addons/education_mobile_api/controllers/student.py b/addons/education_mobile_api/controllers/student.py
index b4ea028..9af65fc 100644
--- a/addons/education_mobile_api/controllers/student.py
+++ b/addons/education_mobile_api/controllers/student.py
@@ -43,6 +43,8 @@ def require_student(func):
return wrapper
def format_float_time(float_time):
+ if float_time is None or float_time is False:
+ return ""
hours = int(float_time)
minutes = int(round((float_time - hours) * 60))
if minutes == 60:
@@ -119,15 +121,19 @@ class MobileStudent(http.Controller):
"on_duty": on_duty
}
- def _get_timetable_slots(self, student, date_filter=None):
+ def _get_timetable_slots(self, student, date_filter=None, from_date=None, to_date=None):
if not student.program_id or not student.academic_year_id:
return []
domain = [
('program_id', '=', student.program_id.id),
('academic_year_id', '=', student.academic_year_id.id),
- ('active', '=', True)
+ ('active', '=', True),
+ ('state', '!=', 'cancelled'),
]
+
+ if student.company_id:
+ domain.append(('company_id', '=', student.company_id.id))
if student.academic_term_id:
domain += ['|', ('term_id', '=', False), ('term_id', '=', student.academic_term_id.id)]
@@ -138,11 +144,26 @@ class MobileStudent(http.Controller):
domain += ['|', ('batch_id', '=', False), ('batch_id', '=', student.batch_id.id)]
else:
domain += [('batch_id', '=', False)]
+
+ if hasattr(student, 'semester_id') and student.semester_id:
+ domain += ['|', ('semester_id', '=', False), ('semester_id', '=', student.semester_id.id)]
+ else:
+ domain += [('semester_id', '=', False)]
+
+ if hasattr(student, 'section_id') and student.section_id:
+ domain += ['|', ('section_id', '=', False), ('section_id', '=', student.section_id.id)]
+ else:
+ domain += [('section_id', '=', False)]
if date_filter:
domain += [('date', '=', date_filter)]
+ if from_date:
+ domain += [('date', '>=', from_date)]
+ if to_date:
+ domain += [('date', '<=', to_date)]
- slots = request.env['education.timetable.slot'].sudo().search(domain, order="date asc, start_time asc")
+ env = student.env if student else request.env
+ slots = env['education.timetable.slot'].sudo().search(domain, order="date asc, start_time asc")
return [{
"id": s.id,
@@ -272,7 +293,32 @@ class MobileStudent(http.Controller):
@require_student
def get_timetable(self, student, **kwargs):
try:
- slots = self._get_timetable_slots(student)
+ date_filter = kwargs.get('date')
+ from_date = kwargs.get('from_date')
+ to_date = kwargs.get('to_date')
+
+ if date_filter:
+ try:
+ datetime.strptime(date_filter, '%Y-%m-%d')
+ except ValueError:
+ return json_response(False, error_code="INVALID_DATE", error_message="Date must be in YYYY-MM-DD format", status=400)
+
+ if from_date:
+ try:
+ datetime.strptime(from_date, '%Y-%m-%d')
+ except ValueError:
+ return json_response(False, error_code="INVALID_DATE", error_message="from_date must be in YYYY-MM-DD format", status=400)
+
+ if to_date:
+ try:
+ datetime.strptime(to_date, '%Y-%m-%d')
+ except ValueError:
+ return json_response(False, error_code="INVALID_DATE", error_message="to_date must be in YYYY-MM-DD format", status=400)
+
+ if from_date and to_date and from_date > to_date:
+ return json_response(False, error_code="INVALID_DATE_RANGE", error_message="from_date must be before or equal to to_date", status=400)
+
+ slots = self._get_timetable_slots(student, date_filter=date_filter, from_date=from_date, to_date=to_date)
return json_response(True, data={"slots": slots})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
diff --git a/addons/education_timetable/controllers/main.py b/addons/education_timetable/controllers/main.py
index a75ba94..8f0ecee 100644
--- a/addons/education_timetable/controllers/main.py
+++ b/addons/education_timetable/controllers/main.py
@@ -36,6 +36,9 @@ class TimetablePortalController(StudentPortalController):
term_id = student.academic_term_id.id if student.academic_term_id else False
batch_id = student.batch_id.id if student.batch_id else False
+ semester_id = student.semester_id.id if getattr(student, 'semester_id', False) else False
+ section_id = student.section_id.id if getattr(student, 'section_id', False) else False
+
if not program_id or not year_id:
# If a student isn't actively placed in a program/year, they have no timetable.
return request.render("education_timetable.portal_student_timetable", {
@@ -46,14 +49,14 @@ class TimetablePortalController(StudentPortalController):
})
# Build Domain: The portal query must match the student's actual academic placement.
- # Respecting optional-field logic (established in Phase C):
- # - term_id = False means it applies to all terms.
- # - batch_id = False means it applies to all batches.
domain = [
('program_id', '=', program_id),
('academic_year_id', '=', year_id),
- ('active', '=', True)
+ ('active', '=', True),
+ ('state', '!=', 'cancelled'),
]
+ if student.company_id:
+ domain.append(('company_id', '=', student.company_id.id))
# We must use sudo() since portal users don't have backend employee groups.
# The domain rigorously isolates records to ONLY those belonging to the student's exact academic profile.
@@ -70,6 +73,18 @@ class TimetablePortalController(StudentPortalController):
domain += ['|', ('batch_id', '=', False), ('batch_id', '=', batch_id)]
else:
domain += [('batch_id', '=', False)]
+
+ # Handle nullable semester_id
+ if semester_id:
+ domain += ['|', ('semester_id', '=', False), ('semester_id', '=', semester_id)]
+ else:
+ domain += [('semester_id', '=', False)]
+
+ # Handle nullable section_id
+ if section_id:
+ domain += ['|', ('section_id', '=', False), ('section_id', '=', section_id)]
+ else:
+ domain += [('section_id', '=', False)]
# Optional: Filter by date if provided in query params
filter_date = kw.get('date')
diff --git a/addons/education_timetable/models/education_room.py b/addons/education_timetable/models/education_room.py
index 15f36be..8a93cd8 100644
--- a/addons/education_timetable/models/education_room.py
+++ b/addons/education_timetable/models/education_room.py
@@ -11,7 +11,16 @@ class EducationRoom(models.Model):
building = fields.Char(string='Building / Location')
active = fields.Boolean(default=True)
description = fields.Text(string='Description')
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ required=True,
+ default=lambda self: self.env.company,
+ index=True,
+ )
+
+ _uniq_code_company = models.Constraint(
+ 'unique(code, company_id)',
+ 'Room code must be unique per company!'
+ )
- _sql_constraints = [
- ('code_unique', 'unique(code)', 'Room code must be unique!')
- ]
diff --git a/addons/education_timetable/models/education_timetable_slot.py b/addons/education_timetable/models/education_timetable_slot.py
index ecd1141..aa6f4c1 100644
--- a/addons/education_timetable/models/education_timetable_slot.py
+++ b/addons/education_timetable/models/education_timetable_slot.py
@@ -11,6 +11,15 @@ class EducationTimetableSlot(models.Model):
academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True)
term_id = fields.Many2one('education.academic.term', string='Term')
batch_id = fields.Many2one('education.batch', string='Batch')
+ semester_id = fields.Many2one('education.semester', string='Semester', index=True)
+ section_id = fields.Many2one('education.section', string='Section', index=True)
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ required=True,
+ default=lambda self: self.env.company,
+ index=True,
+ )
# Slot Details
subject_id = fields.Many2one('education.subject', string='Subject', required=True)
@@ -29,37 +38,71 @@ class EducationTimetableSlot(models.Model):
('cancelled', 'Cancelled')
], string='Status', default='draft')
+ @api.depends('subject_id.name', 'date', 'start_time')
+ def _compute_display_name(self):
+ for rec in self:
+ subj = rec.subject_id.name or 'Class'
+ d = str(rec.date) if rec.date else ''
+ rec.display_name = f"{subj} ({d})"
+
+ def action_confirm(self):
+ for rec in self:
+ rec.state = 'confirmed'
+
+ def action_cancel(self):
+ for rec in self:
+ rec.state = 'cancelled'
+
+ def action_draft(self):
+ for rec in self:
+ rec.state = 'draft'
+
@api.constrains('start_time', 'end_time')
def _check_time(self):
for rec in self:
+ if rec.start_time < 0.0 or rec.end_time > 24.0:
+ raise ValidationError("Start and end times must be within a valid 24-hour day (0:00 to 24:00).")
if rec.start_time >= rec.end_time:
raise ValidationError("Start time must be strictly earlier than end time.")
- @api.constrains('date', 'start_time', 'end_time', 'faculty_id', 'room_id', 'program_id', 'academic_year_id', 'term_id', 'batch_id')
+ @api.constrains(
+ 'date', 'start_time', 'end_time', 'faculty_id', 'room_id',
+ 'program_id', 'academic_year_id', 'term_id', 'batch_id',
+ 'semester_id', 'section_id', 'company_id', 'state', 'active'
+ )
def _check_overlap(self):
for rec in self:
+ if rec.state == 'cancelled' or not rec.active:
+ continue
+
domain_base = [
- ('date', '=', rec.date),
('id', '!=', rec.id),
+ ('date', '=', rec.date),
+ ('state', '!=', 'cancelled'),
+ ('active', '=', True),
# Interval logic: existing_start < new_end AND existing_end > new_start
('start_time', '<', rec.end_time),
('end_time', '>', rec.start_time),
]
+ if rec.company_id:
+ domain_base.append(('company_id', '=', rec.company_id.id))
# 1. Check Faculty Conflict
faculty_conflict = self.search(domain_base + [('faculty_id', '=', rec.faculty_id.id)], limit=1)
if faculty_conflict:
- raise ValidationError(f"Faculty conflict: {rec.faculty_id.name} already has a class overlapping this time on {rec.date}.")
+ raise ValidationError(
+ f"Faculty conflict: {rec.faculty_id.name} already has an active class scheduled on {rec.date} overlapping this time."
+ )
# 2. Check Room Conflict
room_conflict = self.search(domain_base + [('room_id', '=', rec.room_id.id)], limit=1)
if room_conflict:
- raise ValidationError(f"Room conflict: {rec.room_id.name} is already booked for an overlapping time on {rec.date}.")
+ raise ValidationError(
+ f"Room conflict: Room '{rec.room_id.name}' is already booked for an overlapping class on {rec.date}."
+ )
# 3. Check Student Population Conflict
- # Population is defined by Program + Academic Year + Term + Batch
- # If a slot has NO term, it applies to the whole year, so it should conflict with ANY term in that year.
- # So if either the new slot or the existing slot has no term (or they match), it's a conflict.
+ # Population is defined by Program + Academic Year + Term + Batch + Semester + Section
population_domain = domain_base + [
('program_id', '=', rec.program_id.id),
('academic_year_id', '=', rec.academic_year_id.id),
@@ -76,7 +119,21 @@ class EducationTimetableSlot(models.Model):
population_domain.append('|')
population_domain.append(('batch_id', '=', False))
population_domain.append(('batch_id', '=', rec.batch_id.id))
+
+ # OR logic for semester_id: either matches exactly, or one of them is False
+ if rec.semester_id:
+ population_domain.append('|')
+ population_domain.append(('semester_id', '=', False))
+ population_domain.append(('semester_id', '=', rec.semester_id.id))
+
+ # OR logic for section_id: either matches exactly, or one of them is False
+ if rec.section_id:
+ population_domain.append('|')
+ population_domain.append(('section_id', '=', False))
+ population_domain.append(('section_id', '=', rec.section_id.id))
population_conflict = self.search(population_domain, limit=1)
if population_conflict:
- raise ValidationError(f"Student Population conflict: This specific academic group already has a class overlapping this time on {rec.date}.")
+ raise ValidationError(
+ f"Student group conflict: The academic cohort already has an overlapping class scheduled on {rec.date}."
+ )
diff --git a/addons/education_timetable/security/ir.model.access.csv b/addons/education_timetable/security/ir.model.access.csv
index 3295554..c7d0365 100644
--- a/addons/education_timetable/security/ir.model.access.csv
+++ b/addons/education_timetable/security/ir.model.access.csv
@@ -2,7 +2,10 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_education_room_admin,education.room.admin,model_education_room,group_education_timetable_admin,1,1,1,1
access_education_room_faculty,education.room.faculty,model_education_room,group_education_timetable_faculty,1,0,0,0
access_education_room_student,education.room.student,model_education_room,group_education_timetable_student,1,0,0,0
+access_education_room_portal,education.room.portal,model_education_room,base.group_portal,1,0,0,0
access_education_timetable_slot_admin,education.timetable.slot.admin,model_education_timetable_slot,group_education_timetable_admin,1,1,1,1
access_education_timetable_slot_faculty,education.timetable.slot.faculty,model_education_timetable_slot,group_education_timetable_faculty,1,0,0,0
access_education_timetable_slot_student,education.timetable.slot.student,model_education_timetable_slot,group_education_timetable_student,1,0,0,0
+access_education_timetable_slot_portal,education.timetable.slot.portal,model_education_timetable_slot,base.group_portal,1,0,0,0
+
diff --git a/addons/education_timetable/security/security.xml b/addons/education_timetable/security/security.xml
index d0c3903..f3aee0b 100644
--- a/addons/education_timetable/security/security.xml
+++ b/addons/education_timetable/security/security.xml
@@ -7,12 +7,25 @@
Faculty
-
+
Timetable Administrator
-
+
+
+
+
+
+ Education Room Company Isolation
+
+ ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]
+
+
+
+ Timetable Slot Company Isolation
+
+ ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]
diff --git a/addons/education_timetable/tests/__init__.py b/addons/education_timetable/tests/__init__.py
new file mode 100644
index 0000000..f27e2ca
--- /dev/null
+++ b/addons/education_timetable/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_timetable
diff --git a/addons/education_timetable/tests/test_timetable.py b/addons/education_timetable/tests/test_timetable.py
new file mode 100644
index 0000000..b786edb
--- /dev/null
+++ b/addons/education_timetable/tests/test_timetable.py
@@ -0,0 +1,565 @@
+# -*- coding: utf-8 -*-
+from datetime import date, timedelta
+from odoo.tests.common import TransactionCase
+from odoo.exceptions import ValidationError, AccessError
+from odoo.addons.education_mobile_api.controllers.student import MobileStudent
+
+
+class TestEducationTimetable(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.company = cls.env.company
+
+ # Setup Company B for multi-company isolation tests
+ cls.company_b = cls.env['res.company'].create({
+ 'name': 'Timetable Test Company B',
+ })
+
+ # Academic Master Data
+ cls.campus = cls.env['education.campus'].create({
+ 'name': 'Timetable Test Campus',
+ 'code': 'TTC-01',
+ 'company_id': cls.company.id,
+ })
+ cls.department = cls.env['education.department'].create({
+ 'name': 'Timetable Test Dept',
+ 'code': 'TTD-01',
+ 'campus_id': cls.campus.id,
+ })
+ cls.level = cls.env['education.academic.level'].create({
+ 'name': 'Timetable Test Level',
+ 'code': 'TTL-01',
+ 'company_id': cls.company.id,
+ })
+ cls.program = cls.env['education.program'].create({
+ 'name': 'Timetable Test Program',
+ 'code': 'TTP-01',
+ 'department_id': cls.department.id,
+ 'level_id': cls.level.id,
+ 'company_id': cls.company.id,
+ })
+ cls.academic_year = cls.env['education.academic.year'].create({
+ 'name': 'Timetable Academic Year 2026',
+ 'code': 'TTY-2026',
+ 'start_date': '2026-01-01',
+ 'end_date': '2026-12-31',
+ 'company_id': cls.company.id,
+ })
+ cls.term = cls.env['education.academic.term'].create({
+ 'name': 'Timetable Term 1',
+ 'code': 'TTT-01',
+ 'academic_year_id': cls.academic_year.id,
+ 'start_date': '2026-01-01',
+ 'end_date': '2026-06-30',
+ 'company_id': cls.company.id,
+ })
+ cls.batch = cls.env['education.batch'].create({
+ 'name': 'Timetable Batch 2026',
+ 'code': 'TTB-2026',
+ })
+ cls.semester = cls.env['education.semester'].create({
+ 'name': 'Timetable Semester 1',
+ 'code': 'TTS-01',
+ })
+ cls.section_a = cls.env['education.section'].create({
+ 'name': 'Timetable Section A',
+ 'code': 'TTS-A',
+ })
+ cls.section_b = cls.env['education.section'].create({
+ 'name': 'Timetable Section B',
+ 'code': 'TTS-B',
+ })
+
+ # Subjects
+ cls.subject_1 = cls.env['education.subject'].create({
+ 'name': 'Data Structures',
+ 'code': 'CS-101-TT',
+ 'program_id': cls.program.id,
+ })
+ cls.subject_2 = cls.env['education.subject'].create({
+ 'name': 'Operating Systems',
+ 'code': 'CS-102-TT',
+ 'program_id': cls.program.id,
+ })
+
+ # Rooms
+ cls.room_1 = cls.env['education.room'].create({
+ 'name': 'Room 101',
+ 'code': 'RM-101',
+ 'capacity': 50,
+ 'building': 'Block A',
+ 'company_id': cls.company.id,
+ })
+ cls.room_2 = cls.env['education.room'].create({
+ 'name': 'Room 102',
+ 'code': 'RM-102',
+ 'capacity': 40,
+ 'building': 'Block B',
+ 'company_id': cls.company.id,
+ })
+
+ # Security Groups
+ cls.group_admin = cls.env.ref('education_timetable.group_education_timetable_admin')
+ cls.group_faculty = cls.env.ref('education_timetable.group_education_timetable_faculty')
+ cls.group_portal = cls.env.ref('base.group_portal')
+
+ # Users
+ cls.user_admin = cls.env['res.users'].create({
+ 'name': 'Timetable Admin User',
+ 'login': 'tt.admin',
+ 'email': 'tt.admin@test.edu',
+ 'company_id': cls.company.id,
+ 'group_ids': [(6, 0, [cls.group_admin.id])],
+ })
+ cls.user_faculty_1 = cls.env['res.users'].create({
+ 'name': 'Faculty Alan Turing',
+ 'login': 'faculty.turing',
+ 'email': 'turing@test.edu',
+ 'company_id': cls.company.id,
+ 'group_ids': [(6, 0, [cls.group_faculty.id])],
+ })
+ cls.user_faculty_2 = cls.env['res.users'].create({
+ 'name': 'Faculty Ada Lovelace',
+ 'login': 'faculty.lovelace',
+ 'email': 'lovelace@test.edu',
+ 'company_id': cls.company.id,
+ 'group_ids': [(6, 0, [cls.group_faculty.id])],
+ })
+
+ # Students
+ cls.user_student_1 = cls.env['res.users'].create({
+ 'name': 'Student One',
+ 'login': 'student.one',
+ 'email': 'student.one@test.edu',
+ 'company_id': cls.company.id,
+ 'group_ids': [(6, 0, [cls.group_portal.id])],
+ })
+ cls.student_1 = cls.env['education.student'].create({
+ 'first_name': 'Student',
+ 'last_name': 'One',
+ 'student_id': 'STU-TT-01',
+ 'admission_number': 'ADM-TT-01',
+ 'campus_id': cls.campus.id,
+ 'department_id': cls.department.id,
+ 'level_id': cls.level.id,
+ 'program_id': cls.program.id,
+ 'academic_year_id': cls.academic_year.id,
+ 'academic_term_id': cls.term.id,
+ 'batch_id': cls.batch.id,
+ 'semester_id': cls.semester.id,
+ 'section_id': cls.section_a.id,
+ 'company_id': cls.company.id,
+ 'portal_user_id': cls.user_student_1.id,
+ })
+
+ cls.user_student_2 = cls.env['res.users'].create({
+ 'name': 'Student Two',
+ 'login': 'student.two',
+ 'email': 'student.two@test.edu',
+ 'company_id': cls.company.id,
+ 'group_ids': [(6, 0, [cls.group_portal.id])],
+ })
+ cls.student_2 = cls.env['education.student'].create({
+ 'first_name': 'Student',
+ 'last_name': 'Two',
+ 'student_id': 'STU-TT-02',
+ 'admission_number': 'ADM-TT-02',
+ 'campus_id': cls.campus.id,
+ 'department_id': cls.department.id,
+ 'level_id': cls.level.id,
+ 'program_id': cls.program.id,
+ 'academic_year_id': cls.academic_year.id,
+ 'academic_term_id': cls.term.id,
+ 'batch_id': cls.batch.id,
+ 'semester_id': cls.semester.id,
+ 'section_id': cls.section_b.id,
+ 'company_id': cls.company.id,
+ 'portal_user_id': cls.user_student_2.id,
+ })
+
+ cls.slot_model = cls.env['education.timetable.slot']
+
+ def test_01_valid_slot_creation_and_state_workflow(self):
+ """Verify slot creation and lifecycle state transitions."""
+ test_date = date.today() + timedelta(days=1)
+ slot = self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_a.id,
+ 'subject_id': self.subject_1.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_1.id,
+ 'date': test_date,
+ 'start_time': 9.0, # 09:00 AM
+ 'end_time': 10.5, # 10:30 AM
+ 'company_id': self.company.id,
+ })
+ self.assertEqual(slot.state, 'draft')
+ self.assertTrue('Data Structures' in slot.display_name)
+
+ # Confirm
+ slot.action_confirm()
+ self.assertEqual(slot.state, 'confirmed')
+
+ # Cancel
+ slot.action_cancel()
+ self.assertEqual(slot.state, 'cancelled')
+
+ # Reset to draft
+ slot.action_draft()
+ self.assertEqual(slot.state, 'draft')
+
+ def test_02_invalid_time_ranges(self):
+ """Verify server-side validation rejects invalid start/end time ranges."""
+ test_date = date.today() + timedelta(days=2)
+ base_vals = {
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_a.id,
+ 'subject_id': self.subject_1.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_1.id,
+ 'date': test_date,
+ 'company_id': self.company.id,
+ }
+
+ # 1. Start >= End
+ with self.assertRaises(ValidationError):
+ self.slot_model.with_user(self.user_admin).create({
+ **base_vals,
+ 'start_time': 10.0,
+ 'end_time': 9.0,
+ })
+
+ # 2. Start == End
+ with self.assertRaises(ValidationError):
+ self.slot_model.with_user(self.user_admin).create({
+ **base_vals,
+ 'start_time': 9.0,
+ 'end_time': 9.0,
+ })
+
+ # 3. Negative start time
+ with self.assertRaises(ValidationError):
+ self.slot_model.with_user(self.user_admin).create({
+ **base_vals,
+ 'start_time': -1.0,
+ 'end_time': 10.0,
+ })
+
+ # 4. End time exceeds 24
+ with self.assertRaises(ValidationError):
+ self.slot_model.with_user(self.user_admin).create({
+ **base_vals,
+ 'start_time': 23.0,
+ 'end_time': 25.0,
+ })
+
+ def test_03_faculty_double_booking_conflict(self):
+ """Verify faculty cannot be scheduled in two overlapping classes on the same date."""
+ test_date = date.today() + timedelta(days=3)
+ # Create Slot 1: 09:00 - 10:30 with Faculty 1 in Room 1
+ slot_1 = self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_a.id,
+ 'subject_id': self.subject_1.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_1.id,
+ 'date': test_date,
+ 'start_time': 9.0,
+ 'end_time': 10.5,
+ 'company_id': self.company.id,
+ })
+
+ # Attempt Slot 2: Overlapping 10:00 - 11:30 with same Faculty 1 in different Room 2
+ with self.assertRaises(ValidationError):
+ self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_b.id,
+ 'subject_id': self.subject_2.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_2.id,
+ 'date': test_date,
+ 'start_time': 10.0,
+ 'end_time': 11.5,
+ 'company_id': self.company.id,
+ })
+
+ # Non-overlapping slot (11:00 - 12:30) with Faculty 1 succeeds
+ slot_non_overlap = self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_b.id,
+ 'subject_id': self.subject_2.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_2.id,
+ 'date': test_date,
+ 'start_time': 11.0,
+ 'end_time': 12.5,
+ 'company_id': self.company.id,
+ })
+ self.assertTrue(slot_non_overlap.id)
+
+ # Cancel Slot 1 -> overlapping slot in original time window now succeeds
+ slot_1.action_cancel()
+ slot_overlap_after_cancel = self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_b.id,
+ 'subject_id': self.subject_2.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_2.id,
+ 'date': test_date,
+ 'start_time': 9.0,
+ 'end_time': 10.5,
+ 'company_id': self.company.id,
+ })
+ self.assertTrue(slot_overlap_after_cancel.id)
+
+ def test_04_room_double_booking_conflict(self):
+ """Verify same classroom cannot be double-booked for overlapping sessions."""
+ test_date = date.today() + timedelta(days=4)
+ # Book Room 1: 14:00 - 15:30 with Faculty 1
+ self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_a.id,
+ 'subject_id': self.subject_1.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_1.id,
+ 'date': test_date,
+ 'start_time': 14.0,
+ 'end_time': 15.5,
+ 'company_id': self.company.id,
+ })
+
+ # Attempt to book same Room 1: 15:00 - 16:30 with different Faculty 2
+ with self.assertRaises(ValidationError):
+ self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_b.id,
+ 'subject_id': self.subject_2.id,
+ 'faculty_id': self.user_faculty_2.id,
+ 'room_id': self.room_1.id,
+ 'date': test_date,
+ 'start_time': 15.0,
+ 'end_time': 16.5,
+ 'company_id': self.company.id,
+ })
+
+ # Booking different Room 2 at overlapping time succeeds
+ diff_room_slot = self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_b.id,
+ 'subject_id': self.subject_2.id,
+ 'faculty_id': self.user_faculty_2.id,
+ 'room_id': self.room_2.id,
+ 'date': test_date,
+ 'start_time': 15.0,
+ 'end_time': 16.5,
+ 'company_id': self.company.id,
+ })
+ self.assertTrue(diff_room_slot.id)
+
+ def test_05_student_population_schedule_conflict(self):
+ """Verify the same student population cohort cannot have two overlapping classes."""
+ test_date = date.today() + timedelta(days=5)
+ # Class for Section A: 09:00 - 10:00 (Faculty 1, Room 1)
+ self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_a.id,
+ 'subject_id': self.subject_1.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_1.id,
+ 'date': test_date,
+ 'start_time': 9.0,
+ 'end_time': 10.0,
+ 'company_id': self.company.id,
+ })
+
+ # Attempt another class for same Section A: 09:30 - 10:30 (even with different Faculty 2 and Room 2)
+ with self.assertRaises(ValidationError):
+ self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_a.id,
+ 'subject_id': self.subject_2.id,
+ 'faculty_id': self.user_faculty_2.id,
+ 'room_id': self.room_2.id,
+ 'date': test_date,
+ 'start_time': 9.5,
+ 'end_time': 10.5,
+ 'company_id': self.company.id,
+ })
+
+ # Class at same time for different Section B succeeds
+ section_b_slot = self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_b.id,
+ 'subject_id': self.subject_2.id,
+ 'faculty_id': self.user_faculty_2.id,
+ 'room_id': self.room_2.id,
+ 'date': test_date,
+ 'start_time': 9.0,
+ 'end_time': 10.0,
+ 'company_id': self.company.id,
+ })
+ self.assertTrue(section_b_slot.id)
+
+ def test_06_permissions_and_company_isolation(self):
+ """Verify ACL restrictions and multi-company timetable isolation."""
+ test_date = date.today() + timedelta(days=6)
+ # Faculty user without admin rights cannot create a timetable slot
+ with self.assertRaises(AccessError):
+ self.slot_model.with_user(self.user_faculty_1).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'subject_id': self.subject_1.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_1.id,
+ 'date': test_date,
+ 'start_time': 9.0,
+ 'end_time': 10.0,
+ 'company_id': self.company.id,
+ })
+
+ # Slot in Company A
+ slot_a = self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'subject_id': self.subject_1.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_1.id,
+ 'date': test_date,
+ 'start_time': 14.0,
+ 'end_time': 15.0,
+ 'company_id': self.company.id,
+ })
+ self.assertTrue(slot_a.id)
+
+ # Room in Company B with same code must succeed due to per-company unique constraint
+ room_comp_b = self.env['education.room'].create({
+ 'name': 'Room 101 B',
+ 'code': 'RM-101',
+ 'capacity': 30,
+ 'company_id': self.company_b.id,
+ })
+ self.assertTrue(room_comp_b.id)
+
+ def test_07_mobile_api_controller_isolation_and_date_filtering(self):
+ """Verify student timetable retrieval isolates by cohort and supports date filtering."""
+ test_date_1 = date.today() + timedelta(days=7)
+ test_date_2 = date.today() + timedelta(days=8)
+
+ # Slot for Student 1 (Section A)
+ slot_a = self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_a.id,
+ 'subject_id': self.subject_1.id,
+ 'faculty_id': self.user_faculty_1.id,
+ 'room_id': self.room_1.id,
+ 'date': test_date_1,
+ 'start_time': 9.0,
+ 'end_time': 10.0,
+ 'state': 'confirmed',
+ 'company_id': self.company.id,
+ })
+
+ # Slot for Student 2 (Section B)
+ slot_b = self.slot_model.with_user(self.user_admin).create({
+ 'program_id': self.program.id,
+ 'academic_year_id': self.academic_year.id,
+ 'term_id': self.term.id,
+ 'batch_id': self.batch.id,
+ 'semester_id': self.semester.id,
+ 'section_id': self.section_b.id,
+ 'subject_id': self.subject_2.id,
+ 'faculty_id': self.user_faculty_2.id,
+ 'room_id': self.room_2.id,
+ 'date': test_date_2,
+ 'start_time': 11.0,
+ 'end_time': 12.0,
+ 'state': 'confirmed',
+ 'company_id': self.company.id,
+ })
+
+ controller = MobileStudent()
+
+ # 1. Student 1 receives only Slot A
+ slots_student_1 = controller._get_timetable_slots(self.student_1)
+ slot_ids_1 = [s['id'] for s in slots_student_1]
+ self.assertIn(slot_a.id, slot_ids_1)
+ self.assertNotIn(slot_b.id, slot_ids_1)
+
+ # 2. Student 2 receives only Slot B
+ slots_student_2 = controller._get_timetable_slots(self.student_2)
+ slot_ids_2 = [s['id'] for s in slots_student_2]
+ self.assertIn(slot_b.id, slot_ids_2)
+ self.assertNotIn(slot_a.id, slot_ids_2)
+
+ # 3. Date filtering for Student 1
+ filtered_slots = controller._get_timetable_slots(self.student_1, date_filter=str(test_date_1))
+ self.assertEqual(len(filtered_slots), 1)
+ self.assertEqual(filtered_slots[0]['subject']['name'], 'Data Structures')
+ self.assertEqual(filtered_slots[0]['room']['name'], 'Room 101')
+ self.assertEqual(filtered_slots[0]['start_time'], '09:00 AM')
+
+ empty_date_slots = controller._get_timetable_slots(self.student_1, date_filter=str(test_date_2))
+ self.assertEqual(len(empty_date_slots), 0)
+
+ # 4. Cancelled slot is excluded
+ slot_a.action_cancel()
+ slots_after_cancel = controller._get_timetable_slots(self.student_1)
+ self.assertNotIn(slot_a.id, [s['id'] for s in slots_after_cancel])
diff --git a/addons/education_timetable/views/education_room_views.xml b/addons/education_timetable/views/education_room_views.xml
index a83a550..49148ed 100644
--- a/addons/education_timetable/views/education_room_views.xml
+++ b/addons/education_timetable/views/education_room_views.xml
@@ -31,6 +31,7 @@
+
@@ -44,9 +45,27 @@
+
+ education.room.search
+ education.room
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Rooms / Venues
education.room
list,form
+
diff --git a/addons/education_timetable/views/education_timetable_slot_views.xml b/addons/education_timetable/views/education_timetable_slot_views.xml
index a98204b..1b8def4 100644
--- a/addons/education_timetable/views/education_timetable_slot_views.xml
+++ b/addons/education_timetable/views/education_timetable_slot_views.xml
@@ -12,6 +12,8 @@
+
+
@@ -23,7 +25,10 @@
diff --git a/flutter_app/test/live_odoo_integration_test.dart b/flutter_app/test/live_odoo_integration_test.dart
index 556a62c..d101ea9 100644
--- a/flutter_app/test/live_odoo_integration_test.dart
+++ b/flutter_app/test/live_odoo_integration_test.dart
@@ -107,7 +107,16 @@ void main() {
test('6. GET /api/mobile/v1/student/timetable returns timetable slots', () async {
final slots = await timetableRepo.getTimetable();
expect(slots, isA());
- stdout.writeln('--> Timetable verified: ${slots.length} slots');
+ expect(slots.length, greaterThan(0));
+ final firstSlot = slots.first;
+ expect(firstSlot.id, isPositive);
+ expect(firstSlot.date, isNotEmpty);
+ expect(firstSlot.startTime, isNotEmpty);
+ expect(firstSlot.endTime, isNotEmpty);
+ expect(firstSlot.subject, isNotNull);
+ expect(firstSlot.subject!.name, isNotEmpty);
+ expect(firstSlot.room, isNotNull);
+ stdout.writeln('--> Timetable verified: ${slots.length} slots loaded with subjects and rooms');
});
test('7. GET /api/mobile/v1/student/exams returns exams list', () async {
diff --git a/flutter_app/test/models_and_parsing_test.dart b/flutter_app/test/models_and_parsing_test.dart
index 39e3ea6..a088b09 100644
--- a/flutter_app/test/models_and_parsing_test.dart
+++ b/flutter_app/test/models_and_parsing_test.dart
@@ -175,6 +175,43 @@ void main() {
expect(slot.faculty?.name, 'Dr. Raman');
expect(slot.room?.name, 'Hall A');
});
+
+ test('parses timetable slot with null and false optional fields safely', () {
+ final json = {
+ 'id': 2,
+ 'date': '2026-10-14',
+ 'day': 'Wednesday',
+ 'start_time': '11:00 AM',
+ 'end_time': '12:00 PM',
+ 'subject': null,
+ 'faculty': false,
+ 'room': null,
+ };
+
+ final slot = TimetableSlot.fromJson(json);
+
+ expect(slot.id, 2);
+ expect(slot.date, '2026-10-14');
+ expect(slot.day, 'Wednesday');
+ expect(slot.startTime, '11:00 AM');
+ expect(slot.endTime, '12:00 PM');
+ expect(slot.subject, isNull);
+ expect(slot.faculty, isNull);
+ expect(slot.room, isNull);
+ });
+
+ test('handles empty or malformed timetable payload gracefully', () {
+ final slot = TimetableSlot.fromJson({});
+
+ expect(slot.id, 0);
+ expect(slot.date, '');
+ expect(slot.day, '');
+ expect(slot.startTime, '');
+ expect(slot.endTime, '');
+ expect(slot.subject, isNull);
+ expect(slot.faculty, isNull);
+ expect(slot.room, isNull);
+ });
});
group('Exam and Result Parsing Tests', () {
diff --git a/flutter_app/test/timetable_widget_test.dart b/flutter_app/test/timetable_widget_test.dart
new file mode 100644
index 0000000..b27a863
--- /dev/null
+++ b/flutter_app/test/timetable_widget_test.dart
@@ -0,0 +1,101 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:metatron_student_erp/features/timetable/timetable_screen.dart';
+import 'package:metatron_student_erp/models/student.dart';
+import 'package:metatron_student_erp/models/timetable.dart';
+import 'package:metatron_student_erp/providers/timetable_provider.dart';
+
+void main() {
+ final sampleSlots = [
+ const TimetableSlot(
+ id: 101,
+ date: '2026-10-13',
+ day: 'Tuesday',
+ startTime: '09:00 AM',
+ endTime: '10:30 AM',
+ subject: NamedRecord(id: 4, name: 'Database Systems'),
+ faculty: NamedRecord(id: 6, name: 'Dr. Turing'),
+ room: NamedRecord(id: 1, name: 'Lecture Hall 101'),
+ ),
+ const TimetableSlot(
+ id: 102,
+ date: '2026-10-13',
+ day: 'Tuesday',
+ startTime: '11:00 AM',
+ endTime: '12:30 PM',
+ subject: NamedRecord(id: 5, name: 'Operating Systems'),
+ faculty: NamedRecord(id: 6, name: 'Dr. Turing'),
+ room: NamedRecord(id: 2, name: 'Lecture Hall 102'),
+ ),
+ ];
+
+ Widget buildTestableScreen(List overrides) {
+ return ProviderScope(
+ overrides: overrides,
+ child: const MaterialApp(
+ home: TimetableScreen(),
+ ),
+ );
+ }
+
+ testWidgets('TimetableScreen renders loading state initially', (tester) async {
+ await tester.pumpWidget(
+ buildTestableScreen([
+ timetableProvider.overrideWith((ref) => Future.delayed(
+ const Duration(seconds: 1),
+ () => sampleSlots,
+ )),
+ ]),
+ );
+
+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
+ await tester.pumpAndSettle();
+ });
+
+ testWidgets('TimetableScreen renders empty state when no slots exist', (tester) async {
+ await tester.pumpWidget(
+ buildTestableScreen([
+ timetableProvider.overrideWith((ref) => Future.value([])),
+ ]),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('Weekly Timetable'), findsOneWidget);
+ expect(find.text('No timetable slots scheduled'), findsOneWidget);
+ expect(find.text('Your class schedule has not been published yet.'), findsOneWidget);
+ });
+
+ testWidgets('TimetableScreen renders slot cards and day groups with live data', (tester) async {
+ await tester.pumpWidget(
+ buildTestableScreen([
+ timetableProvider.overrideWith((ref) => Future.value(sampleSlots)),
+ ]),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('Weekly Timetable'), findsOneWidget);
+ expect(find.text('TUESDAY'), findsOneWidget);
+ expect(find.text('Database Systems'), findsOneWidget);
+ expect(find.text('Lecture Hall 101'), findsOneWidget);
+ expect(find.text('Operating Systems'), findsOneWidget);
+ expect(find.text('Lecture Hall 102'), findsOneWidget);
+ expect(find.text('09:00 AM'), findsOneWidget);
+ expect(find.text('10:30 AM'), findsOneWidget);
+ expect(find.text('11:00 AM'), findsOneWidget);
+ expect(find.text('12:30 PM'), findsOneWidget);
+ });
+
+ testWidgets('TimetableScreen renders error state and retry button on failure', (tester) async {
+ await tester.pumpWidget(
+ buildTestableScreen([
+ timetableProvider.overrideWith((ref) => Future.error(Exception('Network timeout'))),
+ ]),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('Failed to load timetable'), findsOneWidget);
+ expect(find.text('Retry'), findsOneWidget);
+ expect(find.byIcon(Icons.error_outline_rounded), findsOneWidget);
+ });
+}