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.
This commit is contained in:
Xiao Furen 2026-08-16 23:03:06 +08:00
parent c25a3afc59
commit 750b231a54
25 changed files with 872 additions and 292 deletions

View file

@ -0,0 +1 @@
# Package initialization for formats

View file

@ -0,0 +1 @@
# Package initialization for formats.en

View file

@ -0,0 +1,19 @@
from django.conf.locale.en.formats import *
DATE_INPUT_FORMATS = [
'%Y-%m-%d',
'%Y/%m/%d',
'%Y.%m.%d',
'%Y年%m月%d',
'%Y年%n月%j日',
'%m/%d/%Y',
'%m/%d/%y',
'%b %d %Y',
'%b %d, %Y',
'%d %b %Y',
'%d %b, %Y',
'%B %d %Y',
'%B %d, %Y',
'%d %B %Y',
'%d %B, %Y',
]

View file

@ -0,0 +1 @@
# Package initialization for formats.zh_hant

View file

@ -0,0 +1 @@
from formats.zh_Hant.formats import *

View file

@ -225,5 +225,80 @@ msgstr "%(count)s time"
msgid "無在學學生資料"
msgstr "No active student data"
msgid "在學學生: %(count)s 人"
msgstr "Active Students: %(count)s"
msgid "考勤紀錄提示:"
msgstr "Attendance Note: "
msgid "請勾選要紀錄考勤簽到的學生,點擊「確認考勤簽到」按鈕即可批次寫入點名紀錄。"
msgstr "Please select the students to record attendance for and click \"Submit Attendance\" to save."
msgid "學生考勤點名單"
msgstr "Student Attendance Checklist"
msgid "全選"
msgstr "Select All"
msgid "取消全選"
msgstr "Deselect All"
msgid "搜尋學生姓名..."
msgstr "Search student name..."
msgid "已勾選:"
msgstr "Selected: "
msgid "確認考勤簽到"
msgstr "Submit Attendance"
msgid "本次已成功紀錄考勤"
msgstr "Attendance Recorded Successfully"
msgid "共 %(count)s 筆紀錄"
msgstr "%(count)s Record(s)"
msgid "身分 / 學位"
msgstr "Category / Degree"
msgid "考勤簽到時間"
msgstr "Attendance Timestamp"
msgid "已簽到"
msgstr "Recorded"
msgid "最新紀錄: %(count)s 筆"
msgstr "Latest Records: %(count)s"
msgid "系統狀態: 即時記錄"
msgstr "Status: Real-time"
msgid "顯示系統中最新輸入之 20 筆學生考勤簽到時間明細,點擊學生姓名或查看詳情可瀏覽個人完整簽到與報告紀錄。"
msgstr "Displays the 20 most recent student attendance records. Click student name or view details to see full history."
msgid "最近 20 筆考勤簽到紀錄"
msgstr "Recent 20 Attendance Records"
msgid "目前沒有考勤紀錄。"
msgstr "No attendance records found."
msgid "統計學生數: %(count)s 人"
msgstr "Students Count: %(count)s"
msgid "報表統計提示:"
msgstr "Report Summary: "
msgid "統計各學生於去年度 (%(ly)s) 與本年度 (%(cy)s) 之累積考勤簽到次數總和。"
msgstr "Annual cumulative attendance counts for last year (%(ly)s) and current year (%(cy)s)."
msgid "年度出席統計報表 (%(ly)s vs %(cy)s)"
msgstr "Annual Attendance Report (%(ly)s vs %(cy)s)"
msgid "出席次數"
msgstr "Attendance Count"
msgid "尚無符合條件之考勤統計資料"
msgstr "No attendance statistics available."

View file

@ -147,6 +147,21 @@ LOCALE_PATHS = [
BASE_DIR / 'locale',
]
FORMAT_MODULE_PATH = [
'formats',
]
DATE_INPUT_FORMATS = [
'%Y-%m-%d',
'%Y/%m/%d',
'%Y.%m.%d',
'%Y年%m月%d',
'%Y年%n月%j日',
'%m/%d/%Y',
'%m/%d/%y',
]
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/

View file

@ -77,7 +77,7 @@
<h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>2026-08-10 (Monday)
</h5>
<span class="badge bg-light text-primary fs-6">6 presenters</span>
<span class="badge bg-light text-primary fs-6">4 presenters</span>
</div>
<div class="card-body p-0">
@ -92,22 +92,6 @@
</thead>
<tbody>
<tr>
<td class="fw-bold">BUSHRA UROOJ</td>
<td>
<span class="badge bg-danger text-white"><i
class="fas fa-user-graduate me-1"></i>Ph.D. Student</span>
</td>
<td>
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
</td>
</tr>
<tr>
<td class="fw-bold">林 樸</td>
<td>
@ -125,7 +109,7 @@
</tr>
<tr>
<td class="fw-bold">李亮慶</td>
<td class="fw-bold">Frank</td>
<td>
<span class="badge bg-info text-dark"><i
@ -141,23 +125,7 @@
</tr>
<tr>
<td class="fw-bold">Emran Khan</td>
<td>
<span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>Master's Student</span>
</td>
<td>
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
</td>
</tr>
<tr>
<td class="fw-bold">林宇晨</td>
<td class="fw-bold">Eric</td>
<td>
<span class="badge bg-primary text-white"><i
@ -204,7 +172,7 @@
<h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>2026-08-17 (Monday)
</h5>
<span class="badge bg-light text-primary fs-6">6 presenters</span>
<span class="badge bg-light text-primary fs-6">4 presenters</span>
</div>
<div class="card-body p-0">
@ -220,7 +188,7 @@
<tbody>
<tr>
<td class="fw-bold">林孟忻</td>
<td class="fw-bold">Gloria</td>
<td>
<span class="badge bg-danger text-white"><i
@ -236,23 +204,7 @@
</tr>
<tr>
<td class="fw-bold">劉冠岑</td>
<td>
<span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>Master's Student</span>
</td>
<td>
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
</td>
</tr>
<tr>
<td class="fw-bold">江霈</td>
<td class="fw-bold">Sandy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -299,22 +251,6 @@
</td>
</tr>
<tr>
<td class="fw-bold">孫艾琳</td>
<td>
<span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>Master's Student</span>
</td>
<td>
<span class="badge bg-warning text-dark"><i
class="fas fa-user-tie me-1"></i>In-Service</span>
</td>
</tr>
</tbody>
</table>
</div>
@ -378,7 +314,7 @@
</tr>
<tr>
<td class="fw-bold">劉睿恩</td>
<td class="fw-bold">Roy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -473,7 +409,7 @@
</tr>
<tr>
<td class="fw-bold">林靖哲</td>
<td class="fw-bold">Jimmy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -489,7 +425,7 @@
</tr>
<tr>
<td class="fw-bold">林綺音</td>
<td class="fw-bold">Yin</td>
<td>
<span class="badge bg-info text-dark"><i
@ -579,7 +515,7 @@
<tbody>
<tr>
<td class="fw-bold">林孟忻</td>
<td class="fw-bold">Gloria</td>
<td>
<span class="badge bg-danger text-white"><i
@ -611,7 +547,7 @@
</tr>
<tr>
<td class="fw-bold">劉冠岑</td>
<td class="fw-bold">Sandy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -627,7 +563,7 @@
</tr>
<tr>
<td class="fw-bold">林宇晨</td>
<td class="fw-bold">Eric</td>
<td>
<span class="badge bg-primary text-white"><i
@ -644,7 +580,7 @@
</tr>
<tr>
<td class="fw-bold">陳其蒙</td>
<td class="fw-bold">Kimo</td>
<td>
<span class="badge bg-danger text-white"><i
@ -690,7 +626,7 @@
<h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>2026-09-14 (Monday)
</h5>
<span class="badge bg-light text-success fs-6">6 presenters</span>
<span class="badge bg-light text-success fs-6">5 presenters</span>
</div>
<div class="card-body p-0">
@ -706,23 +642,7 @@
<tbody>
<tr>
<td class="fw-bold">BUSHRA UROOJ</td>
<td>
<span class="badge bg-danger text-white"><i
class="fas fa-user-graduate me-1"></i>Ph.D. Student</span>
</td>
<td>
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
</td>
</tr>
<tr>
<td class="fw-bold">劉睿恩</td>
<td class="fw-bold">Roy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -754,7 +674,7 @@
</tr>
<tr>
<td class="fw-bold">林綺音</td>
<td class="fw-bold">Yin</td>
<td>
<span class="badge bg-info text-dark"><i
@ -848,7 +768,7 @@
</tr>
<tr>
<td class="fw-bold">林靖哲</td>
<td class="fw-bold">Jimmy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -864,7 +784,7 @@
</tr>
<tr>
<td class="fw-bold">江霈</td>
<td class="fw-bold">Pei</td>
<td>
<span class="badge bg-info text-dark"><i
@ -959,7 +879,7 @@
</tr>
<tr>
<td class="fw-bold">李亮慶</td>
<td class="fw-bold">Frank</td>
<td>
<span class="badge bg-info text-dark"><i
@ -992,7 +912,7 @@
</tr>
<tr>
<td class="fw-bold">黃子芬</td>
<td class="fw-bold">Melissa</td>
<td>
<span class="badge bg-danger text-white"><i

View file

@ -1,12 +1,35 @@
from django.contrib import admin
from django import forms
from .models import Student, PointRecord, WeeklySchedule
class StudentForm(forms.ModelForm):
admission_date = forms.DateField(
label='入學日期',
required=False,
input_formats=[
'%Y-%m-%d',
'%Y/%m/%d',
'%Y.%m.%d',
'%Y年%m月%d',
'%Y年%n月%j日',
'%m/%d/%Y',
'%m/%d/%y',
],
widget=admin.widgets.AdminDateWidget()
)
class Meta:
model = Student
fields = '__all__'
@admin.register(Student)
class StudentAdmin(admin.ModelAdmin):
list_display = ('student_id', 'name', 'degree_type', 'student_category', 'grade', 'hope_admission_scheme', 'tuition_waiver', 'financial_assistance_disadvantaged_students', 'loan', 'email', 'email2', 'is_active')
search_fields = ('student_id', 'name', 'email', 'email2')
form = StudentForm
list_display = ('student_id', 'name', 'foreign_alias', 'degree_type', 'student_category', 'grade', 'admission_date', 'hope_admission_scheme', 'tuition_waiver', 'financial_assistance_disadvantaged_students', 'loan', 'email', 'email2', 'is_active')
search_fields = ('student_id', 'name', 'foreign_alias', 'email', 'email2')
ordering = ['student_id']
list_filter = ['degree_type', 'student_category', 'grade', 'hope_admission_scheme', 'tuition_waiver', 'financial_assistance_disadvantaged_students', 'loan', 'is_active']
list_filter = ['degree_type', 'student_category', 'grade', 'admission_date', 'hope_admission_scheme', 'tuition_waiver', 'financial_assistance_disadvantaged_students', 'loan', 'is_active']
@admin.register(PointRecord)
class PointRecordAdmin(admin.ModelAdmin):

View file

@ -12,7 +12,7 @@ class ADLoginForm(forms.Form):
label='AD 帳號',
max_length=150,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '帳號 (例如: user 或 user@samdom.mdi.bar)'})
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '帳號 (例如: user 或 user@domain)'})
)
password = forms.CharField(
label='密碼',

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2026-08-05 04:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('students', '0007_alter_student_degree_type'),
]
operations = [
migrations.AddField(
model_name='student',
name='foreign_alias',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='外文別名'),
),
]

View file

@ -0,0 +1,41 @@
import re
from datetime import date
from django.db import migrations, models
def populate_admission_dates(apps, schema_editor):
Student = apps.get_model('students', 'Student')
for student in Student.objects.all():
if not student.admission_date and student.student_id:
sid = str(student.student_id).strip()
m_letter = re.match(r'^[A-Za-z]+(\d{2})', sid)
if m_letter:
yy = int(m_letter.group(1))
roc_year = (100 + yy) if yy < 90 else yy
ad_year = roc_year + 1911
student.admission_date = date(ad_year, 9, 1)
else:
m_roc3 = re.match(r'^(\d{3})', sid)
if m_roc3:
roc_year = int(m_roc3.group(1))
ad_year = roc_year + 1911
student.admission_date = date(ad_year, 9, 1)
else:
student.admission_date = date(date.today().year, 9, 1)
student.save()
class Migration(migrations.Migration):
dependencies = [
('students', '0008_student_foreign_alias'),
]
operations = [
migrations.AddField(
model_name='student',
name='admission_date',
field=models.DateField(blank=True, null=True, verbose_name='入學日期'),
),
migrations.RunPython(populate_admission_dates, reverse_code=migrations.RunPython.noop),
]

View file

@ -1,3 +1,5 @@
import re
from datetime import date
from django.db import models
DEGREE_CHOICES = [
@ -12,12 +14,35 @@ CATEGORY_CHOICES = [
('交換中', '交換中'),
]
def calculate_admission_date(student_id):
if not student_id:
return date(date.today().year, 9, 1)
sid = str(student_id).strip()
m_letter = re.match(r'^[A-Za-z]+(\d{2})', sid)
if m_letter:
yy = int(m_letter.group(1))
roc_year = (100 + yy) if yy < 90 else yy
ad_year = roc_year + 1911
return date(ad_year, 9, 1)
m_roc3 = re.match(r'^(\d{3})', sid)
if m_roc3:
roc_year = int(m_roc3.group(1))
ad_year = roc_year + 1911
return date(ad_year, 9, 1)
return date(date.today().year, 9, 1)
class Student(models.Model):
student_id = models.CharField(max_length=10, primary_key=True)
name = models.CharField(max_length=255)
foreign_alias = models.CharField(max_length=255, null=True, blank=True, verbose_name='外文別名')
degree_type = models.CharField(max_length=10, choices=DEGREE_CHOICES, default='碩士生', verbose_name='學位類別')
student_category = models.CharField(max_length=10, choices=CATEGORY_CHOICES, default='一般生', verbose_name='身分類別')
grade = models.IntegerField(null=True, blank=True)
admission_date = models.DateField(null=True, blank=True, verbose_name='入學日期')
hope_admission_scheme = models.BooleanField(default=False, null=True, blank=True)
tuition_waiver = models.BooleanField(default=False, null=True, blank=True)
financial_assistance_disadvantaged_students = models.BooleanField(default=False, null=True, blank=True)
@ -26,6 +51,11 @@ class Student(models.Model):
email2 = models.EmailField(null=True, blank=True, verbose_name='第二 Email')
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
if not self.admission_date and self.student_id:
self.admission_date = calculate_admission_date(self.student_id)
super().save(*args, **kwargs)
def __str__(self):
return f"{self.name} ({self.degree_type} / {self.student_category})"

View file

@ -77,7 +77,7 @@
<h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>2026-08-10 (Monday)
</h5>
<span class="badge bg-light text-primary fs-6">6 presenters</span>
<span class="badge bg-light text-primary fs-6">4 presenters</span>
</div>
<div class="card-body p-0">
@ -92,22 +92,6 @@
</thead>
<tbody>
<tr>
<td class="fw-bold">BUSHRA UROOJ</td>
<td>
<span class="badge bg-danger text-white"><i
class="fas fa-user-graduate me-1"></i>Ph.D. Student</span>
</td>
<td>
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
</td>
</tr>
<tr>
<td class="fw-bold">林 樸</td>
<td>
@ -125,7 +109,7 @@
</tr>
<tr>
<td class="fw-bold">李亮慶</td>
<td class="fw-bold">Frank</td>
<td>
<span class="badge bg-info text-dark"><i
@ -141,23 +125,7 @@
</tr>
<tr>
<td class="fw-bold">Emran Khan</td>
<td>
<span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>Master's Student</span>
</td>
<td>
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
</td>
</tr>
<tr>
<td class="fw-bold">林宇晨</td>
<td class="fw-bold">Eric</td>
<td>
<span class="badge bg-primary text-white"><i
@ -204,7 +172,7 @@
<h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>2026-08-17 (Monday)
</h5>
<span class="badge bg-light text-primary fs-6">6 presenters</span>
<span class="badge bg-light text-primary fs-6">4 presenters</span>
</div>
<div class="card-body p-0">
@ -220,7 +188,7 @@
<tbody>
<tr>
<td class="fw-bold">林孟忻</td>
<td class="fw-bold">Gloria</td>
<td>
<span class="badge bg-danger text-white"><i
@ -236,23 +204,7 @@
</tr>
<tr>
<td class="fw-bold">劉冠岑</td>
<td>
<span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>Master's Student</span>
</td>
<td>
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
</td>
</tr>
<tr>
<td class="fw-bold">江霈</td>
<td class="fw-bold">Sandy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -299,22 +251,6 @@
</td>
</tr>
<tr>
<td class="fw-bold">孫艾琳</td>
<td>
<span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>Master's Student</span>
</td>
<td>
<span class="badge bg-warning text-dark"><i
class="fas fa-user-tie me-1"></i>In-Service</span>
</td>
</tr>
</tbody>
</table>
</div>
@ -378,7 +314,7 @@
</tr>
<tr>
<td class="fw-bold">劉睿恩</td>
<td class="fw-bold">Roy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -473,7 +409,7 @@
</tr>
<tr>
<td class="fw-bold">林靖哲</td>
<td class="fw-bold">Jimmy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -489,7 +425,7 @@
</tr>
<tr>
<td class="fw-bold">林綺音</td>
<td class="fw-bold">Yin</td>
<td>
<span class="badge bg-info text-dark"><i
@ -579,7 +515,7 @@
<tbody>
<tr>
<td class="fw-bold">林孟忻</td>
<td class="fw-bold">Gloria</td>
<td>
<span class="badge bg-danger text-white"><i
@ -611,7 +547,7 @@
</tr>
<tr>
<td class="fw-bold">劉冠岑</td>
<td class="fw-bold">Sandy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -627,7 +563,7 @@
</tr>
<tr>
<td class="fw-bold">林宇晨</td>
<td class="fw-bold">Eric</td>
<td>
<span class="badge bg-primary text-white"><i
@ -644,7 +580,7 @@
</tr>
<tr>
<td class="fw-bold">陳其蒙</td>
<td class="fw-bold">Kimo</td>
<td>
<span class="badge bg-danger text-white"><i
@ -690,7 +626,7 @@
<h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>2026-09-14 (Monday)
</h5>
<span class="badge bg-light text-success fs-6">6 presenters</span>
<span class="badge bg-light text-success fs-6">5 presenters</span>
</div>
<div class="card-body p-0">
@ -706,23 +642,7 @@
<tbody>
<tr>
<td class="fw-bold">BUSHRA UROOJ</td>
<td>
<span class="badge bg-danger text-white"><i
class="fas fa-user-graduate me-1"></i>Ph.D. Student</span>
</td>
<td>
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
</td>
</tr>
<tr>
<td class="fw-bold">劉睿恩</td>
<td class="fw-bold">Roy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -754,7 +674,7 @@
</tr>
<tr>
<td class="fw-bold">林綺音</td>
<td class="fw-bold">Yin</td>
<td>
<span class="badge bg-info text-dark"><i
@ -848,7 +768,7 @@
</tr>
<tr>
<td class="fw-bold">林靖哲</td>
<td class="fw-bold">Jimmy</td>
<td>
<span class="badge bg-info text-dark"><i
@ -864,7 +784,7 @@
</tr>
<tr>
<td class="fw-bold">江霈</td>
<td class="fw-bold">Pei</td>
<td>
<span class="badge bg-info text-dark"><i
@ -959,7 +879,7 @@
</tr>
<tr>
<td class="fw-bold">李亮慶</td>
<td class="fw-bold">Frank</td>
<td>
<span class="badge bg-info text-dark"><i
@ -992,7 +912,7 @@
</tr>
<tr>
<td class="fw-bold">黃子芬</td>
<td class="fw-bold">Melissa</td>
<td>
<span class="badge bg-danger text-white"><i

View file

@ -124,8 +124,7 @@
<footer class="bg-light text-center text-lg-start mt-5 py-4 border-top">
<div class="container text-center text-muted small">
<p class="mb-1">© 2023-2026 1218 Lab. All rights reserved.</p>
<p class="mb-0">Active Directory DC: <code>dc1.samdom.mdi.bar</code> (Domain: <code>samdom.mdi.bar</code>)
</p>
<p class="mb-0"><i class="fas fa-shield-alt me-1 text-primary"></i> Active Directory Single Sign-On</p>
</div>
</footer>

View file

@ -1,23 +1,93 @@
{% extends 'base.html' %}
{% block content %}
<h1>年度出席統計報表</h1>
{% load i18n %}
<table>
{% block title %}{% trans "考勤報告" %} - 1218 Lab{% endblock %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3">
<h1 class="h2 text-primary mb-0">
<i class="fas fa-chart-bar me-2"></i>{% trans "考勤報告" %}
</h1>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-success fs-6 px-3 py-2 shadow-sm">
<i class="fas fa-calendar-alt me-1"></i>{{ last_year }} - {{ current_year }}
</span>
<span class="badge bg-primary fs-6 px-3 py-2 shadow-sm">
<i class="fas fa-users me-1"></i>{% blocktrans with count=attendance_data|length %}統計學生數: {{ count }} 人{% endblocktrans %}
</span>
</div>
</div>
<div class="alert alert-info small mb-4 shadow-sm rounded-3 border-start border-4 border-info">
<i class="fas fa-info-circle text-primary me-2"></i>
<strong>{% trans "報表統計提示:" %}</strong>{% blocktrans with ly=last_year cy=current_year %}統計各學生於去年度 ({{ ly }}) 與本年度 ({{ cy }}) 之累積考勤簽到次數總和。{% endblocktrans %}
</div>
<div class="card shadow-sm border-0 rounded-3 mb-4">
<div class="card-header bg-primary text-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold">
<i class="fas fa-table me-2"></i>{% blocktrans with ly=last_year cy=current_year %}年度出席統計報表 ({{ ly }} vs {{ cy }}){% endblocktrans %}
</h5>
</div>
<div class="card-body p-0">
{% if attendance_data %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>學號</th>
<th>姓名</th>
<th>{{ last_year }} 出席次數</th>
<th>{{ current_year }} 出席次數</th>
<th scope="col" style="width: 8%;">#</th>
<th scope="col" style="width: 27%;">{% trans "姓名" %}</th>
<th scope="col" style="width: 25%;">{% trans "身分 / 學位" %}</th>
<th scope="col" style="width: 20%;">{{ last_year }} {% trans "出席次數" %}</th>
<th scope="col" style="width: 20%;">{{ current_year }} {% trans "出席次數" %}</th>
</tr>
</thead>
<tbody>
{% for entry in attendance_data %}
<tr>
<td>{{ entry.student.student_id }}</td>
<td>{{ entry.student.name }}</td>
<td>{{ entry.last_year_count }}</td>
<td>{{ entry.current_year_count }}</td>
<td class="fw-bold text-muted">{{ forloop.counter }}</td>
<td class="fw-bold">
{% get_current_language as CURRENT_LANG %}
{% if CURRENT_LANG == 'en' and entry.student.foreign_alias %}
{{ entry.student.foreign_alias }}
{% else %}
{{ entry.student.name }}
{% endif %}
</td>
<td>
{% if entry.student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>{% trans "博士生" %}</span>
{% elif entry.student.degree_type == '學士生' %}
<span class="badge bg-primary text-white"><i class="fas fa-user-graduate me-1"></i>{% trans "學士生" %}</span>
{% else %}
<span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>{% trans "碩士生" %}</span>
{% endif %}
{% if entry.student.student_category == '在職生' %}
<span class="badge bg-warning text-dark"><i class="fas fa-user-tie me-1"></i>{% trans "在職生" %}</span>
{% elif entry.student.student_category == '交換中' %}
<span class="badge bg-secondary text-white"><i class="fas fa-exchange-alt me-1"></i>{% trans "交換中" %}</span>
{% else %}
<span class="badge bg-success text-white"><i class="fas fa-user me-1"></i>{% trans "一般生" %}</span>
{% endif %}
</td>
<td>
<span class="badge bg-secondary fs-6">{{ entry.last_year_count }}</span>
</td>
<td>
<span class="badge bg-primary fs-6">{{ entry.current_year_count }}</span>
</td>
</tr>
{% endfor %}
</table>
<a href="{% url 'index' %}">返回首頁</a>
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="fas fa-folder-open fa-2x mb-2 d-block"></i>
{% trans "尚無符合條件之考勤統計資料" %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -8,7 +8,6 @@
<div class="card shadow-sm border-0 rounded-3">
<div class="card-header bg-primary text-white text-center py-3 rounded-top">
<h4 class="mb-0"><i class="fas fa-shield-alt me-2"></i>Active Directory 登入</h4>
<small class="text-white-50">DC: dc1.samdom.mdi.bar</small>
</div>
<div class="card-body p-4">
{% if messages %}
@ -27,7 +26,7 @@
<i class="fas fa-user me-1 text-primary"></i> AD 帳號
</label>
{{ form.username }}
<div class="form-text">可以使用短帳號 (例如: <code>username</code>) 或完整 UPN (例如: <code>username@samdom.mdi.bar</code>)。</div>
<div class="form-text">可以使用短帳號 (例如: <code>username</code>) 或完整 UPN (例如: <code>username@domain</code>)。</div>
</div>
<div class="mb-4">
<label for="{{ form.password.id_for_label }}" class="form-label fw-bold">
@ -43,7 +42,7 @@
</form>
</div>
<div class="card-footer text-center bg-light py-3 text-muted small">
網域: <code>samdom.mdi.bar</code> | Domain Controller: <code>dc1.samdom.mdi.bar</code>
<i class="fas fa-shield-alt me-1 text-primary"></i> Active Directory Single Sign-On
</div>
</div>
</div>

View file

@ -1,34 +1,89 @@
{% extends 'base.html' %}
{% load i18n tz %}
{% block title %}最近考勤紀錄{% endblock %}
{% block title %}{% trans "最近考勤" %} - 1218 Lab{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>最近20筆考勤紀錄</h2>
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3">
<h1 class="h2 text-primary mb-0">
<i class="fas fa-history me-2"></i>{% trans "最近考勤" %}
</h1>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-success fs-6 px-3 py-2 shadow-sm">
<i class="fas fa-list-ol me-1"></i>{% blocktrans with count=recent_records|length %}最新紀錄: {{ count }} 筆{% endblocktrans %}
</span>
<span class="badge bg-primary fs-6 px-3 py-2 shadow-sm">
<i class="fas fa-clock me-1"></i>{% trans "系統狀態: 即時記錄" %}
</span>
</div>
</div>
<div class="alert alert-info small mb-4 shadow-sm rounded-3 border-start border-4 border-info">
<i class="fas fa-info-circle text-primary me-2"></i>
<strong>{% trans "考勤紀錄提示:" %}</strong>{% trans "顯示系統中最新輸入之 20 筆學生考勤簽到時間明細,點擊學生姓名或查看詳情可瀏覽個人完整簽到與報告紀錄。" %}
</div>
<div class="card shadow-sm border-0 rounded-3 mb-4">
<div class="card-header bg-primary text-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold">
<i class="fas fa-list-alt me-2"></i>{% trans "最近 20 筆考勤簽到紀錄" %}
</h5>
</div>
<div class="card-body p-0">
{% if recent_records %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>學生姓名</th>
<th>學號</th>
<th>考勤時間</th>
<th scope="col" style="width: 10%;">#</th>
<th scope="col" style="width: 30%;">{% trans "姓名" %}</th>
<th scope="col" style="width: 30%;">{% trans "身分 / 學位" %}</th>
<th scope="col" style="width: 30%;">{% trans "考勤簽到時間" %}</th>
</tr>
</thead>
<tbody>
{% for record in recent_records %}
<tr>
<td>{{ record.student.name }}</td>
<td>{{ record.student.student_id }}</td>
<td>{{ record.datetime|date:"Y-m-d H:i:s" }}</td>
<td class="fw-bold text-muted">{{ forloop.counter }}</td>
<td class="fw-bold">
{% get_current_language as CURRENT_LANG %}
{% if CURRENT_LANG == 'en' and record.student.foreign_alias %}
{{ record.student.foreign_alias }}
{% else %}
{{ record.student.name }}
{% endif %}
</td>
<td>
{% if record.student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>{% trans "博士生" %}</span>
{% elif record.student.degree_type == '學士生' %}
<span class="badge bg-primary text-white"><i class="fas fa-user-graduate me-1"></i>{% trans "學士生" %}</span>
{% else %}
<span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>{% trans "碩士生" %}</span>
{% endif %}
{% if record.student.student_category == '在職生' %}
<span class="badge bg-warning text-dark"><i class="fas fa-user-tie me-1"></i>{% trans "在職生" %}</span>
{% elif record.student.student_category == '交換中' %}
<span class="badge bg-secondary text-white"><i class="fas fa-exchange-alt me-1"></i>{% trans "交換中" %}</span>
{% else %}
<span class="badge bg-success text-white"><i class="fas fa-user me-1"></i>{% trans "一般生" %}</span>
{% endif %}
</td>
<td class="text-primary fw-bold">
<i class="far fa-clock me-1"></i>{{ record.datetime|timezone:"Asia/Taipei"|date:"Y-m-d H:i:s" }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>目前沒有考勤紀錄。</p>
<div class="text-center py-5 text-muted">
<i class="fas fa-folder-open fa-2x mb-2 d-block"></i>
{% trans "目前沒有考勤紀錄。" %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -1,30 +1,203 @@
{% extends 'base.html' %}
{% load tz %} <!-- Load the timezone template tags -->
{% load i18n tz %}
{% block title %}{% trans "記錄考勤" %} - 1218 Lab{% endblock %}
{% block content %}
<h1>紀錄點名</h1>
<form method="post">
{% csrf_token %}
<label for="students">選擇學生:</label>
<div class="student-checklist">
{% for student in students %}
<label>
<input type="checkbox" name="student_id" value="{{ student.student_id }}">
{{ student.name }} ({{ student.student_id }})
</label><br>
{% endfor %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-3">
<h1 class="h2 text-primary mb-0">
<i class="fas fa-clipboard-check me-2"></i>{% trans "記錄考勤" %}
</h1>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-outline-primary btn-sm fw-bold shadow-sm me-1" onclick="toggleSelectAll(true)">
<i class="fas fa-check-square me-1"></i>{% trans "全選" %}
</button>
<button type="button" class="btn btn-outline-secondary btn-sm fw-bold shadow-sm me-2" onclick="toggleSelectAll(false)">
<i class="far fa-square me-1"></i>{% trans "取消全選" %}
</button>
<span class="badge bg-success fs-6 px-3 py-2 shadow-sm">
<i class="fas fa-users me-1"></i>{% blocktrans with count=students|length %}在學學生: {{ count }} 人{% endblocktrans %}
</span>
<span class="badge bg-primary fs-6 px-3 py-2 shadow-sm">
<i class="fas fa-clock me-1"></i>{% now "Y-m-d H:i" %}
</span>
</div>
</div>
<form method="post" id="attendanceForm">
{% csrf_token %}
<div class="card shadow-sm border-0 rounded-3 mb-4">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover table-striped table-borderless align-middle mb-0">
<tbody id="studentCardGrid">
{% for student in students %}
<tr class="student-tile-col" data-student-name="{{ student.name|lower }}" data-student-alias="{{ student.foreign_alias|default:''|lower }}" style="cursor: pointer;" onclick="handleRowClick(event, 'chk_{{ student.student_id }}')">
<td class="ps-3 py-1 text-center" style="width: 45px;">
<input type="checkbox" name="student_id" value="{{ student.student_id }}" class="form-check-input student-checkbox mt-0" id="chk_{{ student.student_id }}" style="transform: scale(1.2); cursor: pointer;" onchange="updateRowStyle(this)">
</td>
<td class="py-1">
<label class="form-check-label text-dark mb-0 fw-bold" for="chk_{{ student.student_id }}" style="cursor: pointer; font-size: 18px;">
{% get_current_language as CURRENT_LANG %}
{% if CURRENT_LANG == 'en' and student.foreign_alias %}
{{ student.foreign_alias }}
{% else %}
{{ student.name }}
{% endif %}
</label>
</td>
<td class="py-1 text-end pe-3">
{% if student.degree_type == '博士生' %}
<span class="badge bg-danger text-white fs-6 py-1 px-2.5"><i class="fas fa-user-graduate me-1"></i>{% trans "博士生" %}</span>
{% elif student.degree_type == '學士生' %}
<span class="badge bg-primary text-white fs-6 py-1 px-2.5"><i class="fas fa-user-graduate me-1"></i>{% trans "學士生" %}</span>
{% else %}
<span class="badge bg-info text-dark fs-6 py-1 px-2.5"><i class="fas fa-graduation-cap me-1"></i>{% trans "碩士生" %}</span>
{% endif %}
{% if student.student_category == '在職生' %}
<span class="badge bg-warning text-dark fs-6 py-1 px-2.5"><i class="fas fa-user-tie me-1"></i>{% trans "在職生" %}</span>
{% elif student.student_category == '交換中' %}
<span class="badge bg-secondary text-white fs-6 py-1 px-2.5"><i class="fas fa-exchange-alt me-1"></i>{% trans "交換中" %}</span>
{% else %}
<span class="badge bg-success text-white fs-6 py-1 px-2.5"><i class="fas fa-user me-1"></i>{% trans "一般生" %}</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center py-4 text-muted">
<i class="fas fa-user-slash me-1"></i>{% trans "無在學學生資料" %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card-footer bg-light p-3 d-flex flex-wrap justify-content-between align-items-center gap-3">
<span class="text-muted fw-bold fs-6">
<i class="fas fa-user-check text-primary me-1"></i>{% trans "已勾選:" %} <span id="selectedCountBadge" class="badge bg-primary fs-6">0</span>
</span>
<button type="submit" class="btn btn-primary btn-lg shadow-sm fw-bold px-4">
<i class="fas fa-check-circle me-2"></i>{% trans "確認考勤簽到" %}
</button>
</div>
</div>
<button type="submit">點名</button>
</form>
{% if point_records %}
<h2>Recent Attendance Records</h2>
<ul>
<div class="card shadow-sm border-0 rounded-3 mb-4 mt-5">
<div class="card-header bg-success text-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold">
<i class="fas fa-check-double me-2"></i>{% trans "本次已成功紀錄考勤" %}
</h5>
<span class="badge bg-light text-success fs-6">{% blocktrans with count=point_records|length %}共 {{ count }} 筆紀錄{% endblocktrans %}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 10%;">#</th>
<th style="width: 30%;">{% trans "姓名" %}</th>
<th style="width: 30%;">{% trans "身分 / 學位" %}</th>
<th style="width: 30%;">{% trans "考勤簽到時間" %}</th>
</tr>
</thead>
<tbody>
{% for record in point_records %}
<li>{{ record.datetime|timezone:"Asia/Taipei"|date:"Y-m-d H:i:s" }} - {{ record.student.name }}</li>
<tr>
<td class="fw-bold text-muted">{{ forloop.counter }}</td>
<td class="fw-bold">
{% get_current_language as CURRENT_LANG %}
{% if CURRENT_LANG == 'en' and record.student.foreign_alias %}
{{ record.student.foreign_alias }}
{% else %}
{{ record.student.name }}
{% endif %}
</td>
<td>
{% if record.student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>{% trans "博士生" %}</span>
{% elif record.student.degree_type == '學士生' %}
<span class="badge bg-primary text-white"><i class="fas fa-user-graduate me-1"></i>{% trans "學士生" %}</span>
{% else %}
<span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>{% trans "碩士生" %}</span>
{% endif %}
{% if record.student.student_category == '在職生' %}
<span class="badge bg-warning text-dark"><i class="fas fa-user-tie me-1"></i>{% trans "在職生" %}</span>
{% elif record.student.student_category == '交換中' %}
<span class="badge bg-secondary text-white"><i class="fas fa-exchange-alt me-1"></i>{% trans "交換中" %}</span>
{% else %}
<span class="badge bg-success text-white"><i class="fas fa-user me-1"></i>{% trans "一般生" %}</span>
{% endif %}
</td>
<td class="text-primary fw-bold">
<i class="far fa-clock me-1"></i>{{ record.datetime|timezone:"Asia/Taipei"|date:"Y-m-d H:i:s" }}
</td>
</tr>
{% endfor %}
</ul>
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<script>
function handleRowClick(event, chkId) {
if (event.target.tagName.toLowerCase() === 'input' || event.target.tagName.toLowerCase() === 'label') {
return;
}
const chk = document.getElementById(chkId);
if (chk) {
chk.checked = !chk.checked;
updateRowStyle(chk);
}
}
function updateRowStyle(chk) {
const tr = chk.closest('tr');
if (chk.checked) {
tr.classList.add('table-primary');
} else {
tr.classList.remove('table-primary');
}
updateSelectedCount();
}
function toggleSelectAll(select) {
const checkboxes = document.querySelectorAll('.student-checkbox');
checkboxes.forEach(chk => {
const tr = chk.closest('.student-tile-col');
if (tr.style.display !== 'none') {
chk.checked = select;
updateRowStyle(chk);
}
});
}
function updateSelectedCount() {
const count = document.querySelectorAll('.student-checkbox:checked').length;
const badge = document.getElementById('selectedCountBadge');
if (badge) badge.textContent = count;
}
function filterStudents() {
const query = document.getElementById('studentSearchInput').value.toLowerCase().trim();
const rows = document.querySelectorAll('.student-tile-col');
rows.forEach(tr => {
const name = tr.getAttribute('data-student-name') || '';
const alias = tr.getAttribute('data-student-alias') || '';
if (name.includes(query) || alias.includes(query)) {
tr.style.display = '';
} else {
tr.style.display = 'none';
}
});
}
</script>
{% endblock %}

View file

@ -18,20 +18,21 @@
</div>
<div class="card-body p-4">
<div class="row g-3">
<div class="col-md-3 col-sm-6">
<div class="p-3 bg-light rounded border">
<div class="col-md-2 col-sm-6">
<div class="p-3 bg-light rounded border h-100">
<small class="text-muted d-block mb-1">學號</small>
<span class="h5 fw-bold text-dark"><code>{{ student.student_id }}</code></span>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="p-3 bg-light rounded border">
<div class="p-3 bg-light rounded border h-100">
<small class="text-muted d-block mb-1">姓名</small>
<span class="h5 fw-bold text-dark">{{ student.name }}</span>
{% if student.foreign_alias %}<small class="text-muted d-block">({{ student.foreign_alias }})</small>{% endif %}
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="p-3 bg-light rounded border">
<div class="p-3 bg-light rounded border h-100">
<small class="text-muted d-block mb-1">學位 / 身分</small>
<div>
{% if student.degree_type == '博士生' %}
@ -52,8 +53,14 @@
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="p-3 bg-light rounded border">
<div class="col-md-2 col-sm-6">
<div class="p-3 bg-light rounded border h-100">
<small class="text-muted d-block mb-1">入學日期</small>
<span class="h6 fw-bold text-dark"><i class="far fa-calendar-alt text-primary me-1"></i>{{ student.admission_date|date:"Y-m-d"|default:"--" }}</span>
</div>
</div>
<div class="col-md-2 col-sm-6">
<div class="p-3 bg-light rounded border h-100">
<small class="text-muted d-block mb-1">總簽到次數</small>
<span class="h5 fw-bold text-primary">{% if can_view_records %}{{ point_records|length }} 次{% else %}<i class="fas fa-lock me-1"></i>受限{% endif %}</span>
</div>

View file

@ -42,17 +42,20 @@
<table class="table table-hover align-middle mb-0">
<thead class="table-dark">
<tr>
<th scope="col">學號</th>
<th scope="col">姓名</th>
<th scope="col">在學狀態</th>
<th scope="col">學位類別</th>
<th scope="col">身分類別</th>
<th scope="col">班級/年級</th>
<th scope="col">入學日期</th>
{% if user.is_authenticated %}<th scope="col">詳細資料</th>{% endif %}
</tr>
</thead>
<tbody>
{% for item in student_data %}
<tr>
<td><code>{{ item.student.student_id }}</code></td>
<td class="fw-bold">{{ item.student.name }}</td>
<td>
{% if item.student.is_active %}
@ -80,11 +83,12 @@
{% endif %}
</td>
<td>{{ item.student.grade|default:"未填" }}</td>
<td><span class="badge bg-light text-dark border"><i class="far fa-calendar-alt me-1 text-primary"></i>{{ item.student.admission_date|date:"Y-m-d"|default:"--" }}</span></td>
{% if user.is_authenticated %}<td><a href="{% url 'show_attendance' item.student.student_id %}" class="btn btn-sm btn-primary"><i class="fas fa-eye me-1"></i>查看詳情</a></td>{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if user.is_authenticated %}6{% else %}5{% endif %}" class="text-center py-4 text-muted">沒有符合條件的學生資料</td>
<td colspan="{% if user.is_authenticated %}8{% else %}7{% endif %}" class="text-center py-4 text-muted">沒有符合條件的學生資料</td>
</tr>
{% endfor %}
</tbody>

View file

@ -127,7 +127,7 @@
{% endif %}
<td class="fw-bold">
{% if user.is_authenticated %}<i
class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}{{ student.name }}
class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}{% get_current_language as CURRENT_LANG %}{% if CURRENT_LANG == 'en' and student.foreign_alias %}{{ student.foreign_alias }}{% else %}{{ student.name }}{% endif %}
</td>
<td>
{% if student.degree_type == '博士生' %}
@ -216,7 +216,7 @@
{% endif %}
<td class="fw-bold">
{% if user.is_authenticated %}<i
class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}{{ student.name }}
class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}{% get_current_language as CURRENT_LANG %}{% if CURRENT_LANG == 'en' and student.foreign_alias %}{{ student.foreign_alias }}{% else %}{{ student.name }}{% endif %}
</td>
<td>
{% if student.degree_type == '博士生' %}
@ -280,7 +280,7 @@
<tbody>
{% for row in summary_data %}
<tr>
<td class="fw-bold">{{ row.student.name }}</td>
<td class="fw-bold">{% get_current_language as CURRENT_LANG %}{% if CURRENT_LANG == 'en' and row.student.foreign_alias %}{{ row.student.foreign_alias }}{% else %}{{ row.student.name }}{% endif %}</td>
<td><small class="fw-semibold text-secondary">{{ row.rule_desc }}</small></td>
<td>
{% if row.assigned_dates %}

View file

@ -72,7 +72,7 @@
<tbody>
{% for student in item.students %}
<tr>
<td class="fw-bold">{{ student.name }}</td>
<td class="fw-bold">{% if student.foreign_alias %}{{ student.foreign_alias }}{% else %}{{ student.name }}{% endif %}</td>
<td>
{% if student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i
@ -150,7 +150,7 @@
<tbody>
{% for student in item.students %}
<tr>
<td class="fw-bold">{{ student.name }}</td>
<td class="fw-bold">{% if student.foreign_alias %}{{ student.foreign_alias }}{% else %}{{ student.name }}{% endif %}</td>
<td>
{% if student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i

View file

@ -1,3 +1,4 @@
import json
from django.test import TestCase, Client
from django.contrib.auth import authenticate, get_user_model
from django.urls import reverse
@ -59,7 +60,7 @@ class ActiveDirectoryBackendTestCase(TestCase):
def test_login_view(self):
response = self.client.get(reverse('login'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'dc1.samdom.mdi.bar')
self.assertContains(response, 'Active Directory 登入')
def test_parse_group_dn(self):
dn = 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'
@ -96,10 +97,22 @@ class ActiveDirectoryBackendTestCase(TestCase):
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)
@ -245,6 +258,7 @@ class ActiveDirectoryBackendTestCase(TestCase):
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)
@ -403,6 +417,7 @@ class ActiveDirectoryBackendTestCase(TestCase):
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)
@ -437,6 +452,7 @@ class ActiveDirectoryBackendTestCase(TestCase):
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)
@ -533,6 +549,7 @@ class ActiveDirectoryBackendTestCase(TestCase):
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')
@ -563,6 +580,11 @@ class ActiveDirectoryBackendTestCase(TestCase):
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",
@ -572,6 +594,7 @@ class EnglishStaticIframeAndPastEmptyCardTestCase(TestCase):
)
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)
@ -584,6 +607,7 @@ class EnglishStaticIframeAndPastEmptyCardTestCase(TestCase):
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",
@ -610,4 +634,126 @@ class EnglishStaticIframeAndPastEmptyCardTestCase(TestCase):
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))

View file

@ -10,8 +10,8 @@ from django.contrib import messages
from django.conf import settings
from django.http import HttpResponseForbidden, JsonResponse, HttpResponse
from django.views.decorators.clickjacking import xframe_options_exempt
from django.db.models import Q, Case, When, Value, IntegerField
from .models import Student, PointRecord, WeeklySchedule
from django.db.models import Q, Case, When, Value, IntegerField, F
from .models import Student, PointRecord, WeeklySchedule, calculate_admission_date
from .forms import StudentLoginForm, ADLoginForm
from .ad_backend import fetch_ad_groups
@ -242,10 +242,36 @@ def process_excel_rows(rows):
email = email_str
break
foreign_alias = None
for k, v in row_dict.items():
if k and ('別名' in str(k) or '外文' in str(k) or 'alias' in str(k).lower()) and v:
foreign_alias = str(v).strip()
break
admission_date = None
for k, v in row_dict.items():
if k and ('入學' in str(k) or 'admission' in str(k).lower()) and v:
val_str = str(v).strip()
try:
if hasattr(v, 'date'):
admission_date = v.date()
else:
for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%Y.%m.%d', '%Y年%m月%d'):
try:
admission_date = datetime.strptime(val_str, fmt).date()
break
except ValueError:
continue
except Exception:
pass
break
existing_student = Student.objects.filter(student_id=student_id).first()
update_defaults = {
'name': name,
'foreign_alias': foreign_alias,
'degree_type': degree_type,
'grade': grade,
'hope_admission_scheme': hope_admission,
@ -257,6 +283,9 @@ def process_excel_rows(rows):
'is_active': True,
}
if admission_date:
update_defaults['admission_date'] = admission_date
if not existing_student:
update_defaults['student_category'] = student_category
@ -324,16 +353,37 @@ def sync_weekly_schedule_to_db(m1_year, m1_month, m2_year, m2_month, force=False
valid_m1 = [d for d in m1_mondays if d >= today] or m1_mondays
valid_m2 = [d for d in m2_mondays if d >= today] or m2_mondays
# Remove any existing records that are prior to student's admission date
WeeklySchedule.objects.filter(
year__in=[m1_year, m2_year],
month__in=[m1_month, m2_month],
student__admission_date__isnull=False,
date__lt=F('student__admission_date')
).delete()
if not m1_exists or force:
for idx, student in enumerate(m1_pool):
monday_date = valid_m1[idx % len(valid_m1)]
valid_mondays_for_student = [
d for d in valid_m1
if not student.admission_date or d >= student.admission_date
]
if not valid_mondays_for_student:
continue
monday_date = valid_mondays_for_student[idx % len(valid_mondays_for_student)]
WeeklySchedule.objects.get_or_create(student=student, date=monday_date)
if not m2_exists or force:
for idx, student in enumerate(m2_pool):
monday_date = valid_m2[idx % len(valid_m2)]
valid_mondays_for_student = [
d for d in valid_m2
if not student.admission_date or d >= student.admission_date
]
if not valid_mondays_for_student:
continue
monday_date = valid_mondays_for_student[idx % len(valid_mondays_for_student)]
WeeklySchedule.objects.get_or_create(student=student, date=monday_date)
@login_required(login_url='login')
def index(request):
"""Home page view showing the Weekly Seminar Schedule."""
return weekly_schedule(request)
@ -360,6 +410,7 @@ def ad_login(request):
form = ADLoginForm()
return render(request, 'students/login.html', {'form': form})
@login_required(login_url='login')
def ad_logout(request):
"""Active Directory Logout View"""
logout(request)
@ -462,6 +513,7 @@ def recent_attendance(request):
recent_records = PointRecord.objects.select_related('student').order_by('-datetime')[:20]
return render(request, 'students/recent_attendance.html', {'recent_records': recent_records})
@login_required(login_url='login')
def student_list(request):
active_only_param = request.GET.get('active_only')
if active_only_param is None:
@ -492,6 +544,7 @@ def student_list(request):
'is_admin_user': is_admin_user,
})
@login_required(login_url='login')
def weekly_schedule(request):
"""
Weekly seminar schedule view listing Mondays of current and next months.
@ -632,6 +685,12 @@ def move_schedule_item(request):
if student.student_category == '交換中':
return JsonResponse({'success': False, 'error': '交換生無需報告,無法排程'}, status=400)
if student.admission_date and to_date < student.admission_date:
return JsonResponse({
'success': False,
'error': f'排程調整限制:該學生入學日期為 {student.admission_date.strftime("%Y-%m-%d")},不可將報告日期移轉至入學日期前 (無法移至 {to_date})。'
}, status=400)
is_monthly = (student.degree_type in ('碩士生', '學士生')) or (student.degree_type == '博士生' and student.student_category == '一般生')
if is_monthly:
@ -655,7 +714,6 @@ def move_schedule_item(request):
log_msg = f"AUDIT LOG: User '{request.user.username}' (authenticated) moved weekly schedule for student '{student.student_id}' ({student.name}) from {from_date} to {to_date}."
logger.info(log_msg)
schedule_logger.info(log_msg)
print(log_msg)
return JsonResponse({
'success': True,
@ -671,7 +729,7 @@ def move_schedule_item(request):
def verify_schedule_rules(request):
"""
Verify and clean up WeeklySchedule records based on frequency rules:
- Rule 1: Delete all schedule cards for Exchange students (交換中) or inactive students.
- Rule 1: Delete all schedule cards for Exchange students (交換中), inactive students, or dates before admission_date.
- Rule 2: 碩士生 & 博士一般生 must have exactly 1 report per month. Delete excess cards, create missing cards.
- Rule 3: 博士在職生 must have exactly 1 report per 2 months. Delete excess cards, create missing cards.
Logs user action and results.
@ -690,13 +748,14 @@ def verify_schedule_rules(request):
deleted_count = 0
created_count = 0
# 1. Delete all cards for Exchange students (交換中) or inactive students
from django.db.models import Q
# 1. Delete cards for Exchange students (交換中), inactive students, or dates before admission_date
invalid_records = WeeklySchedule.objects.filter(
year__in=[m1_year, m2_year],
month__in=[m1_month, m2_month]
).filter(
Q(student__student_category='交換中') | Q(student__is_active=False)
Q(student__student_category='交換中') |
Q(student__is_active=False) |
Q(student__admission_date__isnull=False, date__lt=F('student__admission_date'))
)
deleted_count += invalid_records.count()
invalid_records.delete()
@ -710,6 +769,10 @@ def verify_schedule_rules(request):
for s in active_students:
is_monthly = (s.degree_type in ('碩士生', '學士生')) or (s.degree_type == '博士生' and s.student_category == '一般生')
valid_m1_for_s = [d for d in valid_m1_mondays if not s.admission_date or d >= s.admission_date]
valid_m2_for_s = [d for d in valid_m2_mondays if not s.admission_date or d >= s.admission_date]
valid_all_for_s = [d for d in valid_all_mondays if not s.admission_date or d >= s.admission_date]
if is_monthly:
# Check Month 1
m1_records = list(WeeklySchedule.objects.filter(student=s, year=m1_year, month=m1_month).order_by('date'))
@ -718,8 +781,8 @@ def verify_schedule_rules(request):
r.delete()
deleted_count += 1
elif len(m1_records) == 0:
if valid_m1_mondays:
monday = min(valid_m1_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
if valid_m1_for_s:
monday = min(valid_m1_for_s, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
WeeklySchedule.objects.create(student=s, date=monday)
created_count += 1
@ -730,8 +793,8 @@ def verify_schedule_rules(request):
r.delete()
deleted_count += 1
elif len(m2_records) == 0:
if valid_m2_mondays:
monday = min(valid_m2_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
if valid_m2_for_s:
monday = min(valid_m2_for_s, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
WeeklySchedule.objects.create(student=s, date=monday)
created_count += 1
else:
@ -742,15 +805,14 @@ def verify_schedule_rules(request):
r.delete()
deleted_count += 1
elif len(bimonthly_records) == 0:
if valid_all_mondays:
monday = min(valid_all_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
if valid_all_for_s:
monday = min(valid_all_for_s, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
WeeklySchedule.objects.create(student=s, date=monday)
created_count += 1
log_msg = f"AUDIT LOG: User '{request.user.username}' executed Verify & Clean Schedule Rules. Deleted {deleted_count} non-compliant/exchange cards, added {created_count} missing cards."
logger.info(log_msg)
schedule_logger.info(log_msg)
print(log_msg)
# Update static English HTML for iframe embed
generate_english_schedule_static_html()