367 lines
16 KiB
Python

# 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)