diff --git a/addons/education_attendance/__manifest__.py b/addons/education_attendance/__manifest__.py index 4c536d3..47fbceb 100644 --- a/addons/education_attendance/__manifest__.py +++ b/addons/education_attendance/__manifest__.py @@ -16,6 +16,7 @@ 'depends': [ 'education_base', 'education_student', + 'education_portal', 'mail', ], 'data': [ diff --git a/addons/education_attendance/models/attendance.py b/addons/education_attendance/models/attendance.py index 6096f98..b426739 100644 --- a/addons/education_attendance/models/attendance.py +++ b/addons/education_attendance/models/attendance.py @@ -1,5 +1,6 @@ # pyrefly: ignore [missing-import] from odoo import api, fields, models +from odoo.exceptions import ValidationError class EducationAttendance(models.Model): @@ -27,3 +28,23 @@ class EducationAttendance(models.Model): default=lambda self: self.env.company, index=True, ) + + _uniq_student_session = models.Constraint( + 'unique(student_id, session_id)', + 'An attendance record already exists for this student in this session!' + ) + + def write(self, vals): + for rec in self: + if not self.env.user.has_group('education_attendance.group_attendance_manager'): + raise ValidationError("Unauthorized: Only an Attendance Manager can directly modify attendance history records.") + res = super().write(vals) + self.mapped('student_id').invalidate_recordset() + return res + + def unlink(self): + for rec in self: + if rec.session_id and rec.session_id.state == 'completed' and not self.env.user.has_group('education_attendance.group_attendance_manager'): + raise ValidationError("Unauthorized: Only an Attendance Manager can directly delete attendance history records of completed sessions.") + res = super().unlink() + return res diff --git a/addons/education_attendance/models/attendance_line.py b/addons/education_attendance/models/attendance_line.py index cde4fad..fcc60b6 100644 --- a/addons/education_attendance/models/attendance_line.py +++ b/addons/education_attendance/models/attendance_line.py @@ -1,5 +1,6 @@ # pyrefly: ignore [missing-import] from odoo import api, fields, models +from odoo.exceptions import ValidationError class EducationAttendanceLine(models.Model): @@ -26,3 +27,37 @@ class EducationAttendanceLine(models.Model): store=True, index=True, ) + + _uniq_session_student = models.Constraint( + 'unique(session_id, student_id)', + 'A student cannot be added twice to the same attendance session!' + ) + + def write(self, vals): + for rec in self: + if rec.session_id.state == 'completed': + if not self.env.user.has_group('education_attendance.group_attendance_manager'): + raise ValidationError("Only an Attendance Manager can modify attendance lines for a completed session.") + # Sync correction to permanent history + if 'status' in vals or 'remarks' in vals: + history = self.env['education.attendance'].search([ + ('session_id', '=', rec.session_id.id), + ('student_id', '=', rec.student_id.id), + ], limit=1) + if history: + hist_vals = {} + if 'status' in vals: + hist_vals['status'] = vals['status'] + if 'remarks' in vals: + hist_vals['remarks'] = vals['remarks'] + history.write(hist_vals) + res = super().write(vals) + if any(rec.session_id.state == 'completed' for rec in self): + self.mapped('student_id').invalidate_recordset() + return res + + def unlink(self): + for rec in self: + if rec.session_id.state == 'completed' and not self.env.user.has_group('education_attendance.group_attendance_manager'): + raise ValidationError("Cannot delete attendance lines of a completed session. Revert the session to draft first.") + return super().unlink() diff --git a/addons/education_attendance/models/attendance_session.py b/addons/education_attendance/models/attendance_session.py index e3eabc9..4b6571c 100644 --- a/addons/education_attendance/models/attendance_session.py +++ b/addons/education_attendance/models/attendance_session.py @@ -17,6 +17,9 @@ class EducationAttendanceSession(models.Model): academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True, index=True) academic_term_id = fields.Many2one('education.academic.term', string='Academic Term', required=True, index=True) academic_level_id = fields.Many2one('education.academic.level', string='Academic Level', required=True, index=True) + batch_id = fields.Many2one('education.batch', string='Batch', index=True) + semester_id = fields.Many2one('education.semester', string='Semester', index=True) + section_id = fields.Many2one('education.section', string='Section', index=True) attendance_date = fields.Date(string='Attendance Date', required=True, default=fields.Date.context_today, index=True, tracking=True) period = fields.Selection([ @@ -50,12 +53,33 @@ class EducationAttendanceSession(models.Model): _uniq_session_period = models.Constraint('unique(attendance_date, period, program_id, academic_level_id, company_id)', 'An attendance session has already been created for this period, program, and academic level on this date.') + @api.constrains('attendance_date') + def _check_attendance_date(self): + today = fields.Date.context_today(self) + for rec in self: + if rec.attendance_date and rec.attendance_date > today: + raise ValidationError("Attendance session date cannot be in the future.") + + def write(self, vals): + for rec in self: + if rec.state == 'completed' and not self.env.user.has_group('education_attendance.group_attendance_manager'): + locked_fields = {'campus_id', 'department_id', 'program_id', 'academic_year_id', 'academic_term_id', 'academic_level_id', 'attendance_date', 'period', 'batch_id', 'semester_id', 'section_id'} + if any(f in vals for f in locked_fields): + raise ValidationError("Completed attendance sessions cannot have their schedule or academic grouping modified. Reset to draft or contact an Attendance Manager.") + return super().write(vals) + + def unlink(self): + for rec in self: + if rec.state == 'completed' and not self.env.user.has_group('education_attendance.group_attendance_manager'): + raise ValidationError("Completed attendance sessions cannot be deleted directly. Cancel the session first or contact an Attendance Manager.") + return super().unlink() + def action_open(self): for rec in self: if rec.state != 'draft': continue - # Auto-populate attendance lines with active students - students = self.env['education.student'].search([ + # Auto-populate attendance lines with active students matching academic context + domain = [ ('campus_id', '=', rec.campus_id.id), ('department_id', '=', rec.department_id.id), ('program_id', '=', rec.program_id.id), @@ -63,7 +87,15 @@ class EducationAttendanceSession(models.Model): ('academic_term_id', '=', rec.academic_term_id.id), ('level_id', '=', rec.academic_level_id.id), ('status', '=', 'active'), - ]) + ] + if rec.batch_id: + domain.append(('batch_id', '=', rec.batch_id.id)) + if rec.semester_id: + domain.append(('semester_id', '=', rec.semester_id.id)) + if rec.section_id: + domain.append(('section_id', '=', rec.section_id.id)) + + students = self.env['education.student'].search(domain) if not students: raise ValidationError("No active students found matching the selected academic criteria.") diff --git a/addons/education_attendance/security/education_attendance_security.xml b/addons/education_attendance/security/education_attendance_security.xml index f632ca6..e7ad276 100644 --- a/addons/education_attendance/security/education_attendance_security.xml +++ b/addons/education_attendance/security/education_attendance_security.xml @@ -52,4 +52,16 @@ [(1, '=', 1)] + + + + Portal Student Attendance Isolation + + [('student_id.portal_user_id', '=', user.id)] + + + + + + diff --git a/addons/education_attendance/security/ir.model.access.csv b/addons/education_attendance/security/ir.model.access.csv index 6da361b..f1b21a1 100644 --- a/addons/education_attendance/security/ir.model.access.csv +++ b/addons/education_attendance/security/ir.model.access.csv @@ -5,3 +5,5 @@ access_attendance_line_user,Attendance Line User,model_education_attendance_line access_attendance_line_mgr,Attendance Line Mgr,model_education_attendance_line,group_attendance_manager,1,1,1,1 access_attendance_user,Attendance User,model_education_attendance,group_attendance_user,1,1,1,0 access_attendance_mgr,Attendance Mgr,model_education_attendance,group_attendance_manager,1,1,1,1 +access_attendance_portal,Attendance Portal Read,model_education_attendance,base.group_portal,1,0,0,0 +access_attendance_session_portal,Attendance Session Portal Read,model_education_attendance_session,base.group_portal,1,0,0,0 diff --git a/addons/education_attendance/tests/test_attendance.py b/addons/education_attendance/tests/test_attendance.py index 054c507..60e4ef4 100644 --- a/addons/education_attendance/tests/test_attendance.py +++ b/addons/education_attendance/tests/test_attendance.py @@ -1,7 +1,7 @@ # pyrefly: ignore [missing-import] +from datetime import date, timedelta from odoo.tests.common import TransactionCase -# pyrefly: ignore [missing-import] -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, AccessError class TestStudentAttendance(TransactionCase): @@ -9,61 +9,100 @@ class TestStudentAttendance(TransactionCase): def setUp(self): super(TestStudentAttendance, self).setUp() - self.company = self.env.ref('base.main_company') + self.company = self.env.company # Setup master records self.campus = self.env['education.campus'].create({ 'name': 'Attendance Test Campus', - 'code': 'ATC', + 'code': 'ATC-SEC', + 'company_id': self.company.id, }) self.department = self.env['education.department'].create({ 'name': 'Attendance Test Dept', - 'code': 'ATD', + 'code': 'ATD-SEC', 'campus_id': self.campus.id, }) self.level = self.env['education.academic.level'].create({ 'name': 'Attendance Test Level', - 'code': 'ATL', + 'code': 'ATL-SEC', + 'company_id': self.company.id, }) self.program = self.env['education.program'].create({ 'name': 'Attendance Test Program', - 'code': 'ATP', + 'code': 'ATP-SEC', 'department_id': self.department.id, 'level_id': self.level.id, + 'company_id': self.company.id, }) self.year = self.env['education.academic.year'].create({ 'name': 'Attendance Test Year 2026', - 'code': 'ATY26', + 'code': 'ATY26-SEC', 'start_date': '2026-01-01', 'end_date': '2026-12-31', + 'company_id': self.company.id, }) self.term = self.env['education.academic.term'].create({ 'name': 'Attendance Test Term 1', - 'code': 'ATT1', + 'code': 'ATT1-SEC', 'academic_year_id': self.year.id, 'start_date': '2026-01-01', 'end_date': '2026-06-30', + 'company_id': self.company.id, }) self.batch = self.env['education.batch'].create({ 'name': 'Attendance Test Batch 2026', - 'code': 'ATB26', + 'code': 'ATB26-SEC', }) self.semester = self.env['education.semester'].create({ 'name': 'Attendance Test Semester 1', - 'code': 'ATS1', + 'code': 'ATS1-SEC', }) - self.section = self.env['education.section'].create({ + self.section_a = self.env['education.section'].create({ 'name': 'Attendance Test Section A', - 'code': 'ATSA', + 'code': 'ATSA-SEC', + }) + self.section_b = self.env['education.section'].create({ + 'name': 'Attendance Test Section B', + 'code': 'ATSB-SEC', }) - # Create 3 active students + # Users and Security Groups + self.group_user = self.env.ref('education_attendance.group_attendance_user') + self.group_manager = self.env.ref('education_attendance.group_attendance_manager') + self.group_portal = self.env.ref('base.group_portal') + + self.user_teacher = self.env['res.users'].create({ + 'name': 'Attendance Teacher', + 'login': 'att.teacher@example.com', + 'email': 'att.teacher@example.com', + 'group_ids': [(6, 0, [self.group_user.id])], + }) + self.user_manager = self.env['res.users'].create({ + 'name': 'Attendance Manager', + 'login': 'att.manager@example.com', + 'email': 'att.manager@example.com', + 'group_ids': [(6, 0, [self.group_manager.id])], + }) + self.user_portal_1 = self.env['res.users'].create({ + 'name': 'Portal Student 1', + 'login': 'att.student1@example.com', + 'email': 'att.student1@example.com', + 'group_ids': [(6, 0, [self.group_portal.id])], + }) + self.user_portal_2 = self.env['res.users'].create({ + 'name': 'Portal Student 2', + 'login': 'att.student2@example.com', + 'email': 'att.student2@example.com', + 'group_ids': [(6, 0, [self.group_portal.id])], + }) + + # Create active students self.student_1 = self.env['education.student'].create({ 'first_name': 'TestStudent', 'last_name': 'One', - 'student_id': 'ATSTU01', - 'admission_number': 'ATADM01', - 'roll_number': 'ATRL01', + 'student_id': 'ATSTU01-SEC', + 'admission_number': 'ATADM01-SEC', + 'roll_number': 'ATRL01-SEC', 'campus_id': self.campus.id, 'department_id': self.department.id, 'program_id': self.program.id, @@ -72,16 +111,18 @@ class TestStudentAttendance(TransactionCase): 'academic_term_id': self.term.id, 'batch_id': self.batch.id, 'semester_id': self.semester.id, - 'section_id': self.section.id, + 'section_id': self.section_a.id, 'status': 'active', + 'company_id': self.company.id, + 'portal_user_id': self.user_portal_1.id, }) self.student_2 = self.env['education.student'].create({ 'first_name': 'TestStudent', 'last_name': 'Two', - 'student_id': 'ATSTU02', - 'admission_number': 'ATADM02', - 'roll_number': 'ATRL02', + 'student_id': 'ATSTU02-SEC', + 'admission_number': 'ATADM02-SEC', + 'roll_number': 'ATRL02-SEC', 'campus_id': self.campus.id, 'department_id': self.department.id, 'program_id': self.program.id, @@ -90,16 +131,18 @@ class TestStudentAttendance(TransactionCase): 'academic_term_id': self.term.id, 'batch_id': self.batch.id, 'semester_id': self.semester.id, - 'section_id': self.section.id, + 'section_id': self.section_a.id, 'status': 'active', + 'company_id': self.company.id, + 'portal_user_id': self.user_portal_2.id, }) self.student_3 = self.env['education.student'].create({ 'first_name': 'TestStudent', 'last_name': 'Three', - 'student_id': 'ATSTU03', - 'admission_number': 'ATADM03', - 'roll_number': 'ATRL03', + 'student_id': 'ATSTU03-SEC', + 'admission_number': 'ATADM03-SEC', + 'roll_number': 'ATRL03-SEC', 'campus_id': self.campus.id, 'department_id': self.department.id, 'program_id': self.program.id, @@ -108,11 +151,13 @@ class TestStudentAttendance(TransactionCase): 'academic_term_id': self.term.id, 'batch_id': self.batch.id, 'semester_id': self.semester.id, - 'section_id': self.section.id, + 'section_id': self.section_b.id, 'status': 'active', + 'company_id': self.company.id, }) - # Draft Session + # Draft Session (dated yesterday to be safely in the past) + self.session_date = date.today() - timedelta(days=1) self.session = self.env['education.attendance.session'].create({ 'name': 'Test Lecture Period 1', 'campus_id': self.campus.id, @@ -121,17 +166,18 @@ class TestStudentAttendance(TransactionCase): 'academic_level_id': self.level.id, 'academic_year_id': self.year.id, 'academic_term_id': self.term.id, - 'attendance_date': '2026-02-01', + 'attendance_date': self.session_date, 'period': 'period_1', + 'faculty_id': self.user_teacher.id, + 'company_id': self.company.id, }) - def test_attendance_session_workflow(self): - """Test workflow of opening session, marking attendance, completing it, and reverse logic""" - # Session should start in draft with no lines + def test_01_attendance_session_workflow(self): + """Test workflow of opening session, bulk marking, completing it, and student metrics calculation.""" self.assertEqual(self.session.state, 'draft') self.assertEqual(len(self.session.line_ids), 0) - # Open session (should auto-generate lines for our 3 active students) + # Open session (auto-generates lines for our 3 active students) self.session.action_open() self.assertEqual(self.session.state, 'open') self.assertEqual(len(self.session.line_ids), 3) @@ -145,16 +191,16 @@ class TestStudentAttendance(TransactionCase): for line in self.session.line_ids: self.assertEqual(line.status, 'absent') - # Let's set Student 1 to Present, Student 2 to Late, Student 3 to Medical + # Set individual marks line_1 = self.session.line_ids.filtered(lambda l: l.student_id == self.student_1) line_2 = self.session.line_ids.filtered(lambda l: l.student_id == self.student_2) line_3 = self.session.line_ids.filtered(lambda l: l.student_id == self.student_3) line_1.write({'status': 'present'}) - line_2.write({'status': 'late'}) - line_3.write({'status': 'medical_leave'}) + line_2.write({'status': 'absent'}) + line_3.write({'status': 'late'}) - # Complete Session (should write to permanent history education.attendance) + # Complete Session self.session.action_complete() self.assertEqual(self.session.state, 'completed') @@ -165,31 +211,30 @@ class TestStudentAttendance(TransactionCase): self.assertEqual(len(history_1), 1) self.assertEqual(history_1.status, 'present') - self.assertEqual(history_2.status, 'late') - self.assertEqual(history_3.status, 'medical_leave') + self.assertEqual(len(history_2), 1) + self.assertEqual(history_2.status, 'absent') + self.assertEqual(len(history_3), 1) + self.assertEqual(history_3.status, 'late') # Invalidate student record cache to read computed metrics self.student_1.invalidate_recordset() self.student_2.invalidate_recordset() self.student_3.invalidate_recordset() - # Present, Late, Medical should all count as attended (100% attendance for this single session) + # Student 1: 1 present / 1 total = 100% self.assertEqual(self.student_1.attendance_pct, 100.0) - self.assertEqual(self.student_2.attendance_pct, 100.0) - self.assertEqual(self.student_3.attendance_pct, 100.0) - self.assertEqual(self.student_1.present_count, 1) - self.assertEqual(self.student_2.late_count, 1) - self.assertEqual(self.student_3.medical_leave_count, 1) - # Reset to draft (should unlink lines and history) - self.session.action_draft() - self.assertEqual(self.session.state, 'draft') - self.assertEqual(len(self.session.line_ids), 0) - self.assertEqual(len(self.env['education.attendance'].search([('session_id', '=', self.session.id)])), 0) + # Student 2: 1 absent / 1 total = 0% + self.assertEqual(self.student_2.attendance_pct, 0.0) + self.assertEqual(self.student_2.absent_count, 1) - def test_duplicate_session_constraint(self): - """Test SQL constraints: cannot create duplicate session for same period and date""" + # Student 3: 1 late / 1 total = 100% (attended) + self.assertEqual(self.student_3.attendance_pct, 100.0) + self.assertEqual(self.student_3.late_count, 1) + + def test_02_duplicate_session_constraint(self): + """Prevent creating duplicate attendance session for same period, program, level, date, and company.""" with self.assertRaises(Exception): self.env['education.attendance.session'].create({ 'name': 'Duplicate Session', @@ -199,7 +244,123 @@ class TestStudentAttendance(TransactionCase): 'academic_level_id': self.level.id, 'academic_year_id': self.year.id, 'academic_term_id': self.term.id, - 'attendance_date': '2026-02-01', + 'attendance_date': self.session_date, 'period': 'period_1', + 'company_id': self.company.id, }) self.env.flush_all() + + def test_03_duplicate_line_and_history_constraint(self): + """Prevent duplicate student in attendance lines or permanent attendance history.""" + self.session.action_open() + + # Adding student 1 again to the same session must raise constraint error + with self.assertRaises(Exception): + self.env['education.attendance.line'].create({ + 'session_id': self.session.id, + 'student_id': self.student_1.id, + 'status': 'present', + 'company_id': self.company.id, + }) + self.env.flush_all() + + self.session.action_complete() + + # Creating duplicate attendance history record for same (student, session) must fail + with self.assertRaises(Exception): + self.env['education.attendance'].create({ + 'student_id': self.student_1.id, + 'session_id': self.session.id, + 'date': self.session_date, + 'status': 'present', + 'faculty_id': self.user_teacher.id, + 'company_id': self.company.id, + }) + self.env.flush_all() + + def test_04_future_date_validation(self): + """Attendance sessions cannot be scheduled in the future.""" + future_date = date.today() + timedelta(days=5) + with self.assertRaises(ValidationError): + self.env['education.attendance.session'].create({ + 'name': 'Future Session', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.id, + 'academic_level_id': self.level.id, + 'academic_year_id': self.year.id, + 'academic_term_id': self.term.id, + 'attendance_date': future_date, + 'period': 'period_2', + 'company_id': self.company.id, + }) + + def test_05_completed_session_modification_and_correction(self): + """Test completed session lock and authorized correction by Attendance Manager.""" + self.session.action_open() + line_1 = self.session.line_ids.filtered(lambda l: l.student_id == self.student_1) + line_1.write({'status': 'absent'}) + self.session.action_complete() + + self.student_1.invalidate_recordset() + self.assertEqual(self.student_1.attendance_pct, 0.0) + + # Teacher (regular attendance user) cannot modify lines on completed session + line_as_teacher = line_1.with_user(self.user_teacher) + with self.assertRaises(ValidationError): + line_as_teacher.write({'status': 'present'}) + + # Manager CAN modify line for audited correction, and it syncs to history & student metrics + line_as_manager = line_1.with_user(self.user_manager) + line_as_manager.write({'status': 'present', 'remarks': 'Correction approved by dean'}) + + history_1 = self.env['education.attendance'].search([('student_id', '=', self.student_1.id), ('session_id', '=', self.session.id)]) + self.assertEqual(history_1.status, 'present') + self.assertEqual(history_1.remarks, 'Correction approved by dean') + + self.student_1.invalidate_recordset() + self.assertEqual(self.student_1.attendance_pct, 100.0) + + def test_06_portal_student_isolation(self): + """Portal student user can ONLY read their own attendance records.""" + self.session.action_open() + self.session.action_complete() + + # Portal User 1 should only see Student 1's attendance + records_p1 = self.env['education.attendance'].with_user(self.user_portal_1).search([]) + self.assertEqual(len(records_p1), 1) + self.assertEqual(records_p1.student_id, self.student_1) + + # Portal User 2 should only see Student 2's attendance + records_p2 = self.env['education.attendance'].with_user(self.user_portal_2).search([]) + self.assertEqual(len(records_p2), 1) + self.assertEqual(records_p2.student_id, self.student_2) + + # Portal user cannot write or delete attendance records + with self.assertRaises(ValidationError): + records_p1.write({'status': 'absent'}) + + def test_07_academic_context_filtering(self): + """Session with section_id specified should only populate students of that section.""" + session_sec_a = self.env['education.attendance.session'].create({ + 'name': 'Section A Lecture', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.id, + 'academic_level_id': self.level.id, + 'academic_year_id': self.year.id, + 'academic_term_id': self.term.id, + 'section_id': self.section_a.id, + 'attendance_date': self.session_date, + 'period': 'period_3', + 'faculty_id': self.user_teacher.id, + 'company_id': self.company.id, + }) + session_sec_a.action_open() + + # Only student_1 and student_2 are in section_a; student_3 is in section_b + self.assertEqual(len(session_sec_a.line_ids), 2) + students_loaded = session_sec_a.line_ids.mapped('student_id') + self.assertIn(self.student_1, students_loaded) + self.assertIn(self.student_2, students_loaded) + self.assertNotIn(self.student_3, students_loaded) diff --git a/addons/education_attendance/views/attendance_session_views.xml b/addons/education_attendance/views/attendance_session_views.xml index 2b4a67f..6c4089f 100644 --- a/addons/education_attendance/views/attendance_session_views.xml +++ b/addons/education_attendance/views/attendance_session_views.xml @@ -119,6 +119,9 @@ + + + diff --git a/addons/education_mobile_api/controllers/student.py b/addons/education_mobile_api/controllers/student.py index 121c6f7..b4ea028 100644 --- a/addons/education_mobile_api/controllers/student.py +++ b/addons/education_mobile_api/controllers/student.py @@ -107,8 +107,8 @@ class MobileStudent(http.Controller): medical_leave = len([a for a in records if a.status == 'medical_leave']) on_duty = len([a for a in records if a.status == 'on_duty']) - effective_present = present + on_duty + (late * 0.5) - percentage = round((effective_present / total_sessions * 100), 1) if total_sessions > 0 else 100.0 + attended = present + on_duty + late + medical_leave + percentage = round((attended / total_sessions * 100.0), 1) if total_sessions > 0 else 100.0 return { "percentage": percentage, @@ -222,13 +222,18 @@ class MobileStudent(http.Controller): @require_student def get_attendance(self, student, **kwargs): try: - from_date = kwargs.get('from_date') - to_date = kwargs.get('to_date') + from_date_str = kwargs.get('from_date') + to_date_str = kwargs.get('to_date') + from_date = None + to_date = None - if from_date: - from_date = datetime.strptime(from_date, '%Y-%m-%d').date() - if to_date: - to_date = datetime.strptime(to_date, '%Y-%m-%d').date() + if from_date_str: + from_date = datetime.strptime(from_date_str, '%Y-%m-%d').date() + if to_date_str: + to_date = datetime.strptime(to_date_str, '%Y-%m-%d').date() + + if from_date and to_date and from_date > to_date: + return json_response(False, error_code="INVALID_DATE_RANGE", error_message="'from_date' cannot be later than 'to_date'", status=400) summary = self._get_attendance_summary(student, from_date, to_date) @@ -240,14 +245,23 @@ class MobileStudent(http.Controller): records = request.env['education.attendance'].search_read( domain, - fields=['id', 'date', 'status'], + fields=['id', 'date', 'status', 'remarks'], order='date desc, id desc', limit=100 ) + formatted_records = [] + for r in records: + formatted_records.append({ + "id": r["id"], + "date": str(r["date"]), + "status": r["status"], + "remarks": r.get("remarks") or "" + }) + return json_response(True, data={ "summary": summary, - "records": records + "records": formatted_records }) except ValueError: return json_response(False, error_code="INVALID_DATE", error_message="Dates must be in YYYY-MM-DD format", status=400) diff --git a/flutter_app/lib/features/attendance/attendance_screen.dart b/flutter_app/lib/features/attendance/attendance_screen.dart index 74d19f1..fc44408 100644 --- a/flutter_app/lib/features/attendance/attendance_screen.dart +++ b/flutter_app/lib/features/attendance/attendance_screen.dart @@ -224,19 +224,37 @@ class AttendanceScreen extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - const Icon(Icons.calendar_today_rounded, size: 16, color: AppTheme.textSecondary), - const SizedBox(width: 8), - Text( - record.date, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.calendar_today_rounded, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 8), + Text( + record.date, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], ), - ), - ], + if (record.remarks.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + record.remarks, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), diff --git a/flutter_app/lib/models/attendance.dart b/flutter_app/lib/models/attendance.dart index 1583fe6..3e63a8e 100644 --- a/flutter_app/lib/models/attendance.dart +++ b/flutter_app/lib/models/attendance.dart @@ -42,11 +42,13 @@ class AttendanceRecord { final int id; final String date; final String status; + final String remarks; const AttendanceRecord({ required this.id, required this.date, required this.status, + this.remarks = '', }); factory AttendanceRecord.fromJson(Map json) { @@ -54,6 +56,7 @@ class AttendanceRecord { id: SafeConvert.toSafeInt(json['id']), date: SafeConvert.toSafeString(json['date']), status: SafeConvert.toSafeString(json['status'], 'absent'), + remarks: SafeConvert.toSafeString(json['remarks']), ); } } diff --git a/flutter_app/test/live_odoo_integration_test.dart b/flutter_app/test/live_odoo_integration_test.dart index 7383c32..556a62c 100644 --- a/flutter_app/test/live_odoo_integration_test.dart +++ b/flutter_app/test/live_odoo_integration_test.dart @@ -99,7 +99,9 @@ void main() { test('5. GET /api/mobile/v1/student/attendance returns attendance summary', () async { final attendance = await attendanceRepo.getAttendance(); expect(attendance.summary.percentage, isNonNegative); - stdout.writeln('--> Attendance verified: ${attendance.records.length} records'); + expect(attendance.records, isNotEmpty); + expect(attendance.summary.present, greaterThanOrEqualTo(3)); + stdout.writeln('--> Attendance verified: ${attendance.records.length} records, percentage: ${attendance.summary.percentage}%'); }); test('6. GET /api/mobile/v1/student/timetable returns timetable slots', () async { diff --git a/flutter_app/test/models_and_parsing_test.dart b/flutter_app/test/models_and_parsing_test.dart index 9939085..39e3ea6 100644 --- a/flutter_app/test/models_and_parsing_test.dart +++ b/flutter_app/test/models_and_parsing_test.dart @@ -93,8 +93,9 @@ void main() { 'on_duty': 0, }, 'records': [ - {'id': 101, 'date': '2026-10-12', 'status': 'present'}, + {'id': 101, 'date': '2026-10-12', 'status': 'present', 'remarks': 'On time'}, {'id': 102, 'date': '2026-10-11', 'status': 'absent'}, + {'id': 103, 'date': '2026-10-10', 'status': 'late', 'remarks': 'Bus delay 15 mins'}, ], }; @@ -103,10 +104,14 @@ void main() { expect(data.summary.percentage, 87.5); expect(data.summary.present, 14); expect(data.summary.absent, 2); - expect(data.records.length, 2); + expect(data.records.length, 3); expect(data.records[0].date, '2026-10-12'); expect(data.records[0].status, 'present'); + expect(data.records[0].remarks, 'On time'); expect(data.records[1].status, 'absent'); + expect(data.records[1].remarks, ''); + expect(data.records[2].status, 'late'); + expect(data.records[2].remarks, 'Bus delay 15 mins'); }); test('handles empty or false attendance gracefully', () { @@ -119,6 +124,33 @@ void main() { expect(data.summary.present, 0); expect(data.records, isEmpty); }); + + test('parses all attendance status types correctly', () { + final records = [ + {'id': 1, 'date': '2026-10-01', 'status': 'present'}, + {'id': 2, 'date': '2026-10-02', 'status': 'absent'}, + {'id': 3, 'date': '2026-10-03', 'status': 'late'}, + {'id': 4, 'date': '2026-10-04', 'status': 'medical_leave'}, + {'id': 5, 'date': '2026-10-05', 'status': 'on_duty'}, + ]; + final data = AttendanceData.fromJson({ + 'summary': { + 'percentage': 80.0, + 'present': 1, + 'absent': 1, + 'late': 1, + 'medical_leave': 1, + 'on_duty': 1, + }, + 'records': records, + }); + + expect(data.records.length, 5); + expect(data.summary.medicalLeave, 1); + expect(data.summary.onDuty, 1); + expect(data.records[3].status, 'medical_leave'); + expect(data.records[4].status, 'on_duty'); + }); }); group('Timetable Parsing Tests', () {