566 lines
22 KiB
Python

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