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