feat: implement student attendance tracking and weekly report scheduling system with AD authentication support
This commit is contained in:
parent
994f4db5e1
commit
7b5abd244f
25 changed files with 3231 additions and 322 deletions
415
.gitignore
vendored
415
.gitignore
vendored
|
|
@ -1,206 +1,209 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
#poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
#pdm.lock
|
||||
#pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
#pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
#########
|
||||
db.sqlite3.*
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
#poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
#pdm.lock
|
||||
#pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
#pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
#########
|
||||
db.sqlite3.*
|
||||
*.bak
|
||||
*.csv
|
||||
*.xlsx
|
||||
|
|
|
|||
172
point_system/locale/en/LC_MESSAGES/django.po
Normal file
172
point_system/locale/en/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 1218 Lab 1.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-04 05:20+0800\n"
|
||||
"PO-Revision-Date: 2026-08-04 05:20+0800\n"
|
||||
"Last-Translator: 1218 Lab\n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "首頁"
|
||||
msgstr "Home"
|
||||
|
||||
msgid "記錄考勤"
|
||||
msgstr "Record Attendance"
|
||||
|
||||
msgid "最近考勤"
|
||||
msgstr "Recent Attendance"
|
||||
|
||||
msgid "考勤報告"
|
||||
msgstr "Attendance Report"
|
||||
|
||||
msgid "學生列表"
|
||||
msgstr "Student List"
|
||||
|
||||
msgid "週會排程"
|
||||
msgstr "Weekly Schedule"
|
||||
|
||||
msgid "登出"
|
||||
msgstr "Logout"
|
||||
|
||||
msgid "AD 登入"
|
||||
msgstr "AD Login"
|
||||
|
||||
msgid "僅顯示在學學生 (預設)"
|
||||
msgstr "Active Students Only (Default)"
|
||||
|
||||
msgid "全部學生"
|
||||
msgstr "All Students"
|
||||
|
||||
msgid "僅顯示在學學生"
|
||||
msgstr "Active Students Only"
|
||||
|
||||
msgid "上傳並匯入 students.xlsx"
|
||||
msgstr "Upload & Import students.xlsx"
|
||||
|
||||
msgid "學號"
|
||||
msgstr "Student ID"
|
||||
|
||||
msgid "姓名"
|
||||
msgstr "Name"
|
||||
|
||||
msgid "在學狀態"
|
||||
msgstr "Status"
|
||||
|
||||
msgid "學位類別"
|
||||
msgstr "Degree Type"
|
||||
|
||||
msgid "身分類別"
|
||||
msgstr "Category"
|
||||
|
||||
msgid "班級/年級"
|
||||
msgstr "Grade"
|
||||
|
||||
msgid "詳細資料"
|
||||
msgstr "Details"
|
||||
|
||||
msgid "查看詳情"
|
||||
msgstr "View Details"
|
||||
|
||||
msgid "在學"
|
||||
msgstr "Active"
|
||||
|
||||
msgid "非在學"
|
||||
msgstr "Inactive"
|
||||
|
||||
msgid "碩士生"
|
||||
msgstr "Master Student"
|
||||
|
||||
msgid "博士生"
|
||||
msgstr "Ph.D. Student"
|
||||
|
||||
msgid "一般生"
|
||||
msgstr "Regular"
|
||||
|
||||
msgid "在職生"
|
||||
msgstr "In-Service"
|
||||
|
||||
msgid "交換中"
|
||||
msgstr "Exchange"
|
||||
|
||||
msgid "碩士生 (一般 & 在職)"
|
||||
msgstr "Master Student (Regular & In-Service)"
|
||||
|
||||
msgid "博士一般生"
|
||||
msgstr "Ph.D. Regular Student"
|
||||
|
||||
msgid "博士在職生"
|
||||
msgstr "Ph.D. In-Service Student"
|
||||
|
||||
msgid "交換生 (交換中)"
|
||||
msgstr "Exchange Student (On Exchange)"
|
||||
|
||||
msgid "排程頻率: 每月 1 次"
|
||||
msgstr "Schedule Frequency: 1 time / month"
|
||||
|
||||
msgid "排程頻率: 每 2 個月 1 次"
|
||||
msgstr "Schedule Frequency: 1 time / 2 months"
|
||||
|
||||
msgid "排程狀態: 免報告"
|
||||
msgstr "Schedule Status: Exempt"
|
||||
|
||||
msgid "碩士生 (1次/月)"
|
||||
msgstr "Master Student (1 time/month)"
|
||||
|
||||
msgid "博士一般生 (1次/月)"
|
||||
msgstr "Ph.D. Regular (1 time/month)"
|
||||
|
||||
msgid "博士在職生 (1次/2個月)"
|
||||
msgstr "Ph.D. In-Service (1 time/2 months)"
|
||||
|
||||
msgid "交換生 (免報告)"
|
||||
msgstr "Exchange Student (Exempt)"
|
||||
|
||||
msgid "週會報告排程規則"
|
||||
msgstr "Weekly Seminar Schedule Rules"
|
||||
|
||||
msgid "校正並驗證排程規則"
|
||||
msgstr "Verify & Clean Schedule Rules"
|
||||
|
||||
msgid "資料庫狀態: 已同步儲存"
|
||||
msgstr "DB Status: Synced"
|
||||
|
||||
msgid "位報告者"
|
||||
msgstr "Presenters"
|
||||
|
||||
msgid "當日無排定報告者"
|
||||
msgstr "No presenters scheduled for this day"
|
||||
|
||||
msgid "學生個人報告統計明細 (兩月排程核對)"
|
||||
msgstr "Student Seminar Presentation Statistics"
|
||||
|
||||
msgid "身份 / 規則"
|
||||
msgstr "Category / Rule"
|
||||
|
||||
msgid "排定報告日期 (Mondays)"
|
||||
msgstr "Scheduled Dates (Mondays)"
|
||||
|
||||
msgid "應報告次數"
|
||||
msgstr "Total Reports Required"
|
||||
|
||||
msgid "免報告"
|
||||
msgstr "Exempt"
|
||||
|
||||
msgid "次"
|
||||
msgstr "time(s)"
|
||||
|
||||
msgid "拖曳排程微調提示:"
|
||||
msgstr "Drag & Drop Tip: "
|
||||
|
||||
msgid "您已登入系統,可將學生列直接拖曳 (Drag & Drop) 至其他週一日期卡片中調整報告時間。系統將會在寫入資料庫前自動驗證報告頻率規則,並詳細記錄您的變更日誌。"
|
||||
msgstr "You are logged in. You can drag and drop student cards directly to other Monday date cards to adjust presentation dates. The system will automatically verify schedule frequency rules before saving to the database and log all changes."
|
||||
|
||||
msgid "檢視模式:"
|
||||
msgstr "View-Only Mode: "
|
||||
|
||||
msgid "登入系統後,可拖曳學生調整報告日期。"
|
||||
msgstr "Log in to drag and drop student cards to adjust presentation dates."
|
||||
37
point_system/locale/zh_Hant/LC_MESSAGES/django.po
Normal file
37
point_system/locale/zh_Hant/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 1218 Lab 1.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-04 05:20+0800\n"
|
||||
"PO-Revision-Date: 2026-08-04 05:20+0800\n"
|
||||
"Last-Translator: 1218 Lab\n"
|
||||
"Language-Team: Traditional Chinese\n"
|
||||
"Language: zh_Hant\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
msgid "首頁"
|
||||
msgstr "首頁"
|
||||
|
||||
msgid "記錄考勤"
|
||||
msgstr "記錄考勤"
|
||||
|
||||
msgid "最近考勤"
|
||||
msgstr "最近考勤"
|
||||
|
||||
msgid "考勤報告"
|
||||
msgstr "考勤報告"
|
||||
|
||||
msgid "學生列表"
|
||||
msgstr "學生列表"
|
||||
|
||||
msgid "週會排程"
|
||||
msgstr "週會排程"
|
||||
|
||||
msgid "登出"
|
||||
msgstr "登出"
|
||||
|
||||
msgid "AD 登入"
|
||||
msgstr "AD 登入"
|
||||
|
|
@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
|||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
|
|
@ -65,6 +66,8 @@ TEMPLATES = [
|
|||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.template.context_processors.i18n',
|
||||
'students.context_processors.language_processor',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -102,20 +105,37 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
},
|
||||
]
|
||||
|
||||
# Active Directory Authentication Settings
|
||||
AD_SERVER = 'dc1.samdom.mdi.bar'
|
||||
AD_DOMAIN = 'samdom.mdi.bar'
|
||||
AD_BASE_DN = 'dc=samdom,dc=mdi,dc=bar'
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'students.ad_backend.ActiveDirectoryBackend',
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
]
|
||||
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGE_CODE = 'zh-hant'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
TIME_ZONE = 'Asia/Taipei'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
LANGUAGES = [
|
||||
('zh-hant', _('Traditional Chinese')),
|
||||
('en', _('English')),
|
||||
]
|
||||
|
||||
LOCALE_PATHS = [
|
||||
BASE_DIR / 'locale',
|
||||
]
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||
|
|
@ -126,3 +146,36 @@ STATIC_URL = 'static/'
|
|||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# File Logging Configuration for Schedule Actions
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{asctime} [{levelname}] {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'schedule_file': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': BASE_DIR / 'schedule_actions.log',
|
||||
'formatter': 'verbose',
|
||||
'encoding': 'utf-8',
|
||||
},
|
||||
'console': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'schedule_logger': {
|
||||
'handlers': ['schedule_file', 'console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,15 +11,22 @@ Financial assistances
|
|||
for disadvantaged
|
||||
students","就學貸款
|
||||
Loan",Email
|
||||
D13K48007,李艾臻,1,'-,'-,'-,0,D13K48007@ntu.edu.tw;zaq4747@yahoo.com.tw
|
||||
R13458003,林慕峰,1,'-,'-,'-,0,R13458003@ntu.edu.tw;brenton.lin.tw@gmail.com
|
||||
R13458019,"林 樸",1,'-,'-,'-,0,R13458019@ntu.edu.tw;pu@johnsonandannie.com
|
||||
R13K47020,劉書瑜,1,'-,'-,'-,0,R13K47020@ntu.edu.tw;freestyle770614@gmail.com
|
||||
R13K47029,陳品辰,1,'-,'-,'-,0,R13K47029@ntu.edu.tw;sw1sw2sw3@gmail.com
|
||||
R13K47031,"NANDHITHA SURULIANDI",1,'-,'-,'-,0,R13K47031@ntu.edu.tw;nandhithasuruliandi@gmail.com
|
||||
D12K48020,傅冠豪,2,'-,'-,'-,0,D12K48020@ntu.edu.tw;haobbc@hotmail.com
|
||||
R12458006,周宜葇,2,'-,通過,'-,0,R12458006@ntu.edu.tw;r12458006@g.ntu.edu.tw
|
||||
R12458007,林育暄,2,'-,'-,'-,0,R12458007@ntu.edu.tw;sunny891228@gmail.com
|
||||
R12458014,劉羽軒,2,'-,'-,'-,0,R12458014@ntu.edu.tw;liumilk613@gmail.com
|
||||
R12458015,"ZOLNAMAR DORJSEMBE",2,'-,'-,'-,0,R12458015@ntu.edu.tw;zolnamar@gmail.com
|
||||
R11458015,郭士榮,3,'-,'-,'-,0,R11458015@ntu.edu.tw;tonypaul57@gmail.com
|
||||
D12458003,林孟忻,3,'-,'-,'-,0,D12458003@ntu.edu.tw;glingloria@gmail.com
|
||||
D12K48020,傅冠豪,3,'-,'-,'-,0,D12K48020@ntu.edu.tw;haobbc@hotmail.com
|
||||
D13K48007,李艾臻,2,'-,'-,'-,0,D13K48007@ntu.edu.tw;zaq4747@yahoo.com.tw
|
||||
D13K48008,林冠儒,2,'-,'-,'-,0,D13K48008@ntu.edu.tw;atmoonsamo@gmail.com
|
||||
D14458011,吳瓊芳,1,'-,'-,'-,0,D14458011@ntu.edu.tw;chfu.wu@gmail.com
|
||||
D14K48002,黃子芬,1,'-,'-,'-,0,D14K48002@ntu.edu.tw;18390@s.tmu.edu.tw
|
||||
D14K48007,陳其蒙,1,'-,'-,'-,0,D14K48007@ntu.edu.tw;kimochen53@gmail.com
|
||||
D14K48010,"BUSHRA UROOJ",1,'-,'-,'-,0,D14K48010@ntu.edu.tw;bushrauroojluckygirl@gmil.com
|
||||
R11458015,郭士榮,4,'-,'-,'-,0,R11458015@ntu.edu.tw;tonypaul57@gmail.com
|
||||
R12458006,周宜葇,3,'-,'-,'-,0,R12458006@ntu.edu.tw;r12458006@g.ntu.edu.tw
|
||||
R13458003,林慕峰,2,'-,'-,'-,0,R13458003@ntu.edu.tw;brenton.lin.tw@gmail.com
|
||||
R13458019,"林 樸",2,'-,'-,'-,0,R13458019@ntu.edu.tw;pu@johnsonandannie.com
|
||||
R13K47020,劉書瑜,2,'-,'-,'-,0,R13K47020@ntu.edu.tw;freestyle770614@gmail.com
|
||||
R13K47029,陳品辰,2,'-,'-,'-,0,R13K47029@ntu.edu.tw;sw1sw2sw3@gmail.com
|
||||
R13K47031,"NANDHITHA SURULIANDI",2,'-,'-,'-,0,R13K47031@ntu.edu.tw;nandhithasuruliandi@gmail.com
|
||||
R14458006,林靖哲,1,'-,'-,'-,0,R14458006@ntu.edu.tw;jimmy910225@gmail.com
|
||||
R14K47018,李亮慶,1,'-,'-,'-,0,R14K47018@ntu.edu.tw;liangching0926@gmail.com
|
||||
R14K47019,劉冠岑,1,'-,'-,'-,0,R14K47019@ntu.edu.tw;a0938283665@gmail.com
|
||||
R14K47038,林綺音,1,'-,'-,'-,0,R14K47038@ntu.edu.tw;erinyes21828357@gmail.com
|
||||
|
|
|
|||
|
185
point_system/students/ad_backend.py
Normal file
185
point_system/students/ad_backend.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import logging
|
||||
import ldap3
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import BaseBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
def parse_group_dn(dn):
|
||||
"""Extract Common Name (CN) of group and Organizational Unit (OU) path from DN."""
|
||||
cn = None
|
||||
parts = dn.split(',')
|
||||
ou_list = []
|
||||
for part in parts:
|
||||
part_str = part.strip()
|
||||
if part_str.upper().startswith('CN='):
|
||||
if cn is None:
|
||||
cn = part_str[3:]
|
||||
elif part_str.upper().startswith('OU='):
|
||||
ou_list.append(part_str[3:])
|
||||
|
||||
if cn is None:
|
||||
cn = dn
|
||||
ou_path = " -> ".join(reversed(ou_list)) if ou_list else "Domain Root"
|
||||
return {
|
||||
'cn': cn,
|
||||
'dn': dn,
|
||||
'ou_path': ou_path
|
||||
}
|
||||
|
||||
def fetch_ad_groups(username, password=None):
|
||||
"""
|
||||
Fetch Active Directory groups for a given user from dc1.samdom.mdi.bar.
|
||||
"""
|
||||
ad_server = getattr(settings, 'AD_SERVER', 'dc1.samdom.mdi.bar')
|
||||
ad_domain = getattr(settings, 'AD_DOMAIN', 'samdom.mdi.bar')
|
||||
ad_base_dn = getattr(settings, 'AD_BASE_DN', 'dc=samdom,dc=mdi,dc=bar')
|
||||
|
||||
short_username = username.split('@')[0].split('\\')[-1] if username else username
|
||||
user_principal = f"{short_username}@{ad_domain}" if '@' not in username and '\\' not in username else username
|
||||
|
||||
groups = []
|
||||
try:
|
||||
server = ldap3.Server(ad_server, get_info=ldap3.NONE, connect_timeout=5)
|
||||
if password:
|
||||
conn = ldap3.Connection(server, user=user_principal, password=password, authentication=ldap3.SIMPLE)
|
||||
bound = conn.bind()
|
||||
else:
|
||||
conn = ldap3.Connection(server, auto_bind=True)
|
||||
bound = True
|
||||
|
||||
if bound:
|
||||
search_filter = f"(sAMAccountName={short_username})"
|
||||
conn.search(
|
||||
search_base=ad_base_dn,
|
||||
search_filter=search_filter,
|
||||
attributes=['memberOf', 'cn', 'displayName', 'distinguishedName']
|
||||
)
|
||||
if conn.entries:
|
||||
entry = conn.entries[0]
|
||||
if hasattr(entry, 'memberOf') and entry.memberOf.values:
|
||||
for group_dn in entry.memberOf.values:
|
||||
groups.append(parse_group_dn(group_dn))
|
||||
|
||||
conn.unbind()
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching AD groups for {username}: {e}")
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
class ActiveDirectoryBackend(BaseBackend):
|
||||
"""
|
||||
Authentication backend for Active Directory (DC: dc1.samdom.mdi.bar).
|
||||
Authenticates users against Active Directory via LDAP/AD simple bind.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.ad_server = getattr(settings, 'AD_SERVER', 'dc1.samdom.mdi.bar')
|
||||
self.ad_domain = getattr(settings, 'AD_DOMAIN', 'samdom.mdi.bar')
|
||||
self.ad_base_dn = getattr(settings, 'AD_BASE_DN', 'dc=samdom,dc=mdi,dc=bar')
|
||||
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
if not username or not password:
|
||||
return None
|
||||
|
||||
username = username.strip()
|
||||
if '@' in username:
|
||||
user_principal = username
|
||||
short_username = username.split('@')[0]
|
||||
elif '\\' in username:
|
||||
parts = username.split('\\')
|
||||
short_username = parts[1]
|
||||
user_principal = f"{short_username}@{self.ad_domain}"
|
||||
else:
|
||||
short_username = username
|
||||
user_principal = f"{username}@{self.ad_domain}"
|
||||
|
||||
try:
|
||||
server = ldap3.Server(self.ad_server, get_info=ldap3.NONE, connect_timeout=5)
|
||||
conn = ldap3.Connection(
|
||||
server,
|
||||
user=user_principal,
|
||||
password=password,
|
||||
authentication=ldap3.SIMPLE,
|
||||
auto_bind=False
|
||||
)
|
||||
|
||||
if not conn.bind():
|
||||
logger.warning(f"Active Directory authentication failed for {user_principal}: {conn.result}")
|
||||
return None
|
||||
|
||||
logger.info(f"Active Directory authentication succeeded for {user_principal}")
|
||||
|
||||
email = f"{short_username}@{self.ad_domain}"
|
||||
first_name = ""
|
||||
last_name = ""
|
||||
ad_groups = []
|
||||
|
||||
try:
|
||||
search_filter = f"(sAMAccountName={short_username})"
|
||||
conn.search(
|
||||
search_base=self.ad_base_dn,
|
||||
search_filter=search_filter,
|
||||
attributes=['mail', 'givenName', 'sn', 'displayName', 'memberOf']
|
||||
)
|
||||
if conn.entries:
|
||||
entry = conn.entries[0]
|
||||
if hasattr(entry, 'mail') and entry.mail.value:
|
||||
email = entry.mail.value
|
||||
if hasattr(entry, 'givenName') and entry.givenName.value:
|
||||
first_name = entry.givenName.value
|
||||
if hasattr(entry, 'sn') and entry.sn.value:
|
||||
last_name = entry.sn.value
|
||||
elif hasattr(entry, 'displayName') and entry.displayName.value:
|
||||
first_name = entry.displayName.value
|
||||
|
||||
if hasattr(entry, 'memberOf') and entry.memberOf.values:
|
||||
for group_dn in entry.memberOf.values:
|
||||
ad_groups.append(parse_group_dn(group_dn))
|
||||
except Exception as e:
|
||||
logger.debug(f"Error querying extra user attributes from AD: {e}")
|
||||
finally:
|
||||
try:
|
||||
conn.unbind()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
user, created = User.objects.get_or_create(
|
||||
username=short_username,
|
||||
defaults={
|
||||
'email': email,
|
||||
'first_name': first_name,
|
||||
'last_name': last_name,
|
||||
'is_staff': True,
|
||||
'is_active': True,
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
updated = False
|
||||
if email and user.email != email:
|
||||
user.email = email
|
||||
updated = True
|
||||
if first_name and user.first_name != first_name:
|
||||
user.first_name = first_name
|
||||
updated = True
|
||||
if last_name and user.last_name != last_name:
|
||||
user.last_name = last_name
|
||||
updated = True
|
||||
if updated:
|
||||
user.save()
|
||||
|
||||
user.ad_groups = ad_groups
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Active Directory backend error: {e}")
|
||||
return None
|
||||
|
||||
def get_user(self, user_id):
|
||||
try:
|
||||
return User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
|
|
@ -1,26 +1,33 @@
|
|||
from django.contrib import admin
|
||||
from .models import Student, PointRecord
|
||||
from .models import Student, PointRecord, WeeklySchedule
|
||||
|
||||
@admin.register(Student)
|
||||
class StudentAdmin(admin.ModelAdmin):
|
||||
list_display = ('student_id', 'name', 'grade', 'hope_admission_scheme', 'tuition_waiver', 'financial_assistance_disadvantaged_students', 'loan', 'email', 'is_active')
|
||||
search_fields = ('student_id', 'name')
|
||||
ordering = ['student_id'] # Changed to order by student_id as an example
|
||||
list_filter = ['grade', 'hope_admission_scheme', 'tuition_waiver', 'financial_assistance_disadvantaged_students', 'loan', 'is_active']
|
||||
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')
|
||||
ordering = ['student_id']
|
||||
list_filter = ['degree_type', 'student_category', 'grade', 'hope_admission_scheme', 'tuition_waiver', 'financial_assistance_disadvantaged_students', 'loan', 'is_active']
|
||||
|
||||
@admin.register(PointRecord)
|
||||
class PointRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ('student', 'datetime') # Updated to use datetime
|
||||
list_display = ('student', 'datetime')
|
||||
search_fields = ['student__name', 'student__student_id']
|
||||
list_filter = ['datetime'] # Updated to use datetime
|
||||
ordering = ['-datetime'] # Updated to use datetime
|
||||
list_filter = ['datetime']
|
||||
ordering = ['-datetime']
|
||||
|
||||
fieldsets = [
|
||||
(None, {
|
||||
'fields': ['student']
|
||||
}),
|
||||
('點名時間', {
|
||||
'fields': ['datetime'], # Updated to use datetime
|
||||
'classes': ['collapse'] # 顯示為可展開的區塊
|
||||
'fields': ['datetime'],
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
]
|
||||
]
|
||||
|
||||
@admin.register(WeeklySchedule)
|
||||
class WeeklyScheduleAdmin(admin.ModelAdmin):
|
||||
list_display = ('date', 'student', 'year', 'month')
|
||||
search_fields = ['student__name', 'student__student_id']
|
||||
list_filter = ['year', 'month', 'date']
|
||||
ordering = ['date', 'student__student_id']
|
||||
9
point_system/students/context_processors.py
Normal file
9
point_system/students/context_processors.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
def language_processor(request):
|
||||
"""
|
||||
Context processor to make current_lang available across all template renders.
|
||||
Defaults to 'zh' (Traditional Chinese).
|
||||
"""
|
||||
lang = request.session.get('lang', 'zh')
|
||||
return {
|
||||
'current_lang': lang
|
||||
}
|
||||
|
|
@ -5,4 +5,17 @@ class StudentLoginForm(forms.Form):
|
|||
label='學號或姓名',
|
||||
max_length=100,
|
||||
required=True
|
||||
)
|
||||
|
||||
class ADLoginForm(forms.Form):
|
||||
username = forms.CharField(
|
||||
label='AD 帳號',
|
||||
max_length=150,
|
||||
required=True,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '帳號 (例如: user 或 user@samdom.mdi.bar)'})
|
||||
)
|
||||
password = forms.CharField(
|
||||
label='密碼',
|
||||
required=True,
|
||||
widget=forms.PasswordInput(attrs={'class': 'form-control', 'placeholder': '密碼'})
|
||||
)
|
||||
25
point_system/students/management/commands/test_ad_auth.py
Normal file
25
point_system/students/management/commands/test_ad_auth.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import authenticate
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Test Active Directory authentication against dc1.samdom.mdi.bar'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('username', type=str, help='Active Directory username')
|
||||
parser.add_argument('password', type=str, help='Active Directory password')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = options['username']
|
||||
password = options['password']
|
||||
|
||||
self.stdout.write(f"Attempting AD authentication for user '{username}' on dc1.samdom.mdi.bar...")
|
||||
user = authenticate(username=username, password=password)
|
||||
|
||||
if user is not None:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"SUCCESS: Authenticated user '{user.username}' (Email: '{user.email}', Name: '{user.get_full_name()}')"
|
||||
))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f"FAILURE: Authentication failed for user '{username}'."
|
||||
))
|
||||
18
point_system/students/migrations/0002_student_degree_type.py
Normal file
18
point_system/students/migrations/0002_student_degree_type.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2 on 2026-08-03 15:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('students', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='degree_type',
|
||||
field=models.CharField(choices=[('碩士生', '碩士生'), ('博士生', '博士生')], default='碩士生', max_length=20, verbose_name='學位類別'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2 on 2026-08-03 15:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('students', '0002_student_degree_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='student_category',
|
||||
field=models.CharField(choices=[('一般生', '一般生'), ('在職生', '在職生')], default='一般生', max_length=20, verbose_name='身分類別'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2 on 2026-08-03 16:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('students', '0003_student_student_category'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='student',
|
||||
name='student_category',
|
||||
field=models.CharField(choices=[('一般生', '一般生'), ('在職生', '在職生'), ('交換中', '交換中')], default='一般生', max_length=20, verbose_name='身分類別'),
|
||||
),
|
||||
]
|
||||
30
point_system/students/migrations/0005_weeklyschedule.py
Normal file
30
point_system/students/migrations/0005_weeklyschedule.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 5.2 on 2026-08-03 16:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('students', '0004_alter_student_student_category'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WeeklySchedule',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField(verbose_name='週會日期')),
|
||||
('year', models.IntegerField(verbose_name='年份')),
|
||||
('month', models.IntegerField(verbose_name='月份')),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='students.student')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '週會排程',
|
||||
'verbose_name_plural': '週會排程',
|
||||
'ordering': ['date', 'student__student_id'],
|
||||
'unique_together': {('student', 'date')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 5.2 on 2026-08-03 21:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('students', '0005_weeklyschedule'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='weeklyschedule',
|
||||
options={'verbose_name': '週會排程', 'verbose_name_plural': '週會排程'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='email2',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='第二 Email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='student',
|
||||
name='degree_type',
|
||||
field=models.CharField(choices=[('碩士生', '碩士生'), ('博士生', '博士生')], default='碩士生', max_length=10, verbose_name='學位類別'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='student',
|
||||
name='email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='主要 Email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='student',
|
||||
name='student_category',
|
||||
field=models.CharField(choices=[('一般生', '一般生'), ('在職生', '在職生'), ('交換中', '交換中')], default='一般生', max_length=10, verbose_name='身分類別'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +1,32 @@
|
|||
from django.db import models
|
||||
|
||||
DEGREE_CHOICES = [
|
||||
('碩士生', '碩士生'),
|
||||
('博士生', '博士生'),
|
||||
]
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
('一般生', '一般生'),
|
||||
('在職生', '在職生'),
|
||||
('交換中', '交換中'),
|
||||
]
|
||||
|
||||
class Student(models.Model):
|
||||
student_id = models.CharField(max_length=10, primary_key=True)
|
||||
name = models.CharField(max_length=255)
|
||||
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)
|
||||
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)
|
||||
loan = models.IntegerField(null=True, blank=True, default=0)
|
||||
email = models.EmailField(null=True, blank=True)
|
||||
email = models.EmailField(null=True, blank=True, verbose_name='主要 Email')
|
||||
email2 = models.EmailField(null=True, blank=True, verbose_name='第二 Email')
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
return f"{self.name} ({self.degree_type} / {self.student_category})"
|
||||
|
||||
class PointRecord(models.Model):
|
||||
student = models.ForeignKey(Student, on_delete=models.CASCADE)
|
||||
|
|
@ -24,3 +38,23 @@ class PointRecord(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f"{self.datetime} - {self.student}"
|
||||
|
||||
class WeeklySchedule(models.Model):
|
||||
student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name='schedules')
|
||||
date = models.DateField(verbose_name='週會日期')
|
||||
year = models.IntegerField(verbose_name='年份')
|
||||
month = models.IntegerField(verbose_name='月份')
|
||||
|
||||
class Meta:
|
||||
unique_together = ('student', 'date')
|
||||
verbose_name = '週會排程'
|
||||
verbose_name_plural = '週會排程'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.date:
|
||||
self.year = self.date.year
|
||||
self.month = self.date.month
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.date} - {self.student.name} ({self.student.student_id})"
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
<!-- templates/base.html -->
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<html lang="{% if current_lang == 'en' %}en{% else %}zh-Hant{% endif %}">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}學生考勤系統{% endblock %}</title>
|
||||
<title>{% block title %}1218 Lab{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 导航栏 -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<!-- 導覽列 -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary shadow-sm">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{% url 'index' %}">
|
||||
<i class="fas fa-user-graduate"></i> 學生考勤系統
|
||||
<i class="fas fa-user-graduate me-1"></i> 1218 Lab
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
|
|
@ -21,30 +24,82 @@
|
|||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'index' %}">
|
||||
<i class="fas fa-home"></i> 首頁
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'record_attendance' %}">
|
||||
<i class="fas fa-clipboard-list"></i> 記錄考勤
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'recent_attendance' %}">
|
||||
<i class="fas fa-history"></i> 最近考勤
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'attendance_report' %}">
|
||||
<i class="fas fa-chart-bar"></i> 考勤報告
|
||||
<a class="nav-link" href="{% url 'weekly_schedule' %}">
|
||||
<i class="fas fa-calendar-alt"></i> {% trans "週會排程" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'student_list' %}">
|
||||
<i class="fas fa-users"></i> 學生列表
|
||||
<i class="fas fa-users"></i> {% trans "學生列表" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'record_attendance' %}">
|
||||
<i class="fas fa-clipboard-list"></i> {% trans "記錄考勤" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'recent_attendance' %}">
|
||||
<i class="fas fa-history"></i> {% trans "最近考勤" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'attendance_report' %}">
|
||||
<i class="fas fa-chart-bar"></i> {% trans "考勤報告" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto align-items-center">
|
||||
<li class="nav-item me-3 my-1">
|
||||
<span
|
||||
class="badge bg-light text-primary border shadow-sm px-3 py-2 fw-bold font-monospace fs-6">
|
||||
<i class="far fa-clock me-1 text-primary"></i><span id="clock-display">--:--:--</span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item me-2 my-1">
|
||||
{% if current_lang == 'en' %}
|
||||
<a href="{% url 'set_language' 'zh' %}"
|
||||
class="btn btn-outline-light btn-sm fw-bold px-3 py-1 shadow-sm" id="lang-toggle-btn"
|
||||
title="切換語言 / Switch Language">
|
||||
<i class="fas fa-globe me-1"></i>中/EN
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'set_language' 'en' %}"
|
||||
class="btn btn-outline-light btn-sm fw-bold px-3 py-1 shadow-sm" id="lang-toggle-btn"
|
||||
title="切換語言 / Switch Language">
|
||||
<i class="fas fa-globe me-1"></i>中/EN
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if user.is_authenticated %}
|
||||
<li class="nav-item dropdown me-2">
|
||||
<a class="nav-link dropdown-toggle text-light fw-bold" href="#" id="userMenu" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fas fa-user-circle me-1"></i> {{ user.get_full_name|default:user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="userMenu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'user_groups' %}">
|
||||
<i class="fas fa-users me-2 text-primary"></i>Group
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-danger" href="{% url 'logout' %}">
|
||||
<i class="fas fa-sign-out-alt me-2"></i>{% trans "登出" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-light text-primary btn-sm my-1 fw-bold" href="{% url 'login' %}">
|
||||
<i class="fas fa-shield-alt"></i> {% trans "AD 登入" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -52,20 +107,50 @@
|
|||
|
||||
<!-- 主要內容 -->
|
||||
<div class="container mt-4">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- 頁腳 -->
|
||||
<footer class="bg-light text-center text-lg-start mt-5">
|
||||
<div class="container p-4">
|
||||
<p class="text-center">© 2023 學生考勤系統. All rights reserved.</p>
|
||||
<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>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
function updateRealtimeClock() {
|
||||
const clockElement = document.getElementById('clock-display');
|
||||
if (!clockElement) return;
|
||||
|
||||
const now = new Date();
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
clockElement.textContent = `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
updateRealtimeClock();
|
||||
setInterval(updateRealtimeClock, 1000);
|
||||
});
|
||||
</script>
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
51
point_system/students/templates/students/login.html
Normal file
51
point_system/students/templates/students/login.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Active Directory 登入 - 1218 Lab{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<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 %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.username.id_for_label }}" class="form-label fw-bold">
|
||||
<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>
|
||||
<div class="mb-4">
|
||||
<label for="{{ form.password.id_for_label }}" class="form-label fw-bold">
|
||||
<i class="fas fa-key me-1 text-primary"></i> 密碼
|
||||
</label>
|
||||
{{ form.password }}
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-sign-in-alt me-2"></i>登入 (Active Directory)
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
212
point_system/students/templates/students/show_attendance.html
Normal file
212
point_system/students/templates/students/show_attendance.html
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ student.name }} ({{ student.student_id }}) - 考勤紀錄 - 1218 Lab{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<!-- 學生基本資料卡片 -->
|
||||
<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">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-user-clock me-2"></i>{{ student.name }} 的個人考勤簽到明細
|
||||
</h4>
|
||||
<a href="{% url 'student_list' %}" class="btn btn-light text-primary btn-sm fw-bold">
|
||||
<i class="fas fa-arrow-left me-1"></i>返回學生列表
|
||||
</a>
|
||||
</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">
|
||||
<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">
|
||||
<small class="text-muted d-block mb-1">姓名</small>
|
||||
<span class="h5 fw-bold text-dark">{{ student.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="p-3 bg-light rounded border">
|
||||
<small class="text-muted d-block mb-1">學位 / 身分</small>
|
||||
<div>
|
||||
{% if student.degree_type == '博士生' %}
|
||||
<span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>博士生</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>碩士生</span>
|
||||
{% endif %}
|
||||
|
||||
{% if student.student_category == '在職生' %}
|
||||
<span class="badge bg-warning text-dark"><i class="fas fa-user-tie me-1"></i>在職生</span>
|
||||
{% elif student.student_category == '交換中' %}
|
||||
<span class="badge bg-secondary text-white"><i class="fas fa-exchange-alt me-1"></i>交換中</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success text-white"><i class="fas fa-user me-1"></i>一般生</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="p-3 bg-light rounded border">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 學生個人報告統計明細 (週會排程) -->
|
||||
<div class="card shadow-sm border-0 rounded-3 mb-4">
|
||||
<div class="card-header bg-info text-dark py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 fw-bold"><i class="fas fa-calendar-check me-2"></i>學生個人報告統計明細 (週會排程)</h5>
|
||||
<span class="badge bg-dark text-white fs-6">{{ rule_desc }}</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: 45%;">排定報告日期 (Monday)</th>
|
||||
<th style="width: 45%;">報告排程狀態</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sch in schedules %}
|
||||
<tr>
|
||||
<td class="text-secondary fw-bold">{{ forloop.counter }}</td>
|
||||
<td class="fw-bold">
|
||||
<i class="far fa-calendar-alt text-primary me-2"></i>{{ sch.date|date:"Y-m-d" }} (星期一)
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary px-3 py-2 fs-6">
|
||||
<i class="fas fa-clock me-1"></i>已排定週會報告
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4 text-muted">
|
||||
{% if student.student_category == '交換中' %}
|
||||
<span class="badge bg-secondary fs-6 px-3 py-2"><i class="fas fa-minus-circle me-1"></i>交換生免排定週會報告</span>
|
||||
{% else %}
|
||||
尚無排定週會報告日期
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 簽到歷史紀錄表格 (僅限定包含 Administrators 與 Domain Admins 之 AD 群組管理者檢視) -->
|
||||
{% if can_view_records %}
|
||||
<div class="card shadow-sm border-0 rounded-3 mb-4">
|
||||
<div class="card-header bg-dark text-white py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-list-alt me-2"></i>簽到歷史紀錄</h5>
|
||||
<span class="badge bg-primary fs-6">共 {{ point_records|length }} 筆記錄</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 scope="col" style="width: 10%;">#</th>
|
||||
<th scope="col" style="width: 60%;">簽到日期與時間 (Date & Time)</th>
|
||||
<th scope="col" style="width: 30%;">簽到狀態</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in point_records %}
|
||||
<tr {% if forloop.counter > 10 %}class="extra-record-row d-none"{% endif %}>
|
||||
<td class="text-secondary fw-bold">{{ forloop.counter }}</td>
|
||||
<td class="fw-bold">
|
||||
<i class="far fa-calendar-check text-success me-2"></i>
|
||||
{{ record.datetime|date:"Y-m-d H:i:s" }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success px-3 py-2">
|
||||
<i class="fas fa-check-circle me-1"></i>簽到成功
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-5 text-muted">
|
||||
<i class="fas fa-folder-open fa-2x mb-3 d-block text-secondary"></i>
|
||||
目前無此學生的考勤簽到紀錄
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if point_records|length > 10 %}
|
||||
<div class="p-3 text-center bg-light border-top">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm fw-bold px-4 shadow-sm" id="toggle-records-btn">
|
||||
<i class="fas fa-chevron-down me-1"></i><span id="toggle-records-label">展開其餘 {{ point_records|length|add:"-10" }} 筆紀錄</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer bg-light py-3 d-flex justify-content-between align-items-center">
|
||||
<a href="{% url 'student_list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-1"></i>返回學生列表
|
||||
</a>
|
||||
{% if point_records|length > 10 %}
|
||||
<small class="text-muted"><i class="fas fa-info-circle me-1"></i>預設僅顯示最新 10 筆簽到記錄</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card shadow-sm border-0 rounded-3 mb-4 bg-light">
|
||||
<div class="card-body p-4 text-center text-muted">
|
||||
<i class="fas fa-lock fa-2x mb-2 d-block text-secondary"></i>
|
||||
<strong>權限不足:</strong>簽到歷史紀錄僅供同時屬於 Active Directory <code>Administrators</code> 與 <code>Domain Admins</code> 群組之管理者檢視。
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if can_view_records and point_records|length > 10 %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const toggleBtn = document.getElementById('toggle-records-btn');
|
||||
const extraRows = document.querySelectorAll('.extra-record-row');
|
||||
|
||||
if (toggleBtn) {
|
||||
let isExpanded = false;
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
isExpanded = !isExpanded;
|
||||
extraRows.forEach(row => {
|
||||
if (isExpanded) {
|
||||
row.classList.remove('d-none');
|
||||
} else {
|
||||
row.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
if (isExpanded) {
|
||||
toggleBtn.innerHTML = '<i class="fas fa-chevron-up me-1"></i><span id="toggle-records-label">折疊簽到紀錄</span>';
|
||||
toggleBtn.classList.remove('btn-outline-primary');
|
||||
toggleBtn.classList.add('btn-outline-secondary');
|
||||
} else {
|
||||
toggleBtn.innerHTML = `<i class="fas fa-chevron-down me-1"></i><span id="toggle-records-label">展開其餘 ${extraRows.length} 筆紀錄</span>`;
|
||||
toggleBtn.classList.remove('btn-outline-secondary');
|
||||
toggleBtn.classList.add('btn-outline-primary');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,36 +1,198 @@
|
|||
<!-- 在 templates/students/student_list.html -->
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}學生列表{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>學生列表</h1>
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>學號</th>
|
||||
<th>姓名</th>
|
||||
<th>班級</th>
|
||||
<th>考勤次數</th>
|
||||
<th>詳細資料</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in student_data %}
|
||||
<tr>
|
||||
<td>{{ item.student.student_id }}</td>
|
||||
<td>{{ item.student.name }}</td>
|
||||
<td>{{ item.student.grade|default:"未填" }}</td>
|
||||
<td>{{ item.total_attendance }}</td>
|
||||
<td><a href="{% url 'show_attendance' item.student.student_id %}" class="btn btn-sm btn-primary">查看詳情</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">沒有學生資料</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 在 templates/students/student_list.html -->
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}學生列表{% 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-users me-2"></i>學生列表</h1>
|
||||
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if is_admin_user %}
|
||||
<button type="button" class="btn btn-success btn-sm shadow-sm" data-bs-toggle="modal" data-bs-target="#uploadExcelModal">
|
||||
<i class="fas fa-file-upload me-1"></i>上傳並匯入 students.xlsx
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class="btn-group" role="group" aria-label="Student Filter">
|
||||
<a href="{% url 'student_list' %}" class="btn btn-outline-success btn-sm {% if active_only %}active fw-bold{% endif %}">
|
||||
<i class="fas fa-user-check me-1"></i>僅顯示在學學生 (預設)
|
||||
</a>
|
||||
<a href="{% url 'student_list' %}?active_only=false" class="btn btn-outline-primary btn-sm {% if not active_only %}active fw-bold{% endif %}">
|
||||
<i class="fas fa-list me-1"></i>全部學生
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0 rounded-3">
|
||||
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
|
||||
<span class="text-secondary fw-bold">
|
||||
<i class="fas fa-filter me-1 text-primary"></i> 篩選條件:
|
||||
{% if active_only %}
|
||||
<span class="badge bg-success"><i class="fas fa-check-circle me-1"></i>僅在學學生</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary"><i class="fas fa-users me-1"></i>全部學生 (含停學/畢業)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="badge bg-primary fs-6">共 {{ student_data|length }} 位學生</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in student_data %}
|
||||
<tr>
|
||||
<td class="fw-bold"><code>{{ item.student.student_id }}</code></td>
|
||||
<td>{{ item.student.name }}</td>
|
||||
<td>
|
||||
{% if item.student.is_active %}
|
||||
<span class="badge bg-success text-white"><i class="fas fa-check me-1"></i>在學</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary text-white"><i class="fas fa-times me-1"></i>非在學</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<span class="badge bg-info text-dark px-2 py-1"><i class="fas fa-graduation-cap me-1"></i>碩士生</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.student.student_category == '在職生' %}
|
||||
<span class="badge bg-warning text-dark px-2 py-1"><i class="fas fa-user-tie me-1"></i>在職生</span>
|
||||
{% elif item.student.student_category == '交換中' %}
|
||||
<span class="badge bg-secondary text-white px-2 py-1"><i class="fas fa-exchange-alt me-1"></i>交換中</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success text-white px-2 py-1"><i class="fas fa-user me-1"></i>一般生</span>
|
||||
{% endif %}
|
||||
</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>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4 text-muted">沒有符合條件的學生資料</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_admin_user %}
|
||||
<!-- 上傳 Excel/CSV 彈出視窗 -->
|
||||
<div class="modal fade" id="uploadExcelModal" tabindex="-1" aria-labelledby="uploadExcelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<form method="post" action="{% url 'upload_students_excel' %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title" id="uploadExcelModalLabel">
|
||||
<i class="fas fa-file-upload me-2"></i>上傳並匯入學生名單 (.xlsx / .csv)
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold mb-2">選擇或拖曳檔案 (.xlsx / .csv)</label>
|
||||
<div id="drop-zone" class="border border-2 rounded-3 p-4 text-center bg-light" style="border-color: #198754 !important; border-style: dashed !important; transition: all 0.2s ease-in-out; cursor: pointer;">
|
||||
<i class="fas fa-cloud-upload-alt text-success fs-1 mb-2"></i>
|
||||
<h6 class="fw-bold text-dark mb-1">將 .xlsx 或 .csv 檔案拖曳至此處,或點擊選擇檔案</h6>
|
||||
<p class="text-muted small mb-2" id="file-name-display">支援格式: .xlsx, .csv</p>
|
||||
<input type="file" class="form-control d-none" id="excel_file" name="excel_file" accept=".xlsx,.csv" required>
|
||||
<button type="button" class="btn btn-outline-success btn-sm mt-1" onclick="document.getElementById('excel_file').click();">
|
||||
<i class="fas fa-folder-open me-1"></i>瀏覽檔案
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info small mb-0 rounded-3">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
系統將自動解析學號、姓名、年級與主要/第二 Email,匯入名單內學生將設為在學,現有學生的「身分類別」將予以保留不變。
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">取消</button>
|
||||
<button type="submit" class="btn btn-success btn-sm fw-bold">
|
||||
<i class="fas fa-upload me-1"></i>開始上傳與匯入
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if is_admin_user %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('excel_file');
|
||||
const fileNameDisplay = document.getElementById('file-name-display');
|
||||
|
||||
if (!dropZone || !fileInput) return;
|
||||
|
||||
dropZone.addEventListener('click', function(e) {
|
||||
if (e.target.tagName !== 'BUTTON' && e.target.tagName !== 'INPUT') {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, function() {
|
||||
dropZone.classList.add('bg-white', 'shadow-sm');
|
||||
dropZone.style.backgroundColor = '#e8f5e9';
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, function() {
|
||||
dropZone.classList.remove('bg-white', 'shadow-sm');
|
||||
dropZone.style.backgroundColor = '#f8f9fa';
|
||||
}, false);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
if (files && files.length > 0) {
|
||||
fileInput.files = files;
|
||||
updateFileName();
|
||||
}
|
||||
}, false);
|
||||
|
||||
fileInput.addEventListener('change', updateFileName);
|
||||
|
||||
function updateFileName() {
|
||||
if (fileInput.files && fileInput.files.length > 0) {
|
||||
const fileName = fileInput.files[0].name;
|
||||
fileNameDisplay.innerHTML = `<span class="badge bg-success fs-6"><i class="fas fa-file-excel me-1"></i>已選擇: ${fileName}</span>`;
|
||||
} else {
|
||||
fileNameDisplay.innerText = '支援格式: .xlsx, .csv';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
83
point_system/students/templates/students/user_groups.html
Normal file
83
point_system/students/templates/students/user_groups.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Active Directory 群組成員資料 - 1218 Lab{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-4">
|
||||
<div class="col-lg-10">
|
||||
<div class="card shadow-sm border-0 rounded-3 mb-4">
|
||||
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between py-3">
|
||||
<h4 class="mb-0"><i class="fas fa-users-cog me-2"></i>Active Directory 群組 (Member Of)</h4>
|
||||
<span class="badge bg-light text-primary fs-6">{{ groups|length }} 個群組</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 bg-light rounded-3 border">
|
||||
<h6 class="text-muted mb-2"><i class="fas fa-user me-2 text-primary"></i>目前登入使用者</h6>
|
||||
<p class="h5 mb-1 text-dark fw-bold">{{ user.get_full_name|default:user.username }}</p>
|
||||
<small class="text-muted">帳號: <code>{{ user.username }}</code> | Email: {{ user.email|default:"無" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3 mt-md-0">
|
||||
<div class="p-3 bg-light rounded-3 border">
|
||||
<h6 class="text-muted mb-2"><i class="fas fa-server me-2 text-primary"></i>Active Directory 伺服器資訊</h6>
|
||||
<p class="h5 mb-1 text-dark fw-bold">{{ ad_server }}</p>
|
||||
<small class="text-muted">網域 (Domain): <code>{{ ad_domain }}</code></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="fw-bold text-secondary mb-3"><i class="fas fa-layer-group me-2"></i>所屬群組列表 (Groups User is a Member Of)</h5>
|
||||
|
||||
{% if groups %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-bordered align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th scope="col" style="width: 5%;">#</th>
|
||||
<th scope="col" style="width: 25%;">群組名稱 (Common Name)</th>
|
||||
<th scope="col" style="width: 20%;">組織單位 (OU)</th>
|
||||
<th scope="col" style="width: 50%;">完整辨識名 (Distinguished Name)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for group in groups %}
|
||||
<tr>
|
||||
<td class="text-center text-muted fw-bold">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<span class="badge bg-primary fs-6 px-3 py-2">
|
||||
<i class="fas fa-users me-1"></i> {{ group.cn }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-secondary fw-semibold">{{ group.ou_path }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-break text-dark small bg-light p-1 rounded border d-block">{{ group.dn }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning d-flex align-items-center rounded-3 p-4" role="alert">
|
||||
<i class="fas fa-exclamation-triangle me-3 fs-2 text-warning"></i>
|
||||
<div>
|
||||
<h5 class="alert-heading mb-1">未找到群組資料或尚未從 Active Directory 取得群組</h5>
|
||||
<p class="mb-0">此帳號目前沒有 direct memberOf 群組紀錄,或使用者非經由 Active Directory 登入。</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer bg-light py-3 d-flex justify-content-between align-items-center">
|
||||
<a href="{% url 'index' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-1"></i>返回首頁
|
||||
</a>
|
||||
<small class="text-muted">Domain Controller: <code>{{ ad_server }}</code></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
351
point_system/students/templates/students/weekly_schedule.html
Normal file
351
point_system/students/templates/students/weekly_schedule.html
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}週會排程 - 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-calendar-alt 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-database me-1"></i>{% trans "資料庫狀態: 已同步儲存" %}
|
||||
</span>
|
||||
<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 }}月
|
||||
</span>
|
||||
{% if user.is_authenticated %}
|
||||
<form method="post" action="{% url 'verify_schedule_rules' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-warning btn-sm shadow-sm text-dark fw-bold" onclick="return confirm('確定要校正並驗證排程規則嗎?(如為交換生將自動刪除其卡片,並清理重複或補齊缺漏排程)');">
|
||||
<i class="fas fa-check-double me-1"></i>{% trans "校正並驗證排程規則" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 報告規則說明區塊 -->
|
||||
<div class="card shadow-sm border-0 rounded-3 mb-4 bg-light">
|
||||
<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>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<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>
|
||||
<small class="text-muted">{% trans "排程狀態: 免報告" %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div class="alert alert-info small mb-4 shadow-sm rounded-3 border-start border-4 border-info">
|
||||
<i class="fas fa-hand-pointer text-primary me-2"></i>
|
||||
<strong>{% trans "拖曳排程微調提示:" %}</strong>{% trans "您已登入系統,可將學生列直接拖曳 (Drag & Drop) 至其他週一日期卡片中調整報告時間。系統將會在寫入資料庫前自動驗證報告頻率規則,並詳細記錄您的變更日誌。" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary small mb-4 rounded-3 border-start border-4 border-secondary">
|
||||
<i class="fas fa-lock me-2"></i>
|
||||
<strong>{% trans "檢視模式:" %}</strong>{% trans "登入系統後,可拖曳學生調整報告日期。" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 當月週會排程 (Month 1) -->
|
||||
<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 }} 月週會排程 (當月)
|
||||
</h3>
|
||||
<div class="row g-4">
|
||||
{% for item in schedule_m1 %}
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm border-0 rounded-3 h-100 monday-drop-target" data-date="{{ item.formatted }}" 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">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (星期一)
|
||||
</h5>
|
||||
<span class="badge bg-light text-primary fs-6">{{ item.students|length }} 位報告者</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: 25%;">學號</th>
|
||||
<th scope="col" style="width: 25%;">姓名</th>
|
||||
<th scope="col" style="width: 25%;">學位類別</th>
|
||||
<th scope="col" style="width: 25%;">身分類別</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for student in item.students %}
|
||||
<tr {% if user.is_authenticated %}draggable="true" class="draggable-student" style="cursor: grab;" data-student-id="{{ student.student_id }}" data-student-name="{{ student.name }}" data-from-date="{{ item.formatted }}"{% endif %}>
|
||||
<td class="fw-bold">
|
||||
{% if user.is_authenticated %}<i class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}
|
||||
<code>{{ student.student_id }}</code>
|
||||
</td>
|
||||
<td>{{ student.name }}</td>
|
||||
<td>
|
||||
{% if student.degree_type == '博士生' %}
|
||||
<span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>博士生</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>碩士生</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if student.student_category == '在職生' %}
|
||||
<span class="badge bg-warning text-dark"><i class="fas fa-user-tie me-1"></i>在職生</span>
|
||||
{% elif student.student_category == '交換中' %}
|
||||
<span class="badge bg-secondary text-white"><i class="fas fa-exchange-alt me-1"></i>交換中</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success text-white"><i class="fas fa-user me-1"></i>一般生</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted py-4 mb-0">當日無排定報告者 (可拖曳學生至此)</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下月週會排程 (Month 2) -->
|
||||
<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 }} 月週會排程 (下月)
|
||||
</h3>
|
||||
<div class="row g-4">
|
||||
{% for item in schedule_m2 %}
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm border-0 rounded-3 h-100 monday-drop-target" data-date="{{ item.formatted }}" 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">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (星期一)
|
||||
</h5>
|
||||
<span class="badge bg-light text-success fs-6">{{ item.students|length }} 位報告者</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: 25%;">學號</th>
|
||||
<th scope="col" style="width: 25%;">姓名</th>
|
||||
<th scope="col" style="width: 25%;">學位類別</th>
|
||||
<th scope="col" style="width: 25%;">身分類別</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for student in item.students %}
|
||||
<tr {% if user.is_authenticated %}draggable="true" class="draggable-student" style="cursor: grab;" data-student-id="{{ student.student_id }}" data-student-name="{{ student.name }}" data-from-date="{{ item.formatted }}"{% endif %}>
|
||||
<td class="fw-bold">
|
||||
{% if user.is_authenticated %}<i class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}
|
||||
<code>{{ student.student_id }}</code>
|
||||
</td>
|
||||
<td>{{ student.name }}</td>
|
||||
<td>
|
||||
{% if student.degree_type == '博士生' %}
|
||||
<span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>博士生</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>碩士生</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if student.student_category == '在職生' %}
|
||||
<span class="badge bg-warning text-dark"><i class="fas fa-user-tie me-1"></i>在職生</span>
|
||||
{% elif student.student_category == '交換中' %}
|
||||
<span class="badge bg-secondary text-white"><i class="fas fa-exchange-alt me-1"></i>交換中</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success text-white"><i class="fas fa-user me-1"></i>一般生</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted py-4 mb-0">當日無排定報告者 (可拖曳學生至此)</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 學生個人報告總覽與統計 -->
|
||||
<div class="card shadow-sm border-0 rounded-3 mt-5">
|
||||
<div class="card-header bg-dark text-white py-3">
|
||||
<h5 class="mb-0"><i class="fas fa-tasks me-2"></i>學生個人報告統計明細 (兩月排程核對)</h5>
|
||||
</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>學號</th>
|
||||
<th>姓名</th>
|
||||
<th>身份 / 規則</th>
|
||||
<th>排定報告日期 (Mondays)</th>
|
||||
<th>應報告次數</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in summary_data %}
|
||||
<tr>
|
||||
<td class="fw-bold"><code>{{ row.student.student_id }}</code></td>
|
||||
<td>{{ row.student.name }}</td>
|
||||
<td><small class="fw-semibold text-secondary">{{ row.rule_desc }}</small></td>
|
||||
<td>
|
||||
{% if 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>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="badge bg-secondary"><i class="fas fa-minus-circle me-1"></i>免報告</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if row.total_count > 0 %}
|
||||
<span class="badge bg-success fs-6">{{ row.total_count }} 次</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary fs-6">0 次</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-4 text-muted">無在學學生資料</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if user.is_authenticated %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const draggables = document.querySelectorAll('.draggable-student');
|
||||
const dropTargets = document.querySelectorAll('.monday-drop-target');
|
||||
|
||||
let draggedItem = null;
|
||||
|
||||
draggables.forEach(item => {
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = this;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
student_id: this.dataset.studentId,
|
||||
student_name: this.dataset.studentName,
|
||||
from_date: this.dataset.fromDate
|
||||
}));
|
||||
this.classList.add('table-warning');
|
||||
});
|
||||
|
||||
item.addEventListener('dragend', function() {
|
||||
this.classList.remove('table-warning');
|
||||
});
|
||||
});
|
||||
|
||||
dropTargets.forEach(target => {
|
||||
target.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
this.classList.add('border', 'border-primary', 'border-3', 'shadow');
|
||||
});
|
||||
|
||||
target.addEventListener('dragleave', function() {
|
||||
this.classList.remove('border', 'border-primary', 'border-3', 'shadow');
|
||||
});
|
||||
|
||||
target.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove('border', 'border-primary', 'border-3', 'shadow');
|
||||
const toDate = this.dataset.date;
|
||||
|
||||
if (!draggedItem) return;
|
||||
const studentId = draggedItem.dataset.studentId;
|
||||
const studentName = draggedItem.dataset.studentName;
|
||||
const fromDate = draggedItem.dataset.fromDate;
|
||||
|
||||
if (fromDate === toDate) return;
|
||||
|
||||
fetch('{% url "move_schedule_item" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
student_id: studentId,
|
||||
from_date: fromDate,
|
||||
to_date: toDate
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message);
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('❌ 移轉失敗: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert('❌ 發生錯誤: ' + err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,3 +1,555 @@
|
|||
from django.test import TestCase
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth import authenticate, get_user_model
|
||||
from django.urls import reverse
|
||||
from unittest.mock import patch, MagicMock
|
||||
from students.models import Student
|
||||
from students.ad_backend import ActiveDirectoryBackend, parse_group_dn
|
||||
|
||||
# Create your tests here.
|
||||
User = get_user_model()
|
||||
|
||||
class ActiveDirectoryBackendTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.backend = ActiveDirectoryBackend()
|
||||
self.client = Client()
|
||||
self.test_user = User.objects.create_user(
|
||||
username='aduser',
|
||||
password='TestPassword123!',
|
||||
email='aduser@samdom.mdi.bar'
|
||||
)
|
||||
|
||||
@patch('students.ad_backend.ldap3.Connection')
|
||||
@patch('students.ad_backend.ldap3.Server')
|
||||
def test_ad_authentication_success(self, mock_server, mock_connection):
|
||||
mock_conn_instance = MagicMock()
|
||||
mock_conn_instance.bind.return_value = True
|
||||
|
||||
mock_entry = MagicMock()
|
||||
mock_entry.mail.value = 'testuser@samdom.mdi.bar'
|
||||
mock_entry.givenName.value = 'Test'
|
||||
mock_entry.sn.value = 'User'
|
||||
mock_entry.memberOf.values = [
|
||||
'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar',
|
||||
'CN=IT Department,OU=Departments,DC=samdom,DC=mdi,DC=bar'
|
||||
]
|
||||
mock_conn_instance.entries = [mock_entry]
|
||||
mock_connection.return_value = mock_conn_instance
|
||||
|
||||
user = authenticate(username='testuser', password='Password123!')
|
||||
|
||||
self.assertIsNotNone(user)
|
||||
self.assertEqual(user.username, 'testuser')
|
||||
self.assertEqual(user.email, 'testuser@samdom.mdi.bar')
|
||||
self.assertTrue(hasattr(user, 'ad_groups'))
|
||||
self.assertEqual(len(user.ad_groups), 2)
|
||||
self.assertEqual(user.ad_groups[0]['cn'], 'Domain Users')
|
||||
self.assertEqual(user.ad_groups[1]['cn'], 'IT Department')
|
||||
|
||||
@patch('students.ad_backend.ldap3.Connection')
|
||||
@patch('students.ad_backend.ldap3.Server')
|
||||
def test_ad_authentication_failure(self, mock_server, mock_connection):
|
||||
mock_conn_instance = MagicMock()
|
||||
mock_conn_instance.bind.return_value = False
|
||||
mock_conn_instance.result = {'description': 'invalidCredentials'}
|
||||
mock_connection.return_value = mock_conn_instance
|
||||
|
||||
user = authenticate(username='invaliduser', password='WrongPassword')
|
||||
self.assertIsNone(user)
|
||||
|
||||
def test_login_view(self):
|
||||
response = self.client.get(reverse('login'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'dc1.samdom.mdi.bar')
|
||||
|
||||
def test_parse_group_dn(self):
|
||||
dn = 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'
|
||||
parsed = parse_group_dn(dn)
|
||||
self.assertEqual(parsed['cn'], 'Domain Admins')
|
||||
self.assertEqual(parsed['dn'], dn)
|
||||
|
||||
def test_user_groups_page_requires_login(self):
|
||||
response = self.client.get(reverse('user_groups'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_user_groups_page_authenticated(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [
|
||||
{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar', 'ou_path': 'Domain Root'},
|
||||
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar', 'ou_path': 'Domain Root'}
|
||||
]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('user_groups'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Domain Users')
|
||||
self.assertContains(response, 'Administrators')
|
||||
self.assertContains(response, 'dc1.samdom.mdi.bar')
|
||||
|
||||
def test_me_2_menuitem_group(self):
|
||||
self.client.force_login(self.test_user)
|
||||
response = self.client.get(reverse('index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, 'class="nav-item dropdown me-2"')
|
||||
self.assertContains(response, 'Group')
|
||||
self.assertContains(response, reverse('user_groups'))
|
||||
|
||||
def test_index_page_shows_schedule(self):
|
||||
response = self.client.get(reverse('index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '週會排程')
|
||||
|
||||
def test_record_attendance_access_unauthenticated(self):
|
||||
response = self.client.get(reverse('record_attendance'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_record_attendance_access_denied_missing_both_groups(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('record_attendance'))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertContains(response, "Permission Denied: Access to this page requires membership in both 'Administrators' and 'Domain Admins' groups.", status_code=403)
|
||||
self.assertContains(response, 'http-equiv="refresh" content="5;url=/"', status_code=403)
|
||||
|
||||
def test_record_attendance_access_denied_only_one_group(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('record_attendance'))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_record_attendance_access_granted_both_groups(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [
|
||||
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
||||
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
||||
]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('record_attendance'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_recent_attendance_access_control(self):
|
||||
response = self.client.get(reverse('recent_attendance'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('recent_attendance'))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
session['ad_groups'] = [
|
||||
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
||||
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
||||
]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('recent_attendance'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_attendance_report_access_control(self):
|
||||
response = self.client.get(reverse('attendance_report'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('attendance_report'))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
session['ad_groups'] = [
|
||||
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
||||
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
||||
]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('attendance_report'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_show_attendance_page(self):
|
||||
from students.models import PointRecord
|
||||
s = Student.objects.create(student_id='D12458003', name='測試生', degree_type='博士生', is_active=True)
|
||||
PointRecord.objects.create(student=s)
|
||||
|
||||
# Unauthenticated / non-admin user -> 簽到歷史紀錄 hidden with permission notice
|
||||
response = self.client.get(reverse('show_attendance', kwargs={'student_id': 'D12458003'}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'D12458003')
|
||||
self.assertContains(response, '簽到歷史紀錄僅供同時屬於 Active Directory')
|
||||
|
||||
# Admin user with both groups -> 簽到歷史紀錄 visible
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [
|
||||
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
||||
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
||||
]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('show_attendance', kwargs={'student_id': 'D12458003'}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '簽到歷史紀錄')
|
||||
|
||||
def test_show_attendance_page_folding(self):
|
||||
from students.models import PointRecord
|
||||
s = Student.objects.create(student_id='D12458004', name='測試生多紀錄', degree_type='博士生', is_active=True)
|
||||
for _ in range(15):
|
||||
PointRecord.objects.create(student=s)
|
||||
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [
|
||||
{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'},
|
||||
{'cn': 'Domain Admins', 'dn': 'CN=Domain Admins,CN=Users,DC=samdom,DC=mdi,DC=bar'}
|
||||
]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('show_attendance', kwargs={'student_id': 'D12458004'}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '展開其餘 5 筆紀錄')
|
||||
|
||||
def test_student_degree_type(self):
|
||||
master_student = Student.objects.create(student_id='S001', name='張三', degree_type='碩士生')
|
||||
doctoral_student = Student.objects.create(student_id='S002', name='李四', degree_type='博士生')
|
||||
|
||||
self.assertEqual(master_student.degree_type, '碩士生')
|
||||
self.assertEqual(doctoral_student.degree_type, '博士生')
|
||||
|
||||
def test_student_category(self):
|
||||
regular_student = Student.objects.create(student_id='S003', name='王五', student_category='一般生')
|
||||
inservice_student = Student.objects.create(student_id='S004', name='趙六', student_category='在職生')
|
||||
exchange_student = Student.objects.create(student_id='S005', name='錢七', student_category='交換中')
|
||||
|
||||
self.assertEqual(regular_student.student_category, '一般生')
|
||||
self.assertEqual(inservice_student.student_category, '在職生')
|
||||
self.assertEqual(exchange_student.student_category, '交換中')
|
||||
self.assertEqual(str(regular_student), '王五 (碩士生 / 一般生)')
|
||||
self.assertEqual(str(inservice_student), '趙六 (碩士生 / 在職生)')
|
||||
self.assertEqual(str(exchange_student), '錢七 (碩士生 / 交換中)')
|
||||
|
||||
def test_student_list_active_only_filter(self):
|
||||
active_student = Student.objects.create(student_id='S010', name='在學學生A', is_active=True)
|
||||
inactive_student = Student.objects.create(student_id='S011', name='停學生B', is_active=False)
|
||||
|
||||
response_default = self.client.get(reverse('student_list'))
|
||||
self.assertEqual(response_default.status_code, 200)
|
||||
self.assertContains(response_default, '在學學生A')
|
||||
self.assertNotContains(response_default, '停學生B')
|
||||
|
||||
response_all = self.client.get(reverse('student_list') + '?active_only=false')
|
||||
self.assertEqual(response_all.status_code, 200)
|
||||
self.assertContains(response_all, '在學學生A')
|
||||
self.assertContains(response_all, '停學生B')
|
||||
|
||||
def test_update_from_excel_button_visible_for_administrators(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('student_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '上傳並匯入 students.xlsx')
|
||||
|
||||
def test_update_from_excel_button_hidden_for_non_administrators(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('student_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, '上傳並匯入 students.xlsx')
|
||||
|
||||
def test_update_from_excel_action_denied_for_non_administrators(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Domain Users', 'dn': 'CN=Domain Users,CN=Users,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.post(reverse('update_students_from_excel'))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_update_from_excel_action_success_for_administrators(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.post(reverse('update_students_from_excel'), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '成功從 students.xlsx 更新學生資料')
|
||||
|
||||
def test_inactive_student_activation_rules(self):
|
||||
inactive_not_in_excel = Student.objects.create(
|
||||
student_id='NONEXISTENT999',
|
||||
name='非Excel學生',
|
||||
is_active=False
|
||||
)
|
||||
|
||||
inactive_in_excel = Student.objects.create(
|
||||
student_id='D13K48008',
|
||||
name='林冠儒',
|
||||
is_active=False
|
||||
)
|
||||
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.post(reverse('update_students_from_excel'), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
inactive_not_in_excel.refresh_from_db()
|
||||
inactive_in_excel.refresh_from_db()
|
||||
|
||||
self.assertFalse(inactive_not_in_excel.is_active)
|
||||
self.assertTrue(inactive_in_excel.is_active)
|
||||
|
||||
def test_preserve_student_category_during_import(self):
|
||||
existing_student = Student.objects.create(
|
||||
student_id='D13K48007',
|
||||
name='李艾臻',
|
||||
student_category='在職生'
|
||||
)
|
||||
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.post(reverse('update_students_from_excel'), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
existing_student.refresh_from_db()
|
||||
self.assertEqual(existing_student.student_category, '在職生')
|
||||
|
||||
def test_import_second_email_from_excel(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.post(reverse('update_students_from_excel'), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
student = Student.objects.get(student_id='D13K48007')
|
||||
self.assertEqual(student.email, 'D13K48007@ntu.edu.tw')
|
||||
self.assertEqual(student.email2, 'zaq4747@yahoo.com.tw')
|
||||
|
||||
def test_upload_students_excel_for_administrators(self):
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('student_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '上傳並匯入 students.xlsx')
|
||||
|
||||
with open('students.xlsx', 'rb') as f:
|
||||
post_response = self.client.post(
|
||||
reverse('upload_students_excel'),
|
||||
{'excel_file': f},
|
||||
follow=True
|
||||
)
|
||||
self.assertEqual(post_response.status_code, 200)
|
||||
self.assertContains(post_response, '成功上傳並匯入檔案')
|
||||
|
||||
def test_upload_students_csv_file(self):
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
csv_content = (
|
||||
"學號,姓名,年級,Email\n"
|
||||
"C001,CSV生,1,c001@ntu.edu.tw;c002@yahoo.com.tw\n"
|
||||
).encode('utf-8-sig')
|
||||
|
||||
uploaded_csv = SimpleUploadedFile("students.csv", csv_content, content_type="text/csv")
|
||||
|
||||
self.client.force_login(self.test_user)
|
||||
session = self.client.session
|
||||
session['ad_groups'] = [{'cn': 'Administrators', 'dn': 'CN=Administrators,CN=Builtin,DC=samdom,DC=mdi,DC=bar'}]
|
||||
session.save()
|
||||
|
||||
response = self.client.post(
|
||||
reverse('upload_students_excel'),
|
||||
{'excel_file': uploaded_csv},
|
||||
follow=True
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '成功上傳並匯入檔案 『students.csv』')
|
||||
|
||||
imported_student = Student.objects.get(student_id='C001')
|
||||
self.assertEqual(imported_student.name, 'CSV生')
|
||||
self.assertEqual(imported_student.email, 'c001@ntu.edu.tw')
|
||||
self.assertEqual(imported_student.email2, 'c002@yahoo.com.tw')
|
||||
|
||||
def test_weekly_schedule_rules(self):
|
||||
master_student = Student.objects.create(student_id='M001', name='碩士生A', degree_type='碩士生', student_category='一般生', is_active=True)
|
||||
doc_reg_student = Student.objects.create(student_id='D001', name='博一般B', degree_type='博士生', student_category='一般生', is_active=True)
|
||||
doc_ins_student = Student.objects.create(student_id='D002', name='博在職C', degree_type='博士生', student_category='在職生', is_active=True)
|
||||
exchange_student = Student.objects.create(student_id='EX01', name='交換生D', degree_type='碩士生', student_category='交換中', is_active=True)
|
||||
|
||||
response = self.client.get(reverse('weekly_schedule'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '週會排程')
|
||||
self.assertContains(response, '碩士生A')
|
||||
self.assertContains(response, '博一般B')
|
||||
self.assertContains(response, '博在職C')
|
||||
self.assertContains(response, '交換生D')
|
||||
self.assertContains(response, '免報告')
|
||||
|
||||
summary_data = response.context['summary_data']
|
||||
master_summary = next(s for s in summary_data if s['student'].student_id == 'M001')
|
||||
doc_reg_summary = next(s for s in summary_data if s['student'].student_id == 'D001')
|
||||
doc_ins_summary = next(s for s in summary_data if s['student'].student_id == 'D002')
|
||||
exchange_summary = next(s for s in summary_data if s['student'].student_id == 'EX01')
|
||||
|
||||
self.assertEqual(master_summary['total_count'], 2) # 1 per month x 2
|
||||
self.assertEqual(doc_reg_summary['total_count'], 2) # 1 per month x 2
|
||||
self.assertEqual(doc_ins_summary['total_count'], 1) # 1 per 2 months x 1
|
||||
self.assertEqual(exchange_summary['total_count'], 0) # Exempt
|
||||
|
||||
# Verify database persistence in WeeklySchedule model
|
||||
from students.models import WeeklySchedule
|
||||
self.assertTrue(WeeklySchedule.objects.filter(student=master_student).exists())
|
||||
self.assertEqual(WeeklySchedule.objects.filter(student=master_student).count(), 2)
|
||||
self.assertEqual(WeeklySchedule.objects.filter(student=doc_reg_student).count(), 2)
|
||||
self.assertEqual(WeeklySchedule.objects.filter(student=doc_ins_student).count(), 1)
|
||||
self.assertEqual(WeeklySchedule.objects.filter(student=exchange_student).count(), 0)
|
||||
|
||||
def test_weekly_schedule_students_sorted_by_category_and_student_id(self):
|
||||
from datetime import date
|
||||
from students.models import WeeklySchedule
|
||||
test_date = date(2026, 8, 3)
|
||||
|
||||
s_inservice = Student.objects.create(student_id='A001', name='在職生李', degree_type='碩士生', student_category='在職生', is_active=True)
|
||||
s_regular = Student.objects.create(student_id='Z999', name='一般生張', degree_type='碩士生', student_category='一般生', is_active=True)
|
||||
|
||||
WeeklySchedule.objects.create(student=s_inservice, date=test_date)
|
||||
WeeklySchedule.objects.create(student=s_regular, date=test_date)
|
||||
|
||||
response = self.client.get(reverse('weekly_schedule'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
schedule_m1 = response.context['schedule_m1']
|
||||
day_schedule = next(m for m in schedule_m1 if m['date'] == test_date)
|
||||
student_ids = [student.student_id for student in day_schedule['students']]
|
||||
# '一般生' Z999 comes before '在職生' A001
|
||||
self.assertEqual(student_ids, ['Z999', 'A001'])
|
||||
|
||||
def test_move_schedule_item_rules_and_logging(self):
|
||||
import json
|
||||
from datetime import date
|
||||
from students.models import WeeklySchedule
|
||||
|
||||
master_student = Student.objects.create(student_id='M002', name='碩士生Move', degree_type='碩士生', student_category='一般生', is_active=True)
|
||||
schedule_aug3 = WeeklySchedule.objects.create(student=master_student, date=date(2026, 8, 3))
|
||||
|
||||
# Unauthenticated attempt -> redirect to login
|
||||
unauth_resp = self.client.post(
|
||||
reverse('move_schedule_item'),
|
||||
data=json.dumps({'student_id': 'M002', 'from_date': '2026-08-03', 'to_date': '2026-08-10'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(unauth_resp.status_code, 302)
|
||||
|
||||
# Authenticated user
|
||||
self.client.force_login(self.test_user)
|
||||
|
||||
# Invalid move: Master student cross-month move (Aug 3 -> Sep 7)
|
||||
invalid_resp = self.client.post(
|
||||
reverse('move_schedule_item'),
|
||||
data=json.dumps({'student_id': 'M002', 'from_date': '2026-08-03', 'to_date': '2026-09-07'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(invalid_resp.status_code, 400)
|
||||
invalid_json = invalid_resp.json()
|
||||
self.assertFalse(invalid_json['success'])
|
||||
self.assertIn('頻率規則驗證失敗', invalid_json['error'])
|
||||
|
||||
# Invalid move: Move to past date earlier than today (e.g. 2000-01-01)
|
||||
past_resp = self.client.post(
|
||||
reverse('move_schedule_item'),
|
||||
data=json.dumps({'student_id': 'M002', 'from_date': '2026-08-03', 'to_date': '2000-01-01'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(past_resp.status_code, 400)
|
||||
past_json = past_resp.json()
|
||||
self.assertFalse(past_json['success'])
|
||||
self.assertIn('不可將報告日期移轉至早於今天', past_json['error'])
|
||||
|
||||
# Valid move: Master student same month move (Aug 3 -> Aug 17)
|
||||
valid_resp = self.client.post(
|
||||
reverse('move_schedule_item'),
|
||||
data=json.dumps({'student_id': 'M002', 'from_date': '2026-08-03', 'to_date': '2026-08-17'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(valid_resp.status_code, 200)
|
||||
valid_json = valid_resp.json()
|
||||
self.assertTrue(valid_json['success'])
|
||||
|
||||
schedule_aug3.refresh_from_db()
|
||||
self.assertEqual(schedule_aug3.date, date(2026, 8, 17))
|
||||
|
||||
def test_verify_schedule_rules_cleans_exchange_cards(self):
|
||||
from datetime import date
|
||||
from students.models import WeeklySchedule
|
||||
|
||||
exchange_student = Student.objects.create(
|
||||
student_id='EX999',
|
||||
name='交換生Ex',
|
||||
degree_type='碩士生',
|
||||
student_category='交換中',
|
||||
is_active=True
|
||||
)
|
||||
|
||||
invalid_card = WeeklySchedule.objects.create(student=exchange_student, date=date(2026, 8, 3))
|
||||
self.assertTrue(WeeklySchedule.objects.filter(id=invalid_card.id).exists())
|
||||
|
||||
self.client.force_login(self.test_user)
|
||||
response = self.client.post(reverse('verify_schedule_rules'), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '校正與驗證排程規則完成')
|
||||
|
||||
self.assertFalse(WeeklySchedule.objects.filter(id=invalid_card.id).exists())
|
||||
|
||||
def test_set_language_toggle(self):
|
||||
response = self.client.get(reverse('set_language', kwargs={'lang_code': 'en'}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.client.session.get('lang'), 'en')
|
||||
self.assertContains(response, "Master Student (Regular & In-Service)")
|
||||
self.assertContains(response, "Ph.D. Regular Student")
|
||||
self.assertContains(response, "Ph.D. In-Service Student")
|
||||
self.assertContains(response, "Exchange Student (On Exchange)")
|
||||
self.assertContains(response, "Schedule Frequency: 1 time / month")
|
||||
self.assertContains(response, "Schedule Frequency: 1 time / 2 months")
|
||||
|
||||
response = self.client.get(reverse('set_language', kwargs={'lang_code': 'zh'}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.client.session.get('lang'), 'zh')
|
||||
|
||||
def test_schedule_action_logging_to_file(self):
|
||||
from django.conf import settings
|
||||
log_file = settings.BASE_DIR / 'schedule_actions.log'
|
||||
|
||||
self.client.force_login(self.test_user)
|
||||
response = self.client.post(reverse('verify_schedule_rules'), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertTrue(log_file.exists())
|
||||
with open(log_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
self.assertIn("AUDIT LOG: User 'aduser' executed Verify & Clean Schedule Rules", content)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,18 @@ from . import views
|
|||
|
||||
urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('login/', views.ad_login, name='login'),
|
||||
path('logout/', views.ad_logout, name='logout'),
|
||||
path('groups/', views.user_groups, name='user_groups'),
|
||||
path('record/', views.record_attendance, name='record_attendance'),
|
||||
path('attendance/<str:student_id>/', views.show_attendance, name='show_attendance'),
|
||||
path('report/', views.attendance_report, name='attendance_report'), # 新增路由
|
||||
path('recent/', views.recent_attendance, name='recent_attendance'), # 新增最近考勤紀錄路由
|
||||
path('report/', views.attendance_report, name='attendance_report'),
|
||||
path('recent/', views.recent_attendance, name='recent_attendance'),
|
||||
path('student_list/', views.student_list, name='student_list'),
|
||||
path('schedule/', views.weekly_schedule, name='weekly_schedule'),
|
||||
path('schedule/move/', views.move_schedule_item, name='move_schedule_item'),
|
||||
path('schedule/verify-rules/', views.verify_schedule_rules, name='verify_schedule_rules'),
|
||||
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('set-language/<str:lang_code>/', views.set_language, name='set_language'),
|
||||
]
|
||||
|
|
@ -1,45 +1,433 @@
|
|||
import calendar
|
||||
import json
|
||||
import logging
|
||||
from functools import wraps
|
||||
from datetime import datetime, date, timedelta
|
||||
from django.shortcuts import render, redirect
|
||||
from .models import Student, PointRecord # 從 students.models 引入 PointRecord
|
||||
from .forms import StudentLoginForm
|
||||
from datetime import datetime, timedelta
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseForbidden, JsonResponse
|
||||
from django.db.models import Q, Case, When, Value, IntegerField
|
||||
from .models import Student, PointRecord, WeeklySchedule
|
||||
from .forms import StudentLoginForm, ADLoginForm
|
||||
from .ad_backend import fetch_ad_groups
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
schedule_logger = logging.getLogger('schedule_logger')
|
||||
|
||||
def get_month_mondays(year, month):
|
||||
c = calendar.Calendar(firstweekday=calendar.MONDAY)
|
||||
return [d for d in c.itermonthdates(year, month) if d.month == month and d.weekday() == 0]
|
||||
|
||||
from django.utils import translation
|
||||
|
||||
def set_language(request, lang_code):
|
||||
"""
|
||||
Switch session and Django translation language between 'zh-hant' and 'en'.
|
||||
"""
|
||||
target_lang = 'en' if lang_code == 'en' else 'zh-hant'
|
||||
|
||||
translation.activate(target_lang)
|
||||
request.session['_language'] = target_lang
|
||||
request.session['lang'] = 'en' if target_lang == 'en' else 'zh'
|
||||
|
||||
referer = request.META.get('HTTP_REFERER')
|
||||
response = redirect(referer) if referer else redirect('index')
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, target_lang)
|
||||
return response
|
||||
|
||||
def check_user_has_all_ad_groups(user, request, required_groups):
|
||||
"""
|
||||
Check if the user is in ALL of the required Active Directory groups.
|
||||
required_groups: iterable of group CNs, e.g. ['Administrators', 'Domain Admins']
|
||||
"""
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_groups = request.session.get('ad_groups')
|
||||
if user_groups is None:
|
||||
user_groups = fetch_ad_groups(user.username)
|
||||
request.session['ad_groups'] = user_groups
|
||||
|
||||
user_group_cns = {g.get('cn') for g in user_groups if isinstance(g, dict) and 'cn' in g}
|
||||
return all(req in user_group_cns for req in required_groups)
|
||||
|
||||
def render_permission_denied_response(group_list_str):
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="5;url=/">
|
||||
<title>Permission Denied - 1218 Lab</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="card shadow border-0 rounded-3 text-center p-4">
|
||||
<div class="card-body">
|
||||
<div class="text-danger mb-3">
|
||||
<i class="fas fa-exclamation-triangle fa-4x"></i>
|
||||
</div>
|
||||
<h3 class="fw-bold text-dark mb-3">Permission Denied</h3>
|
||||
<p class="text-secondary fs-5 mb-3 fw-semibold">
|
||||
Permission Denied: Access to this page requires membership in both {group_list_str} groups.
|
||||
</p>
|
||||
<p class="text-muted mb-4 small">
|
||||
權限不足:存取本頁面需要同時屬於 Active Directory 的 {group_list_str} 群組。
|
||||
</p>
|
||||
<div class="alert alert-warning py-2 mb-4 border-start border-4 border-warning">
|
||||
<i class="fas fa-clock me-1 text-warning"></i>將於 <strong id="countdown-sec" class="text-danger fs-5">5</strong> 秒後自動返回首頁 (Returning to Home)...
|
||||
</div>
|
||||
<div>
|
||||
<a href="/" class="btn btn-primary px-4 fw-bold shadow-sm">
|
||||
<i class="fas fa-home me-1"></i>立即返回首頁 (Return to Home Now)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let seconds = 5;
|
||||
const countdownEl = document.getElementById('countdown-sec');
|
||||
const timer = setInterval(function() {{
|
||||
seconds--;
|
||||
if (countdownEl) {{
|
||||
countdownEl.textContent = seconds;
|
||||
}}
|
||||
if (seconds <= 0) {{
|
||||
clearInterval(timer);
|
||||
window.location.href = '/';
|
||||
}}
|
||||
}}, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return HttpResponseForbidden(html_content)
|
||||
|
||||
def ad_groups_required(*required_groups):
|
||||
"""
|
||||
Decorator for views that checks if the logged-in user belongs to ALL required AD groups.
|
||||
"""
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
if not check_user_has_all_ad_groups(request.user, request, required_groups):
|
||||
group_list_str = " and ".join([f"'{g}'" for g in required_groups])
|
||||
messages.error(
|
||||
request,
|
||||
f"權限不足:存取本頁面需要同時屬於 Active Directory 的 {group_list_str} 群組。"
|
||||
)
|
||||
return render_permission_denied_response(group_list_str)
|
||||
return view_func(request, *args, **kwargs)
|
||||
return _wrapped_view
|
||||
return decorator
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
def process_excel_rows(rows):
|
||||
"""
|
||||
Parse a list of row values (list of tuples/lists) and update Student models according to rules:
|
||||
- Sets is_active = True for imported students
|
||||
- Preserves student_category for existing students
|
||||
- Infers degree_type & student_category if not present
|
||||
- Parses primary email & email2 (including semicolon separated strings or dedicated email2 column)
|
||||
Returns (created_count, updated_count)
|
||||
"""
|
||||
if not rows:
|
||||
return 0, 0
|
||||
|
||||
headers = [str(cell).strip() if cell is not None else '' for cell in rows[0]]
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for row_val in rows[1:]:
|
||||
if not row_val or not row_val[0]:
|
||||
continue
|
||||
|
||||
row_dict = dict(zip(headers, row_val))
|
||||
student_id = str(row_val[0]).strip()
|
||||
name = str(row_val[1]).strip() if len(row_val) > 1 and row_val[1] else ''
|
||||
|
||||
if not student_id or not name:
|
||||
continue
|
||||
|
||||
degree_type = '碩士生'
|
||||
for k, v in row_dict.items():
|
||||
if k and '學位' in str(k) and v:
|
||||
degree_type = str(v).strip()
|
||||
break
|
||||
else:
|
||||
if student_id.upper().startswith('D'):
|
||||
degree_type = '博士生'
|
||||
|
||||
student_category = '一般生'
|
||||
for k, v in row_dict.items():
|
||||
if k and '身分' in str(k) and v:
|
||||
student_category = str(v).strip()
|
||||
break
|
||||
else:
|
||||
if 'K' in student_id.upper():
|
||||
student_category = '在職生'
|
||||
|
||||
grade = None
|
||||
for k, v in row_dict.items():
|
||||
if k and '年級' in str(k) and v is not None:
|
||||
try:
|
||||
grade = int(v)
|
||||
except ValueError:
|
||||
pass
|
||||
break
|
||||
|
||||
hope_admission = False
|
||||
for k, v in row_dict.items():
|
||||
if k and '希望' in str(k) and v:
|
||||
hope_admission = str(v).strip() in ['通過', 'True', '1', 'yes']
|
||||
break
|
||||
|
||||
tuition_waiver = False
|
||||
for k, v in row_dict.items():
|
||||
if k and '減免' in str(k) and v:
|
||||
tuition_waiver = str(v).strip() in ['通過', 'True', '1', 'yes']
|
||||
break
|
||||
|
||||
disadvantaged = False
|
||||
for k, v in row_dict.items():
|
||||
if k and '弱勢' in str(k) and v:
|
||||
disadvantaged = str(v).strip() in ['通過', 'True', '1', 'yes']
|
||||
break
|
||||
|
||||
loan = 0
|
||||
for k, v in row_dict.items():
|
||||
if k and '貸款' in str(k) and v is not None:
|
||||
try:
|
||||
loan = int(v)
|
||||
except ValueError:
|
||||
pass
|
||||
break
|
||||
|
||||
email = None
|
||||
email2 = None
|
||||
|
||||
for k, v in row_dict.items():
|
||||
if k and ('email2' in str(k).lower() or '第二' in str(k) or '2nd' in str(k).lower()) and v:
|
||||
email2 = str(v).strip()
|
||||
break
|
||||
|
||||
for k, v in row_dict.items():
|
||||
if k and ('email' in str(k).lower()) and not ('email2' in str(k).lower() or '第二' in str(k) or '2nd' in str(k).lower()) and v:
|
||||
email_str = str(v).strip()
|
||||
if ';' in email_str:
|
||||
parts = [p.strip() for p in email_str.split(';') if p.strip()]
|
||||
if parts:
|
||||
email = parts[0]
|
||||
if len(parts) > 1 and not email2:
|
||||
email2 = parts[1]
|
||||
else:
|
||||
email = email_str
|
||||
break
|
||||
|
||||
existing_student = Student.objects.filter(student_id=student_id).first()
|
||||
|
||||
update_defaults = {
|
||||
'name': name,
|
||||
'degree_type': degree_type,
|
||||
'grade': grade,
|
||||
'hope_admission_scheme': hope_admission,
|
||||
'tuition_waiver': tuition_waiver,
|
||||
'financial_assistance_disadvantaged_students': disadvantaged,
|
||||
'loan': loan,
|
||||
'email': email,
|
||||
'email2': email2,
|
||||
'is_active': True,
|
||||
}
|
||||
|
||||
if not existing_student:
|
||||
update_defaults['student_category'] = student_category
|
||||
|
||||
student, created = Student.objects.update_or_create(
|
||||
student_id=student_id,
|
||||
defaults=update_defaults
|
||||
)
|
||||
|
||||
if created:
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
return created_count, updated_count
|
||||
|
||||
def process_excel_workbook(wb):
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
return process_excel_rows(rows)
|
||||
|
||||
def sync_weekly_schedule_to_db(m1_year, m1_month, m2_year, m2_month, force=False):
|
||||
"""
|
||||
Ensure WeeklySchedule records exist in database for the given 2 months.
|
||||
If force is True, clear existing records and recalculate.
|
||||
"""
|
||||
m1_exists = WeeklySchedule.objects.filter(year=m1_year, month=m1_month).exists()
|
||||
m2_exists = WeeklySchedule.objects.filter(year=m2_year, month=m2_month).exists()
|
||||
|
||||
if force or not (m1_exists and m2_exists):
|
||||
if force:
|
||||
WeeklySchedule.objects.filter(year__in=[m1_year, m2_year], month__in=[m1_month, m2_month]).delete()
|
||||
else:
|
||||
if not m1_exists:
|
||||
WeeklySchedule.objects.filter(year=m1_year, month=m1_month).delete()
|
||||
if not m2_exists:
|
||||
WeeklySchedule.objects.filter(year=m2_year, month=m2_month).delete()
|
||||
|
||||
m1_mondays = get_month_mondays(m1_year, m1_month)
|
||||
m2_mondays = get_month_mondays(m2_year, m2_month)
|
||||
|
||||
active_students = Student.objects.filter(is_active=True).order_by('student_id')
|
||||
|
||||
monthly_students = []
|
||||
bimonthly_students = []
|
||||
|
||||
for s in active_students:
|
||||
if s.student_category == '交換中':
|
||||
continue
|
||||
elif s.degree_type == '碩士生':
|
||||
monthly_students.append(s)
|
||||
elif s.degree_type == '博士生' and s.student_category == '一般生':
|
||||
monthly_students.append(s)
|
||||
elif s.degree_type == '博士生' and s.student_category == '在職生':
|
||||
bimonthly_students.append(s)
|
||||
else:
|
||||
monthly_students.append(s)
|
||||
|
||||
bimonthly_m1 = bimonthly_students[:(len(bimonthly_students) + 1) // 2]
|
||||
bimonthly_m2 = bimonthly_students[(len(bimonthly_students) + 1) // 2:]
|
||||
|
||||
m1_pool = monthly_students + bimonthly_m1
|
||||
m2_pool = monthly_students + bimonthly_m2
|
||||
|
||||
if not m1_exists or force:
|
||||
for idx, student in enumerate(m1_pool):
|
||||
monday_date = m1_mondays[idx % len(m1_mondays)]
|
||||
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 = m2_mondays[idx % len(m2_mondays)]
|
||||
WeeklySchedule.objects.get_or_create(student=student, date=monday_date)
|
||||
|
||||
def index(request):
|
||||
return render(request, 'base.html')
|
||||
"""Home page view showing the Weekly Seminar Schedule."""
|
||||
return weekly_schedule(request)
|
||||
|
||||
def ad_login(request):
|
||||
"""Active Directory Login View"""
|
||||
if request.method == 'POST':
|
||||
form = ADLoginForm(request.POST)
|
||||
if form.is_valid():
|
||||
username = form.cleaned_data['username']
|
||||
password = form.cleaned_data['password']
|
||||
user = authenticate(request, username=username, password=password)
|
||||
if user is not None:
|
||||
login(request, user)
|
||||
if hasattr(user, 'ad_groups'):
|
||||
request.session['ad_groups'] = user.ad_groups
|
||||
else:
|
||||
request.session['ad_groups'] = fetch_ad_groups(username, password)
|
||||
messages.success(request, f"登入成功!歡迎, {user.get_full_name() or user.username}。")
|
||||
return redirect('index')
|
||||
else:
|
||||
messages.error(request, "Active Directory 驗證失敗,請檢查帳號或密碼。")
|
||||
else:
|
||||
form = ADLoginForm()
|
||||
return render(request, 'students/login.html', {'form': form})
|
||||
|
||||
def ad_logout(request):
|
||||
"""Active Directory Logout View"""
|
||||
logout(request)
|
||||
messages.info(request, "您已成功登出。")
|
||||
return redirect('index')
|
||||
|
||||
@login_required(login_url='login')
|
||||
def user_groups(request):
|
||||
"""List Active Directory Groups the logged-in user is a Member Of."""
|
||||
groups = request.session.get('ad_groups')
|
||||
if groups is None:
|
||||
groups = fetch_ad_groups(request.user.username)
|
||||
request.session['ad_groups'] = groups
|
||||
|
||||
ad_server = getattr(settings, 'AD_SERVER', 'dc1.samdom.mdi.bar')
|
||||
ad_domain = getattr(settings, 'AD_DOMAIN', 'samdom.mdi.bar')
|
||||
|
||||
return render(request, 'students/user_groups.html', {
|
||||
'groups': groups,
|
||||
'ad_server': ad_server,
|
||||
'ad_domain': ad_domain,
|
||||
})
|
||||
|
||||
@ad_groups_required('Administrators', 'Domain Admins')
|
||||
def record_attendance(request):
|
||||
"""
|
||||
Record attendance. Only accessible to users who are members of
|
||||
BOTH 'Administrators' and 'Domain Admins' Active Directory groups.
|
||||
"""
|
||||
point_records = []
|
||||
if request.method == 'POST':
|
||||
student_ids = request.POST.getlist('student_id')
|
||||
current_datetime = datetime.now()
|
||||
# one_hour_ago = current_datetime - timedelta(hours=1)
|
||||
four_hours_ago = current_datetime - timedelta(hours=4)
|
||||
|
||||
for student_id in student_ids:
|
||||
try:
|
||||
student = Student.objects.get(student_id=student_id)
|
||||
|
||||
# 檢查該學生在4小時內是否有點名紀錄
|
||||
if not PointRecord.objects.filter(student=student, datetime__gte=four_hours_ago).exists():
|
||||
point_record = PointRecord.objects.create(student=student, datetime=current_datetime)
|
||||
point_records.append(point_record)
|
||||
except Student.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
students = Student.objects.filter(is_active=True).order_by('name') # Filter active students
|
||||
students = Student.objects.filter(is_active=True).order_by('name')
|
||||
return render(request, 'students/record_attendance.html', {'students': students, 'point_records': point_records})
|
||||
|
||||
def show_attendance(request, student_id):
|
||||
student = Student.objects.get(student_id=student_id)
|
||||
point_records = PointRecord.objects.filter(student=student).order_by('-datetime')
|
||||
return render(request, 'students/show_attendance.html', {'point_records': point_records, 'student': student})
|
||||
schedules = WeeklySchedule.objects.filter(student=student).order_by('date')
|
||||
|
||||
if student.student_category == '交換中':
|
||||
rule_desc = '交換生 (免報告)'
|
||||
elif student.degree_type == '碩士生':
|
||||
rule_desc = '碩士生 (1次/月)'
|
||||
elif student.degree_type == '博士生' and student.student_category == '一般生':
|
||||
rule_desc = '博士一般生 (1次/月)'
|
||||
else:
|
||||
rule_desc = '博士在職生 (1次/2個月)'
|
||||
|
||||
can_view_records = check_user_has_all_ad_groups(request.user, request, ['Administrators', 'Domain Admins'])
|
||||
|
||||
return render(request, 'students/show_attendance.html', {
|
||||
'point_records': point_records,
|
||||
'student': student,
|
||||
'schedules': schedules,
|
||||
'rule_desc': rule_desc,
|
||||
'can_view_records': can_view_records,
|
||||
})
|
||||
|
||||
@ad_groups_required('Administrators', 'Domain Admins')
|
||||
def attendance_report(request):
|
||||
current_year = datetime.now().year
|
||||
last_year = current_year - 1
|
||||
|
||||
# 計算每個學生在去年和今年的出席次數
|
||||
students = Student.objects.all()
|
||||
attendance_data = []
|
||||
|
||||
|
|
@ -47,7 +435,6 @@ def attendance_report(request):
|
|||
last_year_records = PointRecord.objects.filter(student=student, datetime__year=last_year).count()
|
||||
current_year_records = PointRecord.objects.filter(student=student, datetime__year=current_year).count()
|
||||
|
||||
# 只添加那些至少有一年的出席記錄的學生
|
||||
if last_year_records > 0 or current_year_records > 0:
|
||||
attendance_data.append({
|
||||
'student': student,
|
||||
|
|
@ -61,25 +448,376 @@ def attendance_report(request):
|
|||
'current_year': current_year
|
||||
})
|
||||
|
||||
@ad_groups_required('Administrators', 'Domain Admins')
|
||||
def recent_attendance(request):
|
||||
"""顯示最後20筆考勤紀錄"""
|
||||
recent_records = PointRecord.objects.select_related('student').order_by('-datetime')[:20]
|
||||
return render(request, 'students/recent_attendance.html', {'recent_records': recent_records})
|
||||
|
||||
def student_list(request):
|
||||
"""顯示所有學生資訊及考勤統計"""
|
||||
students = Student.objects.all().order_by('student_id') # 按學號排序
|
||||
|
||||
# 為每個學生計算考勤統計
|
||||
active_only_param = request.GET.get('active_only')
|
||||
if active_only_param is None:
|
||||
active_only = True
|
||||
else:
|
||||
active_only = active_only_param.lower() in ['true', '1']
|
||||
|
||||
if active_only:
|
||||
students = Student.objects.filter(is_active=True).order_by('student_id')
|
||||
else:
|
||||
students = Student.objects.all().order_by('student_id')
|
||||
|
||||
is_admin_user = request.user.is_authenticated and (
|
||||
request.user.is_superuser or check_user_has_all_ad_groups(request.user, request, ['Administrators'])
|
||||
)
|
||||
|
||||
student_data = []
|
||||
for student in students:
|
||||
total_attendance = PointRecord.objects.filter(student=student).count()
|
||||
# 可以添加更多統計資訊
|
||||
student_data.append({
|
||||
'student': student,
|
||||
'total_attendance': total_attendance
|
||||
})
|
||||
|
||||
|
||||
return render(request, 'students/student_list.html', {
|
||||
'student_data': student_data
|
||||
})
|
||||
'student_data': student_data,
|
||||
'active_only': active_only,
|
||||
'is_admin_user': is_admin_user,
|
||||
})
|
||||
|
||||
def weekly_schedule(request):
|
||||
"""
|
||||
Weekly seminar schedule view listing Mondays of current and next months.
|
||||
Persisted in the WeeklySchedule database model.
|
||||
"""
|
||||
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
|
||||
|
||||
force_regenerate = request.GET.get('regenerate') == 'true'
|
||||
sync_weekly_schedule_to_db(m1_year, m1_month, m2_year, m2_month, force=force_regenerate)
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
schedule_m1 = []
|
||||
for m in m1_mondays:
|
||||
records = WeeklySchedule.objects.filter(date=m).select_related('student').annotate(
|
||||
cat_order=category_order
|
||||
).order_by('cat_order', 'student__student_id')
|
||||
schedule_m1.append({
|
||||
'date': m,
|
||||
'formatted': m.strftime('%Y-%m-%d'),
|
||||
'students': [r.student for r in records]
|
||||
})
|
||||
|
||||
schedule_m2 = []
|
||||
for m in m2_mondays:
|
||||
records = WeeklySchedule.objects.filter(date=m).select_related('student').annotate(
|
||||
cat_order=category_order
|
||||
).order_by('cat_order', 'student__student_id')
|
||||
schedule_m2.append({
|
||||
'date': m,
|
||||
'formatted': m.strftime('%Y-%m-%d'),
|
||||
'students': [r.student for r in records]
|
||||
})
|
||||
|
||||
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()
|
||||
)
|
||||
active_students = Student.objects.filter(is_active=True).annotate(
|
||||
cat_order=student_cat_order
|
||||
).order_by('cat_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 = '交換生 (免報告)'
|
||||
elif s.degree_type == '碩士生':
|
||||
rule_desc = '碩士生 (1次/月)'
|
||||
elif s.degree_type == '博士生' and s.student_category == '一般生':
|
||||
rule_desc = '博士一般生 (1次/月)'
|
||||
else:
|
||||
rule_desc = '博士在職生 (1次/2個月)'
|
||||
|
||||
summary_data.append({
|
||||
'student': s,
|
||||
'rule_desc': rule_desc,
|
||||
'assigned_dates': assigned_dates,
|
||||
'total_count': len(assigned_dates)
|
||||
})
|
||||
|
||||
is_admin_user = request.user.is_authenticated and (
|
||||
request.user.is_superuser or check_user_has_all_ad_groups(request.user, request, ['Administrators'])
|
||||
)
|
||||
|
||||
return render(request, 'students/weekly_schedule.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,
|
||||
'is_admin_user': is_admin_user,
|
||||
})
|
||||
|
||||
@login_required(login_url='login')
|
||||
def move_schedule_item(request):
|
||||
"""
|
||||
Drag-and-drop move endpoint for WeeklySchedule entries.
|
||||
Validates frequency rules before saving to database and logs user action.
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Method Not Allowed'}, status=405)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
student_id = data.get('student_id')
|
||||
from_date_str = data.get('from_date')
|
||||
to_date_str = data.get('to_date')
|
||||
|
||||
if not student_id or not from_date_str or not to_date_str:
|
||||
return JsonResponse({'success': False, 'error': '缺少必要參數'}, status=400)
|
||||
|
||||
from_date = datetime.strptime(from_date_str, '%Y-%m-%d').date()
|
||||
to_date = datetime.strptime(to_date_str, '%Y-%m-%d').date()
|
||||
|
||||
if from_date == to_date:
|
||||
return JsonResponse({'success': True, 'message': '日期未變更'})
|
||||
|
||||
today = date.today()
|
||||
if to_date < today:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'排程調整限制:不可將報告日期移轉至早於今天 ({today.strftime("%Y-%m-%d")}) 的過去日期 (無法移至 {to_date})。'
|
||||
}, status=400)
|
||||
|
||||
student = Student.objects.get(student_id=student_id)
|
||||
schedule_entry = WeeklySchedule.objects.filter(student=student, date=from_date).first()
|
||||
|
||||
if not schedule_entry:
|
||||
return JsonResponse({'success': False, 'error': '在原日期找不到該學生的排程記錄'}, status=404)
|
||||
|
||||
if student.student_category == '交換中':
|
||||
return JsonResponse({'success': False, 'error': '交換生無需報告,無法排程'}, status=400)
|
||||
|
||||
is_monthly = (student.degree_type == '碩士生') or (student.degree_type == '博士生' and student.student_category == '一般生')
|
||||
|
||||
if is_monthly:
|
||||
if from_date.month != to_date.month or from_date.year != to_date.year:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'頻率規則驗證失敗:{student.degree_type}每月須報告 1 次,不可跨月份移轉 (無法由 {from_date} 移至 {to_date})。'
|
||||
}, status=400)
|
||||
|
||||
if WeeklySchedule.objects.filter(student=student, date=to_date).exists():
|
||||
return JsonResponse({'success': False, 'error': f'學生 {student.name} 在 {to_date} 已有排程記錄。'}, status=400)
|
||||
|
||||
schedule_entry.date = to_date
|
||||
schedule_entry.year = to_date.year
|
||||
schedule_entry.month = to_date.month
|
||||
schedule_entry.save()
|
||||
|
||||
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,
|
||||
'message': f"成功將 {student.name} 的報告日期由 {from_date} 調整至 {to_date}!"
|
||||
})
|
||||
|
||||
except Student.DoesNotExist:
|
||||
return JsonResponse({'success': False, 'error': '找不到該學生記錄'}, status=404)
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': f'系統錯誤: {str(e)}'}, status=500)
|
||||
|
||||
@login_required(login_url='login')
|
||||
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 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.
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return HttpResponseForbidden("Method Not Allowed")
|
||||
|
||||
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)
|
||||
|
||||
deleted_count = 0
|
||||
created_count = 0
|
||||
|
||||
# 1. Delete all cards for Exchange students (交換中) or inactive students
|
||||
from django.db.models import Q
|
||||
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)
|
||||
)
|
||||
deleted_count += invalid_records.count()
|
||||
invalid_records.delete()
|
||||
|
||||
active_students = Student.objects.filter(is_active=True).exclude(student_category='交換中')
|
||||
|
||||
for s in active_students:
|
||||
is_monthly = (s.degree_type == '碩士生') or (s.degree_type == '博士生' and s.student_category == '一般生')
|
||||
|
||||
if is_monthly:
|
||||
# Check Month 1
|
||||
m1_records = list(WeeklySchedule.objects.filter(student=s, year=m1_year, month=m1_month).order_by('date'))
|
||||
if len(m1_records) > 1:
|
||||
for r in m1_records[1:]:
|
||||
r.delete()
|
||||
deleted_count += 1
|
||||
elif len(m1_records) == 0:
|
||||
monday = min(m1_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
|
||||
WeeklySchedule.objects.create(student=s, date=monday)
|
||||
created_count += 1
|
||||
|
||||
# Check Month 2
|
||||
m2_records = list(WeeklySchedule.objects.filter(student=s, year=m2_year, month=m2_month).order_by('date'))
|
||||
if len(m2_records) > 1:
|
||||
for r in m2_records[1:]:
|
||||
r.delete()
|
||||
deleted_count += 1
|
||||
elif len(m2_records) == 0:
|
||||
monday = min(m2_mondays, key=lambda d: WeeklySchedule.objects.filter(date=d).count())
|
||||
WeeklySchedule.objects.create(student=s, date=monday)
|
||||
created_count += 1
|
||||
else:
|
||||
# 博士在職生: 1 report per 2 months
|
||||
bimonthly_records = list(WeeklySchedule.objects.filter(student=s, year__in=[m1_year, m2_year], month__in=[m1_month, m2_month]).order_by('date'))
|
||||
if len(bimonthly_records) > 1:
|
||||
for r in bimonthly_records[1:]:
|
||||
r.delete()
|
||||
deleted_count += 1
|
||||
elif len(bimonthly_records) == 0:
|
||||
all_mondays = m1_mondays + m2_mondays
|
||||
monday = min(all_mondays, 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)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"校正與驗證排程規則完成!已清理 {deleted_count} 筆不符規則之卡片(包含刪除交換生卡片與重複排程),補齊 {created_count} 筆缺漏排程。"
|
||||
)
|
||||
return redirect('weekly_schedule')
|
||||
|
||||
@login_required(login_url='login')
|
||||
def update_students_from_excel(request):
|
||||
"""
|
||||
Update student information from server's students.xlsx or students.csv.
|
||||
Restricted to users in 'Administrators' AD group (or superusers).
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return HttpResponseForbidden("Method Not Allowed")
|
||||
|
||||
if not (request.user.is_superuser or check_user_has_all_ad_groups(request.user, request, ['Administrators'])):
|
||||
messages.error(request, "權限不足:僅限 Active Directory 的 'Administrators' 群組成員更新學生資料。")
|
||||
return HttpResponseForbidden("Permission Denied: Only members of 'Administrators' AD group can update student info.")
|
||||
|
||||
excel_path = settings.BASE_DIR / 'students.xlsx'
|
||||
csv_path = settings.BASE_DIR / 'students.csv'
|
||||
|
||||
if not excel_path.exists():
|
||||
excel_path = settings.BASE_DIR.parent / 'students.xlsx'
|
||||
if not csv_path.exists():
|
||||
csv_path = settings.BASE_DIR.parent / 'students.csv'
|
||||
|
||||
try:
|
||||
if excel_path.exists():
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(excel_path)
|
||||
created_count, updated_count = process_excel_workbook(wb)
|
||||
file_name = 'students.xlsx'
|
||||
elif csv_path.exists():
|
||||
with open(csv_path, 'r', encoding='utf-8-sig', errors='replace') as f:
|
||||
csv_reader = csv.reader(f)
|
||||
rows = list(csv_reader)
|
||||
created_count, updated_count = process_excel_rows(rows)
|
||||
file_name = 'students.csv'
|
||||
else:
|
||||
messages.error(request, "錯誤:在伺服器目錄找不到 students.xlsx 或 students.csv 檔案。")
|
||||
return redirect('student_list')
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"成功從 {file_name} 更新學生資料!新增 {created_count} 筆,更新 {updated_count} 筆記錄。"
|
||||
)
|
||||
except Exception as e:
|
||||
messages.error(request, f"更新學生資料失敗: {e}")
|
||||
|
||||
return redirect('student_list')
|
||||
|
||||
@login_required(login_url='login')
|
||||
def upload_students_excel(request):
|
||||
"""
|
||||
Upload and import students from a user-provided Excel (.xlsx) or CSV (.csv) file.
|
||||
Restricted to users in 'Administrators' AD group (or superusers).
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return HttpResponseForbidden("Method Not Allowed")
|
||||
|
||||
if not (request.user.is_superuser or check_user_has_all_ad_groups(request.user, request, ['Administrators'])):
|
||||
messages.error(request, "權限不足:僅限 Active Directory 的 'Administrators' 群組成員上傳匯入學生資料。")
|
||||
return HttpResponseForbidden("Permission Denied: Only members of 'Administrators' AD group can upload student info.")
|
||||
|
||||
uploaded_file = request.FILES.get('excel_file')
|
||||
if not uploaded_file:
|
||||
messages.error(request, "請選擇要上傳的 .xlsx Excel 或 .csv 檔案。")
|
||||
return redirect('student_list')
|
||||
|
||||
filename = uploaded_file.name.lower()
|
||||
if not (filename.endswith('.xlsx') or filename.endswith('.csv')):
|
||||
messages.error(request, "上傳檔案類型錯誤:僅支援 .xlsx Excel 或 .csv 格式之檔案。")
|
||||
return redirect('student_list')
|
||||
|
||||
try:
|
||||
if filename.endswith('.csv'):
|
||||
content = uploaded_file.read().decode('utf-8-sig', errors='replace')
|
||||
csv_reader = csv.reader(io.StringIO(content))
|
||||
rows = list(csv_reader)
|
||||
created_count, updated_count = process_excel_rows(rows)
|
||||
else:
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(uploaded_file)
|
||||
created_count, updated_count = process_excel_workbook(wb)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"成功上傳並匯入檔案 『{uploaded_file.name}』!新增 {created_count} 筆,更新 {updated_count} 筆學生資料。"
|
||||
)
|
||||
except Exception as e:
|
||||
messages.error(request, f"上傳匯入學生資料失敗: {e}")
|
||||
|
||||
return redirect('student_list')
|
||||
Loading…
Reference in a new issue