feat(education_attendance): complete attendance workflow and mobile integration
This commit is contained in:
parent
2b4b4aead1
commit
232e22e89a
@ -16,6 +16,7 @@
|
||||
'depends': [
|
||||
'education_base',
|
||||
'education_student',
|
||||
'education_portal',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.")
|
||||
|
||||
|
||||
@ -52,4 +52,16 @@
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_attendance_manager'))]"/>
|
||||
</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>
|
||||
|
||||
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
@ -119,6 +119,9 @@
|
||||
<field name="department_id" readonly="state != 'draft'"/>
|
||||
<field name="program_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 string="Schedule & Faculty">
|
||||
<field name="academic_year_id" readonly="state != 'draft'"/>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<String, dynamic> 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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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', () {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user