feat: implement dynamic weekly meeting scheduling system with drag-and-drop management and automated rule validation

This commit is contained in:
Xiao Furen 2026-08-05 12:11:21 +08:00
parent 89428f773b
commit c25a3afc59
14 changed files with 2741 additions and 115 deletions

View file

@ -157,7 +157,7 @@ msgid "免報告"
msgstr "Exempt" msgstr "Exempt"
msgid "次" msgid "次"
msgstr "time(s)" msgstr "times"
msgid "拖曳排程微調提示:" msgid "拖曳排程微調提示:"
msgstr "Drag & Drop Tip: " msgstr "Drag & Drop Tip: "
@ -170,3 +170,60 @@ msgstr "View-Only Mode: "
msgid "登入系統後,可拖曳學生調整報告日期。" msgid "登入系統後,可拖曳學生調整報告日期。"
msgstr "Log in to drag and drop student cards to adjust presentation dates." msgstr "Log in to drag and drop student cards to adjust presentation dates."
msgid "不可將報告日期移轉至早於今天"
msgstr "Cannot move schedule date to earlier than today"
msgid "當日無排定報告者 (可拖曳學生至此)"
msgstr "No presenter scheduled for this day (drag & drop student here)"
msgid "學士生"
msgstr "Undergraduate Student"
msgid "學士生 (1次/月)"
msgstr "Undergraduate Student (1 time/month)"
msgid "%(y1)s年%(m1)s月 & %(y2)s年%(m2)s月"
msgstr "%(y1)s-%(m1)s & %(y2)s-%(m2)s"
msgid "%(year)s 年 %(month)s 月週會排程 (當月)"
msgstr "%(year)s-%(month)s Schedule (Current Month)"
msgid "%(year)s 年 %(month)s 月週會排程 (下月)"
msgstr "%(year)s-%(month)s Schedule (Next Month)"
msgid "%(date_str)s (星期一)"
msgstr "%(date_str)s (Monday)"
msgid "%(count)s 位報告者"
msgstr "%(count)s presenters"
msgid "%(count)s 位報告者(單數)"
msgstr "%(count)s presenter"
msgid "經調整後但仍未能報告者,須準備一小時之進度簡報,當週向指導教授當面報告,次週週會補報。若連續兩次應報告而未報告,將視同解除指導關係。"
msgstr "Students who fail to present after schedule adjustment must prepare a 1-hour progress report, present in person to their advisor that week, and make up the presentation at next week's seminar. Failing to present twice consecutively will be treated as termination of the advising relationship."
msgid "確定要校正並驗證排程規則嗎?(如為交換生將自動刪除其卡片,並清理重複或補齊缺漏排程)"
msgstr "Are you sure you want to verify and clean schedule rules? Exchange students' cards will be deleted, and missing/duplicate schedules will be cleaned up."
msgid "博士在職生 (1次/2月)"
msgstr "Ph.D. In-Service Student (1 time/2 months)"
msgid "一般生 (1次/月)"
msgstr "Regular Student (1 time/month)"
msgid "0 次"
msgstr "0 time"
msgid "%(count)s 次"
msgstr "%(count)s times"
msgid "%(count)s 次(單數)"
msgstr "%(count)s time"
msgid "無在學學生資料"
msgstr "No active student data"

View file

@ -35,3 +35,9 @@ msgstr "登出"
msgid "AD 登入" msgid "AD 登入"
msgstr "AD 登入" msgstr "AD 登入"
msgid "%(count)s 位報告者(單數)"
msgstr "%(count)s 位報告者"
msgid "%(count)s 次(單數)"
msgstr "%(count)s 次"

View file

@ -27,6 +27,17 @@ DEBUG = True
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
CSRF_TRUSTED_ORIGINS = [
'https://point.mdi.bar',
'http://point.mdi.bar',
'https://*.mdi.bar',
'http://*.mdi.bar',
'http://localhost',
'http://127.0.0.1',
]
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Application definition # Application definition
@ -141,6 +152,9 @@ LOCALE_PATHS = [
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATICFILES_DIRS = [
BASE_DIR / 'static',
]
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2026-08-04 14:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('students', '0006_alter_weeklyschedule_options_student_email2_and_more'),
]
operations = [
migrations.AlterField(
model_name='student',
name='degree_type',
field=models.CharField(choices=[('學士生', '學士生'), ('碩士生', '碩士生'), ('博士生', '博士生')], default='碩士生', max_length=10, verbose_name='學位類別'),
),
]

View file

@ -1,6 +1,7 @@
from django.db import models from django.db import models
DEGREE_CHOICES = [ DEGREE_CHOICES = [
('學士生', '學士生'),
('碩士生', '碩士生'), ('碩士生', '碩士生'),
('博士生', '博士生'), ('博士生', '博士生'),
] ]

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ student.name }} ({{ student.student_id }}) - 考勤紀錄 - 1218 Lab{% endblock %} {% block title %}{{ student.name }} ({{ student.student_id }}) - 考勤紀錄 - 1218 Lab{% endblock %}
@ -34,9 +35,11 @@
<small class="text-muted d-block mb-1">學位 / 身分</small> <small class="text-muted d-block mb-1">學位 / 身分</small>
<div> <div>
{% if student.degree_type == '博士生' %} {% if student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>博士生</span> <span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>{% trans "博士生" %}</span>
{% elif student.degree_type == '學士生' %}
<span class="badge bg-primary text-white"><i class="fas fa-user-graduate me-1"></i>{% trans "學士生" %}</span>
{% else %} {% else %}
<span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>碩士生</span> <span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>{% trans "碩士生" %}</span>
{% endif %} {% endif %}
{% if student.student_category == '在職生' %} {% if student.student_category == '在職生' %}

View file

@ -1,5 +1,6 @@
<!-- 在 templates/students/student_list.html --> <!-- 在 templates/students/student_list.html -->
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %}
{% block title %}學生列表{% endblock %} {% block title %}學生列表{% endblock %}
@ -41,20 +42,18 @@
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-dark"> <thead class="table-dark">
<tr> <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>
<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> </tr>
</thead> </thead>
<tbody> <tbody>
{% for item in student_data %} {% for item in student_data %}
<tr> <tr>
<td class="fw-bold"><code>{{ item.student.student_id }}</code></td> <td class="fw-bold">{{ item.student.name }}</td>
<td>{{ item.student.name }}</td>
<td> <td>
{% if item.student.is_active %} {% if item.student.is_active %}
<span class="badge bg-success text-white"><i class="fas fa-check me-1"></i>在學</span> <span class="badge bg-success text-white"><i class="fas fa-check me-1"></i>在學</span>
@ -64,9 +63,11 @@
</td> </td>
<td> <td>
{% if item.student.degree_type == '博士生' %} {% if item.student.degree_type == '博士生' %}
<span class="badge bg-danger text-white px-2 py-1"><i class="fas fa-user-graduate me-1"></i>博士生</span> <span class="badge bg-danger text-white px-2 py-1"><i class="fas fa-user-graduate me-1"></i>{% trans "博士生" %}</span>
{% elif item.student.degree_type == '學士生' %}
<span class="badge bg-primary text-white px-2 py-1"><i class="fas fa-user-graduate me-1"></i>{% trans "學士生" %}</span>
{% else %} {% else %}
<span class="badge bg-info text-dark px-2 py-1"><i class="fas fa-graduation-cap me-1"></i>碩士生</span> <span class="badge bg-info text-dark px-2 py-1"><i class="fas fa-graduation-cap me-1"></i>{% trans "碩士生" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -79,11 +80,11 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ item.student.grade|default:"未填" }}</td> <td>{{ item.student.grade|default:"未填" }}</td>
<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> {% 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> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="7" class="text-center py-4 text-muted">沒有符合條件的學生資料</td> <td colspan="{% if user.is_authenticated %}6{% else %}5{% endif %}" class="text-center py-4 text-muted">沒有符合條件的學生資料</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -1,7 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n %}
{% block title %}週會排程 - 1218 Lab{% endblock %} {% block title %}{% trans "週會排程" %} - 1218 Lab{% endblock %}
{% block content %} {% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3"> <div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3">
@ -13,13 +13,13 @@
<i class="fas fa-database me-1"></i>{% trans "資料庫狀態: 已同步儲存" %} <i class="fas fa-database me-1"></i>{% trans "資料庫狀態: 已同步儲存" %}
</span> </span>
<span class="badge bg-primary fs-6 px-3 py-2 shadow-sm"> <span class="badge bg-primary fs-6 px-3 py-2 shadow-sm">
<i class="fas fa-clock me-1"></i>{{ m1_year }}年{{ m1_month }}月 & {{ m2_year }}年{{ m2_month }}月 <i class="fas fa-clock me-1"></i>{% blocktrans with y1=m1_year m1=m1_month y2=m2_year m2=m2_month %}{{ y1 }}年{{ m1 }}月 & {{ y2 }}年{{ m2 }}月{% endblocktrans %}
</span> </span>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<form method="post" action="{% url 'verify_schedule_rules' %}" class="d-inline"> <form method="post" action="{% url 'verify_schedule_rules' %}" class="d-inline">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-warning btn-sm shadow-sm text-dark fw-bold" <button type="submit" class="btn btn-warning btn-sm shadow-sm text-dark fw-bold"
onclick="return confirm('確定要校正並驗證排程規則嗎?(如為交換生將自動刪除其卡片,並清理重複或補齊缺漏排程)');"> onclick="return confirm('{% trans "確定要校正並驗證排程規則嗎?(如為交換生將自動刪除其卡片,並清理重複或補齊缺漏排程)" %}');">
<i class="fas fa-check-double me-1"></i>{% trans "校正並驗證排程規則" %} <i class="fas fa-check-double me-1"></i>{% trans "校正並驗證排程規則" %}
</button> </button>
</form> </form>
@ -31,26 +31,32 @@
<div class="card shadow-sm border-0 rounded-3 mb-4 bg-light"> <div class="card shadow-sm border-0 rounded-3 mb-4 bg-light">
<div class="card-body p-4"> <div class="card-body p-4">
<h5 class="fw-bold text-dark mb-3"><i class="fas fa-info-circle text-primary me-2"></i>{% trans "週會報告排程規則" %}</h5> <h5 class="fw-bold text-dark mb-3"><i class="fas fa-info-circle text-primary me-2"></i>{% trans "週會報告排程規則" %}</h5>
<div class="row g-3"> <div class="row g-3 row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-5">
<div class="col-md-3 col-sm-6"> <div class="col">
<div class="p-3 bg-white rounded border border-start border-4 border-info shadow-sm h-100">
<div class="fw-bold text-dark mb-1"><i class="fas fa-graduation-cap text-info me-1"></i>{% trans "碩士生 (一般 & 在職)" %}</div>
<small class="text-muted">{% trans "排程頻率: 每月 1 次" %}</small>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="p-3 bg-white rounded border border-start border-4 border-danger shadow-sm h-100">
<div class="fw-bold text-dark mb-1"><i class="fas fa-user-graduate text-danger me-1"></i>{% trans "博士一般生" %}</div>
<small class="text-muted">{% trans "排程頻率: 每月 1 次" %}</small>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="p-3 bg-white rounded border border-start border-4 border-warning shadow-sm h-100"> <div class="p-3 bg-white rounded border border-start border-4 border-warning shadow-sm h-100">
<div class="fw-bold text-dark mb-1"><i class="fas fa-user-tie text-warning me-1"></i>{% trans "博士在職生" %}</div> <div class="fw-bold text-dark mb-1"><i class="fas fa-user-tie text-warning me-1"></i>{% trans "博士在職生" %}</div>
<small class="text-muted">{% trans "排程頻率: 每 2 個月 1 次" %}</small> <small class="text-muted">{% trans "排程頻率: 每 2 個月 1 次" %}</small>
</div> </div>
</div> </div>
<div class="col-md-3 col-sm-6"> <div class="col">
<div class="p-3 bg-white rounded border border-start border-4 border-danger shadow-sm h-100">
<div class="fw-bold text-dark mb-1"><i class="fas fa-user-graduate text-danger me-1"></i>{% trans "博士一般生" %}</div>
<small class="text-muted">{% trans "排程頻率: 每月 1 次" %}</small>
</div>
</div>
<div class="col">
<div class="p-3 bg-white rounded border border-start border-4 border-info shadow-sm h-100">
<div class="fw-bold text-dark mb-1"><i class="fas fa-graduation-cap text-info me-1"></i>{% trans "碩士生 (一般 & 在職)" %}</div>
<small class="text-muted">{% trans "排程頻率: 每月 1 次" %}</small>
</div>
</div>
<div class="col">
<div class="p-3 bg-white rounded border border-start border-4 border-primary shadow-sm h-100">
<div class="fw-bold text-dark mb-1"><i class="fas fa-user-graduate text-primary me-1"></i>{% trans "學士生" %}</div>
<small class="text-muted">{% trans "排程頻率: 每月 1 次" %}</small>
</div>
</div>
<div class="col">
<div class="p-3 bg-white rounded border border-start border-4 border-secondary shadow-sm h-100"> <div class="p-3 bg-white rounded border border-start border-4 border-secondary shadow-sm h-100">
<div class="fw-bold text-dark mb-1"><i class="fas fa-exchange-alt text-secondary me-1"></i>{% trans "交換生 (交換中)" %}</div> <div class="fw-bold text-dark mb-1"><i class="fas fa-exchange-alt text-secondary me-1"></i>{% trans "交換生 (交換中)" %}</div>
<small class="text-muted">{% trans "排程狀態: 免報告" %}</small> <small class="text-muted">{% trans "排程狀態: 免報告" %}</small>
@ -80,7 +86,7 @@
<!-- 當月週會排程 (Month 1) --> <!-- 當月週會排程 (Month 1) -->
<div class="mb-5"> <div class="mb-5">
<h3 class="h4 text-primary border-bottom border-primary border-2 pb-2 mb-4"> <h3 class="h4 text-primary border-bottom border-primary border-2 pb-2 mb-4">
<i class="fas fa-calendar-day me-2"></i>{{ m1_year }} 年 {{ m1_month }} 月週會排程 (當月) <i class="fas fa-calendar-day me-2"></i>{% blocktrans with year=m1_year month=m1_month %}{{ year }} 年 {{ month }} 月週會排程 (當月){% endblocktrans %}
</h3> </h3>
<div class="row g-4"> <div class="row g-4">
{% for item in schedule_m1 %} {% for item in schedule_m1 %}
@ -89,9 +95,15 @@
style="transition: all 0.2s ease-in-out;"> style="transition: all 0.2s ease-in-out;">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-3"> <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0 fw-bold"> <h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (星期一) <i class="fas fa-calendar-check me-2"></i>{% blocktrans with date_str=item.formatted %}{{ date_str }} (星期一){% endblocktrans %}
</h5> </h5>
<span class="badge bg-light text-primary fs-6">{{ item.students|length }} 位報告者</span> <span class="badge bg-light text-primary fs-6">
{% if item.students|length > 1 %}
{% blocktrans with count=item.students|length %}{{ count }} 位報告者{% endblocktrans %}
{% else %}
{% blocktrans with count=item.students|length %}{{ count }} 位報告者(單數){% endblocktrans %}
{% endif %}
</span>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
{% if item.students %} {% if item.students %}
@ -99,10 +111,9 @@
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th scope="col" style="width: 25%;">學號</th> <th scope="col" style="width: 34%;">{% trans "姓名" %}</th>
<th scope="col" style="width: 25%;">姓名</th> <th scope="col" style="width: 33%;">{% trans "學位類別" %}</th>
<th scope="col" style="width: 25%;">學位類別</th> <th scope="col" style="width: 33%;">{% trans "身分類別" %}</th>
<th scope="col" style="width: 25%;">身分類別</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -116,29 +127,30 @@
{% endif %} {% endif %}
<td class="fw-bold"> <td class="fw-bold">
{% if user.is_authenticated %}<i {% if user.is_authenticated %}<i
class="fas fa-grip-vertical text-muted me-2"></i>{% endif %} class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}{{ student.name }}
<code>{{ student.student_id }}</code>
</td> </td>
<td>{{ student.name }}</td>
<td> <td>
{% if student.degree_type == '博士生' %} {% if student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i <span class="badge bg-danger text-white"><i
class="fas fa-user-graduate me-1"></i>博士生</span> class="fas fa-user-graduate me-1"></i>{% trans "博士生" %}</span>
{% elif student.degree_type == '學士生' %}
<span class="badge bg-primary text-white"><i
class="fas fa-user-graduate me-1"></i>{% trans "學士生" %}</span>
{% else %} {% else %}
<span class="badge bg-info text-dark"><i <span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>碩士生</span> class="fas fa-graduation-cap me-1"></i>{% trans "碩士生" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if student.student_category == '在職生' %} {% if student.student_category == '在職生' %}
<span class="badge bg-warning text-dark"><i <span class="badge bg-warning text-dark"><i
class="fas fa-user-tie me-1"></i>在職生</span> class="fas fa-user-tie me-1"></i>{% trans "在職生" %}</span>
{% elif student.student_category == '交換中' %} {% elif student.student_category == '交換中' %}
<span class="badge bg-secondary text-white"><i <span class="badge bg-secondary text-white"><i
class="fas fa-exchange-alt me-1"></i>交換中</span> class="fas fa-exchange-alt me-1"></i>{% trans "交換中" %}</span>
{% else %} {% else %}
<span class="badge bg-success text-white"><i <span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>一般生</span> class="fas fa-user me-1"></i>{% trans "一般生" %}</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -147,7 +159,11 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="text-center text-muted py-4 mb-0">當日無排定報告者 (可拖曳學生至此)</p> {% if item.is_past %}
<p class="text-center text-muted py-4 mb-0"><i class="fas fa-ban me-1 text-danger"></i>{% trans "不可將報告日期移轉至早於今天" %}</p>
{% else %}
<p class="text-center text-muted py-4 mb-0">{% trans "當日無排定報告者 (可拖曳學生至此)" %}</p>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -159,7 +175,7 @@
<!-- 下月週會排程 (Month 2) --> <!-- 下月週會排程 (Month 2) -->
<div class="mb-5"> <div class="mb-5">
<h3 class="h4 text-success border-bottom border-success border-2 pb-2 mb-4"> <h3 class="h4 text-success border-bottom border-success border-2 pb-2 mb-4">
<i class="fas fa-calendar-week me-2"></i>{{ m2_year }} 年 {{ m2_month }} 月週會排程 (下月) <i class="fas fa-calendar-week me-2"></i>{% blocktrans with year=m2_year month=m2_month %}{{ year }} 年 {{ month }} 月週會排程 (下月){% endblocktrans %}
</h3> </h3>
<div class="row g-4"> <div class="row g-4">
{% for item in schedule_m2 %} {% for item in schedule_m2 %}
@ -168,9 +184,15 @@
style="transition: all 0.2s ease-in-out;"> style="transition: all 0.2s ease-in-out;">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center py-3"> <div class="card-header bg-success text-white d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0 fw-bold"> <h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (星期一) <i class="fas fa-calendar-check me-2"></i>{% blocktrans with date_str=item.formatted %}{{ date_str }} (星期一){% endblocktrans %}
</h5> </h5>
<span class="badge bg-light text-success fs-6">{{ item.students|length }} 位報告者</span> <span class="badge bg-light text-success fs-6">
{% if item.students|length > 1 %}
{% blocktrans with count=item.students|length %}{{ count }} 位報告者{% endblocktrans %}
{% else %}
{% blocktrans with count=item.students|length %}{{ count }} 位報告者(單數){% endblocktrans %}
{% endif %}
</span>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
{% if item.students %} {% if item.students %}
@ -178,10 +200,9 @@
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th scope="col" style="width: 25%;">學號</th> <th scope="col" style="width: 34%;">{% trans "姓名" %}</th>
<th scope="col" style="width: 25%;">姓名</th> <th scope="col" style="width: 33%;">{% trans "學位類別" %}</th>
<th scope="col" style="width: 25%;">學位類別</th> <th scope="col" style="width: 33%;">{% trans "身分類別" %}</th>
<th scope="col" style="width: 25%;">身分類別</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -195,29 +216,30 @@
{% endif %} {% endif %}
<td class="fw-bold"> <td class="fw-bold">
{% if user.is_authenticated %}<i {% if user.is_authenticated %}<i
class="fas fa-grip-vertical text-muted me-2"></i>{% endif %} class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}{{ student.name }}
<code>{{ student.student_id }}</code>
</td> </td>
<td>{{ student.name }}</td>
<td> <td>
{% if student.degree_type == '博士生' %} {% if student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i <span class="badge bg-danger text-white"><i
class="fas fa-user-graduate me-1"></i>博士生</span> class="fas fa-user-graduate me-1"></i>{% trans "博士生" %}</span>
{% elif student.degree_type == '學士生' %}
<span class="badge bg-primary text-white"><i
class="fas fa-user-graduate me-1"></i>{% trans "學士生" %}</span>
{% else %} {% else %}
<span class="badge bg-info text-dark"><i <span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>碩士生</span> class="fas fa-graduation-cap me-1"></i>{% trans "碩士生" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if student.student_category == '在職生' %} {% if student.student_category == '在職生' %}
<span class="badge bg-warning text-dark"><i <span class="badge bg-warning text-dark"><i
class="fas fa-user-tie me-1"></i>在職生</span> class="fas fa-user-tie me-1"></i>{% trans "在職生" %}</span>
{% elif student.student_category == '交換中' %} {% elif student.student_category == '交換中' %}
<span class="badge bg-secondary text-white"><i <span class="badge bg-secondary text-white"><i
class="fas fa-exchange-alt me-1"></i>交換中</span> class="fas fa-exchange-alt me-1"></i>{% trans "交換中" %}</span>
{% else %} {% else %}
<span class="badge bg-success text-white"><i <span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>一般生</span> class="fas fa-user me-1"></i>{% trans "一般生" %}</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -226,7 +248,11 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="text-center text-muted py-4 mb-0">當日無排定報告者 (可拖曳學生至此)</p> {% if item.is_past %}
<p class="text-center text-muted py-4 mb-0"><i class="fas fa-ban me-1 text-danger"></i>{% trans "不可將報告日期移轉至早於今天" %}</p>
{% else %}
<p class="text-center text-muted py-4 mb-0">{% trans "當日無排定報告者 (可拖曳學生至此)" %}</p>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -238,47 +264,46 @@
<!-- 學生個人報告總覽與統計 --> <!-- 學生個人報告總覽與統計 -->
<div class="card shadow-sm border-0 rounded-3 mt-5"> <div class="card shadow-sm border-0 rounded-3 mt-5">
<div class="card-header bg-dark text-white py-3"> <div class="card-header bg-dark text-white py-3">
<h5 class="mb-0"><i class="fas fa-tasks me-2"></i>學生個人報告統計明細 (兩月排程核對)</h5> <h5 class="mb-0"><i class="fas fa-tasks me-2"></i>{% trans "學生個人報告統計明細 (兩月排程核對)" %}</h5>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>學號</th> <th>{% trans "姓名" %}</th>
<th>姓名</th> <th>{% trans "身份 / 規則" %}</th>
<th>身份 / 規則</th> <th>{% trans "排定報告日期 (Mondays)" %}</th>
<th>排定報告日期 (Mondays)</th> <th>{% trans "應報告次數" %}</th>
<th>應報告次數</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for row in summary_data %} {% for row in summary_data %}
<tr> <tr>
<td class="fw-bold"><code>{{ row.student.student_id }}</code></td> <td class="fw-bold">{{ row.student.name }}</td>
<td>{{ row.student.name }}</td>
<td><small class="fw-semibold text-secondary">{{ row.rule_desc }}</small></td> <td><small class="fw-semibold text-secondary">{{ row.rule_desc }}</small></td>
<td> <td>
{% if row.assigned_dates %} {% if row.assigned_dates %}
{% for d in row.assigned_dates %} {% for d in row.assigned_dates %}
<span class="badge bg-primary me-1 mb-1"><i class="far fa-calendar-alt me-1"></i>{{ d <span class="badge bg-primary me-1 mb-1"><i class="far fa-calendar-alt me-1"></i>{{ d }}</span>
}}</span>
{% endfor %} {% endfor %}
{% else %} {% else %}
<span class="badge bg-secondary"><i class="fas fa-minus-circle me-1"></i>免報告</span> <span class="badge bg-secondary"><i class="fas fa-minus-circle me-1"></i>{% trans "免報告" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if row.total_count > 0 %} {% if row.total_count > 1 %}
<span class="badge bg-success fs-6">{{ row.total_count }} 次</span> <span class="badge bg-success fs-6">{% blocktrans with count=row.total_count %}{{ count }} 次{% endblocktrans %}</span>
{% elif row.total_count == 1 %}
<span class="badge bg-success fs-6">{% blocktrans with count=row.total_count %}{{ count }} 次(單數){% endblocktrans %}</span>
{% else %} {% else %}
<span class="badge bg-secondary fs-6">0 次</span> <span class="badge bg-secondary fs-6">{% trans "0 次" %}</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="text-center py-4 text-muted">無在學學生資料</td> <td colspan="4" class="text-center py-4 text-muted">{% trans "無在學學生資料" %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Weekly Seminar Schedule</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f8f9fa;
color: #212529;
padding: 1.5rem;
margin: 0;
}
.card {
border: none;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border-radius: 0.5rem;
}
.table code {
color: #0d6efd;
font-size: 0.9em;
}
.badge {
font-weight: 500;
}
</style>
</head>
<body>
<div class="container-fluid p-0">
<!-- Month 1 Schedule -->
<div class="mb-5">
<h3 class="h4 text-primary border-bottom border-primary border-2 pb-2 mb-4">
<i class="fas fa-calendar-day me-2"></i>{{ m1_year }}-{{ m1_month|stringformat:"02d" }} Schedule
(Current Month)
</h3>
<div class="row g-4">
{% for item in schedule_m1 %}
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div
class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (Monday)
</h5>
<span class="badge bg-light text-primary fs-6">{% if item.students|length > 1 %}{{ item.students|length }} presenters{% else %}{{ item.students|length }} presenter{% endif %}</span>
</div>
<div class="card-body p-0">
{% if item.students %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th scope="col" style="width: 34%;">Name</th>
<th scope="col" style="width: 33%;">Degree Level</th>
<th scope="col" style="width: 33%;">Category</th>
</tr>
</thead>
<tbody>
{% for student in item.students %}
<tr>
<td class="fw-bold">{{ student.name }}</td>
<td>
{% if student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i
class="fas fa-user-graduate me-1"></i>Ph.D. Student</span>
{% elif student.degree_type == '學士生' %}
<span class="badge bg-primary text-white"><i
class="fas fa-user-graduate me-1"></i>Undergraduate
Student</span>
{% else %}
<span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>Master's Student</span>
{% endif %}
</td>
<td>
{% if student.student_category == '在職生' %}
<span class="badge bg-warning text-dark"><i
class="fas fa-user-tie me-1"></i>In-Service</span>
{% elif student.student_category == '交換中' %}
<span class="badge bg-secondary text-white"><i
class="fas fa-exchange-alt me-1"></i>On Exchange</span>
{% else %}
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{% if item.is_past %}
<p class="text-center text-muted py-4 mb-0"><i
class="fas fa-ban me-1 text-danger"></i>Cannot move schedule date to earlier than
today</p>
{% else %}
<p class="text-center text-muted py-4 mb-0">No presenter scheduled for this day</p>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Month 2 Schedule -->
<div class="mb-5">
<h3 class="h4 text-success border-bottom border-success border-2 pb-2 mb-4">
<i class="fas fa-calendar-week me-2"></i>{{ m2_year }}-{{ m2_month|stringformat:"02d" }} Schedule (Next
Month)
</h3>
<div class="row g-4">
{% for item in schedule_m2 %}
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div
class="card-header bg-success text-white d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0 fw-bold">
<i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (Monday)
</h5>
<span class="badge bg-light text-success fs-6">{% if item.students|length > 1 %}{{ item.students|length }} presenters{% else %}{{ item.students|length }} presenter{% endif %}</span>
</div>
<div class="card-body p-0">
{% if item.students %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th scope="col" style="width: 34%;">Name</th>
<th scope="col" style="width: 33%;">Degree Level</th>
<th scope="col" style="width: 33%;">Category</th>
</tr>
</thead>
<tbody>
{% for student in item.students %}
<tr>
<td class="fw-bold">{{ student.name }}</td>
<td>
{% if student.degree_type == '博士生' %}
<span class="badge bg-danger text-white"><i
class="fas fa-user-graduate me-1"></i>Ph.D. Student</span>
{% elif student.degree_type == '學士生' %}
<span class="badge bg-primary text-white"><i
class="fas fa-user-graduate me-1"></i>Undergraduate
Student</span>
{% else %}
<span class="badge bg-info text-dark"><i
class="fas fa-graduation-cap me-1"></i>Master's Student</span>
{% endif %}
</td>
<td>
{% if student.student_category == '在職生' %}
<span class="badge bg-warning text-dark"><i
class="fas fa-user-tie me-1"></i>In-Service</span>
{% elif student.student_category == '交換中' %}
<span class="badge bg-secondary text-white"><i
class="fas fa-exchange-alt me-1"></i>On Exchange</span>
{% else %}
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>Regular</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{% if item.is_past %}
<p class="text-center text-muted py-4 mb-0"><i
class="fas fa-ban me-1 text-danger"></i>Cannot move schedule date to earlier than
today</p>
{% else %}
<p class="text-center text-muted py-4 mb-0">No presenter scheduled for this day</p>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</body>
</html>

View file

@ -1,6 +1,7 @@
from django.test import TestCase, Client from django.test import TestCase, Client
from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import authenticate, get_user_model
from django.urls import reverse from django.urls import reverse
from django.conf import settings
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from students.models import Student from students.models import Student
from students.ad_backend import ActiveDirectoryBackend, parse_group_dn from students.ad_backend import ActiveDirectoryBackend, parse_group_dn
@ -182,7 +183,12 @@ class ActiveDirectoryBackendTestCase(TestCase):
s = Student.objects.create(student_id='D12458003', name='測試生', degree_type='博士生', is_active=True) s = Student.objects.create(student_id='D12458003', name='測試生', degree_type='博士生', is_active=True)
PointRecord.objects.create(student=s) PointRecord.objects.create(student=s)
# Unauthenticated / non-admin user -> 簽到歷史紀錄 hidden with permission notice # 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'})) response = self.client.get(reverse('show_attendance', kwargs={'student_id': 'D12458003'}))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'D12458003') self.assertContains(response, 'D12458003')
@ -553,3 +559,55 @@ class ActiveDirectoryBackendTestCase(TestCase):
with open(log_file, 'r', encoding='utf-8') as f: with open(log_file, 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
self.assertIn("AUDIT LOG: User 'aduser' executed Verify & Clean Schedule Rules", content) self.assertIn("AUDIT LOG: User 'aduser' executed Verify & Clean Schedule Rules", content)
class EnglishStaticIframeAndPastEmptyCardTestCase(TestCase):
def setUp(self):
self.client = Client()
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):
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):
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)

View file

@ -14,6 +14,7 @@ urlpatterns = [
path('schedule/', views.weekly_schedule, name='weekly_schedule'), path('schedule/', views.weekly_schedule, name='weekly_schedule'),
path('schedule/move/', views.move_schedule_item, name='move_schedule_item'), path('schedule/move/', views.move_schedule_item, name='move_schedule_item'),
path('schedule/verify-rules/', views.verify_schedule_rules, name='verify_schedule_rules'), path('schedule/verify-rules/', views.verify_schedule_rules, name='verify_schedule_rules'),
path('schedule/embed/en/', views.weekly_schedule_en_iframe, name='weekly_schedule_en_iframe'),
path('update-from-excel/', views.update_students_from_excel, name='update_students_from_excel'), path('update-from-excel/', views.update_students_from_excel, name='update_students_from_excel'),
path('upload-excel/', views.upload_students_excel, name='upload_students_excel'), path('upload-excel/', views.upload_students_excel, name='upload_students_excel'),
path('set-language/<str:lang_code>/', views.set_language, name='set_language'), path('set-language/<str:lang_code>/', views.set_language, name='set_language'),

View file

@ -8,7 +8,8 @@ from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
from django.conf import settings from django.conf import settings
from django.http import HttpResponseForbidden, JsonResponse 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 django.db.models import Q, Case, When, Value, IntegerField
from .models import Student, PointRecord, WeeklySchedule from .models import Student, PointRecord, WeeklySchedule
from .forms import StudentLoginForm, ADLoginForm from .forms import StudentLoginForm, ADLoginForm
@ -22,6 +23,7 @@ def get_month_mondays(year, month):
return [d for d in c.itermonthdates(year, month) if d.month == month and d.weekday() == 0] return [d for d in c.itermonthdates(year, month) if d.month == month and d.weekday() == 0]
from django.utils import translation from django.utils import translation
from django.utils.translation import gettext_lazy as _
def set_language(request, lang_code): def set_language(request, lang_code):
""" """
@ -171,6 +173,8 @@ def process_excel_rows(rows):
else: else:
if student_id.upper().startswith('D'): if student_id.upper().startswith('D'):
degree_type = '博士生' degree_type = '博士生'
elif student_id.upper().startswith('B'):
degree_type = '學士生'
student_category = '一般生' student_category = '一般生'
for k, v in row_dict.items(): for k, v in row_dict.items():
@ -301,7 +305,7 @@ def sync_weekly_schedule_to_db(m1_year, m1_month, m2_year, m2_month, force=False
for s in active_students: for s in active_students:
if s.student_category == '交換中': if s.student_category == '交換中':
continue continue
elif s.degree_type == '碩士生': elif s.degree_type in ('碩士生', '學士生'):
monthly_students.append(s) monthly_students.append(s)
elif s.degree_type == '博士生' and s.student_category == '一般生': elif s.degree_type == '博士生' and s.student_category == '一般生':
monthly_students.append(s) monthly_students.append(s)
@ -316,14 +320,18 @@ def sync_weekly_schedule_to_db(m1_year, m1_month, m2_year, m2_month, force=False
m1_pool = monthly_students + bimonthly_m1 m1_pool = monthly_students + bimonthly_m1
m2_pool = monthly_students + bimonthly_m2 m2_pool = monthly_students + bimonthly_m2
today = date.today()
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
if not m1_exists or force: if not m1_exists or force:
for idx, student in enumerate(m1_pool): for idx, student in enumerate(m1_pool):
monday_date = m1_mondays[idx % len(m1_mondays)] monday_date = valid_m1[idx % len(valid_m1)]
WeeklySchedule.objects.get_or_create(student=student, date=monday_date) WeeklySchedule.objects.get_or_create(student=student, date=monday_date)
if not m2_exists or force: if not m2_exists or force:
for idx, student in enumerate(m2_pool): for idx, student in enumerate(m2_pool):
monday_date = m2_mondays[idx % len(m2_mondays)] monday_date = valid_m2[idx % len(valid_m2)]
WeeklySchedule.objects.get_or_create(student=student, date=monday_date) WeeklySchedule.objects.get_or_create(student=student, date=monday_date)
def index(request): def index(request):
@ -399,6 +407,7 @@ def record_attendance(request):
students = Student.objects.filter(is_active=True).order_by('name') students = Student.objects.filter(is_active=True).order_by('name')
return render(request, 'students/record_attendance.html', {'students': students, 'point_records': point_records}) return render(request, 'students/record_attendance.html', {'students': students, 'point_records': point_records})
@login_required(login_url='login')
def show_attendance(request, student_id): def show_attendance(request, student_id):
student = Student.objects.get(student_id=student_id) student = Student.objects.get(student_id=student_id)
point_records = PointRecord.objects.filter(student=student).order_by('-datetime') point_records = PointRecord.objects.filter(student=student).order_by('-datetime')
@ -506,39 +515,41 @@ def weekly_schedule(request):
default=Value(4), default=Value(4),
output_field=IntegerField() output_field=IntegerField()
) )
degree_order = Case(
When(student__degree_type='博士生', then=Value(1)),
When(student__degree_type='碩士生', then=Value(2)),
When(student__degree_type='學士生', then=Value(3)),
default=Value(4),
output_field=IntegerField()
)
schedule_m1 = [] schedule_m1 = []
for m in m1_mondays: for m in m1_mondays:
records = WeeklySchedule.objects.filter(date=m).select_related('student').annotate( records = WeeklySchedule.objects.filter(date=m).select_related('student').annotate(
cat_order=category_order cat_order=category_order,
).order_by('cat_order', 'student__student_id') deg_order=degree_order,
).order_by('cat_order', 'deg_order', 'student__student_id')
schedule_m1.append({ schedule_m1.append({
'date': m, 'date': m,
'formatted': m.strftime('%Y-%m-%d'), 'formatted': m.strftime('%Y-%m-%d'),
'students': [r.student for r in records] 'students': [r.student for r in records],
'is_past': m < today,
}) })
schedule_m2 = [] schedule_m2 = []
for m in m2_mondays: for m in m2_mondays:
records = WeeklySchedule.objects.filter(date=m).select_related('student').annotate( records = WeeklySchedule.objects.filter(date=m).select_related('student').annotate(
cat_order=category_order cat_order=category_order,
).order_by('cat_order', 'student__student_id') deg_order=degree_order,
).order_by('cat_order', 'deg_order', 'student__student_id')
schedule_m2.append({ schedule_m2.append({
'date': m, 'date': m,
'formatted': m.strftime('%Y-%m-%d'), 'formatted': m.strftime('%Y-%m-%d'),
'students': [r.student for r in records] 'students': [r.student for r in records],
'is_past': m < today,
}) })
student_cat_order = Case( active_students = Student.objects.filter(is_active=True).order_by('student_id')
When(student_category='一般生', then=Value(1)),
When(student_category='在職生', then=Value(2)),
When(student_category='交換中', then=Value(3)),
default=Value(4),
output_field=IntegerField()
)
active_students = Student.objects.filter(is_active=True).annotate(
cat_order=student_cat_order
).order_by('cat_order', 'student_id')
summary_data = [] summary_data = []
for s in active_students: for s in active_students:
assigned_m1 = [m['formatted'] for m in schedule_m1 if s in m['students']] assigned_m1 = [m['formatted'] for m in schedule_m1 if s in m['students']]
@ -546,13 +557,15 @@ def weekly_schedule(request):
assigned_dates = assigned_m1 + assigned_m2 assigned_dates = assigned_m1 + assigned_m2
if s.student_category == '交換中': if s.student_category == '交換中':
rule_desc = '交換生 (免報告)' rule_desc = _('交換生 (免報告)')
elif s.degree_type == '學士生':
rule_desc = _('學士生 (1次/月)')
elif s.degree_type == '碩士生': elif s.degree_type == '碩士生':
rule_desc = '碩士生 (1次/月)' rule_desc = _('碩士生 (1次/月)')
elif s.degree_type == '博士生' and s.student_category == '一般生': elif s.degree_type == '博士生' and s.student_category == '一般生':
rule_desc = '博士一般生 (1次/月)' rule_desc = _('博士一般生 (1次/月)')
else: else:
rule_desc = '博士在職生 (1次/2個月)' rule_desc = _('博士在職生 (1次/2個月)')
summary_data.append({ summary_data.append({
'student': s, 'student': s,
@ -565,6 +578,9 @@ def weekly_schedule(request):
request.user.is_superuser or check_user_has_all_ad_groups(request.user, request, ['Administrators']) request.user.is_superuser or check_user_has_all_ad_groups(request.user, request, ['Administrators'])
) )
# Generate clean English static HTML for iframe embedding
generate_english_schedule_static_html()
return render(request, 'students/weekly_schedule.html', { return render(request, 'students/weekly_schedule.html', {
'm1_year': m1_year, 'm1_year': m1_year,
'm1_month': m1_month, 'm1_month': m1_month,
@ -616,7 +632,7 @@ def move_schedule_item(request):
if student.student_category == '交換中': if student.student_category == '交換中':
return JsonResponse({'success': False, 'error': '交換生無需報告,無法排程'}, status=400) return JsonResponse({'success': False, 'error': '交換生無需報告,無法排程'}, status=400)
is_monthly = (student.degree_type == '碩士生') or (student.degree_type == '博士生' and student.student_category == '一般生') is_monthly = (student.degree_type in ('碩士生', '學士生')) or (student.degree_type == '博士生' and student.student_category == '一般生')
if is_monthly: if is_monthly:
if from_date.month != to_date.month or from_date.year != to_date.year: if from_date.month != to_date.month or from_date.year != to_date.year:
@ -633,6 +649,9 @@ def move_schedule_item(request):
schedule_entry.month = to_date.month schedule_entry.month = to_date.month
schedule_entry.save() schedule_entry.save()
# Update static English HTML for iframe embed
generate_english_schedule_static_html()
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}." 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) logger.info(log_msg)
schedule_logger.info(log_msg) schedule_logger.info(log_msg)
@ -684,8 +703,12 @@ def verify_schedule_rules(request):
active_students = Student.objects.filter(is_active=True).exclude(student_category='交換中') active_students = Student.objects.filter(is_active=True).exclude(student_category='交換中')
valid_m1_mondays = [d for d in m1_mondays if d >= today]
valid_m2_mondays = [d for d in m2_mondays if d >= today]
valid_all_mondays = [d for d in (m1_mondays + m2_mondays) if d >= today]
for s in active_students: for s in active_students:
is_monthly = (s.degree_type == '碩士生') or (s.degree_type == '博士生' and s.student_category == '一般生') is_monthly = (s.degree_type in ('碩士生', '學士生')) or (s.degree_type == '博士生' and s.student_category == '一般生')
if is_monthly: if is_monthly:
# Check Month 1 # Check Month 1
@ -695,7 +718,8 @@ def verify_schedule_rules(request):
r.delete() r.delete()
deleted_count += 1 deleted_count += 1
elif len(m1_records) == 0: elif len(m1_records) == 0:
monday = min(m1_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count()) if valid_m1_mondays:
monday = min(valid_m1_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
WeeklySchedule.objects.create(student=s, date=monday) WeeklySchedule.objects.create(student=s, date=monday)
created_count += 1 created_count += 1
@ -706,7 +730,8 @@ def verify_schedule_rules(request):
r.delete() r.delete()
deleted_count += 1 deleted_count += 1
elif len(m2_records) == 0: elif len(m2_records) == 0:
monday = min(m2_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count()) if valid_m2_mondays:
monday = min(valid_m2_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
WeeklySchedule.objects.create(student=s, date=monday) WeeklySchedule.objects.create(student=s, date=monday)
created_count += 1 created_count += 1
else: else:
@ -717,8 +742,8 @@ def verify_schedule_rules(request):
r.delete() r.delete()
deleted_count += 1 deleted_count += 1
elif len(bimonthly_records) == 0: elif len(bimonthly_records) == 0:
all_mondays = m1_mondays + m2_mondays if valid_all_mondays:
monday = min(all_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count()) monday = min(valid_all_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
WeeklySchedule.objects.create(student=s, date=monday) WeeklySchedule.objects.create(student=s, date=monday)
created_count += 1 created_count += 1
@ -727,6 +752,9 @@ def verify_schedule_rules(request):
schedule_logger.info(log_msg) schedule_logger.info(log_msg)
print(log_msg) print(log_msg)
# Update static English HTML for iframe embed
generate_english_schedule_static_html()
messages.success( messages.success(
request, request,
f"校正與驗證排程規則完成!已清理 {deleted_count} 筆不符規則之卡片(包含刪除交換生卡片與重複排程),補齊 {created_count} 筆缺漏排程。" f"校正與驗證排程規則完成!已清理 {deleted_count} 筆不符規則之卡片(包含刪除交換生卡片與重複排程),補齊 {created_count} 筆缺漏排程。"
@ -821,3 +849,134 @@ def upload_students_excel(request):
messages.error(request, f"上傳匯入學生資料失敗: {e}") messages.error(request, f"上傳匯入學生資料失敗: {e}")
return redirect('student_list') return redirect('student_list')
def generate_english_schedule_static_html():
"""
Generates a standalone, clean English static HTML file for the weekly schedule,
suitable for embedding directly in an <iframe>.
Saves the output to static directories.
"""
today = date.today()
m1_year, m1_month = today.year, today.month
m2_date = (today.replace(day=28) + timedelta(days=4)).replace(day=1)
m2_year, m2_month = m2_date.year, m2_date.month
m1_mondays = get_month_mondays(m1_year, m1_month)
m2_mondays = get_month_mondays(m2_year, m2_month)
category_order = Case(
When(student__student_category='一般生', then=Value(1)),
When(student__student_category='在職生', then=Value(2)),
When(student__student_category='交換中', then=Value(3)),
default=Value(4),
output_field=IntegerField()
)
degree_order = Case(
When(student__degree_type='博士生', then=Value(1)),
When(student__degree_type='碩士生', then=Value(2)),
When(student__degree_type='學士生', then=Value(3)),
default=Value(4),
output_field=IntegerField()
)
schedule_m1 = []
for m in m1_mondays:
records = WeeklySchedule.objects.filter(date=m).select_related('student').annotate(
cat_order=category_order,
deg_order=degree_order,
).order_by('cat_order', 'deg_order', 'student__student_id')
schedule_m1.append({
'date': m,
'formatted': m.strftime('%Y-%m-%d'),
'students': [r.student for r in records],
'is_past': m < today,
})
schedule_m2 = []
for m in m2_mondays:
records = WeeklySchedule.objects.filter(date=m).select_related('student').annotate(
cat_order=category_order,
deg_order=degree_order,
).order_by('cat_order', 'deg_order', 'student__student_id')
schedule_m2.append({
'date': m,
'formatted': m.strftime('%Y-%m-%d'),
'students': [r.student for r in records],
'is_past': m < today,
})
student_cat_order = Case(
When(student_category='一般生', then=Value(1)),
When(student_category='在職生', then=Value(2)),
When(student_category='交換中', then=Value(3)),
default=Value(4),
output_field=IntegerField()
)
student_deg_order = Case(
When(degree_type='博士生', then=Value(1)),
When(degree_type='碩士生', then=Value(2)),
When(degree_type='學士生', then=Value(3)),
default=Value(4),
output_field=IntegerField()
)
active_students = Student.objects.filter(is_active=True).annotate(
cat_order=student_cat_order,
deg_order=student_deg_order,
).order_by('cat_order', 'deg_order', 'student_id')
summary_data = []
for s in active_students:
assigned_m1 = [m['formatted'] for m in schedule_m1 if s in m['students']]
assigned_m2 = [m['formatted'] for m in schedule_m2 if s in m['students']]
assigned_dates = assigned_m1 + assigned_m2
if s.student_category == '交換中':
rule_desc = 'Exchange Student (Exempt)'
elif s.degree_type == '學士生':
rule_desc = 'Undergraduate Student (1 time/month)'
elif s.degree_type == '碩士生':
rule_desc = 'Master Student (1 time/month)'
elif s.degree_type == '博士生' and s.student_category == '一般生':
rule_desc = 'Ph.D. Regular (1 time/month)'
else:
rule_desc = 'Ph.D. In-Service (1 time/2 months)'
summary_data.append({
'student': s,
'rule_desc': rule_desc,
'assigned_dates': assigned_dates,
'total_count': len(assigned_dates)
})
from django.template.loader import render_to_string
html_content = render_to_string('students/weekly_schedule_en_iframe.html', {
'm1_year': m1_year,
'm1_month': m1_month,
'm2_year': m2_year,
'm2_month': m2_month,
'schedule_m1': schedule_m1,
'schedule_m2': schedule_m2,
'summary_data': summary_data,
'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
})
import os
target_paths = [
settings.BASE_DIR / 'static' / 'weekly_schedule_en.html',
settings.BASE_DIR / 'students' / 'static' / 'students' / 'weekly_schedule_en.html',
]
for path in target_paths:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
f.write(html_content)
return html_content
@xframe_options_exempt
def weekly_schedule_en_iframe(request):
"""
Serves the clean English static HTML schedule for iframe embedding.
"""
html_content = generate_english_schedule_static_html()
return HttpResponse(html_content, content_type='text/html; charset=utf-8')