From 1d40349dce1e34d217748c4833a3058294e6f7a2 Mon Sep 17 00:00:00 2001 From: vidhya Date: Tue, 13 Oct 2026 20:36:30 +0530 Subject: [PATCH] feat(education_timetable): complete schedule management and mobile integration --- .../demo_data_generator.py | 87 +++ .../controllers/student.py | 54 +- .../education_timetable/controllers/main.py | 23 +- .../models/education_room.py | 15 +- .../models/education_timetable_slot.py | 73 ++- .../security/ir.model.access.csv | 3 + .../education_timetable/security/security.xml | 17 +- addons/education_timetable/tests/__init__.py | 1 + .../tests/test_timetable.py | 565 ++++++++++++++++++ .../views/education_room_views.xml | 19 + .../views/education_timetable_slot_views.xml | 19 +- .../test/live_odoo_integration_test.dart | 11 +- flutter_app/test/models_and_parsing_test.dart | 37 ++ flutter_app/test/timetable_widget_test.dart | 101 ++++ 14 files changed, 1002 insertions(+), 23 deletions(-) create mode 100644 addons/education_timetable/tests/__init__.py create mode 100644 addons/education_timetable/tests/test_timetable.py create mode 100644 flutter_app/test/timetable_widget_test.dart diff --git a/addons/education_demo_data/demo_data_generator.py b/addons/education_demo_data/demo_data_generator.py index a443e1c..20d8fb8 100644 --- a/addons/education_demo_data/demo_data_generator.py +++ b/addons/education_demo_data/demo_data_generator.py @@ -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() diff --git a/addons/education_mobile_api/controllers/student.py b/addons/education_mobile_api/controllers/student.py index b4ea028..9af65fc 100644 --- a/addons/education_mobile_api/controllers/student.py +++ b/addons/education_mobile_api/controllers/student.py @@ -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) diff --git a/addons/education_timetable/controllers/main.py b/addons/education_timetable/controllers/main.py index a75ba94..8f0ecee 100644 --- a/addons/education_timetable/controllers/main.py +++ b/addons/education_timetable/controllers/main.py @@ -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') diff --git a/addons/education_timetable/models/education_room.py b/addons/education_timetable/models/education_room.py index 15f36be..8a93cd8 100644 --- a/addons/education_timetable/models/education_room.py +++ b/addons/education_timetable/models/education_room.py @@ -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!') - ] diff --git a/addons/education_timetable/models/education_timetable_slot.py b/addons/education_timetable/models/education_timetable_slot.py index ecd1141..aa6f4c1 100644 --- a/addons/education_timetable/models/education_timetable_slot.py +++ b/addons/education_timetable/models/education_timetable_slot.py @@ -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}." + ) diff --git a/addons/education_timetable/security/ir.model.access.csv b/addons/education_timetable/security/ir.model.access.csv index 3295554..c7d0365 100644 --- a/addons/education_timetable/security/ir.model.access.csv +++ b/addons/education_timetable/security/ir.model.access.csv @@ -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 + diff --git a/addons/education_timetable/security/security.xml b/addons/education_timetable/security/security.xml index d0c3903..f3aee0b 100644 --- a/addons/education_timetable/security/security.xml +++ b/addons/education_timetable/security/security.xml @@ -7,12 +7,25 @@ Faculty - + Timetable Administrator - + + + + + + Education Room Company Isolation + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Timetable Slot Company Isolation + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] diff --git a/addons/education_timetable/tests/__init__.py b/addons/education_timetable/tests/__init__.py new file mode 100644 index 0000000..f27e2ca --- /dev/null +++ b/addons/education_timetable/tests/__init__.py @@ -0,0 +1 @@ +from . import test_timetable diff --git a/addons/education_timetable/tests/test_timetable.py b/addons/education_timetable/tests/test_timetable.py new file mode 100644 index 0000000..b786edb --- /dev/null +++ b/addons/education_timetable/tests/test_timetable.py @@ -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]) diff --git a/addons/education_timetable/views/education_room_views.xml b/addons/education_timetable/views/education_room_views.xml index a83a550..49148ed 100644 --- a/addons/education_timetable/views/education_room_views.xml +++ b/addons/education_timetable/views/education_room_views.xml @@ -31,6 +31,7 @@ + @@ -44,9 +45,27 @@ + + education.room.search + education.room + + + + + + + + + + + + + + Rooms / Venues education.room list,form + diff --git a/addons/education_timetable/views/education_timetable_slot_views.xml b/addons/education_timetable/views/education_timetable_slot_views.xml index a98204b..1b8def4 100644 --- a/addons/education_timetable/views/education_timetable_slot_views.xml +++ b/addons/education_timetable/views/education_timetable_slot_views.xml @@ -12,6 +12,8 @@ + + @@ -23,7 +25,10 @@
- +
@@ -40,6 +45,9 @@ + + + @@ -60,6 +68,13 @@ + + + + + + + @@ -67,6 +82,8 @@ + + diff --git a/flutter_app/test/live_odoo_integration_test.dart b/flutter_app/test/live_odoo_integration_test.dart index 556a62c..d101ea9 100644 --- a/flutter_app/test/live_odoo_integration_test.dart +++ b/flutter_app/test/live_odoo_integration_test.dart @@ -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()); - 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 { diff --git a/flutter_app/test/models_and_parsing_test.dart b/flutter_app/test/models_and_parsing_test.dart index 39e3ea6..a088b09 100644 --- a/flutter_app/test/models_and_parsing_test.dart +++ b/flutter_app/test/models_and_parsing_test.dart @@ -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', () { diff --git a/flutter_app/test/timetable_widget_test.dart b/flutter_app/test/timetable_widget_test.dart new file mode 100644 index 0000000..b27a863 --- /dev/null +++ b/flutter_app/test/timetable_widget_test.dart @@ -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 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); + }); +}