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'
|
'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()
|
env.cr.commit()
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,8 @@ def require_student(func):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def format_float_time(float_time):
|
def format_float_time(float_time):
|
||||||
|
if float_time is None or float_time is False:
|
||||||
|
return ""
|
||||||
hours = int(float_time)
|
hours = int(float_time)
|
||||||
minutes = int(round((float_time - hours) * 60))
|
minutes = int(round((float_time - hours) * 60))
|
||||||
if minutes == 60:
|
if minutes == 60:
|
||||||
@ -119,16 +121,20 @@ class MobileStudent(http.Controller):
|
|||||||
"on_duty": on_duty
|
"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:
|
if not student.program_id or not student.academic_year_id:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
domain = [
|
domain = [
|
||||||
('program_id', '=', student.program_id.id),
|
('program_id', '=', student.program_id.id),
|
||||||
('academic_year_id', '=', student.academic_year_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:
|
if student.academic_term_id:
|
||||||
domain += ['|', ('term_id', '=', False), ('term_id', '=', student.academic_term_id.id)]
|
domain += ['|', ('term_id', '=', False), ('term_id', '=', student.academic_term_id.id)]
|
||||||
else:
|
else:
|
||||||
@ -139,10 +145,25 @@ class MobileStudent(http.Controller):
|
|||||||
else:
|
else:
|
||||||
domain += [('batch_id', '=', False)]
|
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:
|
if date_filter:
|
||||||
domain += [('date', '=', 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 [{
|
return [{
|
||||||
"id": s.id,
|
"id": s.id,
|
||||||
@ -272,7 +293,32 @@ class MobileStudent(http.Controller):
|
|||||||
@require_student
|
@require_student
|
||||||
def get_timetable(self, student, **kwargs):
|
def get_timetable(self, student, **kwargs):
|
||||||
try:
|
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})
|
return json_response(True, data={"slots": slots})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
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
|
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
|
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 not program_id or not year_id:
|
||||||
# If a student isn't actively placed in a program/year, they have no timetable.
|
# If a student isn't actively placed in a program/year, they have no timetable.
|
||||||
return request.render("education_timetable.portal_student_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.
|
# 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 = [
|
domain = [
|
||||||
('program_id', '=', program_id),
|
('program_id', '=', program_id),
|
||||||
('academic_year_id', '=', year_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.
|
# 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.
|
# The domain rigorously isolates records to ONLY those belonging to the student's exact academic profile.
|
||||||
@ -71,6 +74,18 @@ class TimetablePortalController(StudentPortalController):
|
|||||||
else:
|
else:
|
||||||
domain += [('batch_id', '=', False)]
|
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
|
# Optional: Filter by date if provided in query params
|
||||||
filter_date = kw.get('date')
|
filter_date = kw.get('date')
|
||||||
if filter_date:
|
if filter_date:
|
||||||
|
|||||||
@ -11,7 +11,16 @@ class EducationRoom(models.Model):
|
|||||||
building = fields.Char(string='Building / Location')
|
building = fields.Char(string='Building / Location')
|
||||||
active = fields.Boolean(default=True)
|
active = fields.Boolean(default=True)
|
||||||
description = fields.Text(string='Description')
|
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)
|
academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True)
|
||||||
term_id = fields.Many2one('education.academic.term', string='Term')
|
term_id = fields.Many2one('education.academic.term', string='Term')
|
||||||
batch_id = fields.Many2one('education.batch', string='Batch')
|
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
|
# Slot Details
|
||||||
subject_id = fields.Many2one('education.subject', string='Subject', required=True)
|
subject_id = fields.Many2one('education.subject', string='Subject', required=True)
|
||||||
@ -29,37 +38,71 @@ class EducationTimetableSlot(models.Model):
|
|||||||
('cancelled', 'Cancelled')
|
('cancelled', 'Cancelled')
|
||||||
], string='Status', default='draft')
|
], 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')
|
@api.constrains('start_time', 'end_time')
|
||||||
def _check_time(self):
|
def _check_time(self):
|
||||||
for rec in 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:
|
if rec.start_time >= rec.end_time:
|
||||||
raise ValidationError("Start time must be strictly earlier than 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):
|
def _check_overlap(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
|
if rec.state == 'cancelled' or not rec.active:
|
||||||
|
continue
|
||||||
|
|
||||||
domain_base = [
|
domain_base = [
|
||||||
('date', '=', rec.date),
|
|
||||||
('id', '!=', rec.id),
|
('id', '!=', rec.id),
|
||||||
|
('date', '=', rec.date),
|
||||||
|
('state', '!=', 'cancelled'),
|
||||||
|
('active', '=', True),
|
||||||
# Interval logic: existing_start < new_end AND existing_end > new_start
|
# Interval logic: existing_start < new_end AND existing_end > new_start
|
||||||
('start_time', '<', rec.end_time),
|
('start_time', '<', rec.end_time),
|
||||||
('end_time', '>', rec.start_time),
|
('end_time', '>', rec.start_time),
|
||||||
]
|
]
|
||||||
|
if rec.company_id:
|
||||||
|
domain_base.append(('company_id', '=', rec.company_id.id))
|
||||||
|
|
||||||
# 1. Check Faculty Conflict
|
# 1. Check Faculty Conflict
|
||||||
faculty_conflict = self.search(domain_base + [('faculty_id', '=', rec.faculty_id.id)], limit=1)
|
faculty_conflict = self.search(domain_base + [('faculty_id', '=', rec.faculty_id.id)], limit=1)
|
||||||
if faculty_conflict:
|
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
|
# 2. Check Room Conflict
|
||||||
room_conflict = self.search(domain_base + [('room_id', '=', rec.room_id.id)], limit=1)
|
room_conflict = self.search(domain_base + [('room_id', '=', rec.room_id.id)], limit=1)
|
||||||
if room_conflict:
|
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
|
# 3. Check Student Population Conflict
|
||||||
# Population is defined by Program + Academic Year + Term + Batch
|
# Population is defined by Program + Academic Year + Term + Batch + Semester + Section
|
||||||
# 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_domain = domain_base + [
|
population_domain = domain_base + [
|
||||||
('program_id', '=', rec.program_id.id),
|
('program_id', '=', rec.program_id.id),
|
||||||
('academic_year_id', '=', rec.academic_year_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', '=', False))
|
||||||
population_domain.append(('batch_id', '=', rec.batch_id.id))
|
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)
|
population_conflict = self.search(population_domain, limit=1)
|
||||||
if population_conflict:
|
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_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_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_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_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_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_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">
|
<record id="group_education_timetable_faculty" model="res.groups">
|
||||||
<field name="name">Faculty</field>
|
<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>
|
||||||
|
|
||||||
<record id="group_education_timetable_admin" model="res.groups">
|
<record id="group_education_timetable_admin" model="res.groups">
|
||||||
<field name="name">Timetable Administrator</field>
|
<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>
|
</record>
|
||||||
</data>
|
</data>
|
||||||
</odoo>
|
</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>
|
||||||
<group>
|
<group>
|
||||||
<field name="building"/>
|
<field name="building"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
<field name="active"/>
|
<field name="active"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
@ -44,9 +45,27 @@
|
|||||||
</field>
|
</field>
|
||||||
</record>
|
</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">
|
<record id="action_education_room" model="ir.actions.act_window">
|
||||||
<field name="name">Rooms / Venues</field>
|
<field name="name">Rooms / Venues</field>
|
||||||
<field name="res_model">education.room</field>
|
<field name="res_model">education.room</field>
|
||||||
<field name="view_mode">list,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_education_room_search"/>
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@ -12,6 +12,8 @@
|
|||||||
<field name="faculty_id"/>
|
<field name="faculty_id"/>
|
||||||
<field name="room_id"/>
|
<field name="room_id"/>
|
||||||
<field name="batch_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'"/>
|
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'confirmed'" decoration-danger="state == 'cancelled'"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
@ -23,7 +25,10 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Timetable Slot">
|
<form string="Timetable Slot">
|
||||||
<header>
|
<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>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<group>
|
<group>
|
||||||
@ -40,6 +45,9 @@
|
|||||||
<field name="academic_year_id"/>
|
<field name="academic_year_id"/>
|
||||||
<field name="term_id"/>
|
<field name="term_id"/>
|
||||||
<field name="batch_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"/>
|
<field name="active"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
@ -60,6 +68,13 @@
|
|||||||
<field name="academic_year_id"/>
|
<field name="academic_year_id"/>
|
||||||
<field name="term_id"/>
|
<field name="term_id"/>
|
||||||
<field name="batch_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>
|
<group>
|
||||||
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
|
<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="Room" name="group_room" context="{'group_by': 'room_id'}"/>
|
||||||
<filter string="Program" name="group_program" context="{'group_by': 'program_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="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>
|
</group>
|
||||||
</search>
|
</search>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@ -107,7 +107,16 @@ void main() {
|
|||||||
test('6. GET /api/mobile/v1/student/timetable returns timetable slots', () async {
|
test('6. GET /api/mobile/v1/student/timetable returns timetable slots', () async {
|
||||||
final slots = await timetableRepo.getTimetable();
|
final slots = await timetableRepo.getTimetable();
|
||||||
expect(slots, isA<List>());
|
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 {
|
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.faculty?.name, 'Dr. Raman');
|
||||||
expect(slot.room?.name, 'Hall A');
|
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', () {
|
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