367 lines
16 KiB
Python
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)
|