566 lines
22 KiB
Python
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])
|