diff --git a/.env.example b/.env.example index f6c5fac..ab2e79d 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,7 @@ HTTP_PORT=80 # ── 預建映像版本(僅「拉取映像」部署方式需要)────────── # 用 docker compose pull 拉官方映像時,決定要拉哪一版。 # 預設 latest(最新);正式部署建議釘選版本號以利可控升級,例如: -# IMAGE_TAG=v1.3.2 +# IMAGE_TAG=v1.3.3 # 若你是從原始碼 docker compose up -d 自行建置,此項可忽略。 IMAGE_TAG=latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be657f..8fed987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,29 @@ 破壞性變更(需人工介入才能升級)以 ⚠️ 標註。 -> **新使用者請直接安裝 v1.3.2**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.3.1 是先前版本;以下條目保留作為變更紀錄。 +> **新使用者請直接安裝 v1.3.3**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.3.2 是先前版本;以下條目保留作為變更紀錄。 ## [Unreleased] +## [1.3.3] — 2026-10-21 + +**儀表板新增「即時概況」:現在這一節,各班實際在上什麼課、誰在上。** 來自使用者 Issue #50:儀表板原本只有資料筆數與今日異動數,看不出現在各班在上什麼。 + +**本版沒有資料表結構變更**,升級只需換映像;若要退回 v1.3.2,把 `IMAGE_TAG` 改回即可。 + +官方映像(amd64 + arm64 雙架構)已發布於 GHCR: +`ghcr.io/begin0808/course_scheduling_system-{api,worker,web}:v1.3.3` + +### 新增 +- **儀表板即時概況**:每一班一格,寫科目、授課老師、教室。 + - 和巡堂表同一套計算:已發布課表疊上**當天的調代課**(代課、調課、併班、自習、停課),單雙週依當週判斷;有異動的班級加框標色,「請假待處理」以紅色標出還沒安排人的節次。社團這類 4 組以上的跑班寫「社團 共 N 組」。 + - **下課、早自習、午休**時列出**下一節**各班的課,讓組長先確認有沒有缺人;上學前、放學後、沒有課的日子只顯示一行說明。 + - 現在是第幾節,依各班**自己的節次表**的上下課時間判斷;國中小、完全中學兩部作息不同時分開列。節次表沒填時間時,提示到「學期與節次表」補上。 + - 每分鐘自動更新;可直接開啟今天整天的巡堂表。 + - 限教學組長、教務主任、系統管理員(同巡堂表);一般教師的儀表板不顯示。 + - 新 API:`GET /api/live-overview?semester_id=`(可加 `at=` 指定時刻,供測試與預覽)。 +- 手冊新增 8.9「儀表板即時概況」。 + ## [1.3.2] — 2026-10-20 **系統管理員忘了密碼、或剛裝好就登不進去,現在有指令可以重設。** 來自使用者來信:照手冊手動安裝,先啟動過一次才改 `.env` 的密碼,結果怎麼登都是「帳號或密碼錯誤」,接著被鎖 15 分鐘。管理員帳號只在系統第一次啟動時依 `.env` 建立,之後改 `.env` 不會生效;以前只能刪資料重裝或還原備份。 diff --git a/README.md b/README.md index b992438..b4a1a90 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,11 @@ docker compose up -d # 首次會建置映像,需數分鐘 ## 專案狀態 -**v1.3.2 已發行(2026-10-20)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 670 項單元/整合測試、51 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。 +**v1.3.3 已發行(2026-10-21)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 685 項單元/整合測試、52 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。 -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.3 的儀表板新增**即時概況**(現在這一節各班在上什麼課、誰在上,已套用當天的調代課);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.2` 是目前建議使用的版本。各版變更見 [CHANGELOG](CHANGELOG.md),開發歷程見 [docs/tasks.md](docs/tasks.md)。 +**請直接從最新版開始安裝**(見上方快速開始);`v1.3.3` 是目前建議使用的版本。各版變更見 [CHANGELOG](CHANGELOG.md),開發歷程見 [docs/tasks.md](docs/tasks.md)。 系統仍在實際校園環境試用中,若你是第一批使用者,歡迎加入 [LINE 社群](https://line.me/ti/g2/hP0Lg7mqvE5rvWg0sE9MHI-QsLkd94Cfp3wKcg) 或透過 [Issues](https://github.com/begin0808/Course_Scheduling_System/issues) 回報任何問題。 diff --git a/backend/app/api/substitution_log.py b/backend/app/api/substitution_log.py index efdeb20..de9b557 100644 --- a/backend/app/api/substitution_log.py +++ b/backend/app/api/substitution_log.py @@ -4,18 +4,24 @@ """ from dataclasses import asdict -from datetime import date +from datetime import date, datetime from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session from app.core.auth import require_roles +from app.core.clock import school_now from app.core.db import get_db from app.models.semester import Semester from app.models.user import Role, User -from app.schemas.substitution_log import DailyBoardOut, LogEntryOut, PatrolSheetsOut -from app.services import patrol_sheets, patrol_xlsx +from app.schemas.substitution_log import ( + DailyBoardOut, + LiveOverviewOut, + LogEntryOut, + PatrolSheetsOut, +) +from app.services import live_overview, patrol_sheets, patrol_xlsx from app.services import settings as app_settings from app.services import substitution_log as log_service @@ -55,6 +61,25 @@ def daily_board( ) +@router.get("/live-overview", response_model=LiveOverviewOut) +def get_live_overview( + semester_id: int = Query(...), + at: datetime | None = Query( + default=None, description="指定時刻(學校時區,供測試與預覽);預設為現在"), + db: Session = Depends(get_db), + _: User = Depends(viewer), +): + """儀表板即時概況:現在這一節各班實際在上什麼課、誰在上(已套用當天的調代課)。 + + 下課、早自習、午休時列出下一節;上學前、放學後、沒有課的日子只回一行說明。 + """ + _get_semester(db, semester_id) + now = school_now() if at is None else at + if now.tzinfo is not None: + now = now.astimezone(school_now().tzinfo).replace(tzinfo=None) + return LiveOverviewOut.model_validate(asdict(live_overview.build(db, semester_id, now))) + + @router.get("/patrol-sheets", response_model=PatrolSheetsOut) def get_patrol_sheets( semester_id: int = Query(...), diff --git a/backend/app/schemas/substitution_log.py b/backend/app/schemas/substitution_log.py index 1b49568..efcc4cf 100644 --- a/backend/app/schemas/substitution_log.py +++ b/backend/app/schemas/substitution_log.py @@ -94,3 +94,32 @@ class DailyBoardOut(BaseModel): school_name: str semester_label: str entries: list[LogEntryOut] = [] + + +class LiveCellOut(BaseModel): + class_name: str + subject: str + teacher: str + room: str + note: str + + +class LiveSectionOut(BaseModel): + table_name: str + state: str + message: str + now_label: str + period_name: str + start: str + end: str + cells: list[LiveCellOut] + + +class LiveOverviewOut(BaseModel): + """儀表板即時概況(v1.3.3):現在這一節(或下課時的下一節)各班實際的課。""" + + date: _Date + weekday: int + time: str + message: str + sections: list[LiveSectionOut] diff --git a/backend/app/services/live_overview.py b/backend/app/services/live_overview.py new file mode 100644 index 0000000..461bae6 --- /dev/null +++ b/backend/app/services/live_overview.py @@ -0,0 +1,160 @@ +"""儀表板「即時概況」(v1.3.3):現在這一節,各班實際在上什麼課、誰在上。 + +起因(使用者 Issue #50):儀表板只有資料筆數與今日異動數,看不出「現在這一節各班在上什麼」。 + +巡堂表已經把「已發布課表 + 當天的調代課」疊成那一天每班每節的實際樣子(含單雙週、 +跨部作息,見 `patrol_sheets.Day`),這裡只取「現在」那一節。現在是第幾節,依各班**自己的 +節次表**的上下課時間判斷,所以國中小兩套作息的學校,同一時刻兩部可能在不同節,分開列。 + +使用者定案: +- 下課、早自習、午休時,列出**下一節**各班的課,讓組長先確認有沒有缺人。 +- 上學前、放學後、沒有課的日子只顯示一行說明。 +- 節次表沒填上下課時間就無從判斷現在是第幾節,請組長去填。 + +本模組只讀,不寫資料庫。 +""" + +import enum +from dataclasses import dataclass, field +from datetime import date, datetime, time + +from sqlalchemy.orm import Session + +from app.models.basedata import ClassUnit +from app.models.period import Period, PeriodType +from app.models.semester import Semester +from app.services import patrol_sheets +from app.services.availability import published_timetable + + +class LiveState(enum.StrEnum): + in_class = "in_class" # 正在上課:列出這一節 + upcoming = "upcoming" # 下課、早自習、午休:列出下一節 + before_school = "before_school" + after_school = "after_school" + no_class = "no_class" # 今天沒有課(假日、這天沒有節次或沒有排課) + no_times = "no_times" # 節次表沒填上下課時間 + + +@dataclass(frozen=True, slots=True) +class LiveCell: + class_name: str + subject: str = "" + teacher: str = "" + room: str = "" + note: str = "" # 代課/調課/併班/自習/停課/請假待處理 + + +@dataclass +class LiveSection: + """一套節次表(全校只有一套時就只有這一段)。""" + + table_name: str # 全校只有一套節次表時為空 + state: str + message: str = "" # 不列課表時的一行說明 + now_label: str = "" # 現在是什麼時段:「下課」「午休」「早自習」;上課中為空 + period_name: str = "" + start: str = "" + end: str = "" + cells: list[LiveCell] = field(default_factory=list) + + +@dataclass +class LiveOverview: + date: date + weekday: int + time: str + message: str = "" # 整體無法顯示的原因(沒有已發布課表、不在學期期間) + sections: list[LiveSection] = field(default_factory=list) + + +def _hhmm(t: time | None) -> str: + return t.strftime("%H:%M") if t else "" + + +def _cell(klass: ClassUnit, lessons: list) -> LiveCell: + if not lessons: + return LiveCell(class_name=klass.name) + if lessons[0].is_group and len(lessons) >= patrol_sheets.GROUP_LIST_MIN: + # 社團這類大型跑班一格塞不下,只寫群組名稱與組數(同巡堂表) + return LiveCell(class_name=klass.name, subject=lessons[0].unit_name, + teacher=f"共 {len(lessons)} 組") + j = patrol_sheets.join + return LiveCell( + class_name=klass.name, + subject=j([x.subject for x in lessons]), + teacher=j([x.teacher for x in lessons]), + room=j([x.room for x in lessons]), + note=j([x.note for x in lessons], "、"), + ) + + +def _section(periods: list[Period], now: time) -> tuple[LiveSection, Period | None]: + """判斷這套節次表「現在」的狀態,回傳 (狀態, 要列出的那一節)。""" + regular = [p for p in periods if p.type == PeriodType.regular.value] + if not regular: + return LiveSection("", LiveState.no_class, "今天沒有課"), None + if any(p.start_time is None or p.end_time is None for p in regular): + return LiveSection( + "", LiveState.no_times, + "節次表沒有填上下課時間,無法判斷現在是第幾節;" + "請到「基礎資料 → 學期與節次表」補上。", + ), None + regular.sort(key=lambda p: p.start_time or time.min) + timed = [p for p in periods if p.start_time is not None] + first = min(p.start_time for p in timed if p.start_time) + if now < first: + return LiveSection( + "", LiveState.before_school, + f"還沒到上課時間({regular[0].name} {_hhmm(regular[0].start_time)} 開始)", + ), None + if now >= max(p.end_time for p in regular if p.end_time): + return LiveSection("", LiveState.after_school, "今天的課已結束"), None + + for p in regular: + if p.start_time and p.end_time and p.start_time <= now < p.end_time: + return LiveSection("", LiveState.in_class), p + nxt = next(p for p in regular if p.start_time and p.start_time > now) + during = next( + (p.name for p in timed + if p.start_time and p.end_time and p.start_time <= now < p.end_time), + "下課", + ) + return LiveSection("", LiveState.upcoming, now_label=during), nxt + + +def build(db: Session, semester_id: int, now: datetime) -> LiveOverview: + """`now` 為學校時區的現在(呼叫端傳入,測試可指定時刻)。""" + today = now.date() + result = LiveOverview(date=today, weekday=today.isoweekday(), time=now.strftime("%H:%M")) + semester = db.get(Semester, semester_id) + if semester is not None and ( + (semester.start_date and today < semester.start_date) + or (semester.end_date and today > semester.end_date) + ): + result.message = "今天不在這個學期的起訖日期內" + return result + timetable = published_timetable(db, semester_id) + if timetable is None: + result.message = "這個學期還沒有發布課表" + return result + + by_table = patrol_sheets.classes_by_period_table(db, semester_id) + if not by_table: + result.message = "這個學期還沒有班級" + return result + day = patrol_sheets.Day(db, semester_id, timetable.id, today) + multi = len(by_table) > 1 + for table, members in by_table.values(): + periods = [p for p in table.periods if p.weekday == today.isoweekday()] + if not day.entries: + section, period = LiveSection("", LiveState.no_class, "今天沒有課"), None + else: + section, period = _section(periods, now.time()) + section.table_name = table.name if multi else "" + if period is not None: + section.period_name = period.name + section.start, section.end = _hhmm(period.start_time), _hhmm(period.end_time) + section.cells = [_cell(c, day.lessons(c.id, period.period_no)) for c in members] + result.sections.append(section) + return result diff --git a/backend/app/services/patrol_sheets.py b/backend/app/services/patrol_sheets.py index ea25934..8096395 100644 --- a/backend/app/services/patrol_sheets.py +++ b/backend/app/services/patrol_sheets.py @@ -153,8 +153,11 @@ def _same_class(ap: AffectedPeriod, sub: Substitution) -> bool: return ap.class_names == sub.swap_class_names -class _Day: - """某一天的實際課表:週課表格位 + 當天的請假與調代課。""" +class Day: + """某一天的實際課表:週課表格位 + 當天的請假與調代課。 + + 儀表板的即時概況(`live_overview`)也用它取「現在這一節」。 + """ def __init__(self, db: Session, semester_id: int, timetable_id: int, on: date) -> None: self.db = db @@ -259,7 +262,22 @@ def lessons(self, class_id: int, period_no: int) -> list[_Lesson]: return [lesson for _, lesson in sorted(out, key=lambda x: x[0])] -def _join(values: list[str], sep: str = "/") -> str: +def classes_by_period_table( + db: Session, semester_id: int, +) -> dict[int, tuple[PeriodTable, list[ClassUnit]]]: + """各班依自己的節次表分組(國中小、完全中學兩部作息不同);班級依年級、班名排序。""" + by_table: dict[int, tuple[PeriodTable, list[ClassUnit]]] = {} + for c in db.scalars( + select(ClassUnit).where(ClassUnit.semester_id == semester_id) + .order_by(ClassUnit.grade, ClassUnit.name) + ): + table = pt_service.resolve_period_table(db, c) + if table is not None: + by_table.setdefault(table.id, (table, []))[1].append(c) + return by_table + + +def join(values: list[str], sep: str = "/") -> str: return sep.join(dict.fromkeys(v for v in values if v)) @@ -270,10 +288,10 @@ def _cell(lessons: list[_Lesson]) -> PatrolCell: return PatrolCell( subject=lessons[0].unit_name, teacher=f"共 {len(lessons)} 組", note="見分組巡堂單") return PatrolCell( - subject=_join([x.subject for x in lessons]), - teacher=_join([x.teacher for x in lessons]), - room=_join([x.room for x in lessons]), - note=_join([x.note for x in lessons], "、"), + subject=join([x.subject for x in lessons]), + teacher=join([x.teacher for x in lessons]), + room=join([x.room for x in lessons]), + note=join([x.note for x in lessons], "、"), ) @@ -291,20 +309,12 @@ def build(db: Session, semester_id: int, date_from: date, date_to: date) -> Patr if timetable is None: raise PatrolError("此學期尚無已發布的課表") - classes = list(db.scalars( - select(ClassUnit).where(ClassUnit.semester_id == semester_id) - .order_by(ClassUnit.grade, ClassUnit.name) - )) - by_table: dict[int, tuple[PeriodTable, list[ClassUnit]]] = {} - for c in classes: - table = pt_service.resolve_period_table(db, c) - if table is not None: - by_table.setdefault(table.id, (table, []))[1].append(c) + by_table = classes_by_period_table(db, semester_id) multi = len(by_table) > 1 result = PatrolSheets(title=school_title(db, semester_id), legend=legend(db)) for on in _days(date_from, date_to): - day = _Day(db, semester_id, timetable.id, on) + day = Day(db, semester_id, timetable.id, on) if not day.entries: continue listed: set[tuple[int, int]] = set() # 已出過分組單的 (群組, 節次) diff --git a/backend/tests/test_live_overview.py b/backend/tests/test_live_overview.py new file mode 100644 index 0000000..36851ac --- /dev/null +++ b/backend/tests/test_live_overview.py @@ -0,0 +1,186 @@ +"""儀表板即時概況(v1.3.3,Issue #50):現在這一節各班實際在上什麼課、誰在上。 + +國中節次表(範本):早自習 07:50–08:20、第一節 08:20–09:05、第二節 09:15–10:00、 +第三節 10:20–11:05、第四節 11:15–12:00、午休 12:00–13:10、第五節 13:10–13:55 … +第七節 15:10–15:55。 +""" + +from datetime import datetime, time + +import pytest + +from app.models.period import Period +from app.models.user import Role +from tests.conftest import make_user +from tests.dates import AFTER_SEM, SAT, SEM_END, SEM_START, WED +from tests.test_patrol_sheets import _group, _group_course, _place_group +from tests.test_substitutions import _World + +PW = "password123" + + +@pytest.fixture +def w(env): + client, db = env + make_user(db, "s", PW, roles=[Role.scheduler]) + client.post("/api/auth/login", json={"username": "s", "password": PW}) + sid = client.post("/api/semesters", json={ + "academic_year": 115, "term": 1, "template_key": "junior_high", + "start_date": SEM_START.isoformat(), "end_date": SEM_END.isoformat(), + }).json()["id"] + return _World(client, db, sid) + + +def _live(w, hhmm: str, day=WED): + at = datetime.combine(day, time.fromisoformat(hhmm)).isoformat() + r = w.client.get(f"/api/live-overview{w.q}&at={at}") + assert r.status_code == 200, r.json() + return r.json() + + +def _only(body): + assert body["message"] == "" + assert len(body["sections"]) == 1 + return body["sections"][0] + + +def _cell(section, klass): + return next(c for c in section["cells"] if c["class_name"] == klass) + + +@pytest.fixture +def school(w): + """701 第一節國文(王師)、第二節數學(李師);702 第一節沒有課。""" + w.teacher("王師", ["國文"]) + w.teacher("李師", ["數學"]) + w.teacher("陳師", ["國文"]) + w.place("王師", "國文", "701", 0) + w.place("李師", "數學", "701", 1) + w.klass("702") + w.publish() + return w + + +def test_in_class_shows_what_each_class_is_having_now(school): + s = _only(_live(school, "08:30")) + assert (s["state"], s["period_name"], s["start"], s["end"]) == ( + "in_class", "第一節", "08:20", "09:05") + assert s["table_name"] == "" # 全校一套節次表,不必寫是哪一套 + assert _cell(s, "701") == { + "class_name": "701", "subject": "國文", "teacher": "王師", "room": "", "note": ""} + assert _cell(s, "702")["subject"] == "" # 這一節沒課,仍列出班級 + names = [c["class_name"] for c in s["cells"]] + assert names.index("701") < names.index("702") # 依年級、班名排序 + + +def test_time_with_offset_is_read_in_school_time(school): + """瀏覽器若送 UTC 時刻,換算成學校時區(台北 08:30 = UTC 00:30)。""" + at = f"{WED.isoformat()}T00:30:00Z" + s = _only(school.client.get(f"/api/live-overview{school.q}&at={at}").json()) + assert (s["state"], s["period_name"]) == ("in_class", "第一節") + + +@pytest.mark.parametrize(("hhmm", "label", "period"), [ + ("09:10", "下課", "第二節"), + ("08:00", "早自習", "第一節"), + ("12:30", "午休", "第五節"), +]) +def test_between_lessons_shows_the_next_period(school, hhmm, label, period): + """下課、早自習、午休:列出下一節,讓組長先確認有沒有缺人(使用者定案)。""" + s = _only(_live(school, hhmm)) + assert (s["state"], s["now_label"], s["period_name"]) == ("upcoming", label, period) + if period == "第二節": + assert _cell(s, "701")["subject"] == "數學" + + +def test_lesson_boundaries(school): + """09:05 第一節剛結束 → 下課;10:00 整點第二節結束。""" + assert _only(_live(school, "09:04"))["period_name"] == "第一節" + s = _only(_live(school, "09:05")) + assert (s["state"], s["period_name"]) == ("upcoming", "第二節") + assert _only(_live(school, "09:15"))["state"] == "in_class" + + +@pytest.mark.parametrize(("hhmm", "day", "state", "text"), [ + ("07:00", WED, "before_school", "第一節 08:20 開始"), + ("16:30", WED, "after_school", "今天的課已結束"), + ("10:30", SAT, "no_class", "今天沒有課"), +]) +def test_outside_school_hours_is_a_single_line(school, hhmm, day, state, text): + s = _only(_live(school, hhmm, day)) + assert s["state"] == state and text in s["message"] + assert s["cells"] == [] + + +def test_today_substitution_is_applied(school): + w = school + affected = w.leave("王師") + cell = _cell(_only(_live(w, "08:30")), "701") + assert (cell["teacher"], cell["note"]) == ("王師", "請假待處理") + + status, _ = w.assign( + affected[0]["id"], type="substitute", handler_teacher_id=w.teachers["陳師"]) + assert status == 200 + cell = _cell(_only(_live(w, "08:30")), "701") + assert (cell["subject"], cell["teacher"], cell["note"]) == ("國文", "陳師", "代課") + + +def test_big_group_is_summarised(w): + """社團這類大型跑班一格塞不下,只寫群組名稱與組數(同巡堂表)。""" + unit = _group(w, "社團", ["701", "702"]) + ids = [] + for i, club in enumerate(["羽球社", "桌球社", "棒球社", "漫畫社"]): + w.teacher(f"師{i}", [club]) + ids.append(_group_course(w, unit, club, f"師{i}")) + _place_group(w, ids[0], 5) # 第六節 14:05–14:50 + w.publish() + cell = _cell(_only(_live(w, "14:10")), "702") + assert (cell["subject"], cell["teacher"], cell["note"]) == ("社團", "共 4 組", "") + + +def test_two_period_tables_are_judged_separately(w): + """國中小:11:10 國中第三節已下課(下一節第四節 11:15),國小第四節(11:10–11:50)正在上。""" + table = w.client.post(f"/api/semesters/{w.sid}/period-tables", + json={"name": "國小節次表", "template_key": "elementary"}).json() + w.classes["601"] = w.client.post(f"/api/class-units{w.q}", json={ + "grade": 6, "name": "601", "track": "elementary", "period_table_id": table["id"], + }).json()["id"] + w.teacher("林師", ["英語"]) + w.place("林師", "英語", "701", 0) + w.place("林師", "英語", "601", 3) + w.publish() + + body = _live(w, "11:10") + by_table = {s["table_name"]: s for s in body["sections"]} + assert set(by_table) == {"國中節次表", "國小節次表"} + jh, el = by_table["國中節次表"], by_table["國小節次表"] + assert (jh["state"], jh["period_name"]) == ("upcoming", "第四節") + assert "701" in [c["class_name"] for c in jh["cells"]] + assert [c["class_name"] for c in el["cells"]] == ["601"] + assert (el["state"], el["period_name"], el["start"]) == ("in_class", "第四節", "11:10") + assert _cell(el, "601")["teacher"] == "林師" + + +def test_missing_period_times_ask_for_them(school): + w = school + for p in w.db.query(Period).all(): + p.start_time = p.end_time = None + w.db.commit() + s = _only(_live(w, "08:30")) + assert s["state"] == "no_times" and "學期與節次表" in s["message"] + + +def test_nothing_published_or_outside_the_semester(w): + w.teacher("王師", ["國文"]) + w.place("王師", "國文", "701", 0) + assert _live(w, "08:30")["message"] == "這個學期還沒有發布課表" + w.publish() + body = _live(w, "08:30", AFTER_SEM) + assert body["message"] == "今天不在這個學期的起訖日期內" and body["sections"] == [] + + +def test_teachers_cannot_see_it(env): + client, db = env + make_user(db, "t", PW, roles=[Role.teacher]) + client.post("/api/auth/login", json={"username": "t", "password": PW}) + assert client.get("/api/live-overview?semester_id=1").status_code == 403 diff --git a/docs/deploy/upgrade.html b/docs/deploy/upgrade.html index 70f268e..519c8df 100644 --- a/docs/deploy/upgrade.html +++ b/docs/deploy/upgrade.html @@ -213,7 +213,7 @@
IMAGE_TAG=v1.3.2 這樣釘住特定版本,你才能決定何時升級、升到哪一版,而不是每次 pull 都可能變動。IMAGE_TAG=v1.3.3 這樣釘住特定版本,你才能決定何時升級、升到哪一版,而不是每次 pull 都可能變動。IMAGE_TAG 改成新版本號,再 docker compose pull && up -d。