point_system/point_system/students/tests.py
Xiao Furen 750b231a54 feat(students): enhance student management and attendance reporting
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.
2026-08-16 23:03:06 +08:00

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