feat(education_timetable): complete schedule management and mobile integration

This commit is contained in:
Vidhya 2026-10-13 20:36:30 +05:30
parent 232e22e89a
commit 1d40349dce
14 changed files with 1002 additions and 23 deletions

View File

@ -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()

View File

@ -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)

View File

@ -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')

View File

@ -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!')
]

View File

@ -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}."
)

View File

@ -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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_education_room_admin education.room.admin model_education_room group_education_timetable_admin 1 1 1 1
3 access_education_room_faculty education.room.faculty model_education_room group_education_timetable_faculty 1 0 0 0
4 access_education_room_student education.room.student model_education_room group_education_timetable_student 1 0 0 0
5 access_education_room_portal education.room.portal model_education_room base.group_portal 1 0 0 0
6 access_education_timetable_slot_admin education.timetable.slot.admin model_education_timetable_slot group_education_timetable_admin 1 1 1 1
7 access_education_timetable_slot_faculty education.timetable.slot.faculty model_education_timetable_slot group_education_timetable_faculty 1 0 0 0
8 access_education_timetable_slot_student education.timetable.slot.student model_education_timetable_slot group_education_timetable_student 1 0 0 0
9 access_education_timetable_slot_portal education.timetable.slot.portal model_education_timetable_slot base.group_portal 1 0 0 0
10
11

View File

@ -7,12 +7,25 @@
<record id="group_education_timetable_faculty" model="res.groups">
<field name="name">Faculty</field>
<field name="implied_ids" eval="[(4, ref('group_education_timetable_student'))]"/>
<field name="implied_ids" eval="[(4, ref('group_education_timetable_student')), (4, ref('base.group_user')), (4, ref('education_exam.group_education_exam_teacher'))]"/>
</record>
<record id="group_education_timetable_admin" model="res.groups">
<field name="name">Timetable Administrator</field>
<field name="implied_ids" eval="[(4, ref('group_education_timetable_faculty'))]"/>
<field name="implied_ids" eval="[(4, ref('group_education_timetable_faculty')), (4, ref('education_exam.group_education_exam_administrator'))]"/>
</record>
<!-- Multi-Company Isolation -->
<record id="rule_education_room_company" model="ir.rule">
<field name="name">Education Room Company Isolation</field>
<field name="model_id" ref="model_education_room"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
</record>
<record id="rule_education_timetable_slot_company" model="ir.rule">
<field name="name">Timetable Slot Company Isolation</field>
<field name="model_id" ref="model_education_timetable_slot"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1 @@
from . import test_timetable

View File

@ -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])

View File

@ -31,6 +31,7 @@
</group>
<group>
<field name="building"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="active"/>
</group>
</group>
@ -44,9 +45,27 @@
</field>
</record>
<record id="view_education_room_search" model="ir.ui.view">
<field name="name">education.room.search</field>
<field name="model">education.room</field>
<field name="arch" type="xml">
<search string="Search Rooms">
<field name="name"/>
<field name="code"/>
<field name="building"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group>
<filter string="Building" name="group_building" context="{'group_by': 'building'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_room" model="ir.actions.act_window">
<field name="name">Rooms / Venues</field>
<field name="res_model">education.room</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_education_room_search"/>
</record>
</odoo>

View File

@ -12,6 +12,8 @@
<field name="faculty_id"/>
<field name="room_id"/>
<field name="batch_id"/>
<field name="semester_id" optional="show"/>
<field name="section_id" optional="show"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'confirmed'" decoration-danger="state == 'cancelled'"/>
</list>
</field>
@ -23,7 +25,10 @@
<field name="arch" type="xml">
<form string="Timetable Slot">
<header>
<field name="state" widget="statusbar" options="{'clickable': '1'}"/>
<button name="action_confirm" string="Confirm" type="object" invisible="state != 'draft'" class="oe_highlight"/>
<button name="action_cancel" string="Cancel" type="object" invisible="state == 'cancelled'"/>
<button name="action_draft" string="Reset to Draft" type="object" invisible="state == 'draft'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,cancelled"/>
</header>
<sheet>
<group>
@ -40,6 +45,9 @@
<field name="academic_year_id"/>
<field name="term_id"/>
<field name="batch_id"/>
<field name="semester_id"/>
<field name="section_id"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="active"/>
</group>
</group>
@ -60,6 +68,13 @@
<field name="academic_year_id"/>
<field name="term_id"/>
<field name="batch_id"/>
<field name="semester_id"/>
<field name="section_id"/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group>
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
@ -67,6 +82,8 @@
<filter string="Room" name="group_room" context="{'group_by': 'room_id'}"/>
<filter string="Program" name="group_program" context="{'group_by': 'program_id'}"/>
<filter string="Batch" name="group_batch" context="{'group_by': 'batch_id'}"/>
<filter string="Semester" name="group_semester" context="{'group_by': 'semester_id'}"/>
<filter string="Section" name="group_section" context="{'group_by': 'section_id'}"/>
</group>
</search>
</field>

View File

@ -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<List>());
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 {

View File

@ -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', () {

View File

@ -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<Override> 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);
});
}