# -*- 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])