1179 lines
52 KiB
Python
1179 lines
52 KiB
Python
#!/usr/bin/env python3
|
||
"""学生自助分组系统 - B/S 架构,仅使用 Python 标准库。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import http.server
|
||
import hmac
|
||
import json
|
||
import logging
|
||
import os
|
||
import secrets
|
||
import sqlite3
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
from http import HTTPStatus
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from xlsxio import export_filename, read_xlsx_rows, write_xlsx
|
||
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
DB_PATH = Path(os.environ.get("GROUP_DB", BASE_DIR / "selfgroup.db"))
|
||
XLSX_PATH = Path(os.environ.get("GROUP_XLSX", BASE_DIR / "2025mt.xlsx"))
|
||
STATIC_DIR = BASE_DIR / "static"
|
||
TEACHER_USER = os.environ.get("TEACHER_USER", "teacher")
|
||
TEACHER_PASSWORD = os.environ.get("TEACHER_PASSWORD", "teacher123")
|
||
GROUP_LIMIT = 6
|
||
SESSION_DAYS = 7
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
LOG = logging.getLogger("group-system")
|
||
|
||
|
||
def hash_password(password: str, salt: bytes | None = None) -> str:
|
||
salt = salt or secrets.token_bytes(16)
|
||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 180_000)
|
||
return salt.hex() + "$" + digest.hex()
|
||
|
||
|
||
def verify_password(password: str, stored: str | None) -> bool:
|
||
if not stored or "$" not in stored:
|
||
return False
|
||
try:
|
||
salt_hex, digest_hex = stored.split("$", 1)
|
||
salt = bytes.fromhex(salt_hex)
|
||
expected = bytes.fromhex(digest_hex)
|
||
except ValueError:
|
||
return False
|
||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 180_000)
|
||
return hmac.compare_digest(actual, expected)
|
||
|
||
|
||
class GroupStore:
|
||
def __init__(self, path: Path):
|
||
self.path = path
|
||
self.lock = threading.RLock()
|
||
self.connection = sqlite3.connect(str(path), check_same_thread=False)
|
||
self.connection.row_factory = sqlite3.Row
|
||
self.connection.execute("PRAGMA foreign_keys = ON")
|
||
self.init_database()
|
||
|
||
def init_database(self) -> None:
|
||
with self.lock, self.connection:
|
||
self.connection.executescript(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS students (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
class_name TEXT NOT NULL,
|
||
student_no TEXT NOT NULL UNIQUE,
|
||
name TEXT NOT NULL,
|
||
gender TEXT DEFAULT '',
|
||
admission_no TEXT DEFAULT '',
|
||
remark TEXT DEFAULT '',
|
||
password_hash TEXT NOT NULL,
|
||
must_change_password INTEGER NOT NULL DEFAULT 1
|
||
);
|
||
CREATE TABLE IF NOT EXISTS groups (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
class_name TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
leader_student_id INTEGER REFERENCES students(id) ON DELETE SET NULL,
|
||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
CREATE TABLE IF NOT EXISTS group_members (
|
||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||
student_id INTEGER NOT NULL UNIQUE REFERENCES students(id) ON DELETE CASCADE,
|
||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
CREATE TABLE IF NOT EXISTS class_controls (
|
||
class_name TEXT PRIMARY KEY,
|
||
locked INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
token_hash TEXT PRIMARY KEY,
|
||
role TEXT NOT NULL,
|
||
username TEXT NOT NULL,
|
||
student_id INTEGER REFERENCES students(id) ON DELETE CASCADE,
|
||
created_at INTEGER NOT NULL,
|
||
expires_at INTEGER NOT NULL
|
||
);
|
||
"""
|
||
)
|
||
self.connection.execute(
|
||
"INSERT OR IGNORE INTO settings(key,value) VALUES('active_class','1')"
|
||
)
|
||
self.connection.execute(
|
||
"INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_username',?)",
|
||
(TEACHER_USER,),
|
||
)
|
||
self.connection.execute(
|
||
"INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_password',?)",
|
||
(hash_password(TEACHER_PASSWORD),),
|
||
)
|
||
|
||
def import_roster(self, path: Path) -> tuple[int, int]:
|
||
rows = read_xlsx_rows(path)
|
||
students = []
|
||
errors = []
|
||
seen = set()
|
||
for index, row in enumerate(rows, 2):
|
||
student_no = str(row.get("学号", "")).strip()
|
||
name = str(row.get("姓名", "")).strip()
|
||
class_name = str(
|
||
row.get("班级", row.get("班别", row.get("行政班", "")))
|
||
).strip()
|
||
gender = str(row.get("性别", "")).strip()
|
||
admission_no = str(row.get("录取编号", "")).strip()
|
||
remark = str(row.get("备注", "")).strip()
|
||
if not student_no or not name or not class_name:
|
||
errors.append(f"第 {index} 行:学号、姓名、班级均不能为空")
|
||
continue
|
||
if student_no in seen:
|
||
errors.append(f"第 {index} 行:学号 {student_no} 重复")
|
||
continue
|
||
seen.add(student_no)
|
||
students.append(
|
||
(class_name, student_no, name, gender, admission_no, remark)
|
||
)
|
||
if errors:
|
||
raise ValueError(";".join(errors[:8]))
|
||
if not students:
|
||
raise ValueError("Excel 中没有可导入的学生")
|
||
|
||
with self.lock, self.connection:
|
||
self.connection.executescript(
|
||
"DELETE FROM sessions; DELETE FROM group_members; DELETE FROM groups; DELETE FROM students;"
|
||
)
|
||
self.connection.executemany(
|
||
"""INSERT INTO students
|
||
(class_name,student_no,name,gender,admission_no,remark,password_hash,must_change_password)
|
||
VALUES(?,?,?,?,?,?,?,1)""",
|
||
[
|
||
(
|
||
item[0],
|
||
item[1],
|
||
item[2],
|
||
item[3],
|
||
item[4],
|
||
item[5],
|
||
hash_password(item[1]),
|
||
)
|
||
for item in students
|
||
],
|
||
)
|
||
active = self.get_setting("active_class", conn=self.connection) or students[0][0]
|
||
if not any(item[0] == active for item in students):
|
||
active = students[0][0]
|
||
self.connection.execute(
|
||
"UPDATE settings SET value=? WHERE key='active_class'", (active,)
|
||
)
|
||
return len(students), len({item[0] for item in students})
|
||
|
||
def get_setting(self, key: str, conn: sqlite3.Connection | None = None) -> str | None:
|
||
conn = conn or self.connection
|
||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||
return None if row is None else row["value"]
|
||
|
||
def set_setting(self, key: str, value: str) -> None:
|
||
with self.lock, self.connection:
|
||
self.connection.execute(
|
||
"INSERT INTO settings(key,value) VALUES(?,?) "
|
||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||
(key, value),
|
||
)
|
||
|
||
def class_list(self) -> list[dict[str, Any]]:
|
||
with self.lock:
|
||
rows = self.connection.execute(
|
||
"""SELECT class_name, COUNT(*) AS student_count
|
||
FROM students GROUP BY class_name
|
||
ORDER BY CAST(class_name AS REAL), class_name"""
|
||
).fetchall()
|
||
active = self.get_setting("active_class")
|
||
locked = {
|
||
row["class_name"]: bool(row["locked"])
|
||
for row in self.connection.execute("SELECT class_name,locked FROM class_controls")
|
||
}
|
||
return [
|
||
{
|
||
"class_name": row["class_name"],
|
||
"student_count": row["student_count"],
|
||
"locked": bool(locked.get(row["class_name"], False)),
|
||
"active": row["class_name"] == active,
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
def active_class(self) -> str:
|
||
return self.get_setting("active_class") or ""
|
||
|
||
def is_locked(self, class_name: str) -> bool:
|
||
with self.lock:
|
||
row = self.connection.execute(
|
||
"SELECT locked FROM class_controls WHERE class_name=?", (class_name,)
|
||
).fetchone()
|
||
return bool(row and row["locked"])
|
||
|
||
def create_session(self, role: str, username: str, student_id: int | None = None) -> str:
|
||
token = secrets.token_urlsafe(32)
|
||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||
now = int(time.time())
|
||
with self.lock, self.connection:
|
||
self.connection.execute(
|
||
"INSERT INTO sessions(token_hash,role,username,student_id,created_at,expires_at) VALUES(?,?,?,?,?,?)",
|
||
(token_hash, role, username, student_id, now, now + SESSION_DAYS * 86400),
|
||
)
|
||
self.connection.execute(
|
||
"DELETE FROM sessions WHERE expires_at < ?", (now,)
|
||
)
|
||
return token
|
||
|
||
def get_session(self, token: str | None) -> dict[str, Any] | None:
|
||
if not token:
|
||
return None
|
||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||
now = int(time.time())
|
||
with self.lock:
|
||
row = self.connection.execute(
|
||
"""SELECT s.token_hash,s.role,s.username,s.student_id,s.expires_at,
|
||
st.class_name,st.student_no,st.name,st.must_change_password
|
||
FROM sessions s LEFT JOIN students st ON st.id=s.student_id
|
||
WHERE s.token_hash=? AND s.expires_at>?""",
|
||
(token_hash, now),
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
return dict(row)
|
||
|
||
def destroy_session(self, token: str | None) -> None:
|
||
if not token:
|
||
return
|
||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||
with self.lock, self.connection:
|
||
self.connection.execute("DELETE FROM sessions WHERE token_hash=?", (token_hash,))
|
||
|
||
def login_student(self, student_no: str, password: str) -> tuple[dict[str, Any] | None, str]:
|
||
student_no = student_no.strip()
|
||
active = self.active_class()
|
||
with self.lock:
|
||
student = self.connection.execute(
|
||
"SELECT * FROM students WHERE student_no=?", (student_no,)
|
||
).fetchone()
|
||
if student is None or not verify_password(password, student["password_hash"]):
|
||
return None, "学号或密码不正确"
|
||
if student["class_name"] != active:
|
||
return None, f"当前仅开放 {active} 班登录,{student['class_name']} 班暂不能登录"
|
||
token = self.create_session("student", student["student_no"], student["id"])
|
||
return {
|
||
"token": token,
|
||
"student": {
|
||
"id": student["id"],
|
||
"name": student["name"],
|
||
"student_no": student["student_no"],
|
||
"class_name": student["class_name"],
|
||
"must_change_password": bool(student["must_change_password"]),
|
||
},
|
||
}, "登录成功"
|
||
|
||
def login_teacher(self, username: str, password: str) -> tuple[dict[str, Any] | None, str]:
|
||
with self.lock:
|
||
expected_user = self.get_setting("teacher_username") or TEACHER_USER
|
||
expected_password = self.get_setting("teacher_password")
|
||
if username.strip() != expected_user or not verify_password(password, expected_password):
|
||
return None, "教师账号或密码不正确"
|
||
token = self.create_session("teacher", expected_user, None)
|
||
return {"token": token, "teacher": {"username": expected_user}}, "登录成功"
|
||
|
||
def change_student_password(
|
||
self, session: dict[str, Any], old_password: str, new_password: str
|
||
) -> tuple[bool, str]:
|
||
student_id = session.get("student_id")
|
||
if not student_id:
|
||
return False, "学生会话无效"
|
||
old_password = old_password or ""
|
||
new_password = new_password or ""
|
||
if len(new_password) < 8:
|
||
return False, "新密码至少 8 位"
|
||
if new_password == old_password:
|
||
return False, "新密码不能与原密码相同"
|
||
with self.lock:
|
||
student = self.connection.execute(
|
||
"SELECT * FROM students WHERE id=?", (student_id,)
|
||
).fetchone()
|
||
if student is None or not verify_password(old_password, student["password_hash"]):
|
||
return False, "原密码不正确"
|
||
if new_password == student["student_no"]:
|
||
return False, "新密码不能设置为学号"
|
||
with self.connection:
|
||
self.connection.execute(
|
||
"UPDATE students SET password_hash=?, must_change_password=0 WHERE id=?",
|
||
(hash_password(new_password), student_id),
|
||
)
|
||
token_hash = hashlib.sha256(session["_token"].encode()).hexdigest()
|
||
self.connection.execute(
|
||
"DELETE FROM sessions WHERE student_id=? AND token_hash<>?",
|
||
(student_id, token_hash),
|
||
)
|
||
return True, "密码修改成功"
|
||
|
||
def get_student(self, student_id: int) -> dict[str, Any] | None:
|
||
with self.lock:
|
||
row = self.connection.execute("SELECT * FROM students WHERE id=?", (student_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
def student_membership(self, student_id: int) -> dict[str, Any] | None:
|
||
with self.lock:
|
||
row = self.connection.execute(
|
||
"""SELECT m.group_id,g.name,g.class_name,g.leader_student_id
|
||
FROM group_members m JOIN groups g ON g.id=m.group_id
|
||
WHERE m.student_id=?""",
|
||
(student_id,),
|
||
).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
def class_snapshot(self, class_name: str) -> dict[str, Any]:
|
||
with self.lock:
|
||
groups = self.connection.execute(
|
||
"""SELECT g.id,g.name,g.leader_student_id,g.created_at,
|
||
s.student_no AS leader_student_no,s.name AS leader_name
|
||
FROM groups g LEFT JOIN students s ON s.id=g.leader_student_id
|
||
WHERE g.class_name=? ORDER BY g.id""",
|
||
(class_name,),
|
||
).fetchall()
|
||
group_ids = [row["id"] for row in groups]
|
||
members: dict[int, list[dict[str, Any]]] = {row["id"]: [] for row in groups}
|
||
if group_ids:
|
||
placeholders = ",".join("?" for _ in group_ids)
|
||
member_rows = self.connection.execute(
|
||
f"""SELECT m.group_id,st.id,st.student_no,st.name,st.gender,g.leader_student_id
|
||
FROM group_members m JOIN students st ON st.id=m.student_id
|
||
JOIN groups g ON g.id=m.group_id
|
||
WHERE m.group_id IN ({placeholders})
|
||
ORDER BY CASE WHEN st.id=g.leader_student_id THEN 0 ELSE 1 END,m.joined_at,st.student_no""",
|
||
group_ids,
|
||
).fetchall()
|
||
pending = [dict(row) for row in member_rows]
|
||
for member in pending:
|
||
member["is_leader"] = member["id"] == member["leader_student_id"]
|
||
member.pop("leader_student_id", None)
|
||
members[member["group_id"]].append(member)
|
||
|
||
ungrouped_rows = self.connection.execute(
|
||
"""SELECT id,student_no,name,gender FROM students
|
||
WHERE class_name=? AND id NOT IN (SELECT student_id FROM group_members)
|
||
ORDER BY student_no""",
|
||
(class_name,),
|
||
).fetchall()
|
||
total = self.connection.execute(
|
||
"SELECT COUNT(*) AS n FROM students WHERE class_name=?", (class_name,)
|
||
).fetchone()["n"]
|
||
grouped = self.connection.execute(
|
||
"""SELECT COUNT(*) AS n FROM group_members m
|
||
JOIN students s ON s.id=m.student_id WHERE s.class_name=?""",
|
||
(class_name,),
|
||
).fetchone()["n"]
|
||
|
||
group_data = []
|
||
for row in groups:
|
||
group_data.append(
|
||
{
|
||
**dict(row),
|
||
"locked": self.is_locked(class_name),
|
||
"member_count": len(members[row["id"]]),
|
||
"full": len(members[row["id"]]) >= GROUP_LIMIT,
|
||
"members": members[row["id"]],
|
||
}
|
||
)
|
||
return {
|
||
"class_name": class_name,
|
||
"locked": self.is_locked(class_name),
|
||
"group_limit": GROUP_LIMIT,
|
||
"groups": group_data,
|
||
"ungrouped": [dict(row) for row in ungrouped_rows],
|
||
"statistics": {"total_students": total, "grouped_students": grouped, "group_count": len(group_data)},
|
||
}
|
||
|
||
def create_group(self, student_id: int, group_name: str) -> tuple[bool, str, int | None]:
|
||
student = self.get_student(student_id)
|
||
if student is None:
|
||
return False, "学生不存在", None
|
||
if self.is_locked(student["class_name"]):
|
||
return False, "老师已停止分组操作,当前只能查看", None
|
||
current = self.student_membership(student_id)
|
||
if current is not None:
|
||
return False, "你已加入其他组,不能创建组;请先退出当前组", None
|
||
if student["must_change_password"]:
|
||
return False, "首次登录必须先修改密码", None
|
||
group_name = group_name.strip() or student["name"]
|
||
with self.lock, self.connection:
|
||
count = self.connection.execute(
|
||
"SELECT COUNT(*) AS n FROM groups WHERE class_name=?", (student["class_name"],)
|
||
).fetchone()["n"]
|
||
cursor = self.connection.execute(
|
||
"INSERT INTO groups(class_name,name,leader_student_id) VALUES(?,?,?)",
|
||
(student["class_name"], group_name, student_id),
|
||
)
|
||
group_id = int(cursor.lastrowid)
|
||
self.connection.execute(
|
||
"INSERT INTO group_members(group_id,student_id) VALUES(?,?)",
|
||
(group_id, student_id),
|
||
)
|
||
return True, f"“{group_name}”创建成功,你是组长", group_id
|
||
|
||
def join_group(self, student_id: int, group_id: int, confirmed: bool) -> tuple[bool, str]:
|
||
student = self.get_student(student_id)
|
||
if student is None:
|
||
return False, "学生不存在"
|
||
if student["must_change_password"]:
|
||
return False, "首次登录必须先修改密码"
|
||
if self.is_locked(student["class_name"]):
|
||
return False, "老师已停止分组操作,当前只能查看"
|
||
with self.lock:
|
||
group = self.connection.execute(
|
||
"SELECT * FROM groups WHERE id=?", (group_id,)
|
||
).fetchone()
|
||
current = self.connection.execute(
|
||
"""SELECT m.group_id,g.name,g.leader_student_id
|
||
FROM group_members m JOIN groups g ON g.id=m.group_id WHERE m.student_id=?""",
|
||
(student_id,),
|
||
).fetchone()
|
||
if group is None:
|
||
return False, "目标小组不存在"
|
||
if group["class_name"] != student["class_name"]:
|
||
return False, "不能加入其他班级的小组"
|
||
if current and current["group_id"] == group_id:
|
||
return False, "你已经在这个组里"
|
||
member_count = self.connection.execute(
|
||
"SELECT COUNT(*) AS n FROM group_members WHERE group_id=?", (group_id,)
|
||
).fetchone()["n"]
|
||
if member_count >= GROUP_LIMIT:
|
||
return False, f"该组已有 {GROUP_LIMIT} 人,不能再加入新成员"
|
||
if current and not confirmed:
|
||
role = "组长,原组会被解散" if current["leader_student_id"] == student_id else "成员"
|
||
return False, f"你当前是“{current['name']}”的{role}。加入新组前必须退出原组;确认后才会执行。"
|
||
|
||
with self.lock, self.connection:
|
||
if current:
|
||
if current["leader_student_id"] == student_id:
|
||
self.connection.execute("DELETE FROM groups WHERE id=?", (current["group_id"],))
|
||
else:
|
||
self.connection.execute(
|
||
"DELETE FROM group_members WHERE student_id=?", (student_id,)
|
||
)
|
||
self.connection.execute(
|
||
"INSERT INTO group_members(group_id,student_id) VALUES(?,?)",
|
||
(group_id, student_id),
|
||
)
|
||
return True, "加入成功"
|
||
|
||
def leave_group(self, student_id: int, confirmed: bool) -> tuple[bool, str]:
|
||
if self.get_student(student_id) is None:
|
||
return False, "学生不存在"
|
||
current = self.student_membership(student_id)
|
||
if current is None:
|
||
return False, "你当前没有加入任何小组"
|
||
if self.is_locked(current["class_name"]):
|
||
return False, "老师已停止分组操作,当前只能查看"
|
||
is_leader = current["leader_student_id"] == student_id
|
||
if not confirmed:
|
||
if is_leader:
|
||
return False, "你是组长。退出时你创建的组将被解散,所有成员都会变为未分组;确认后才会执行。"
|
||
return False, "退出后可以再加入其他组;确认后才会执行。"
|
||
with self.lock, self.connection:
|
||
if is_leader:
|
||
self.connection.execute("DELETE FROM groups WHERE id=?", (current["group_id"],))
|
||
else:
|
||
self.connection.execute(
|
||
"DELETE FROM group_members WHERE group_id=? AND student_id=?",
|
||
(current["group_id"], student_id),
|
||
)
|
||
return True, "组长已解散小组" if is_leader else "已退出小组"
|
||
|
||
def leader_remove_member(self, leader_student_id: int, member_student_id: int) -> tuple[bool, str]:
|
||
with self.lock, self.connection:
|
||
leader_group = self.connection.execute(
|
||
"""SELECT m.group_id,g.name,g.class_name,g.leader_student_id
|
||
FROM group_members m JOIN groups g ON g.id=m.group_id
|
||
WHERE m.student_id=?""",
|
||
(leader_student_id,),
|
||
).fetchone()
|
||
if leader_group is None or leader_group["leader_student_id"] != leader_student_id:
|
||
return False, "只有组长可以移出组员"
|
||
if leader_student_id == member_student_id:
|
||
return False, "组长不能移出自己;如需退出请解散小组"
|
||
if self.is_locked(leader_group["class_name"]):
|
||
return False, "老师已停止分组操作,当前只能查看"
|
||
member = self.connection.execute(
|
||
"""SELECT st.name
|
||
FROM group_members m JOIN students st ON st.id=m.student_id
|
||
WHERE m.student_id=? AND m.group_id=?""",
|
||
(member_student_id, leader_group["group_id"]),
|
||
).fetchone()
|
||
if member is None:
|
||
return False, "该学生不在你的小组中"
|
||
self.connection.execute(
|
||
"DELETE FROM group_members WHERE student_id=? AND group_id=?",
|
||
(member_student_id, leader_group["group_id"]),
|
||
)
|
||
return True, f"已将 {member['name']} 移出小组"
|
||
|
||
def switch_class(self, class_name: str) -> tuple[bool, str]:
|
||
with self.lock:
|
||
row = self.connection.execute(
|
||
"SELECT COUNT(*) AS n FROM students WHERE class_name=?", (class_name,)
|
||
).fetchone()
|
||
if row["n"] == 0:
|
||
return False, "班级不存在"
|
||
self.set_setting("active_class", class_name)
|
||
return True, f"已切换到 {class_name} 班,仅该班学生可登录"
|
||
|
||
def set_locked(self, class_name: str, locked: bool) -> tuple[bool, str]:
|
||
with self.lock:
|
||
exists = self.connection.execute(
|
||
"SELECT 1 FROM students WHERE class_name=? LIMIT 1", (class_name,)
|
||
).fetchone()
|
||
if not exists:
|
||
return False, "班级不存在"
|
||
with self.lock, self.connection:
|
||
self.connection.execute(
|
||
"""INSERT INTO class_controls(class_name,locked) VALUES(?,?)
|
||
ON CONFLICT(class_name) DO UPDATE SET locked=excluded.locked""",
|
||
(class_name, int(locked)),
|
||
)
|
||
return True, ("已停止学生操作" if locked else "已恢复学生操作")
|
||
|
||
def _require_teacher_group(self, class_name: str) -> tuple[bool, str, sqlite3.Row | None]:
|
||
if not any(item["class_name"] == class_name for item in self.class_list()):
|
||
return False, "班级不存在", None
|
||
return True, "", None
|
||
|
||
def teacher_create_group(self, class_name: str, name: str) -> tuple[bool, str]:
|
||
if not self._require_teacher_group(class_name)[0]:
|
||
return False, "班级不存在"
|
||
name = name.strip() or "未命名小组"
|
||
with self.lock, self.connection:
|
||
self.connection.execute(
|
||
"INSERT INTO groups(class_name,name,leader_student_id) VALUES(?,?,NULL)",
|
||
(class_name, name),
|
||
)
|
||
return True, f"小组“{name}”已创建"
|
||
|
||
def teacher_rename_group(self, group_id: int, name: str) -> tuple[bool, str]:
|
||
name = name.strip()
|
||
if not name:
|
||
return False, "组名不能为空"
|
||
with self.lock, self.connection:
|
||
cursor = self.connection.execute("UPDATE groups SET name=? WHERE id=?", (name, group_id))
|
||
if cursor.rowcount == 0:
|
||
return False, "小组不存在"
|
||
return True, "组名已保存"
|
||
|
||
def teacher_set_leader(self, group_id: int, student_id: int | None) -> tuple[bool, str]:
|
||
with self.lock, self.connection:
|
||
group = self.connection.execute("SELECT * FROM groups WHERE id=?", (group_id,)).fetchone()
|
||
if group is None:
|
||
return False, "小组不存在"
|
||
if student_id is None:
|
||
self.connection.execute(
|
||
"UPDATE groups SET leader_student_id=NULL WHERE id=?", (group_id,)
|
||
)
|
||
return True, "已取消组长"
|
||
member = self.connection.execute(
|
||
"SELECT 1 FROM group_members WHERE group_id=? AND student_id=?",
|
||
(group_id, student_id),
|
||
).fetchone()
|
||
if not member:
|
||
return False, "组长必须是该组成员"
|
||
self.connection.execute(
|
||
"UPDATE groups SET leader_student_id=? WHERE id=?", (student_id, group_id)
|
||
)
|
||
return True, "组长已设置"
|
||
|
||
def teacher_add_member(self, group_id: int, student_id: int) -> tuple[bool, str]:
|
||
with self.lock, self.connection:
|
||
group = self.connection.execute("SELECT * FROM groups WHERE id=?", (group_id,)).fetchone()
|
||
student = self.connection.execute("SELECT * FROM students WHERE id=?", (student_id,)).fetchone()
|
||
if group is None or student is None:
|
||
return False, "小组或学生不存在"
|
||
if group["class_name"] != student["class_name"]:
|
||
return False, "不能跨班调整"
|
||
current_group = self.connection.execute(
|
||
"SELECT group_id FROM group_members WHERE student_id=?", (student_id,)
|
||
).fetchone()
|
||
if current_group and current_group["group_id"] == group_id:
|
||
return False, "学生已在该组"
|
||
count = self.connection.execute(
|
||
"SELECT COUNT(*) AS n FROM group_members WHERE group_id=?", (group_id,)
|
||
).fetchone()["n"]
|
||
if count >= GROUP_LIMIT:
|
||
return False, f"该组已有 {GROUP_LIMIT} 人;请先移出一人"
|
||
if current_group:
|
||
self.connection.execute(
|
||
"DELETE FROM group_members WHERE student_id=?", (student_id,)
|
||
)
|
||
self.connection.execute(
|
||
"UPDATE groups SET leader_student_id=NULL WHERE id=? AND leader_student_id=?",
|
||
(current_group["group_id"], student_id),
|
||
)
|
||
self.connection.execute(
|
||
"INSERT INTO group_members(group_id,student_id) VALUES(?,?)",
|
||
(group_id, student_id),
|
||
)
|
||
return True, "已加入小组"
|
||
|
||
def teacher_remove_member(self, student_id: int) -> tuple[bool, str]:
|
||
with self.lock, self.connection:
|
||
membership = self.connection.execute(
|
||
"SELECT * FROM group_members WHERE student_id=?", (student_id,)
|
||
).fetchone()
|
||
if not membership:
|
||
return False, "学生当前未分组"
|
||
self.connection.execute(
|
||
"DELETE FROM group_members WHERE student_id=?", (student_id,)
|
||
)
|
||
self.connection.execute(
|
||
"""UPDATE groups SET leader_student_id=NULL
|
||
WHERE leader_student_id=?""", (student_id,)
|
||
)
|
||
return True, "已移出小组"
|
||
|
||
def teacher_disband_group(self, group_id: int) -> tuple[bool, str]:
|
||
with self.lock, self.connection:
|
||
cursor = self.connection.execute("DELETE FROM groups WHERE id=?", (group_id,))
|
||
if cursor.rowcount == 0:
|
||
return False, "小组不存在"
|
||
return True, "小组已解散"
|
||
|
||
def export_groups(self, class_name: str | None = None) -> tuple[bytes, str]:
|
||
with self.lock:
|
||
if class_name:
|
||
class_filter = "WHERE g.class_name=?"
|
||
params: tuple[Any, ...] = (class_name,)
|
||
else:
|
||
class_filter = ""
|
||
params = ()
|
||
class_name = None
|
||
group_rows = self.connection.execute(
|
||
f"""SELECT g.id,g.class_name,g.name,s1.student_no leader_no,s1.name leader_name
|
||
FROM groups g LEFT JOIN students s1 ON s1.id=g.leader_student_id
|
||
{class_filter} ORDER BY g.class_name,g.id""",
|
||
params,
|
||
).fetchall()
|
||
member_rows = self.connection.execute(
|
||
f"""SELECT m.group_id,s.student_no,s.name,s.gender,s.class_name
|
||
FROM group_members m JOIN groups g ON g.id=m.group_id
|
||
JOIN students s ON s.id=m.student_id
|
||
{class_filter}
|
||
ORDER BY g.class_name,g.id,CASE WHEN s.id=g.leader_student_id THEN 0 ELSE 1 END,s.student_no""",
|
||
params,
|
||
).fetchall()
|
||
ungrouped_rows = self.connection.execute(
|
||
f"""SELECT s.class_name,s.student_no,s.name,s.gender
|
||
FROM students s WHERE s.id NOT IN (SELECT student_id FROM group_members)
|
||
{'AND s.class_name=?' if class_name else ''}
|
||
ORDER BY s.class_name,s.student_no""",
|
||
((class_name,) if class_name else ()),
|
||
).fetchall()
|
||
|
||
by_group: dict[int, list[sqlite3.Row]] = {}
|
||
for row in member_rows:
|
||
by_group.setdefault(row["group_id"], []).append(row)
|
||
summary_headers = ["序号", "班级", "组名", "组内序号", "学号", "姓名", "性别", "是否组长"]
|
||
summary_records = []
|
||
row_number = 0
|
||
for group in group_rows:
|
||
for member_number, member in enumerate(by_group.get(group["id"], []), 1):
|
||
row_number += 1
|
||
summary_records.append(
|
||
{
|
||
"序号": row_number,
|
||
"班级": member["class_name"],
|
||
"组名": group["name"],
|
||
"组内序号": member_number,
|
||
"学号": member["student_no"],
|
||
"姓名": member["name"],
|
||
"性别": member["gender"],
|
||
"是否组长": "是" if member["student_no"] == group["leader_no"] else "否",
|
||
}
|
||
)
|
||
if not summary_records:
|
||
summary_records.append({
|
||
"序号": "", "班级": "", "组名": "暂无分组", "组内序号": "",
|
||
"学号": "", "姓名": "", "性别": "", "是否组长": "",
|
||
})
|
||
|
||
ungrouped_headers = ["序号", "班级", "学号", "姓名", "性别", "说明"]
|
||
ungrouped_records = [
|
||
{
|
||
"序号": index,
|
||
"班级": row["class_name"],
|
||
"学号": row["student_no"],
|
||
"姓名": row["name"],
|
||
"性别": row["gender"],
|
||
"说明": "未分组",
|
||
}
|
||
for index, row in enumerate(ungrouped_rows, 1)
|
||
]
|
||
if not ungrouped_records:
|
||
ungrouped_records.append({
|
||
"序号": "", "班级": "", "学号": "", "姓名": "", "性别": "", "说明": "全部学生均已分组"
|
||
})
|
||
|
||
title = f"{class_name}班" if class_name else "全部班级"
|
||
xlsx = write_xlsx(
|
||
[
|
||
(f"{title}分组", summary_headers, summary_records),
|
||
("未分组学生", ungrouped_headers, ungrouped_records),
|
||
]
|
||
)
|
||
return xlsx, export_filename("学生分组", class_name)
|
||
|
||
|
||
STORE = GroupStore(DB_PATH)
|
||
|
||
|
||
def json_response(status: int, payload: Any, cookies: list[dict[str, Any]] | None = None):
|
||
from http.server import BaseHTTPRequestHandler
|
||
|
||
def send(handler: BaseHTTPRequestHandler):
|
||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
handler.send_response(status)
|
||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||
handler.send_header("Cache-Control", "no-store")
|
||
handler.send_header("Content-Length", str(len(body)))
|
||
for cookie in cookies or []:
|
||
handler.send_header("Set-Cookie", cookie["value"])
|
||
handler.end_headers()
|
||
handler.wfile.write(body)
|
||
|
||
return send
|
||
|
||
|
||
def make_session_cookie(token: str) -> str:
|
||
return f"GROUP_SESSION={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_DAYS * 86400}"
|
||
|
||
|
||
def clear_session_cookie() -> str:
|
||
return "GROUP_SESSION=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||
|
||
|
||
class GroupRequestHandler:
|
||
server_version = "StudentGroupSystem/1.0"
|
||
|
||
def __init__(self, handler):
|
||
self.handler = handler
|
||
|
||
def cookies(self) -> dict[str, str]:
|
||
header = self.handler.headers.get("Cookie", "")
|
||
result = {}
|
||
for part in header.split(";"):
|
||
if "=" in part:
|
||
key, value = part.strip().split("=", 1)
|
||
result[key] = urllib.parse.unquote(value)
|
||
return result
|
||
|
||
def token(self) -> str | None:
|
||
return self.cookies().get("GROUP_SESSION")
|
||
|
||
def session(self) -> dict[str, Any] | None:
|
||
result = STORE.get_session(self.token())
|
||
if result:
|
||
result["_token"] = self.token()
|
||
return result
|
||
|
||
def read_json(self) -> dict[str, Any]:
|
||
length = int(self.handler.headers.get("Content-Length", "0") or 0)
|
||
if length <= 0:
|
||
return {}
|
||
if length > 1024 * 1024:
|
||
raise ValueError("请求体过大")
|
||
raw = self.handler.rfile.read(length)
|
||
return json.loads(raw.decode("utf-8"))
|
||
|
||
def require_teacher(self):
|
||
session = self.session()
|
||
if not session or session["role"] != "teacher":
|
||
return None, json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录教师后台"})
|
||
return session, None
|
||
|
||
def require_student(self):
|
||
session = self.session()
|
||
if not session or session["role"] != "student" or not session["student_id"]:
|
||
return None, json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录"})
|
||
return session, None
|
||
|
||
def handle_api(self, action: str, method: str):
|
||
try:
|
||
return self.dispatch_api(action, method)
|
||
except (ValueError, sqlite3.IntegrityError, sqlite3.OperationalError) as exc:
|
||
LOG.warning("API error %s %s: %s", method, action, exc)
|
||
return json_response(HTTPStatus.BAD_REQUEST, {"ok": False, "message": str(exc)})
|
||
except Exception:
|
||
LOG.exception("Unexpected API error")
|
||
return json_response(HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "message": "服务器内部错误"})
|
||
|
||
def dispatch_api(self, action: str, method: str):
|
||
if action == "login" and method == "POST":
|
||
data = self.read_json()
|
||
result, message = STORE.login_student(data.get("username", ""), data.get("password", ""))
|
||
if not result:
|
||
return json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": message})
|
||
return json_response(
|
||
HTTPStatus.OK,
|
||
{"ok": True, "message": message, "user": result["student"]},
|
||
[{"value": make_session_cookie(result["token"])}],
|
||
)
|
||
|
||
if action == "teacher-login" and method == "POST":
|
||
data = self.read_json()
|
||
result, message = STORE.login_teacher(data.get("username", ""), data.get("password", ""))
|
||
if not result:
|
||
return json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": message})
|
||
return json_response(
|
||
HTTPStatus.OK,
|
||
{"ok": True, "message": message, "user": result["teacher"]},
|
||
[{"value": make_session_cookie(result["token"])}],
|
||
)
|
||
|
||
if action == "logout" and method == "POST":
|
||
STORE.destroy_session(self.token())
|
||
return json_response(HTTPStatus.OK, {"ok": True, "message": "已退出"}, [{"value": clear_session_cookie()}])
|
||
|
||
if action == "state" and method == "GET":
|
||
session = self.session()
|
||
if not session:
|
||
return json_response(HTTPStatus.OK, {"ok": True, "authenticated": False})
|
||
active = STORE.active_class()
|
||
common = {
|
||
"ok": True,
|
||
"authenticated": True,
|
||
"role": session["role"],
|
||
"active_class": active,
|
||
"classes": STORE.class_list() if session["role"] == "teacher" else [],
|
||
}
|
||
if session["role"] == "teacher":
|
||
selected = self.handler.path.split("class=", 1)[1].split("&", 1)[0] if "class=" in self.handler.path else active
|
||
selected = urllib.parse.unquote(selected)
|
||
if selected and selected != active:
|
||
# Teacher can preview any class, but active_class remains explicit.
|
||
return json_response(
|
||
HTTPStatus.OK,
|
||
{
|
||
**common,
|
||
"view_class": selected,
|
||
"snapshot": STORE.class_snapshot(selected),
|
||
},
|
||
)
|
||
return json_response(
|
||
HTTPStatus.OK,
|
||
{
|
||
**common,
|
||
"view_class": active,
|
||
"teacher": {"username": session["username"]},
|
||
"snapshot": STORE.class_snapshot(active),
|
||
},
|
||
)
|
||
|
||
student = STORE.get_student(session["student_id"])
|
||
if student is None:
|
||
return json_response(HTTPStatus.OK, {"ok": True, "authenticated": False})
|
||
membership = STORE.student_membership(student["id"])
|
||
snapshot = STORE.class_snapshot(student["class_name"])
|
||
return json_response(
|
||
HTTPStatus.OK,
|
||
{
|
||
**common,
|
||
"user": {
|
||
"id": student["id"],
|
||
"name": student["name"],
|
||
"student_no": student["student_no"],
|
||
"class_name": student["class_name"],
|
||
"must_change_password": bool(student["must_change_password"]),
|
||
},
|
||
"membership": membership,
|
||
"snapshot": snapshot,
|
||
},
|
||
)
|
||
|
||
if action == "change-password" and method == "POST":
|
||
session, error = self.require_student()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.change_student_password(
|
||
session, data.get("old_password", ""), data.get("new_password", "")
|
||
)
|
||
status = HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST
|
||
return json_response(status, {"ok": ok, "message": message})
|
||
|
||
if action == "groups" and method == "POST":
|
||
session, error = self.require_student()
|
||
if error:
|
||
return error
|
||
student = STORE.get_student(session["student_id"])
|
||
if student and student["must_change_password"]:
|
||
return json_response(HTTPStatus.FORBIDDEN, {"ok": False, "message": "首次登录必须先修改密码"})
|
||
data = self.read_json()
|
||
ok, message, _ = STORE.create_group(session["student_id"], data.get("name", ""))
|
||
return json_response(
|
||
HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST,
|
||
{"ok": ok, "message": message},
|
||
)
|
||
|
||
if action == "join" and method == "POST":
|
||
session, error = self.require_student()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.join_group(
|
||
session["student_id"], int(data.get("group_id", 0)), bool(data.get("confirmed"))
|
||
)
|
||
return json_response(
|
||
HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST,
|
||
{"ok": ok, "message": message, "require_confirm": (not ok and "确认后才会执行" in message)},
|
||
)
|
||
|
||
if action == "leave" and method == "POST":
|
||
session, error = self.require_student()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.leave_group(session["student_id"], bool(data.get("confirmed")))
|
||
return json_response(
|
||
HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST,
|
||
{"ok": ok, "message": message, "require_confirm": (not ok and "确认后才会执行" in message)},
|
||
)
|
||
|
||
if action == "groups/remove-member" and method == "POST":
|
||
session, error = self.require_student()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.leader_remove_member(
|
||
session["student_id"], int(data.get("student_id", 0))
|
||
)
|
||
return json_response(
|
||
HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST,
|
||
{"ok": ok, "message": message},
|
||
)
|
||
|
||
if action == "teacher/class" and method == "POST":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.switch_class(str(data.get("class_name", "")))
|
||
return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message})
|
||
|
||
if action == "teacher/lock" and method == "POST":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
class_name = str(data.get("class_name", STORE.active_class()))
|
||
ok, message = STORE.set_locked(class_name, bool(data.get("locked")))
|
||
return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message})
|
||
|
||
if action == "teacher/create-group" and method == "POST":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.teacher_create_group(
|
||
str(data.get("class_name", STORE.active_class())), data.get("name", "")
|
||
)
|
||
return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message})
|
||
|
||
if action == "teacher/rename-group" and method == "POST":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.teacher_rename_group(int(data.get("group_id", 0)), data.get("name", ""))
|
||
return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message})
|
||
|
||
if action == "teacher/set-leader" and method == "POST":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
student_id = data.get("student_id")
|
||
ok, message = STORE.teacher_set_leader(
|
||
int(data.get("group_id", 0)), int(student_id) if student_id else None
|
||
)
|
||
return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message})
|
||
|
||
if action == "teacher/add-member" and method == "POST":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.teacher_add_member(int(data.get("group_id", 0)), int(data.get("student_id", 0)))
|
||
return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message})
|
||
|
||
if action == "teacher/remove-member" and method == "POST":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.teacher_remove_member(int(data.get("student_id", 0)))
|
||
return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message})
|
||
|
||
if action == "teacher/disband" and method == "POST":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
data = self.read_json()
|
||
ok, message = STORE.teacher_disband_group(int(data.get("group_id", 0)))
|
||
return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message})
|
||
|
||
if action == "teacher/import" and method == "POST":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
length = int(self.handler.headers.get("Content-Length", "0") or 0)
|
||
if length <= 0 or length > 10 * 1024 * 1024:
|
||
return json_response(HTTPStatus.BAD_REQUEST, {"ok": False, "message": "Excel 文件大小无效"})
|
||
raw = self.handler.rfile.read(length)
|
||
temp_path = BASE_DIR / ".upload-roster.xlsx"
|
||
temp_path.write_bytes(raw)
|
||
try:
|
||
student_count, class_count = STORE.import_roster(temp_path)
|
||
finally:
|
||
temp_path.unlink(missing_ok=True)
|
||
return json_response(
|
||
HTTPStatus.OK,
|
||
{"ok": True, "message": f"导入成功:{student_count} 名学生,{class_count} 个班。所有原分组已重置。"},
|
||
)
|
||
|
||
if action == "teacher/export" and method == "GET":
|
||
_, error = self.require_teacher()
|
||
if error:
|
||
return error
|
||
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.handler.path).query)
|
||
class_name = query.get("class", [None])[0]
|
||
if class_name:
|
||
class_name = urllib.parse.unquote(class_name)
|
||
content, filename = STORE.export_groups(class_name)
|
||
|
||
def send(handler):
|
||
handler.send_response(HTTPStatus.OK)
|
||
handler.send_header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
quoted = urllib.parse.quote(filename)
|
||
handler.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{quoted}")
|
||
handler.send_header("Content-Length", str(len(content)))
|
||
handler.send_header("Cache-Control", "no-store")
|
||
handler.end_headers()
|
||
handler.wfile.write(content)
|
||
|
||
return send
|
||
|
||
return json_response(HTTPStatus.NOT_FOUND, {"ok": False, "message": "接口不存在"})
|
||
|
||
|
||
def route(handler):
|
||
parsed = urllib.parse.urlparse(handler.path)
|
||
path = parsed.path
|
||
request = GroupRequestHandler(handler)
|
||
if path.startswith("/api/"):
|
||
result = request.handle_api(path[len("/api/"):], handler.command)
|
||
if callable(result):
|
||
result(handler)
|
||
return
|
||
|
||
if path in ("/", "/index.html", "/student"):
|
||
return serve_file(handler, STATIC_DIR / "index.html")
|
||
if path in ("/teacher", "/admin"):
|
||
return serve_file(handler, STATIC_DIR / "teacher.html")
|
||
safe_path = (STATIC_DIR / urllib.parse.unquote(path).lstrip("/")).resolve()
|
||
try:
|
||
safe_path.relative_to(STATIC_DIR.resolve())
|
||
except ValueError:
|
||
return send_text(handler, HTTPStatus.NOT_FOUND, "Not Found")
|
||
return serve_file(handler, safe_path)
|
||
|
||
|
||
def serve_file(handler, path: Path):
|
||
from http.server import SimpleHTTPRequestHandler
|
||
|
||
path = path if path.is_absolute() else STATIC_DIR / path
|
||
if not path.is_file():
|
||
return send_text(handler, HTTPStatus.NOT_FOUND, "Not Found")
|
||
suffix = path.suffix.lower()
|
||
if suffix in (".html", ".htm"):
|
||
guess = "text/html; charset=utf-8"
|
||
elif suffix == ".css":
|
||
guess = "text/css; charset=utf-8"
|
||
elif suffix in (".js", ".mjs"):
|
||
guess = "text/javascript; charset=utf-8"
|
||
elif suffix == ".svg":
|
||
guess = "image/svg+xml"
|
||
elif suffix == ".png":
|
||
guess = "image/png"
|
||
else:
|
||
guess = "application/octet-stream"
|
||
content = path.read_bytes()
|
||
handler.send_response(HTTPStatus.OK)
|
||
handler.send_header("Content-Type", guess or "application/octet-stream")
|
||
handler.send_header("Content-Length", str(len(content)))
|
||
handler.send_header("Cache-Control", "no-cache")
|
||
handler.end_headers()
|
||
handler.wfile.write(content)
|
||
|
||
|
||
def send_text(handler, status: int, text: str):
|
||
body = text.encode("utf-8")
|
||
handler.send_response(status)
|
||
handler.send_header("Content-Type", "text/plain; charset=utf-8")
|
||
handler.send_header("Content-Length", str(len(body)))
|
||
handler.end_headers()
|
||
handler.wfile.write(body)
|
||
|
||
|
||
class Handler(http.server.BaseHTTPRequestHandler):
|
||
def do_GET(self):
|
||
route(self)
|
||
|
||
def do_POST(self):
|
||
route(self)
|
||
|
||
def do_HEAD(self):
|
||
route(self)
|
||
|
||
def log_message(self, format_string, *args):
|
||
LOG.info("%s - %s", self.address_string(), format_string % args)
|
||
|
||
|
||
def main() -> None:
|
||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
if not any(STORE.class_list()):
|
||
if XLSX_PATH.exists():
|
||
student_count, class_count = STORE.import_roster(XLSX_PATH)
|
||
LOG.info("已导入 %s: %d 名学生 / %d 个班", XLSX_PATH, student_count, class_count)
|
||
else:
|
||
LOG.warning("未找到 %s,请先通过教师后台上传学生名单", XLSX_PATH)
|
||
|
||
host = os.environ.get("HOST", "0.0.0.0")
|
||
port = int(os.environ.get("PORT", "8000"))
|
||
server = http.server.ThreadingHTTPServer((host, port), Handler)
|
||
LOG.info("学生分组系统已启动:http://0.0.0.0:%d", port)
|
||
LOG.info("学生入口:http://<老师电脑IP>:%d/ 教师入口:http://<老师电脑IP>:%d/teacher", port, port)
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
LOG.info("服务停止")
|
||
finally:
|
||
server.server_close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|