feat(education_timetable): complete schedule management and mobile integration
This commit is contained in:
parent
232e22e89a
commit
1d40349dce
@ -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()
|
||||
|
||||
|
||||
@ -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,16 +121,20 @@ 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)]
|
||||
else:
|
||||
@ -139,10 +145,25 @@ class MobileStudent(http.Controller):
|
||||
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)
|
||||
|
||||
@ -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.
|
||||
@ -71,6 +74,18 @@ class TimetablePortalController(StudentPortalController):
|
||||
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')
|
||||
if filter_date:
|
||||
|
||||
@ -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!')
|
||||
]
|
||||
|
||||
@ -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),
|
||||
@ -77,6 +120,20 @@ class EducationTimetableSlot(models.Model):
|
||||
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}."
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
@ -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>
|
||||
|
||||
1
addons/education_timetable/tests/__init__.py
Normal file
1
addons/education_timetable/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import test_timetable
|
||||
565
addons/education_timetable/tests/test_timetable.py
Normal file
565
addons/education_timetable/tests/test_timetable.py
Normal 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])
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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', () {
|
||||
|
||||
101
flutter_app/test/timetable_widget_test.dart
Normal file
101
flutter_app/test/timetable_widget_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user