feat(education_attendance): complete attendance workflow and mobile integration

This commit is contained in:
Vidhya 2026-10-13 20:16:30 +05:30
parent 2b4b4aead1
commit 232e22e89a
13 changed files with 416 additions and 80 deletions

View File

@ -16,6 +16,7 @@
'depends': [ 'depends': [
'education_base', 'education_base',
'education_student', 'education_student',
'education_portal',
'mail', 'mail',
], ],
'data': [ 'data': [

View File

@ -1,5 +1,6 @@
# pyrefly: ignore [missing-import] # pyrefly: ignore [missing-import]
from odoo import api, fields, models from odoo import api, fields, models
from odoo.exceptions import ValidationError
class EducationAttendance(models.Model): class EducationAttendance(models.Model):
@ -27,3 +28,23 @@ class EducationAttendance(models.Model):
default=lambda self: self.env.company, default=lambda self: self.env.company,
index=True, 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

View File

@ -1,5 +1,6 @@
# pyrefly: ignore [missing-import] # pyrefly: ignore [missing-import]
from odoo import api, fields, models from odoo import api, fields, models
from odoo.exceptions import ValidationError
class EducationAttendanceLine(models.Model): class EducationAttendanceLine(models.Model):
@ -26,3 +27,37 @@ class EducationAttendanceLine(models.Model):
store=True, store=True,
index=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()

View File

@ -17,6 +17,9 @@ class EducationAttendanceSession(models.Model):
academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True, index=True) 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_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) 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) attendance_date = fields.Date(string='Attendance Date', required=True, default=fields.Date.context_today, index=True, tracking=True)
period = fields.Selection([ 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.') _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): def action_open(self):
for rec in self: for rec in self:
if rec.state != 'draft': if rec.state != 'draft':
continue continue
# Auto-populate attendance lines with active students # Auto-populate attendance lines with active students matching academic context
students = self.env['education.student'].search([ domain = [
('campus_id', '=', rec.campus_id.id), ('campus_id', '=', rec.campus_id.id),
('department_id', '=', rec.department_id.id), ('department_id', '=', rec.department_id.id),
('program_id', '=', rec.program_id.id), ('program_id', '=', rec.program_id.id),
@ -63,7 +87,15 @@ class EducationAttendanceSession(models.Model):
('academic_term_id', '=', rec.academic_term_id.id), ('academic_term_id', '=', rec.academic_term_id.id),
('level_id', '=', rec.academic_level_id.id), ('level_id', '=', rec.academic_level_id.id),
('status', '=', 'active'), ('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: if not students:
raise ValidationError("No active students found matching the selected academic criteria.") raise ValidationError("No active students found matching the selected academic criteria.")

View File

@ -52,4 +52,16 @@
<field name="domain_force">[(1, '=', 1)]</field> <field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_attendance_manager'))]"/> <field name="groups" eval="[(4, ref('group_attendance_manager'))]"/>
</record> </record>
<!-- Portal Student Attendance Isolation -->
<record id="rule_attendance_history_portal" model="ir.rule">
<field name="name">Portal Student Attendance Isolation</field>
<field name="model_id" ref="model_education_attendance"/>
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo> </odoo>

View File

@ -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_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_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_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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_attendance_line_mgr Attendance Line Mgr model_education_attendance_line group_attendance_manager 1 1 1 1
6 access_attendance_user Attendance User model_education_attendance group_attendance_user 1 1 1 0
7 access_attendance_mgr Attendance Mgr model_education_attendance group_attendance_manager 1 1 1 1
8 access_attendance_portal Attendance Portal Read model_education_attendance base.group_portal 1 0 0 0
9 access_attendance_session_portal Attendance Session Portal Read model_education_attendance_session base.group_portal 1 0 0 0

View File

@ -1,7 +1,7 @@
# pyrefly: ignore [missing-import] # pyrefly: ignore [missing-import]
from datetime import date, timedelta
from odoo.tests.common import TransactionCase from odoo.tests.common import TransactionCase
# pyrefly: ignore [missing-import] from odoo.exceptions import ValidationError, AccessError
from odoo.exceptions import ValidationError
class TestStudentAttendance(TransactionCase): class TestStudentAttendance(TransactionCase):
@ -9,61 +9,100 @@ class TestStudentAttendance(TransactionCase):
def setUp(self): def setUp(self):
super(TestStudentAttendance, self).setUp() super(TestStudentAttendance, self).setUp()
self.company = self.env.ref('base.main_company') self.company = self.env.company
# Setup master records # Setup master records
self.campus = self.env['education.campus'].create({ self.campus = self.env['education.campus'].create({
'name': 'Attendance Test Campus', 'name': 'Attendance Test Campus',
'code': 'ATC', 'code': 'ATC-SEC',
'company_id': self.company.id,
}) })
self.department = self.env['education.department'].create({ self.department = self.env['education.department'].create({
'name': 'Attendance Test Dept', 'name': 'Attendance Test Dept',
'code': 'ATD', 'code': 'ATD-SEC',
'campus_id': self.campus.id, 'campus_id': self.campus.id,
}) })
self.level = self.env['education.academic.level'].create({ self.level = self.env['education.academic.level'].create({
'name': 'Attendance Test Level', 'name': 'Attendance Test Level',
'code': 'ATL', 'code': 'ATL-SEC',
'company_id': self.company.id,
}) })
self.program = self.env['education.program'].create({ self.program = self.env['education.program'].create({
'name': 'Attendance Test Program', 'name': 'Attendance Test Program',
'code': 'ATP', 'code': 'ATP-SEC',
'department_id': self.department.id, 'department_id': self.department.id,
'level_id': self.level.id, 'level_id': self.level.id,
'company_id': self.company.id,
}) })
self.year = self.env['education.academic.year'].create({ self.year = self.env['education.academic.year'].create({
'name': 'Attendance Test Year 2026', 'name': 'Attendance Test Year 2026',
'code': 'ATY26', 'code': 'ATY26-SEC',
'start_date': '2026-01-01', 'start_date': '2026-01-01',
'end_date': '2026-12-31', 'end_date': '2026-12-31',
'company_id': self.company.id,
}) })
self.term = self.env['education.academic.term'].create({ self.term = self.env['education.academic.term'].create({
'name': 'Attendance Test Term 1', 'name': 'Attendance Test Term 1',
'code': 'ATT1', 'code': 'ATT1-SEC',
'academic_year_id': self.year.id, 'academic_year_id': self.year.id,
'start_date': '2026-01-01', 'start_date': '2026-01-01',
'end_date': '2026-06-30', 'end_date': '2026-06-30',
'company_id': self.company.id,
}) })
self.batch = self.env['education.batch'].create({ self.batch = self.env['education.batch'].create({
'name': 'Attendance Test Batch 2026', 'name': 'Attendance Test Batch 2026',
'code': 'ATB26', 'code': 'ATB26-SEC',
}) })
self.semester = self.env['education.semester'].create({ self.semester = self.env['education.semester'].create({
'name': 'Attendance Test Semester 1', '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', '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({ self.student_1 = self.env['education.student'].create({
'first_name': 'TestStudent', 'first_name': 'TestStudent',
'last_name': 'One', 'last_name': 'One',
'student_id': 'ATSTU01', 'student_id': 'ATSTU01-SEC',
'admission_number': 'ATADM01', 'admission_number': 'ATADM01-SEC',
'roll_number': 'ATRL01', 'roll_number': 'ATRL01-SEC',
'campus_id': self.campus.id, 'campus_id': self.campus.id,
'department_id': self.department.id, 'department_id': self.department.id,
'program_id': self.program.id, 'program_id': self.program.id,
@ -72,16 +111,18 @@ class TestStudentAttendance(TransactionCase):
'academic_term_id': self.term.id, 'academic_term_id': self.term.id,
'batch_id': self.batch.id, 'batch_id': self.batch.id,
'semester_id': self.semester.id, 'semester_id': self.semester.id,
'section_id': self.section.id, 'section_id': self.section_a.id,
'status': 'active', 'status': 'active',
'company_id': self.company.id,
'portal_user_id': self.user_portal_1.id,
}) })
self.student_2 = self.env['education.student'].create({ self.student_2 = self.env['education.student'].create({
'first_name': 'TestStudent', 'first_name': 'TestStudent',
'last_name': 'Two', 'last_name': 'Two',
'student_id': 'ATSTU02', 'student_id': 'ATSTU02-SEC',
'admission_number': 'ATADM02', 'admission_number': 'ATADM02-SEC',
'roll_number': 'ATRL02', 'roll_number': 'ATRL02-SEC',
'campus_id': self.campus.id, 'campus_id': self.campus.id,
'department_id': self.department.id, 'department_id': self.department.id,
'program_id': self.program.id, 'program_id': self.program.id,
@ -90,16 +131,18 @@ class TestStudentAttendance(TransactionCase):
'academic_term_id': self.term.id, 'academic_term_id': self.term.id,
'batch_id': self.batch.id, 'batch_id': self.batch.id,
'semester_id': self.semester.id, 'semester_id': self.semester.id,
'section_id': self.section.id, 'section_id': self.section_a.id,
'status': 'active', 'status': 'active',
'company_id': self.company.id,
'portal_user_id': self.user_portal_2.id,
}) })
self.student_3 = self.env['education.student'].create({ self.student_3 = self.env['education.student'].create({
'first_name': 'TestStudent', 'first_name': 'TestStudent',
'last_name': 'Three', 'last_name': 'Three',
'student_id': 'ATSTU03', 'student_id': 'ATSTU03-SEC',
'admission_number': 'ATADM03', 'admission_number': 'ATADM03-SEC',
'roll_number': 'ATRL03', 'roll_number': 'ATRL03-SEC',
'campus_id': self.campus.id, 'campus_id': self.campus.id,
'department_id': self.department.id, 'department_id': self.department.id,
'program_id': self.program.id, 'program_id': self.program.id,
@ -108,11 +151,13 @@ class TestStudentAttendance(TransactionCase):
'academic_term_id': self.term.id, 'academic_term_id': self.term.id,
'batch_id': self.batch.id, 'batch_id': self.batch.id,
'semester_id': self.semester.id, 'semester_id': self.semester.id,
'section_id': self.section.id, 'section_id': self.section_b.id,
'status': 'active', '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({ self.session = self.env['education.attendance.session'].create({
'name': 'Test Lecture Period 1', 'name': 'Test Lecture Period 1',
'campus_id': self.campus.id, 'campus_id': self.campus.id,
@ -121,17 +166,18 @@ class TestStudentAttendance(TransactionCase):
'academic_level_id': self.level.id, 'academic_level_id': self.level.id,
'academic_year_id': self.year.id, 'academic_year_id': self.year.id,
'academic_term_id': self.term.id, 'academic_term_id': self.term.id,
'attendance_date': '2026-02-01', 'attendance_date': self.session_date,
'period': 'period_1', 'period': 'period_1',
'faculty_id': self.user_teacher.id,
'company_id': self.company.id,
}) })
def test_attendance_session_workflow(self): def test_01_attendance_session_workflow(self):
"""Test workflow of opening session, marking attendance, completing it, and reverse logic""" """Test workflow of opening session, bulk marking, completing it, and student metrics calculation."""
# Session should start in draft with no lines
self.assertEqual(self.session.state, 'draft') self.assertEqual(self.session.state, 'draft')
self.assertEqual(len(self.session.line_ids), 0) 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.session.action_open()
self.assertEqual(self.session.state, 'open') self.assertEqual(self.session.state, 'open')
self.assertEqual(len(self.session.line_ids), 3) self.assertEqual(len(self.session.line_ids), 3)
@ -145,16 +191,16 @@ class TestStudentAttendance(TransactionCase):
for line in self.session.line_ids: for line in self.session.line_ids:
self.assertEqual(line.status, 'absent') 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_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_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_3 = self.session.line_ids.filtered(lambda l: l.student_id == self.student_3)
line_1.write({'status': 'present'}) line_1.write({'status': 'present'})
line_2.write({'status': 'late'}) line_2.write({'status': 'absent'})
line_3.write({'status': 'medical_leave'}) line_3.write({'status': 'late'})
# Complete Session (should write to permanent history education.attendance) # Complete Session
self.session.action_complete() self.session.action_complete()
self.assertEqual(self.session.state, 'completed') self.assertEqual(self.session.state, 'completed')
@ -165,31 +211,30 @@ class TestStudentAttendance(TransactionCase):
self.assertEqual(len(history_1), 1) self.assertEqual(len(history_1), 1)
self.assertEqual(history_1.status, 'present') self.assertEqual(history_1.status, 'present')
self.assertEqual(history_2.status, 'late') self.assertEqual(len(history_2), 1)
self.assertEqual(history_3.status, 'medical_leave') 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 # Invalidate student record cache to read computed metrics
self.student_1.invalidate_recordset() self.student_1.invalidate_recordset()
self.student_2.invalidate_recordset() self.student_2.invalidate_recordset()
self.student_3.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_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_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) # Student 2: 1 absent / 1 total = 0%
self.session.action_draft() self.assertEqual(self.student_2.attendance_pct, 0.0)
self.assertEqual(self.session.state, 'draft') self.assertEqual(self.student_2.absent_count, 1)
self.assertEqual(len(self.session.line_ids), 0)
self.assertEqual(len(self.env['education.attendance'].search([('session_id', '=', self.session.id)])), 0)
def test_duplicate_session_constraint(self): # Student 3: 1 late / 1 total = 100% (attended)
"""Test SQL constraints: cannot create duplicate session for same period and date""" 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): with self.assertRaises(Exception):
self.env['education.attendance.session'].create({ self.env['education.attendance.session'].create({
'name': 'Duplicate Session', 'name': 'Duplicate Session',
@ -199,7 +244,123 @@ class TestStudentAttendance(TransactionCase):
'academic_level_id': self.level.id, 'academic_level_id': self.level.id,
'academic_year_id': self.year.id, 'academic_year_id': self.year.id,
'academic_term_id': self.term.id, 'academic_term_id': self.term.id,
'attendance_date': '2026-02-01', 'attendance_date': self.session_date,
'period': 'period_1', 'period': 'period_1',
'company_id': self.company.id,
}) })
self.env.flush_all() 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)

View File

@ -119,6 +119,9 @@
<field name="department_id" readonly="state != 'draft'"/> <field name="department_id" readonly="state != 'draft'"/>
<field name="program_id" readonly="state != 'draft'"/> <field name="program_id" readonly="state != 'draft'"/>
<field name="academic_level_id" readonly="state != 'draft'"/> <field name="academic_level_id" readonly="state != 'draft'"/>
<field name="batch_id" readonly="state != 'draft'"/>
<field name="semester_id" readonly="state != 'draft'"/>
<field name="section_id" readonly="state != 'draft'"/>
</group> </group>
<group string="Schedule &amp; Faculty"> <group string="Schedule &amp; Faculty">
<field name="academic_year_id" readonly="state != 'draft'"/> <field name="academic_year_id" readonly="state != 'draft'"/>

View File

@ -107,8 +107,8 @@ class MobileStudent(http.Controller):
medical_leave = len([a for a in records if a.status == 'medical_leave']) 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']) on_duty = len([a for a in records if a.status == 'on_duty'])
effective_present = present + on_duty + (late * 0.5) attended = present + on_duty + late + medical_leave
percentage = round((effective_present / total_sessions * 100), 1) if total_sessions > 0 else 100.0 percentage = round((attended / total_sessions * 100.0), 1) if total_sessions > 0 else 100.0
return { return {
"percentage": percentage, "percentage": percentage,
@ -222,13 +222,18 @@ class MobileStudent(http.Controller):
@require_student @require_student
def get_attendance(self, student, **kwargs): def get_attendance(self, student, **kwargs):
try: try:
from_date = kwargs.get('from_date') from_date_str = kwargs.get('from_date')
to_date = kwargs.get('to_date') to_date_str = kwargs.get('to_date')
from_date = None
to_date = None
if from_date: if from_date_str:
from_date = datetime.strptime(from_date, '%Y-%m-%d').date() from_date = datetime.strptime(from_date_str, '%Y-%m-%d').date()
if to_date: if to_date_str:
to_date = datetime.strptime(to_date, '%Y-%m-%d').date() 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) 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( records = request.env['education.attendance'].search_read(
domain, domain,
fields=['id', 'date', 'status'], fields=['id', 'date', 'status', 'remarks'],
order='date desc, id desc', order='date desc, id desc',
limit=100 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={ return json_response(True, data={
"summary": summary, "summary": summary,
"records": records "records": formatted_records
}) })
except ValueError: except ValueError:
return json_response(False, error_code="INVALID_DATE", error_message="Dates must be in YYYY-MM-DD format", status=400) return json_response(False, error_code="INVALID_DATE", error_message="Dates must be in YYYY-MM-DD format", status=400)

View File

@ -224,19 +224,37 @@ class AttendanceScreen extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Expanded(
children: [ child: Column(
const Icon(Icons.calendar_today_rounded, size: 16, color: AppTheme.textSecondary), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 8), children: [
Text( Row(
record.date, children: [
style: const TextStyle( const Icon(Icons.calendar_today_rounded, size: 16, color: AppTheme.textSecondary),
fontSize: 14, const SizedBox(width: 8),
fontWeight: FontWeight.w600, Text(
color: AppTheme.textPrimary, 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( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),

View File

@ -42,11 +42,13 @@ class AttendanceRecord {
final int id; final int id;
final String date; final String date;
final String status; final String status;
final String remarks;
const AttendanceRecord({ const AttendanceRecord({
required this.id, required this.id,
required this.date, required this.date,
required this.status, required this.status,
this.remarks = '',
}); });
factory AttendanceRecord.fromJson(Map<String, dynamic> json) { factory AttendanceRecord.fromJson(Map<String, dynamic> json) {
@ -54,6 +56,7 @@ class AttendanceRecord {
id: SafeConvert.toSafeInt(json['id']), id: SafeConvert.toSafeInt(json['id']),
date: SafeConvert.toSafeString(json['date']), date: SafeConvert.toSafeString(json['date']),
status: SafeConvert.toSafeString(json['status'], 'absent'), status: SafeConvert.toSafeString(json['status'], 'absent'),
remarks: SafeConvert.toSafeString(json['remarks']),
); );
} }
} }

View File

@ -99,7 +99,9 @@ void main() {
test('5. GET /api/mobile/v1/student/attendance returns attendance summary', () async { test('5. GET /api/mobile/v1/student/attendance returns attendance summary', () async {
final attendance = await attendanceRepo.getAttendance(); final attendance = await attendanceRepo.getAttendance();
expect(attendance.summary.percentage, isNonNegative); 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 { test('6. GET /api/mobile/v1/student/timetable returns timetable slots', () async {

View File

@ -93,8 +93,9 @@ void main() {
'on_duty': 0, 'on_duty': 0,
}, },
'records': [ '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': 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.percentage, 87.5);
expect(data.summary.present, 14); expect(data.summary.present, 14);
expect(data.summary.absent, 2); 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].date, '2026-10-12');
expect(data.records[0].status, 'present'); expect(data.records[0].status, 'present');
expect(data.records[0].remarks, 'On time');
expect(data.records[1].status, 'absent'); 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', () { test('handles empty or false attendance gracefully', () {
@ -119,6 +124,33 @@ void main() {
expect(data.summary.present, 0); expect(data.summary.present, 0);
expect(data.records, isEmpty); 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', () { group('Timetable Parsing Tests', () {