# pyrefly: ignore [missing-import] from datetime import date, timedelta from odoo.tests.common import TransactionCase from odoo.exceptions import ValidationError, AccessError class TestStudentAttendance(TransactionCase): def setUp(self): super(TestStudentAttendance, self).setUp() self.company = self.env.company # Setup master records self.campus = self.env['education.campus'].create({ 'name': 'Attendance Test Campus', 'code': 'ATC-SEC', 'company_id': self.company.id, }) self.department = self.env['education.department'].create({ 'name': 'Attendance Test Dept', 'code': 'ATD-SEC', 'campus_id': self.campus.id, }) self.level = self.env['education.academic.level'].create({ 'name': 'Attendance Test Level', 'code': 'ATL-SEC', 'company_id': self.company.id, }) self.program = self.env['education.program'].create({ 'name': 'Attendance Test Program', '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-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-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-SEC', }) self.semester = self.env['education.semester'].create({ 'name': 'Attendance Test Semester 1', 'code': 'ATS1-SEC', }) self.section_a = self.env['education.section'].create({ 'name': 'Attendance Test Section A', 'code': 'ATSA-SEC', }) self.section_b = self.env['education.section'].create({ 'name': 'Attendance Test Section B', 'code': 'ATSB-SEC', }) # 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-SEC', 'admission_number': 'ATADM01-SEC', 'roll_number': 'ATRL01-SEC', 'campus_id': self.campus.id, 'department_id': self.department.id, 'program_id': self.program.id, 'level_id': self.level.id, 'academic_year_id': self.year.id, 'academic_term_id': self.term.id, 'batch_id': self.batch.id, 'semester_id': self.semester.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-SEC', 'admission_number': 'ATADM02-SEC', 'roll_number': 'ATRL02-SEC', 'campus_id': self.campus.id, 'department_id': self.department.id, 'program_id': self.program.id, 'level_id': self.level.id, 'academic_year_id': self.year.id, 'academic_term_id': self.term.id, 'batch_id': self.batch.id, 'semester_id': self.semester.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-SEC', 'admission_number': 'ATADM03-SEC', 'roll_number': 'ATRL03-SEC', 'campus_id': self.campus.id, 'department_id': self.department.id, 'program_id': self.program.id, 'level_id': self.level.id, 'academic_year_id': self.year.id, 'academic_term_id': self.term.id, 'batch_id': self.batch.id, 'semester_id': self.semester.id, 'section_id': self.section_b.id, 'status': 'active', 'company_id': self.company.id, }) # 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, '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': self.session_date, 'period': 'period_1', 'faculty_id': self.user_teacher.id, 'company_id': self.company.id, }) 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 (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) # Confirm default state is 'present' for line in self.session.line_ids: self.assertEqual(line.status, 'present') # Test bulk marking: set all to 'absent' self.session.action_bulk_absent() for line in self.session.line_ids: self.assertEqual(line.status, 'absent') # 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': 'absent'}) line_3.write({'status': 'late'}) # Complete Session self.session.action_complete() self.assertEqual(self.session.state, 'completed') # Verify history matches history_1 = self.env['education.attendance'].search([('student_id', '=', self.student_1.id), ('session_id', '=', self.session.id)]) history_2 = self.env['education.attendance'].search([('student_id', '=', self.student_2.id), ('session_id', '=', self.session.id)]) history_3 = self.env['education.attendance'].search([('student_id', '=', self.student_3.id), ('session_id', '=', self.session.id)]) self.assertEqual(len(history_1), 1) self.assertEqual(history_1.status, 'present') 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() # Student 1: 1 present / 1 total = 100% self.assertEqual(self.student_1.attendance_pct, 100.0) self.assertEqual(self.student_1.present_count, 1) # Student 2: 1 absent / 1 total = 0% self.assertEqual(self.student_2.attendance_pct, 0.0) self.assertEqual(self.student_2.absent_count, 1) # 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', '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': 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)