Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

# ── 必改 ─────────────────────────────
# 系統管理員預設帳密(首次登入後會強制改密碼)
# ⚠️ 只在系統「第一次啟動」時用來建立帳號,之後再改這裡不會生效。
# 密碼請只用英文與數字:$ 會被 docker compose 當成變數截掉,# 前有空白會被當成註解。
# 登不進去時:docker compose exec api python -m app.scripts.reset_admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
# 學校名稱(顯示於介面與匯出課表)。這只是「初始值」——系統啟用後可於
Expand All @@ -29,7 +32,7 @@ HTTP_PORT=80
# ── 預建映像版本(僅「拉取映像」部署方式需要)──────────
# 用 docker compose pull 拉官方映像時,決定要拉哪一版。
# 預設 latest(最新);正式部署建議釘選版本號以利可控升級,例如:
# IMAGE_TAG=v1.3.1
# IMAGE_TAG=v1.3.2
# 若你是從原始碼 docker compose up -d 自行建置,此項可忽略。
IMAGE_TAG=latest

Expand Down
28 changes: 26 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,35 @@

破壞性變更(需人工介入才能升級)以 ⚠️ 標註。

> **新使用者請直接安裝 v1.3.1**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.3.0 是先前版本;以下條目保留作為變更紀錄。
> **新使用者請直接安裝 v1.3.2**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.3.1 是先前版本;以下條目保留作為變更紀錄。

## [Unreleased]

## [1.3.1] — 2026-10-19
## [1.3.2] — 2026-10-20

**系統管理員忘了密碼、或剛裝好就登不進去,現在有指令可以重設。** 來自使用者來信:照手冊手動安裝,先啟動過一次才改 `.env` 的密碼,結果怎麼登都是「帳號或密碼錯誤」,接著被鎖 15 分鐘。管理員帳號只在系統第一次啟動時依 `.env` 建立,之後改 `.env` 不會生效;以前只能刪資料重裝或還原備份。

**本版沒有資料表結構變更**,升級只需換映像;若要退回 v1.3.1,把 `IMAGE_TAG` 改回即可。

官方映像(amd64 + arm64 雙架構)已發布於 GHCR:
`ghcr.io/begin0808/course_scheduling_system-{api,worker,web}:v1.3.2`

### 新增
- **重設管理員密碼指令**:在系統資料夾執行 `docker compose exec api python -m app.scripts.reset_admin`。
- 產生一組只含英文與數字的**臨時密碼**印在畫面上(不必把密碼打在指令裡),同時**解除登入鎖定、重新啟用帳號**;用它登入後必須立即改密碼,原本各裝置的登入都會失效。資料完全不受影響。
- 預設重設 `.env` 的 `ADMIN_USERNAME`(通常是 `admin`);其他管理員帳號加 `--username`。帳號打錯時列出現有的系統管理員帳號。
- 只能重設**系統管理員**帳號;教師、組長的密碼由系統管理員到「帳號管理」重設。
- 重設會記入操作軌跡(操作者顯示為「主機指令」,不記密碼)。

### 文件
- 安裝指南第 2 步:`.env` 的管理員密碼**只在第一次啟動時有效**,以及手動編輯時 `$`、`#` 會被 docker compose 截掉;第 5 步新增「登不進去怎麼辦」。
- 常見問題:新增「第一次登入就一直帳號或密碼錯誤、被鎖 15 分鐘」(與網路、IP、所在國家無關);「忘記管理員密碼」改用新指令。
- 手冊 FAQ「忘記密碼了?」、`.env.example` 的管理員密碼說明一併更新;安裝程式結束時提示這個指令。

### 修正
- 變更紀錄等文件中 v1.3.1 的發布日期更正為 2026-10-20。

## [1.3.1] — 2026-10-20

**帳號管理:教學組長、教務主任的帳號終於有地方設定。** 來自使用者詢問:「行政職稱」填了教學組長,登入後仍是教師,找不到設定角色的地方。

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ docker compose up -d # 首次會建置映像,需數分鐘

## 專案狀態

**v1.3.1 已發行(2026-10-19)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 663 項單元/整合測試、51 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。
**v1.3.2 已發行(2026-10-20)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 670 項單元/整合測試、51 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。

v1.3.1 新增**帳號管理**(系統管理員可設定教學組長、教務主任的帳號);v1.3.0 支援**單雙週課程**(同一節單週、雙週輪流上不同的課,排課、請假代課、調課、巡堂表、鐘點都分單雙週;⚠️ 首次改動資料表結構,升級前請先備份);v1.2.19 支援**日薪代課**(國小常見:月結統計把鐘點與日薪分開列)並可**整批指派代課**(整張假單或整天一次派給同一位老師);v1.2.18 可**檢查課表**(發布前列出衝堂、教師不可排時段等衝突;事後修改不可排時段時當場提示已排的課);v1.2.17 修正登入按一次送出兩筆請求(輸錯密碼約第 3 次就被鎖定);v1.2.15、v1.2.16 修正跑班群組的兩個問題(同一位老師在群組裡重複配課時自動排課會排出撞課的課表;同一格有多筆同科目配課時,手動排課把每日節數重複計算);v1.2.12~v1.2.14 可列印**巡堂表**並匯出 Excel(依當天的調代課結果產生,社團另出分組巡堂單);v1.2.10、v1.2.11 讓**國中小、完全中學**跨部上課的老師,課表與通知單都標出實際上課時間,兩部節數不同(國中七節、高中八節)也不會漏課;v1.2.9 可一次匯出**全校教師課表**(總表與批次打包);v1.2.8 可用 Excel **批次更新既有教師**(新學年職務異動),並能**停用/啟用教師登入帳號**(離職教師保留歷史紀錄又進不來);v1.2.7 補上**學期起訖日期**的填寫與修改(先前畫面上無處可填,導致請假被擋);v1.2.6 修掉第一批外部使用者回報的三個問題(教師帳號首次登入卡死、無法事後補開教師帳號、未排課務清單過長時拖不動),並可在畫面上自行修改與重設密碼;v1.2.5 可列印**教師代課通知單與班級代課通知單**(照學校紙本格式,代課時可選計費方式);v1.2.4 的調代課處理會寫出處置方式(代課或調課、哪天補課);v1.2.3 起系統會顯示目前版本,並在有新版時提醒系統管理員(只提醒、不自動升級);v1.2.2 可列印**教師調課通知單與班級調課通知單**(照學校紙本格式);v1.2.1 補上**調課**的操作畫面——請假(含研習)的節次可直接列出雙方都有空的對調節次,支援跨週(最多四週)與連堂,補課日也會出現在今日看板。v1.2.0 起另有**一鍵示範資料**,裝好後兩分鐘內就能看到一張排好的全校課表。
v1.3.2 新增**重設管理員密碼**的指令(忘了密碼或第一次就登不進去時,不必刪資料或還原備份);v1.3.1 新增**帳號管理**(系統管理員可設定教學組長、教務主任的帳號);v1.3.0 支援**單雙週課程**(同一節單週、雙週輪流上不同的課,排課、請假代課、調課、巡堂表、鐘點都分單雙週;⚠️ 首次改動資料表結構,升級前請先備份);v1.2.19 支援**日薪代課**(國小常見:月結統計把鐘點與日薪分開列)並可**整批指派代課**(整張假單或整天一次派給同一位老師);v1.2.18 可**檢查課表**(發布前列出衝堂、教師不可排時段等衝突;事後修改不可排時段時當場提示已排的課);v1.2.17 修正登入按一次送出兩筆請求(輸錯密碼約第 3 次就被鎖定);v1.2.15、v1.2.16 修正跑班群組的兩個問題(同一位老師在群組裡重複配課時自動排課會排出撞課的課表;同一格有多筆同科目配課時,手動排課把每日節數重複計算);v1.2.12~v1.2.14 可列印**巡堂表**並匯出 Excel(依當天的調代課結果產生,社團另出分組巡堂單);v1.2.10、v1.2.11 讓**國中小、完全中學**跨部上課的老師,課表與通知單都標出實際上課時間,兩部節數不同(國中七節、高中八節)也不會漏課;v1.2.9 可一次匯出**全校教師課表**(總表與批次打包);v1.2.8 可用 Excel **批次更新既有教師**(新學年職務異動),並能**停用/啟用教師登入帳號**(離職教師保留歷史紀錄又進不來);v1.2.7 補上**學期起訖日期**的填寫與修改(先前畫面上無處可填,導致請假被擋);v1.2.6 修掉第一批外部使用者回報的三個問題(教師帳號首次登入卡死、無法事後補開教師帳號、未排課務清單過長時拖不動),並可在畫面上自行修改與重設密碼;v1.2.5 可列印**教師代課通知單與班級代課通知單**(照學校紙本格式,代課時可選計費方式);v1.2.4 的調代課處理會寫出處置方式(代課或調課、哪天補課);v1.2.3 起系統會顯示目前版本,並在有新版時提醒系統管理員(只提醒、不自動升級);v1.2.2 可列印**教師調課通知單與班級調課通知單**(照學校紙本格式);v1.2.1 補上**調課**的操作畫面——請假(含研習)的節次可直接列出雙方都有空的對調節次,支援跨週(最多四週)與連堂,補課日也會出現在今日看板。v1.2.0 起另有**一鍵示範資料**,裝好後兩分鐘內就能看到一張排好的全校課表。

**請直接從最新版開始安裝**(見上方快速開始);`v1.3.1` 是目前建議使用的版本。各版變更見 [CHANGELOG](CHANGELOG.md),開發歷程見 [docs/tasks.md](docs/tasks.md)。
**請直接從最新版開始安裝**(見上方快速開始);`v1.3.2` 是目前建議使用的版本。各版變更見 [CHANGELOG](CHANGELOG.md),開發歷程見 [docs/tasks.md](docs/tasks.md)。

系統仍在實際校園環境試用中,若你是第一批使用者,歡迎透過 [Issues](https://github.com/begin0808/Course_Scheduling_System/issues) 回報任何問題。

Expand Down
109 changes: 109 additions & 0 deletions backend/app/scripts/reset_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""重設系統管理員密碼(v1.3.2):忘了密碼、或第一次就登不進去時,不必刪資料、也不必還原備份。

起因(使用者來信):照手冊手動安裝,先啟動過一次才改 `.env` 的密碼。管理員帳號只在系統
**第一次啟動**時依 `.env` 建立,之後改 `.env` 不會生效,結果怎麼登都是「帳號或密碼錯誤」,
接著被鎖 15 分鐘。

做法:產生一組只含英文與數字的臨時密碼(避開 `.env` 裡 `$`、`#` 的問題,也不必把密碼打在
指令上而留在終端機紀錄),同時解除登入鎖定、重新啟用帳號。對方登入後必須立即改密碼;
密碼一換,原本各裝置的登入都會失效。只能重設**系統管理員**帳號,其他帳號由系統管理員登入後
到「帳號管理」重設。

用法(在系統資料夾執行):
docker compose exec api python -m app.scripts.reset_admin
docker compose exec api python -m app.scripts.reset_admin --username 其他管理員帳號
"""

import argparse
import secrets
import sys
from typing import TextIO

from sqlalchemy import select
from sqlalchemy.orm import Session

# 不易看錯的字元:去掉 0/O、1/l/I
_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
_LENGTH = 12


class ResetError(Exception):
"""找不到帳號或不是系統管理員;訊息直接給使用者看。"""


def temp_password() -> str:
return "".join(secrets.choice(_ALPHABET) for _ in range(_LENGTH))


def reset_admin_password(db: Session, username: str, password: str | None = None) -> str:
"""把系統管理員帳號的密碼重設為臨時密碼並解除鎖定,回傳臨時密碼。"""
from app.core.security import hash_password
from app.models.audit import AuditLog
from app.models.user import Role, User

user = db.scalar(select(User).where(User.username == username))
if user is None:
admins = sorted(
u.username for u in db.scalars(select(User)) if Role.admin.value in u.role_names
)
hint = f"系統管理員帳號有:{'、'.join(admins)},請用 --username 指定。" if admins else ""
raise ResetError(f"找不到帳號「{username}」。{hint}")
if Role.admin.value not in user.role_names:
raise ResetError(
f"「{username}」不是系統管理員帳號。其他帳號請由系統管理員登入後到「帳號管理」重設密碼。"
)
temp = password or temp_password()
user.password_hash = hash_password(temp)
user.must_change_password = True
user.failed_login_attempts = 0
user.locked_until = None
user.is_active = True
db.add(AuditLog(
user_id=None, username="主機指令", action="reset_admin_password",
target_type="user", target_id=user.id, detail=f"重設「{user.username}」的密碼",
))
db.commit()
return temp


def run(db: Session, argv: list[str], out: TextIO, err: TextIO) -> int:
from app.core.config import settings

parser = argparse.ArgumentParser(
prog="python -m app.scripts.reset_admin", description="重設系統管理員密碼"
)
parser.add_argument(
"--username", default=settings.admin_username,
help=f"要重設的系統管理員帳號(預設 {settings.admin_username})",
)
args = parser.parse_args(argv)
try:
temp = reset_admin_password(db, args.username)
except ResetError as exc:
print(str(exc), file=err)
return 1
print(f"已重設「{args.username}」的密碼,並解除登入鎖定。", file=out)
print(f"臨時密碼:{temp}", file=out)
print(
f"請用帳號 {args.username} 和這組臨時密碼登入,系統會要求立即改成新密碼;"
"原本已登入的裝置都要重新登入。",
file=out,
)
print(".env 裡的 ADMIN_PASSWORD 只在系統第一次啟動時使用,不必修改。", file=out)
return 0


def main() -> None:
import app.models # noqa: F401 - 載入全部 model,關聯才完整
from app.core.db import SessionLocal

db = SessionLocal()
try:
code = run(db, sys.argv[1:], sys.stdout, sys.stderr)
finally:
db.close()
sys.exit(code)


if __name__ == "__main__":
main()
93 changes: 93 additions & 0 deletions backend/tests/test_reset_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""重設系統管理員密碼的指令(v1.3.2)。

起因(使用者來信):先啟動過一次才改 `.env` 的密碼,管理員帳號早已依舊密碼建立,
怎麼登都進不去、接著被鎖 15 分鐘。以前只能刪資料重裝或還原備份。
"""

import io
from datetime import UTC, datetime, timedelta

import pytest

from app.core.security import verify_password
from app.models.audit import AuditLog
from app.models.user import Role
from app.scripts.reset_admin import ResetError, reset_admin_password, run, temp_password
from tests.conftest import make_user

PW = "password123"


def test_locked_out_admin_can_log_in_with_the_temp_password_and_must_change_it(env):
client, db = env
admin = make_user(db, "admin", PW, roles=[Role.admin])
for _ in range(5):
client.post("/api/auth/login", json={"username": "admin", "password": "wrong-pass"})
r = client.post("/api/auth/login", json={"username": "admin", "password": PW})
assert r.status_code == 423 # 已被鎖

temp = reset_admin_password(db, "admin")

db.refresh(admin)
assert admin.locked_until is None and admin.failed_login_attempts == 0
r = client.post("/api/auth/login", json={"username": "admin", "password": temp})
assert r.status_code == 200
assert r.json()["must_change_password"] is True
old = client.post("/api/auth/login", json={"username": "admin", "password": PW})
assert old.status_code == 401


def test_reset_ends_existing_sessions(env):
client, db = env
make_user(db, "admin", PW, roles=[Role.admin])
client.post("/api/auth/login", json={"username": "admin", "password": PW})
assert client.get("/api/auth/me").status_code == 200
reset_admin_password(db, "admin")
assert client.get("/api/auth/me").status_code == 401


def test_deactivated_admin_is_enabled_again(db):
admin = make_user(db, "admin", PW, roles=[Role.admin])
admin.is_active = False
admin.locked_until = datetime.now(UTC) + timedelta(minutes=10)
db.commit()
temp = reset_admin_password(db, "admin")
assert admin.is_active is True
assert verify_password(temp, admin.password_hash)


def test_unknown_username_lists_the_admin_accounts(db):
make_user(db, "root", PW, roles=[Role.admin])
make_user(db, "boss", PW, roles=[Role.admin, Role.director])
make_user(db, "s", PW, roles=[Role.scheduler])
with pytest.raises(ResetError, match="找不到帳號「admin」。系統管理員帳號有:boss、root"):
reset_admin_password(db, "admin")


def test_only_admin_accounts_can_be_reset(db):
s = make_user(db, "s", PW, roles=[Role.scheduler])
with pytest.raises(ResetError, match="不是系統管理員帳號"):
reset_admin_password(db, "s")
assert verify_password(PW, s.password_hash)


def test_temp_password_is_plain_letters_and_digits():
for _ in range(50):
p = temp_password()
assert len(p) == 12 and p.isalnum() and p.isascii()
assert not set(p) & set("0O1lI")


def test_command_prints_the_temp_password_and_records_it(db):
make_user(db, "admin", PW, roles=[Role.admin])
out, err = io.StringIO(), io.StringIO()
assert run(db, [], out, err) == 0
temp = out.getvalue().split("臨時密碼:")[1].split()[0]
assert len(temp) == 12
log = db.query(AuditLog).one()
assert (log.action, log.username) == ("reset_admin_password", "主機指令")
assert temp not in log.detail

out, err = io.StringIO(), io.StringIO()
assert run(db, ["--username", "nobody"], out, err) == 1
assert "找不到帳號「nobody」" in err.getvalue() and out.getvalue() == ""
Loading
Loading