Implement several improvements to the student management system, including: - Add `foreign_alias` and `admission_date` fields to the `Student` model. - Implement automatic `admission_date` calculation based on `student_id` patterns. - Enhance attendance reporting with internationalization (i18n) support and improved UI using Bootstrap components. - Update Excel import logic to support `foreign_alias` and `admission_date` columns. - Refine weekly schedule synchronization to respect student admission dates. - Improve the attendance recording interface with better UX and selection controls. - Update admin interface and templates to display new student attributes. - Add comprehensive test cases for authentication and protected view access.
759 lines
35 KiB
Python
759 lines
35 KiB
Python
import json
|
|
from django.test import TestCase, Client
|
|
from django.contrib.auth import authenticate, get_user_model
|
|
from django.urls import reverse
|
|
from django.conf import settings
|
|
from unittest.mock import patch, MagicMock
|
|
from students.models import Student
|
|
from students.ad_backend import ActiveDirectoryBackend, parse_group_dn
|
|
|
|
User = get_user_model()
|
|
|
|
class ActiveDirectoryBackendTestCase(TestCase):
|
|
def setUp(self):
|
|
self.backend = ActiveDirectoryBackend()
|
|
self.client = Client()
|
|
self.test_user = User.objects.create_user(
|
|
username='aduser',
|
|
password='TestPassword123!',
|
|
email='aduser@samdom.mdi.bar'
|
|
)
|
|
|
|
@patch('students.ad_backend.ldap3.Connection')
|
|
@patch('students.ad_backend.ldap3.Server')
|
|
def test_ad_authentication_success(self, mock_server, mock_connection):
|
|
mock_conn_instance = MagicMock()
|
|
mock_conn_instance.bind.return_value = True
|
|
|
|
mock_entry = MagicMock()
|
|
mock_entry.mail.value = 'testuser@samdom.mdi.bar'
|
|
mock_entry.givenName.value = 'Test'
|
|
mock_entry.sn.value = 'User'
|
|
mock_entry.memberOf.values = [
|
|
'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar',
|
|
'CN=IT Department,OU=Departments,DC=samdom,DC=mdi,DC=bar'
|
|
]
|
|
mock_conn_instance.entries = [mock_entry]
|
|
mock_connection.return_value = mock_conn_instance
|
|
|
|
user = authenticate(username='testuser', password='Password123!')
|
|
|
|
self.assertIsNotNone(user)
|
|
self.assertEqual(user.username, 'testuser')
|
|
self.assertEqual(user.email, 'testuser@samdom.mdi.bar')
|
|
self.assertTrue(hasattr(user, 'ad_groups'))
|
|
self.assertEqual(len(user.ad_groups), 2)
|
|
self.assertEqual(user.ad_groups[0]['cn'], 'Domain Users')
|
|
self.assertEqual(user.ad_groups[1]['cn'], 'IT Department')
|
|
|
|
@patch('students.ad_backend.ldap3.Connection')
|
|
@patch('students.ad_backend.ldap3.Server')
|
|
def test_ad_authentication_failure(self, mock_server, mock_connection):
|
|
mock_conn_instance = MagicMock()
|
|
mock_conn_instance.bind.return_value = False
|
|
mock_conn_instance.result = {'description': 'invalidCredentials'}
|
|
mock_connection.return_value = mock_conn_instance
|
|
|
|
user = authenticate(username='invaliduser', password='WrongPassword')
|
|
self.assertIsNone(user)
|
|
|
|
def test_login_view(self):
|
|
response = self.client.get(reverse('login'))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Active Directory 登入')
|
|
|
|
def test_parse_group_dn(self):
|
|
dn = 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'
|
|
parsed = parse_group_dn(dn)
|
|
self.assertEqual(parsed['cn'], 'Domain Admins')
|
|
self.assertEqual(parsed['dn'], dn)
|
|
|
|
def test_user_groups_page_requires_login(self):
|
|
response = self.client.get(reverse('user_groups'))
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
def test_user_groups_page_authenticated(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [
|
|
{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar', 'ou_path': 'Domain Root'},
|
|
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar', 'ou_path': 'Domain Root'}
|
|
]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('user_groups'))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Domain Users')
|
|
self.assertContains(response, 'Administrators')
|
|
self.assertContains(response, 'dc1.samdom.mdi.bar')
|
|
|
|
def test_me_2_menuitem_group(self):
|
|
self.client.force_login(self.test_user)
|
|
response = self.client.get(reverse('index'))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
self.assertContains(response, 'class="nav-item dropdown me-2"')
|
|
self.assertContains(response, 'Group')
|
|
self.assertContains(response, reverse('user_groups'))
|
|
|
|
def test_index_page_shows_schedule(self):
|
|
self.client.force_login(self.test_user)
|
|
response = self.client.get(reverse('index'))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '週會排程')
|
|
|
|
def test_pages_require_login_except_static_iframe(self):
|
|
# Unauthenticated access to protected pages redirects to login
|
|
for url_name in ['index', 'weekly_schedule', 'student_list', 'show_attendance']:
|
|
kwargs = {'student_id': 'D12458003'} if url_name == 'show_attendance' else {}
|
|
response = self.client.get(reverse(url_name, kwargs=kwargs))
|
|
self.assertEqual(response.status_code, 302, f"URL {url_name} should redirect unauthenticated user")
|
|
|
|
# Static iframe view remains publicly accessible
|
|
response = self.client.get(reverse('weekly_schedule_en_iframe'))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_record_attendance_access_unauthenticated(self):
|
|
response = self.client.get(reverse('record_attendance'))
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
def test_record_attendance_access_denied_missing_both_groups(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('record_attendance'))
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertContains(response, "Permission Denied: Access to this page requires membership in both 'Administrators' and 'Domain Admins' groups.", status_code=403)
|
|
self.assertContains(response, 'http-equiv="refresh" content="5;url=/"', status_code=403)
|
|
|
|
def test_record_attendance_access_denied_only_one_group(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('record_attendance'))
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
def test_record_attendance_access_granted_both_groups(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [
|
|
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
|
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
|
]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('record_attendance'))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_recent_attendance_access_control(self):
|
|
response = self.client.get(reverse('recent_attendance'))
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('recent_attendance'))
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
session['ad_groups'] = [
|
|
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
|
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
|
]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('recent_attendance'))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_attendance_report_access_control(self):
|
|
response = self.client.get(reverse('attendance_report'))
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('attendance_report'))
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
session['ad_groups'] = [
|
|
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
|
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
|
]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('attendance_report'))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_show_attendance_page(self):
|
|
from students.models import PointRecord
|
|
s = Student.objects.create(student_id='D12458003', name='測試生', degree_type='博士生', is_active=True)
|
|
PointRecord.objects.create(student=s)
|
|
|
|
# Unauthenticated user -> redirected to login
|
|
response = self.client.get(reverse('show_attendance', kwargs={'student_id': 'D12458003'}))
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
# Authenticated non-admin user -> 簽到歷史紀錄 hidden with permission notice
|
|
self.client.force_login(self.test_user)
|
|
response = self.client.get(reverse('show_attendance', kwargs={'student_id': 'D12458003'}))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'D12458003')
|
|
self.assertContains(response, '簽到歷史紀錄僅供同時屬於 Active Directory')
|
|
|
|
# Admin user with both groups -> 簽到歷史紀錄 visible
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [
|
|
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
|
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
|
]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('show_attendance', kwargs={'student_id': 'D12458003'}))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '簽到歷史紀錄')
|
|
|
|
def test_show_attendance_page_folding(self):
|
|
from students.models import PointRecord
|
|
s = Student.objects.create(student_id='D12458004', name='測試生多紀錄', degree_type='博士生', is_active=True)
|
|
for _ in range(15):
|
|
PointRecord.objects.create(student=s)
|
|
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [
|
|
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
|
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
|
]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('show_attendance', kwargs={'student_id': 'D12458004'}))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '展開其餘 5 筆紀錄')
|
|
|
|
def test_student_degree_type(self):
|
|
master_student = Student.objects.create(student_id='S001', name='張三', degree_type='碩士生')
|
|
doctoral_student = Student.objects.create(student_id='S002', name='李四', degree_type='博士生')
|
|
|
|
self.assertEqual(master_student.degree_type, '碩士生')
|
|
self.assertEqual(doctoral_student.degree_type, '博士生')
|
|
|
|
def test_student_category(self):
|
|
regular_student = Student.objects.create(student_id='S003', name='王五', student_category='一般生')
|
|
inservice_student = Student.objects.create(student_id='S004', name='趙六', student_category='在職生')
|
|
exchange_student = Student.objects.create(student_id='S005', name='錢七', student_category='交換中')
|
|
|
|
self.assertEqual(regular_student.student_category, '一般生')
|
|
self.assertEqual(inservice_student.student_category, '在職生')
|
|
self.assertEqual(exchange_student.student_category, '交換中')
|
|
self.assertEqual(str(regular_student), '王五 (碩士生 / 一般生)')
|
|
self.assertEqual(str(inservice_student), '趙六 (碩士生 / 在職生)')
|
|
self.assertEqual(str(exchange_student), '錢七 (碩士生 / 交換中)')
|
|
|
|
def test_student_list_active_only_filter(self):
|
|
self.client.force_login(self.test_user)
|
|
active_student = Student.objects.create(student_id='S010', name='在學學生A', is_active=True)
|
|
inactive_student = Student.objects.create(student_id='S011', name='停學生B', is_active=False)
|
|
|
|
response_default = self.client.get(reverse('student_list'))
|
|
self.assertEqual(response_default.status_code, 200)
|
|
self.assertContains(response_default, '在學學生A')
|
|
self.assertNotContains(response_default, '停學生B')
|
|
|
|
response_all = self.client.get(reverse('student_list') + '?active_only=false')
|
|
self.assertEqual(response_all.status_code, 200)
|
|
self.assertContains(response_all, '在學學生A')
|
|
self.assertContains(response_all, '停學生B')
|
|
|
|
def test_update_from_excel_button_visible_for_administrators(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('student_list'))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '上傳並匯入 students.xlsx')
|
|
|
|
def test_update_from_excel_button_hidden_for_non_administrators(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('student_list'))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertNotContains(response, '上傳並匯入 students.xlsx')
|
|
|
|
def test_update_from_excel_action_denied_for_non_administrators(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.post(reverse('update_students_from_excel'))
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
def test_update_from_excel_action_success_for_administrators(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.post(reverse('update_students_from_excel'), follow=True)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '成功從 students.xlsx 更新學生資料')
|
|
|
|
def test_inactive_student_activation_rules(self):
|
|
inactive_not_in_excel = Student.objects.create(
|
|
student_id='NONEXISTENT999',
|
|
name='非Excel學生',
|
|
is_active=False
|
|
)
|
|
|
|
inactive_in_excel = Student.objects.create(
|
|
student_id='D13K48008',
|
|
name='林冠儒',
|
|
is_active=False
|
|
)
|
|
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.post(reverse('update_students_from_excel'), follow=True)
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
inactive_not_in_excel.refresh_from_db()
|
|
inactive_in_excel.refresh_from_db()
|
|
|
|
self.assertFalse(inactive_not_in_excel.is_active)
|
|
self.assertTrue(inactive_in_excel.is_active)
|
|
|
|
def test_preserve_student_category_during_import(self):
|
|
existing_student = Student.objects.create(
|
|
student_id='D13K48007',
|
|
name='李艾臻',
|
|
student_category='在職生'
|
|
)
|
|
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.post(reverse('update_students_from_excel'), follow=True)
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
existing_student.refresh_from_db()
|
|
self.assertEqual(existing_student.student_category, '在職生')
|
|
|
|
def test_import_second_email_from_excel(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.post(reverse('update_students_from_excel'), follow=True)
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
student = Student.objects.get(student_id='D13K48007')
|
|
self.assertEqual(student.email, 'D13K48007@ntu.edu.tw')
|
|
self.assertEqual(student.email2, 'zaq4747@yahoo.com.tw')
|
|
|
|
def test_upload_students_excel_for_administrators(self):
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.get(reverse('student_list'))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '上傳並匯入 students.xlsx')
|
|
|
|
with open('students.xlsx', 'rb') as f:
|
|
post_response = self.client.post(
|
|
reverse('upload_students_excel'),
|
|
{'excel_file': f},
|
|
follow=True
|
|
)
|
|
self.assertEqual(post_response.status_code, 200)
|
|
self.assertContains(post_response, '成功上傳並匯入檔案')
|
|
|
|
def test_upload_students_csv_file(self):
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
|
|
csv_content = (
|
|
"學號,姓名,年級,Email\n"
|
|
"C001,CSV生,1,c001@ntu.edu.tw;c002@yahoo.com.tw\n"
|
|
).encode('utf-8-sig')
|
|
|
|
uploaded_csv = SimpleUploadedFile("students.csv", csv_content, content_type="text/csv")
|
|
|
|
self.client.force_login(self.test_user)
|
|
session = self.client.session
|
|
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
|
session.save()
|
|
|
|
response = self.client.post(
|
|
reverse('upload_students_excel'),
|
|
{'excel_file': uploaded_csv},
|
|
follow=True
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '成功上傳並匯入檔案 『students.csv』')
|
|
|
|
imported_student = Student.objects.get(student_id='C001')
|
|
self.assertEqual(imported_student.name, 'CSV生')
|
|
self.assertEqual(imported_student.email, 'c001@ntu.edu.tw')
|
|
self.assertEqual(imported_student.email2, 'c002@yahoo.com.tw')
|
|
|
|
def test_weekly_schedule_rules(self):
|
|
self.client.force_login(self.test_user)
|
|
master_student = Student.objects.create(student_id='M001', name='碩士生A', degree_type='碩士生', student_category='一般生', is_active=True)
|
|
doc_reg_student = Student.objects.create(student_id='D001', name='博一般B', degree_type='博士生', student_category='一般生', is_active=True)
|
|
doc_ins_student = Student.objects.create(student_id='D002', name='博在職C', degree_type='博士生', student_category='在職生', is_active=True)
|
|
exchange_student = Student.objects.create(student_id='EX01', name='交換生D', degree_type='碩士生', student_category='交換中', is_active=True)
|
|
|
|
response = self.client.get(reverse('weekly_schedule'))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '週會排程')
|
|
self.assertContains(response, '碩士生A')
|
|
self.assertContains(response, '博一般B')
|
|
self.assertContains(response, '博在職C')
|
|
self.assertContains(response, '交換生D')
|
|
self.assertContains(response, '免報告')
|
|
|
|
summary_data = response.context['summary_data']
|
|
master_summary = next(s for s in summary_data if s['student'].student_id == 'M001')
|
|
doc_reg_summary = next(s for s in summary_data if s['student'].student_id == 'D001')
|
|
doc_ins_summary = next(s for s in summary_data if s['student'].student_id == 'D002')
|
|
exchange_summary = next(s for s in summary_data if s['student'].student_id == 'EX01')
|
|
|
|
self.assertEqual(master_summary['total_count'], 2) # 1 per month x 2
|
|
self.assertEqual(doc_reg_summary['total_count'], 2) # 1 per month x 2
|
|
self.assertEqual(doc_ins_summary['total_count'], 1) # 1 per 2 months x 1
|
|
self.assertEqual(exchange_summary['total_count'], 0) # Exempt
|
|
|
|
# Verify database persistence in WeeklySchedule model
|
|
from students.models import WeeklySchedule
|
|
self.assertTrue(WeeklySchedule.objects.filter(student=master_student).exists())
|
|
self.assertEqual(WeeklySchedule.objects.filter(student=master_student).count(), 2)
|
|
self.assertEqual(WeeklySchedule.objects.filter(student=doc_reg_student).count(), 2)
|
|
self.assertEqual(WeeklySchedule.objects.filter(student=doc_ins_student).count(), 1)
|
|
self.assertEqual(WeeklySchedule.objects.filter(student=exchange_student).count(), 0)
|
|
|
|
def test_weekly_schedule_students_sorted_by_category_and_student_id(self):
|
|
self.client.force_login(self.test_user)
|
|
from datetime import date
|
|
from students.models import WeeklySchedule
|
|
test_date = date(2026, 8, 3)
|
|
|
|
s_inservice = Student.objects.create(student_id='A001', name='在職生李', degree_type='碩士生', student_category='在職生', is_active=True)
|
|
s_regular = Student.objects.create(student_id='Z999', name='一般生張', degree_type='碩士生', student_category='一般生', is_active=True)
|
|
|
|
WeeklySchedule.objects.create(student=s_inservice, date=test_date)
|
|
WeeklySchedule.objects.create(student=s_regular, date=test_date)
|
|
|
|
response = self.client.get(reverse('weekly_schedule'))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
schedule_m1 = response.context['schedule_m1']
|
|
day_schedule = next(m for m in schedule_m1 if m['date'] == test_date)
|
|
student_ids = [student.student_id for student in day_schedule['students']]
|
|
# '一般生' Z999 comes before '在職生' A001
|
|
self.assertEqual(student_ids, ['Z999', 'A001'])
|
|
|
|
def test_move_schedule_item_rules_and_logging(self):
|
|
import json
|
|
from datetime import date
|
|
from students.models import WeeklySchedule
|
|
|
|
master_student = Student.objects.create(student_id='M002', name='碩士生Move', degree_type='碩士生', student_category='一般生', is_active=True)
|
|
schedule_aug3 = WeeklySchedule.objects.create(student=master_student, date=date(2026, 8, 3))
|
|
|
|
# Unauthenticated attempt -> redirect to login
|
|
unauth_resp = self.client.post(
|
|
reverse('move_schedule_item'),
|
|
data=json.dumps({'student_id': 'M002', 'from_date': '2026-08-03', 'to_date': '2026-08-10'}),
|
|
content_type='application/json'
|
|
)
|
|
self.assertEqual(unauth_resp.status_code, 302)
|
|
|
|
# Authenticated user
|
|
self.client.force_login(self.test_user)
|
|
|
|
# Invalid move: Master student cross-month move (Aug 3 -> Sep 7)
|
|
invalid_resp = self.client.post(
|
|
reverse('move_schedule_item'),
|
|
data=json.dumps({'student_id': 'M002', 'from_date': '2026-08-03', 'to_date': '2026-09-07'}),
|
|
content_type='application/json'
|
|
)
|
|
self.assertEqual(invalid_resp.status_code, 400)
|
|
invalid_json = invalid_resp.json()
|
|
self.assertFalse(invalid_json['success'])
|
|
self.assertIn('頻率規則驗證失敗', invalid_json['error'])
|
|
|
|
# Invalid move: Move to past date earlier than today (e.g. 2000-01-01)
|
|
past_resp = self.client.post(
|
|
reverse('move_schedule_item'),
|
|
data=json.dumps({'student_id': 'M002', 'from_date': '2026-08-03', 'to_date': '2000-01-01'}),
|
|
content_type='application/json'
|
|
)
|
|
self.assertEqual(past_resp.status_code, 400)
|
|
past_json = past_resp.json()
|
|
self.assertFalse(past_json['success'])
|
|
self.assertIn('不可將報告日期移轉至早於今天', past_json['error'])
|
|
|
|
# Valid move: Master student same month move (Aug 3 -> Aug 17)
|
|
valid_resp = self.client.post(
|
|
reverse('move_schedule_item'),
|
|
data=json.dumps({'student_id': 'M002', 'from_date': '2026-08-03', 'to_date': '2026-08-17'}),
|
|
content_type='application/json'
|
|
)
|
|
self.assertEqual(valid_resp.status_code, 200)
|
|
valid_json = valid_resp.json()
|
|
self.assertTrue(valid_json['success'])
|
|
|
|
schedule_aug3.refresh_from_db()
|
|
self.assertEqual(schedule_aug3.date, date(2026, 8, 17))
|
|
|
|
def test_verify_schedule_rules_cleans_exchange_cards(self):
|
|
from datetime import date
|
|
from students.models import WeeklySchedule
|
|
|
|
exchange_student = Student.objects.create(
|
|
student_id='EX999',
|
|
name='交換生Ex',
|
|
degree_type='碩士生',
|
|
student_category='交換中',
|
|
is_active=True
|
|
)
|
|
|
|
invalid_card = WeeklySchedule.objects.create(student=exchange_student, date=date(2026, 8, 3))
|
|
self.assertTrue(WeeklySchedule.objects.filter(id=invalid_card.id).exists())
|
|
|
|
self.client.force_login(self.test_user)
|
|
response = self.client.post(reverse('verify_schedule_rules'), follow=True)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '校正與驗證排程規則完成')
|
|
|
|
self.assertFalse(WeeklySchedule.objects.filter(id=invalid_card.id).exists())
|
|
|
|
def test_set_language_toggle(self):
|
|
self.client.force_login(self.test_user)
|
|
response = self.client.get(reverse('set_language', kwargs={'lang_code': 'en'}), follow=True)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(self.client.session.get('lang'), 'en')
|
|
self.assertContains(response, "Master Student (Regular & In-Service)")
|
|
self.assertContains(response, "Ph.D. Regular Student")
|
|
self.assertContains(response, "Ph.D. In-Service Student")
|
|
self.assertContains(response, "Exchange Student (On Exchange)")
|
|
self.assertContains(response, "Schedule Frequency: 1 time / month")
|
|
self.assertContains(response, "Schedule Frequency: 1 time / 2 months")
|
|
|
|
response = self.client.get(reverse('set_language', kwargs={'lang_code': 'zh'}), follow=True)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(self.client.session.get('lang'), 'zh')
|
|
|
|
def test_schedule_action_logging_to_file(self):
|
|
from django.conf import settings
|
|
log_file = settings.BASE_DIR / 'schedule_actions.log'
|
|
|
|
self.client.force_login(self.test_user)
|
|
response = self.client.post(reverse('verify_schedule_rules'), follow=True)
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
self.assertTrue(log_file.exists())
|
|
with open(log_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
self.assertIn("AUDIT LOG: User 'aduser' executed Verify & Clean Schedule Rules", content)
|
|
|
|
class EnglishStaticIframeAndPastEmptyCardTestCase(TestCase):
|
|
def setUp(self):
|
|
self.client = Client()
|
|
self.test_user = User.objects.create_user(
|
|
username='aduser_iframe',
|
|
password='TestPassword123!',
|
|
email='aduser_iframe@samdom.mdi.bar'
|
|
)
|
|
self.student = Student.objects.create(
|
|
student_id="M999",
|
|
name="Test Student",
|
|
degree_type="碩士生",
|
|
student_category="一般生",
|
|
is_active=True
|
|
)
|
|
|
|
def test_past_date_empty_card_message(self):
|
|
self.client.force_login(self.test_user)
|
|
response = self.client.get(reverse('weekly_schedule'))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_generate_english_schedule_static_html_and_iframe_endpoint(self):
|
|
response = self.client.get(reverse('weekly_schedule_en_iframe'))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn("Weekly Seminar Schedule", response.content.decode('utf-8'))
|
|
|
|
static_file = settings.BASE_DIR / 'static' / 'weekly_schedule_en.html'
|
|
self.assertTrue(static_file.exists())
|
|
|
|
def test_bachelor_student_degree_choice_and_scheduling(self):
|
|
self.client.force_login(self.test_user)
|
|
bachelor = Student.objects.create(
|
|
student_id="B101",
|
|
name="Bachelor Student",
|
|
degree_type="學士生",
|
|
student_category="一般生",
|
|
is_active=True
|
|
)
|
|
self.assertEqual(bachelor.degree_type, "學士生")
|
|
|
|
response = self.client.get(reverse('weekly_schedule'))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn("學士生", response.content.decode('utf-8'))
|
|
|
|
def test_verify_schedule_rules_never_adds_past_dates(self):
|
|
from datetime import date
|
|
today = date.today()
|
|
# Delete schedules for a student
|
|
s = Student.objects.create(student_id="M888", name="New M Student", degree_type="碩士生", student_category="一般生", is_active=True)
|
|
# Verify rules
|
|
self.client.post(reverse('verify_schedule_rules'))
|
|
# Check all schedules for this student
|
|
from students.models import WeeklySchedule
|
|
past_schedules = WeeklySchedule.objects.filter(student=s, date__lt=today)
|
|
self.assertEqual(past_schedules.count(), 0)
|
|
|
|
|
|
class AdmissionDateTestCase(TestCase):
|
|
def test_b10401005_admission_date_2021(self):
|
|
from datetime import date
|
|
student = Student.objects.create(student_id="B10401005", name="張學士")
|
|
self.assertEqual(student.admission_date, date(2021, 9, 1))
|
|
|
|
def test_r15k47005_admission_date_2026(self):
|
|
from datetime import date
|
|
student = Student.objects.create(student_id="R15K47005", name="李碩士")
|
|
self.assertEqual(student.admission_date, date(2026, 9, 1))
|
|
|
|
def test_d12458003_admission_date_2023(self):
|
|
from datetime import date
|
|
student = Student.objects.create(student_id="D12458003", name="王博士")
|
|
self.assertEqual(student.admission_date, date(2023, 9, 1))
|
|
|
|
def test_custom_admission_date_preserved(self):
|
|
from datetime import date
|
|
custom_date = date(2020, 2, 15)
|
|
student = Student.objects.create(student_id="B10401005", name="趙學士", admission_date=custom_date)
|
|
self.assertEqual(student.admission_date, custom_date)
|
|
|
|
def test_process_excel_rows_auto_sets_admission_date(self):
|
|
from datetime import date
|
|
from students.views import process_excel_rows
|
|
rows = [
|
|
['學號\nStudent ID', '姓名\nName'],
|
|
['B10401005', '測試生A'],
|
|
['R15K47005', '測試生B'],
|
|
]
|
|
created, updated = process_excel_rows(rows)
|
|
self.assertEqual(created, 2)
|
|
s_a = Student.objects.get(student_id='B10401005')
|
|
s_b = Student.objects.get(student_id='R15K47005')
|
|
def test_schedule_only_after_admission_date(self):
|
|
from datetime import date
|
|
from students.models import WeeklySchedule
|
|
from students.views import sync_weekly_schedule_to_db
|
|
# Future student admitted in 2027
|
|
future_student = Student.objects.create(student_id="R16K47005", name="未來生", admission_date=date(2027, 9, 1))
|
|
# Sync schedule for August/September 2026
|
|
sync_weekly_schedule_to_db(2026, 8, 2026, 9, force=True)
|
|
# Should have 0 schedules for 2026 because 2026 < admission date 2027-09-01
|
|
self.assertEqual(WeeklySchedule.objects.filter(student=future_student).count(), 0)
|
|
|
|
def test_move_schedule_before_admission_date_rejected(self):
|
|
from datetime import date
|
|
from students.models import WeeklySchedule
|
|
user = User.objects.create_user(username='admin_user', password='password123')
|
|
self.client.force_login(user)
|
|
student = Student.objects.create(student_id="R15K47005", name="近期生", admission_date=date(2026, 9, 1))
|
|
WeeklySchedule.objects.create(student=student, date=date(2026, 9, 7))
|
|
|
|
response = self.client.post(
|
|
reverse('move_schedule_item'),
|
|
data=json.dumps({
|
|
'student_id': 'R15K47005',
|
|
'from_date': '2026-09-07',
|
|
'to_date': '2026-08-17'
|
|
}),
|
|
content_type='application/json'
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertIn('不可將報告日期移轉至入學日期前', response.json().get('error', ''))
|
|
|
|
def test_verify_schedule_cleans_cards_before_admission_date(self):
|
|
from datetime import date
|
|
from students.models import WeeklySchedule
|
|
user = User.objects.create_user(username='admin_verify', password='password123')
|
|
self.client.force_login(user)
|
|
student = Student.objects.create(student_id="R15K47006", name="校正生", admission_date=date(2026, 9, 1))
|
|
# Manually create invalid card before admission date
|
|
invalid_card = WeeklySchedule.objects.create(student=student, date=date(2026, 8, 10))
|
|
|
|
today = date.today()
|
|
if today <= date(2026, 8, 31):
|
|
self.client.post(reverse('verify_schedule_rules'))
|
|
self.assertFalse(WeeklySchedule.objects.filter(id=invalid_card.id).exists())
|
|
|
|
|
|
class AdmissionDateInputFormatsTestCase(TestCase):
|
|
def test_student_admin_form_accepts_slash_date(self):
|
|
from datetime import date
|
|
from django.contrib.admin.sites import AdminSite
|
|
from django.utils import translation
|
|
from students.admin import StudentAdmin
|
|
|
|
ma = StudentAdmin(Student, AdminSite())
|
|
Form = ma.get_form(None)
|
|
|
|
for locale in ['en', 'zh-hant']:
|
|
translation.activate(locale)
|
|
for date_str in ['2025/09/01', '2025-09-01', '2025/9/1', '2025.09.01']:
|
|
form = Form({
|
|
'student_id': 'D14K48010',
|
|
'name': '測試學生',
|
|
'degree_type': '學士生',
|
|
'student_category': '一般生',
|
|
'is_active': True,
|
|
'admission_date': date_str,
|
|
})
|
|
self.assertTrue(form.is_valid(), f"Failed for date_str='{date_str}' in locale='{locale}': {form.errors}")
|
|
self.assertEqual(form.cleaned_data['admission_date'], date(2025, 9, 1))
|
|
|
|
def test_import_students_accepts_slash_date(self):
|
|
from datetime import date
|
|
from students.views import process_excel_rows
|
|
|
|
rows = [
|
|
['學號', '姓名', '學位類別', '身分類別', '入學日期'],
|
|
['D14K48099', 'slash日期生', '碩士生', '一般生', '2025/09/01']
|
|
]
|
|
created, updated = process_excel_rows(rows)
|
|
self.assertEqual(created, 1)
|
|
student = Student.objects.get(student_id='D14K48099')
|
|
self.assertEqual(student.admission_date, date(2025, 9, 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|