rewrited logic structure
This commit is contained in:
15
README.md
15
README.md
@@ -74,6 +74,21 @@ TEACHER_USER=yourname TEACHER_PASSWORD=your-strong-password python3 app.py
|
||||
- `selfgroup.db`:学生、密码、分组、班级状态和登录会话。
|
||||
- `2025mt.xlsx`:首次启动导入的原始名单。
|
||||
|
||||
## 代码结构
|
||||
|
||||
服务端按职责拆分在 `group_system/` 包中:
|
||||
|
||||
```text
|
||||
app.py 启动入口,保留 python3 app.py 的用法
|
||||
group_system/config.py 路径、端口、账号和业务默认值
|
||||
group_system/security.py PBKDF2 密码散列与校验
|
||||
group_system/storage/ SQLite 连接、名单导入、账号、分组、管理、导出
|
||||
group_system/http/ API 适配、静态文件路由和 HTTP 服务器
|
||||
group_system/xlsx_io.py 无第三方依赖的 XLSX 读取/生成
|
||||
```
|
||||
|
||||
前端仍在 `static/` 目录中,接口协议保持不变。
|
||||
|
||||
## 建议部署方式
|
||||
|
||||
1. 在教师电脑上运行 `python3 app.py`。
|
||||
|
||||
1
group_system/__init__.py
Normal file
1
group_system/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""学生自助分组系统应用包。"""
|
||||
17
group_system/config.py
Normal file
17
group_system/config.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""运行配置和环境变量。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.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", "teacher!@#")
|
||||
GROUP_LIMIT = 6
|
||||
SESSION_DAYS = 7
|
||||
MAX_JSON_BYTES = 1024 * 1024
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
1
group_system/http/__init__.py
Normal file
1
group_system/http/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""HTTP 路由和响应工具。"""
|
||||
358
group_system/http/api.py
Normal file
358
group_system/http/api.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""学生和教师 API 适配层。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from group_system.config import BASE_DIR, MAX_JSON_BYTES, MAX_UPLOAD_BYTES
|
||||
from group_system.http.responses import (
|
||||
clear_session_cookie,
|
||||
json_response,
|
||||
make_session_cookie,
|
||||
)
|
||||
from group_system.storage.store import GroupStore
|
||||
|
||||
LOG = logging.getLogger("group-system")
|
||||
|
||||
|
||||
class GroupRequestHandler:
|
||||
"""把 HTTP 请求转换成 GroupStore 调用,不直接访问数据库。"""
|
||||
|
||||
server_version = "StudentGroupSystem/1.0"
|
||||
|
||||
def __init__(self, handler, store: GroupStore):
|
||||
self.handler = handler
|
||||
self.store = store
|
||||
|
||||
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 = self.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 > MAX_JSON_BYTES:
|
||||
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 = self.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 = self.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":
|
||||
self.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 = self.store.active_class()
|
||||
common = {
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"role": session["role"],
|
||||
"active_class": active,
|
||||
"classes": self.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": self.store.class_snapshot(selected),
|
||||
},
|
||||
)
|
||||
return json_response(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
**common,
|
||||
"view_class": active,
|
||||
"teacher": {"username": session["username"]},
|
||||
"snapshot": self.store.class_snapshot(active),
|
||||
},
|
||||
)
|
||||
|
||||
student = self.store.get_student(session["student_id"])
|
||||
if student is None:
|
||||
return json_response(HTTPStatus.OK, {"ok": True, "authenticated": False})
|
||||
membership = self.store.student_membership(student["id"])
|
||||
snapshot = self.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 = self.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 == "teacher/change-password" and method == "POST":
|
||||
session, error = self.require_teacher()
|
||||
if error:
|
||||
return error
|
||||
data = self.read_json()
|
||||
ok, message = self.store.change_teacher_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 == "teacher/group-limit" and method == "POST":
|
||||
_, error = self.require_teacher()
|
||||
if error:
|
||||
return error
|
||||
data = self.read_json()
|
||||
ok, message = self.store.set_group_limit(int(data.get("limit", 0)))
|
||||
return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {
|
||||
"ok": ok, "message": message, "group_limit": self.store.group_limit()
|
||||
})
|
||||
|
||||
if action == "groups" and method == "POST":
|
||||
session, error = self.require_student()
|
||||
if error:
|
||||
return error
|
||||
student = self.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, _ = self.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 = self.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 = self.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 = self.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 = self.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", self.store.active_class()))
|
||||
ok, message = self.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 = self.store.teacher_create_group(
|
||||
str(data.get("class_name", self.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 = self.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 = self.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 = self.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 = self.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 = self.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 > MAX_UPLOAD_BYTES:
|
||||
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 = self.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 = self.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": "接口不存在"})
|
||||
41
group_system/http/responses.py
Normal file
41
group_system/http/responses.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""HTTP 响应和 Cookie 工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from typing import Any
|
||||
|
||||
from group_system.config import SESSION_DAYS
|
||||
|
||||
|
||||
def json_response(
|
||||
status: int | HTTPStatus,
|
||||
payload: Any,
|
||||
cookies: list[dict[str, Any]] | None = None,
|
||||
):
|
||||
"""Return a callable that serializes and sends a JSON response."""
|
||||
def send(handler: BaseHTTPRequestHandler):
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
handler.send_response(int(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; "
|
||||
f"Max-Age={SESSION_DAYS * 86400}"
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie() -> str:
|
||||
return "GROUP_SESSION=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||||
138
group_system/http/server.py
Normal file
138
group_system/http/server.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""HTTP 服务器、静态路由和应用启动流程。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
|
||||
from group_system.config import STATIC_DIR, XLSX_PATH
|
||||
from group_system.http.api import GroupRequestHandler
|
||||
from group_system.http.static_files import send_text, serve_file
|
||||
from group_system.storage.store import GroupStore
|
||||
|
||||
LOG = logging.getLogger("group-system")
|
||||
|
||||
|
||||
class GroupHTTPServer(http.server.ThreadingHTTPServer):
|
||||
"""Carry the application store on the standard threading HTTP server."""
|
||||
|
||||
def __init__(self, address: tuple[str, int], store: GroupStore):
|
||||
super().__init__(address, RequestHandler)
|
||||
self.store = store
|
||||
|
||||
|
||||
class RequestHandler(http.server.BaseHTTPRequestHandler):
|
||||
@property
|
||||
def store(self) -> GroupStore:
|
||||
return self.server.store # type: ignore[attr-defined]
|
||||
|
||||
def do_GET(self) -> None:
|
||||
route(self)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
route(self)
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
route(self)
|
||||
|
||||
def log_message(self, format_string: str, *args) -> None:
|
||||
LOG.info("%s - %s", self.address_string(), format_string % args)
|
||||
|
||||
|
||||
def route(handler: RequestHandler) -> None:
|
||||
path = urllib.parse.urlparse(handler.path).path
|
||||
if path.startswith("/api/"):
|
||||
request = GroupRequestHandler(handler, handler.store)
|
||||
result = request.handle_api(path[len("/api/"):], handler.command)
|
||||
if callable(result):
|
||||
result(handler)
|
||||
return
|
||||
|
||||
if path in ("/", "/index.html", "/student"):
|
||||
serve_file(handler, STATIC_DIR / "index.html")
|
||||
return
|
||||
if path in ("/teacher", "/admin"):
|
||||
serve_file(handler, STATIC_DIR / "teacher.html")
|
||||
return
|
||||
|
||||
static_root = STATIC_DIR.resolve()
|
||||
safe_path = (static_root / urllib.parse.unquote(path).lstrip("/")).resolve()
|
||||
try:
|
||||
safe_path.relative_to(static_root)
|
||||
except ValueError:
|
||||
send_text(handler, HTTPStatus.NOT_FOUND, "Not Found")
|
||||
return
|
||||
serve_file(handler, safe_path)
|
||||
|
||||
|
||||
def local_ipv4_addresses() -> list[str]:
|
||||
addresses: set[str] = set()
|
||||
try:
|
||||
host_name = socket.gethostname()
|
||||
for item in socket.getaddrinfo(host_name, None, socket.AF_INET):
|
||||
address = item[4][0]
|
||||
if address != "127.0.0.1":
|
||||
addresses.add(address)
|
||||
except socket.gaierror:
|
||||
pass
|
||||
|
||||
# A UDP connection does not send packets here, but reveals the default-route address.
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
probe.connect(("192.0.2.1", 9))
|
||||
address = probe.getsockname()[0]
|
||||
if address != "127.0.0.1":
|
||||
addresses.add(address)
|
||||
finally:
|
||||
probe.close()
|
||||
except OSError:
|
||||
pass
|
||||
return sorted(addresses)
|
||||
|
||||
|
||||
def bootstrap_store(store: GroupStore, xlsx_path: Path) -> None:
|
||||
"""Import the initial roster only when the database is still empty."""
|
||||
if any(store.class_list()):
|
||||
return
|
||||
if not xlsx_path.exists():
|
||||
LOG.warning("未找到 %s,请先通过教师后台上传学生名单", xlsx_path)
|
||||
return
|
||||
student_count, class_count = store.import_roster(xlsx_path)
|
||||
LOG.info("已导入 %s: %d 名学生 / %d 个班", xlsx_path, student_count, class_count)
|
||||
|
||||
|
||||
def run(
|
||||
store: GroupStore,
|
||||
xlsx_path: Path = XLSX_PATH,
|
||||
host: str | None = None,
|
||||
port: int | None = None,
|
||||
) -> None:
|
||||
bootstrap_store(store, xlsx_path)
|
||||
host = host or os.environ.get("HOST", "0.0.0.0")
|
||||
port = port if port is not None else int(os.environ.get("PORT", "8000"))
|
||||
server = GroupHTTPServer((host, port), store)
|
||||
LOG.info("学生分组系统已启动:http://0.0.0.0:%d", port)
|
||||
addresses = local_ipv4_addresses()
|
||||
if addresses:
|
||||
for address in addresses:
|
||||
LOG.info(
|
||||
"学生入口:http://%s:%d/ 教师入口:http://%s:%d/teacher",
|
||||
address,
|
||||
port,
|
||||
address,
|
||||
port,
|
||||
)
|
||||
else:
|
||||
LOG.warning("未能自动获取局域网 IP,请在教师机上查看网络设置;端口:%d", port)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
LOG.info("服务停止")
|
||||
finally:
|
||||
server.server_close()
|
||||
43
group_system/http/static_files.py
Normal file
43
group_system/http/static_files.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""静态页面与资源文件服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
from group_system.config import STATIC_DIR
|
||||
|
||||
CONTENT_TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
}
|
||||
|
||||
|
||||
def send_text(handler: BaseHTTPRequestHandler, status: int, text: str) -> None:
|
||||
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)
|
||||
|
||||
|
||||
def serve_file(handler: BaseHTTPRequestHandler, path: Path) -> None:
|
||||
path = path if path.is_absolute() else STATIC_DIR / path
|
||||
if not path.is_file():
|
||||
send_text(handler, HTTPStatus.NOT_FOUND, "Not Found")
|
||||
return
|
||||
content_type = CONTENT_TYPES.get(path.suffix.lower(), "application/octet-stream")
|
||||
content = path.read_bytes()
|
||||
handler.send_response(HTTPStatus.OK)
|
||||
handler.send_header("Content-Type", content_type)
|
||||
handler.send_header("Content-Length", str(len(content)))
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(content)
|
||||
26
group_system/security.py
Normal file
26
group_system/security.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""密码散列与校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
|
||||
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)
|
||||
1
group_system/storage/__init__.py
Normal file
1
group_system/storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""SQLite 存储层。"""
|
||||
152
group_system/storage/accounts.py
Normal file
152
group_system/storage/accounts.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""账号登录、会话和密码管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from group_system.config import SESSION_DAYS, TEACHER_USER
|
||||
from group_system.security import hash_password, verify_password
|
||||
|
||||
|
||||
class AccountStoreMixin:
|
||||
|
||||
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_teacher_password(
|
||||
self, session: dict[str, Any], old_password: str, new_password: str
|
||||
) -> tuple[bool, str]:
|
||||
if session.get("role") != "teacher":
|
||||
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:
|
||||
expected_user = self.get_setting("teacher_username") or TEACHER_USER
|
||||
if session.get("username") != expected_user:
|
||||
return False, "教师账号已变更,请重新登录"
|
||||
stored_password = self.get_setting("teacher_password")
|
||||
if not verify_password(old_password, stored_password):
|
||||
return False, "原密码不正确"
|
||||
self.set_setting("teacher_password", hash_password(new_password))
|
||||
return True, "教师密码修改成功"
|
||||
|
||||
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
|
||||
136
group_system/storage/admin.py
Normal file
136
group_system/storage/admin.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""教师端班级和分组管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
class TeacherGroupAdminMixin:
|
||||
|
||||
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"]
|
||||
group_limit = self.group_limit()
|
||||
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, "小组已解散"
|
||||
142
group_system/storage/database.py
Normal file
142
group_system/storage/database.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""数据库连接、Schema 和系统级设置。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from group_system.config import GROUP_LIMIT, TEACHER_PASSWORD, TEACHER_USER
|
||||
from group_system.security import hash_password
|
||||
|
||||
|
||||
class DatabaseCore:
|
||||
|
||||
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),),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT OR IGNORE INTO settings(key,value) VALUES('group_limit',?)",
|
||||
(str(GROUP_LIMIT),),
|
||||
)
|
||||
|
||||
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 group_limit(self) -> int:
|
||||
value = self.get_setting("group_limit")
|
||||
try:
|
||||
limit = int(value or GROUP_LIMIT)
|
||||
except ValueError:
|
||||
limit = GROUP_LIMIT
|
||||
return max(2, limit)
|
||||
|
||||
def set_group_limit(self, limit: int) -> tuple[bool, str]:
|
||||
if limit < 2 or limit > 100:
|
||||
return False, "分组上限必须在 2 到 100 人之间"
|
||||
self.set_setting("group_limit", str(limit))
|
||||
return True, f"分组上限已调整为 {limit} 人"
|
||||
|
||||
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"])
|
||||
92
group_system/storage/export.py
Normal file
92
group_system/storage/export.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""分组数据导出。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from group_system.xlsx_io import export_filename, write_xlsx
|
||||
|
||||
|
||||
class ExportStoreMixin:
|
||||
|
||||
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)
|
||||
195
group_system/storage/groups.py
Normal file
195
group_system/storage/groups.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""学生端分组用例和班级快照查询。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class GroupOperationsMixin:
|
||||
|
||||
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"]]) >= self.group_limit(),
|
||||
"members": members[row["id"]],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"class_name": class_name,
|
||||
"locked": self.is_locked(class_name),
|
||||
"group_limit": self.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"]
|
||||
group_limit = self.group_limit()
|
||||
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']} 移出小组"
|
||||
69
group_system/storage/roster.py
Normal file
69
group_system/storage/roster.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""学生名单导入。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from group_system.security import hash_password
|
||||
from group_system.xlsx_io import read_xlsx_rows
|
||||
|
||||
|
||||
class RosterStoreMixin:
|
||||
|
||||
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})
|
||||
23
group_system/storage/store.py
Normal file
23
group_system/storage/store.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""GroupStore 组合各个存储模块,作为兼容旧代码的门面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from group_system.storage.accounts import AccountStoreMixin
|
||||
from group_system.storage.admin import TeacherGroupAdminMixin
|
||||
from group_system.storage.database import DatabaseCore
|
||||
from group_system.storage.export import ExportStoreMixin
|
||||
from group_system.storage.groups import GroupOperationsMixin
|
||||
from group_system.storage.roster import RosterStoreMixin
|
||||
|
||||
|
||||
class GroupStore(
|
||||
DatabaseCore,
|
||||
RosterStoreMixin,
|
||||
AccountStoreMixin,
|
||||
GroupOperationsMixin,
|
||||
TeacherGroupAdminMixin,
|
||||
ExportStoreMixin,
|
||||
):
|
||||
"""封装所有 SQLite 持久化操作。HTTP 层只依赖这个门面。"""
|
||||
242
group_system/xlsx_io.py
Normal file
242
group_system/xlsx_io.py
Normal file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal XLSX reader/writer using only the Python standard library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
|
||||
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
NS = {"m": MAIN_NS, "r": REL_NS, "p": PKG_REL_NS}
|
||||
|
||||
|
||||
def _column_number(cell_ref: str) -> int:
|
||||
letters = re.match(r"([A-Z]+)", cell_ref.upper())
|
||||
if not letters:
|
||||
return 0
|
||||
value = 0
|
||||
for letter in letters.group(1):
|
||||
value = value * 26 + ord(letter) - 64
|
||||
return value
|
||||
|
||||
|
||||
def _column_letter(number: int) -> str:
|
||||
result = ""
|
||||
while number:
|
||||
number, remainder = divmod(number - 1, 26)
|
||||
result = chr(65 + remainder) + result
|
||||
return result
|
||||
|
||||
|
||||
def _shared_strings(archive: ZipFile) -> list[str]:
|
||||
if "xl/sharedStrings.xml" not in archive.namelist():
|
||||
return []
|
||||
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
|
||||
values = []
|
||||
for item in root.findall("m:si", NS):
|
||||
values.append("".join(node.text or "" for node in item.iter(f"{{{MAIN_NS}}}t")))
|
||||
return values
|
||||
|
||||
|
||||
def _relationships(archive: ZipFile) -> dict[str, str]:
|
||||
root = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
|
||||
return {item.attrib["Id"]: item.attrib["Target"] for item in root}
|
||||
|
||||
|
||||
def _cell_value(cell, shared: list[str]) -> str:
|
||||
cell_type = cell.attrib.get("t")
|
||||
inline = cell.find("m:is", NS)
|
||||
if cell_type == "inlineStr" and inline is not None:
|
||||
return "".join(node.text or "" for node in inline.iter(f"{{{MAIN_NS}}}t")).strip()
|
||||
value_node = cell.find("m:v", NS)
|
||||
raw = "" if value_node is None else (value_node.text or "")
|
||||
if cell_type == "s" and raw:
|
||||
return shared[int(raw)].strip()
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def read_xlsx_rows(path: str | Path) -> list[dict[str, str]]:
|
||||
"""Read the first worksheet as dictionaries keyed by header text."""
|
||||
path = Path(path)
|
||||
with ZipFile(path, "r") as archive:
|
||||
shared = _shared_strings(archive)
|
||||
relationships = _relationships(archive)
|
||||
workbook = ET.fromstring(archive.read("xl/workbook.xml"))
|
||||
sheets = workbook.findall("m:sheets/m:sheet", NS)
|
||||
if not sheets:
|
||||
raise ValueError("Excel 文件中没有工作表")
|
||||
relationship_id = sheets[0].attrib[f"{{{REL_NS}}}id"]
|
||||
target = relationships[relationship_id].lstrip("/")
|
||||
if not target.startswith("xl/"):
|
||||
target = "xl/" + target
|
||||
worksheet = ET.fromstring(archive.read(target))
|
||||
|
||||
rows: list[dict[int, str]] = []
|
||||
for row in worksheet.findall("m:sheetData/m:row", NS):
|
||||
values: dict[int, str] = {}
|
||||
for cell in row.findall("m:c", NS):
|
||||
number = _column_number(cell.attrib.get("r", "A1"))
|
||||
value = _cell_value(cell, shared)
|
||||
if value != "":
|
||||
values[number] = value
|
||||
rows.append(values)
|
||||
|
||||
header_row_index = -1
|
||||
for index, values in enumerate(rows[:20]):
|
||||
texts = set(values.values())
|
||||
if "学号" in texts and "姓名" in texts:
|
||||
header_row_index = index
|
||||
break
|
||||
if header_row_index < 0:
|
||||
raise ValueError("Excel 中必须包含“学号”和“姓名”表头")
|
||||
|
||||
headers = rows[header_row_index]
|
||||
result: list[dict[str, str]] = []
|
||||
for values in rows[header_row_index + 1:]:
|
||||
row = {headers.get(number, f"列{number}"): value for number, value in values.items()}
|
||||
if any(str(value).strip() for value in row.values()):
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
||||
def _xml_escape(value: object) -> str:
|
||||
return (
|
||||
str(value)
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
def _sheet_xml(headers: list[str], records: list[dict[str, object]]) -> str:
|
||||
all_headers = list(headers)
|
||||
all_records = list(records)
|
||||
row_xml: list[str] = []
|
||||
header_cells = []
|
||||
for column_number, header in enumerate(all_headers, 1):
|
||||
reference = f"{_column_letter(column_number)}1"
|
||||
header_cells.append(
|
||||
f'<c r="{reference}" t="inlineStr"><is><t>{_xml_escape(header)}</t></is></c>'
|
||||
)
|
||||
row_xml.append(f'<row r="1">{"".join(header_cells)}</row>')
|
||||
|
||||
for row_number, record in enumerate(all_records, 2):
|
||||
cells = []
|
||||
for column_number, header in enumerate(all_headers, 1):
|
||||
value = record.get(header, "")
|
||||
reference = f"{_column_letter(column_number)}{row_number}"
|
||||
if isinstance(value, bool):
|
||||
cells.append(f'<c r="{reference}" t="inlineStr"><is><t>{_xml_escape("是" if value else "否")}</t></is></c>')
|
||||
elif isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
cells.append(f'<c r="{reference}"><v>{value}</v></c>')
|
||||
else:
|
||||
text = "" if value is None else str(value)
|
||||
preserve = ' xml:space="preserve"' if text != text.strip() else ""
|
||||
cells.append(
|
||||
f'<c r="{reference}" t="inlineStr"><is><t{preserve}>{_xml_escape(text)}</t></is></c>'
|
||||
)
|
||||
row_xml.append(f'<row r="{row_number}">{"".join(cells)}</row>')
|
||||
|
||||
column_xml = []
|
||||
for number in range(1, len(all_headers) + 1):
|
||||
column_xml.append(f'<col min="{number}" max="{number}" width="18" customWidth="1"/>')
|
||||
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="{MAIN_NS}">
|
||||
<sheetViews><sheetView workbookViewId="0"/></sheetViews>
|
||||
<cols>{"".join(column_xml)}</cols>
|
||||
<sheetData>{"".join(row_xml)}</sheetData>
|
||||
</worksheet>'''
|
||||
|
||||
|
||||
def write_xlsx(sheets: list[tuple[str, list[str], list[dict[str, object]]]]) -> bytes:
|
||||
"""Create a valid XLSX in memory. Each item is (sheet name, headers, records)."""
|
||||
import posixpath
|
||||
|
||||
used_names = set()
|
||||
safe_names = []
|
||||
for index, (name, _, _) in enumerate(sheets, 1):
|
||||
clean = re.sub(r"[\\/?*\\[\\]:]", "-", name).strip() or f"Sheet{index}"
|
||||
clean = clean[:31]
|
||||
candidate = clean
|
||||
suffix = 2
|
||||
while candidate.lower() in used_names:
|
||||
tail = f"-{suffix}"
|
||||
candidate = clean[: 31 - len(tail)] + tail
|
||||
suffix += 1
|
||||
used_names.add(candidate.lower())
|
||||
safe_names.append(candidate)
|
||||
|
||||
sheet_entries = []
|
||||
workbook_entries = []
|
||||
worksheet_files = []
|
||||
for index, name in enumerate(safe_names, 1):
|
||||
filename = f"xl/worksheets/sheet{index}.xml"
|
||||
worksheet_files.append((filename, _sheet_xml(sheets[index - 1][1], sheets[index - 1][2])))
|
||||
sheet_entries.append(
|
||||
f'<sheet name="{_xml_escape(name)}" sheetId="{index}" '
|
||||
f'r:id="rId{index}"/>'
|
||||
)
|
||||
workbook_entries.append(
|
||||
f'<Relationship Id="rId{index}" '
|
||||
f'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
|
||||
f'Target="worksheets/sheet{index}.xml"/>'
|
||||
)
|
||||
style_relationship_id = len(sheets) + 1
|
||||
|
||||
content_overrides = "".join(
|
||||
f'<Override PartName="/xl/worksheets/sheet{index}.xml" '
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
for index in range(1, len(sheets) + 1)
|
||||
)
|
||||
|
||||
files = {
|
||||
"[Content_Types].xml": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>{content_overrides}
|
||||
</Types>''',
|
||||
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>''',
|
||||
"xl/workbook.xml": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="{MAIN_NS}" xmlns:r="{REL_NS}">
|
||||
<sheets>{"".join(sheet_entries)}</sheets>
|
||||
</workbook>''',
|
||||
"xl/_rels/workbook.xml.rels": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{"".join(workbook_entries)}
|
||||
<Relationship Id="rId{style_relationship_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>''',
|
||||
"xl/styles.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
|
||||
<borders count="1"><border/></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
|
||||
</styleSheet>''',
|
||||
}
|
||||
files.update(worksheet_files)
|
||||
|
||||
import io
|
||||
output = io.BytesIO()
|
||||
with ZipFile(output, "w", ZIP_DEFLATED) as archive:
|
||||
for filename, content in files.items():
|
||||
archive.writestr(filename, content.encode("utf-8"))
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def export_filename(prefix: str, class_name: str | None = None) -> str:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
suffix = f"-{class_name}班" if class_name else ""
|
||||
return f"{prefix}{suffix}-{timestamp}.xlsx"
|
||||
243
xlsxio.py
243
xlsxio.py
@@ -1,242 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal XLSX reader/writer using only the Python standard library."""
|
||||
"""Compatibility import facade for the original xlsxio module."""
|
||||
|
||||
from __future__ import annotations
|
||||
from group_system.xlsx_io import export_filename, read_xlsx_rows, write_xlsx
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
|
||||
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
NS = {"m": MAIN_NS, "r": REL_NS, "p": PKG_REL_NS}
|
||||
|
||||
|
||||
def _column_number(cell_ref: str) -> int:
|
||||
letters = re.match(r"([A-Z]+)", cell_ref.upper())
|
||||
if not letters:
|
||||
return 0
|
||||
value = 0
|
||||
for letter in letters.group(1):
|
||||
value = value * 26 + ord(letter) - 64
|
||||
return value
|
||||
|
||||
|
||||
def _column_letter(number: int) -> str:
|
||||
result = ""
|
||||
while number:
|
||||
number, remainder = divmod(number - 1, 26)
|
||||
result = chr(65 + remainder) + result
|
||||
return result
|
||||
|
||||
|
||||
def _shared_strings(archive: ZipFile) -> list[str]:
|
||||
if "xl/sharedStrings.xml" not in archive.namelist():
|
||||
return []
|
||||
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
|
||||
values = []
|
||||
for item in root.findall("m:si", NS):
|
||||
values.append("".join(node.text or "" for node in item.iter(f"{{{MAIN_NS}}}t")))
|
||||
return values
|
||||
|
||||
|
||||
def _relationships(archive: ZipFile) -> dict[str, str]:
|
||||
root = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
|
||||
return {item.attrib["Id"]: item.attrib["Target"] for item in root}
|
||||
|
||||
|
||||
def _cell_value(cell, shared: list[str]) -> str:
|
||||
cell_type = cell.attrib.get("t")
|
||||
inline = cell.find("m:is", NS)
|
||||
if cell_type == "inlineStr" and inline is not None:
|
||||
return "".join(node.text or "" for node in inline.iter(f"{{{MAIN_NS}}}t")).strip()
|
||||
value_node = cell.find("m:v", NS)
|
||||
raw = "" if value_node is None else (value_node.text or "")
|
||||
if cell_type == "s" and raw:
|
||||
return shared[int(raw)].strip()
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def read_xlsx_rows(path: str | Path) -> list[dict[str, str]]:
|
||||
"""Read the first worksheet as dictionaries keyed by header text."""
|
||||
path = Path(path)
|
||||
with ZipFile(path, "r") as archive:
|
||||
shared = _shared_strings(archive)
|
||||
relationships = _relationships(archive)
|
||||
workbook = ET.fromstring(archive.read("xl/workbook.xml"))
|
||||
sheets = workbook.findall("m:sheets/m:sheet", NS)
|
||||
if not sheets:
|
||||
raise ValueError("Excel 文件中没有工作表")
|
||||
relationship_id = sheets[0].attrib[f"{{{REL_NS}}}id"]
|
||||
target = relationships[relationship_id].lstrip("/")
|
||||
if not target.startswith("xl/"):
|
||||
target = "xl/" + target
|
||||
worksheet = ET.fromstring(archive.read(target))
|
||||
|
||||
rows: list[dict[int, str]] = []
|
||||
for row in worksheet.findall("m:sheetData/m:row", NS):
|
||||
values: dict[int, str] = {}
|
||||
for cell in row.findall("m:c", NS):
|
||||
number = _column_number(cell.attrib.get("r", "A1"))
|
||||
value = _cell_value(cell, shared)
|
||||
if value != "":
|
||||
values[number] = value
|
||||
rows.append(values)
|
||||
|
||||
header_row_index = -1
|
||||
for index, values in enumerate(rows[:20]):
|
||||
texts = set(values.values())
|
||||
if "学号" in texts and "姓名" in texts:
|
||||
header_row_index = index
|
||||
break
|
||||
if header_row_index < 0:
|
||||
raise ValueError("Excel 中必须包含“学号”和“姓名”表头")
|
||||
|
||||
headers = rows[header_row_index]
|
||||
result: list[dict[str, str]] = []
|
||||
for values in rows[header_row_index + 1:]:
|
||||
row = {headers.get(number, f"列{number}"): value for number, value in values.items()}
|
||||
if any(str(value).strip() for value in row.values()):
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
||||
def _xml_escape(value: object) -> str:
|
||||
return (
|
||||
str(value)
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
def _sheet_xml(headers: list[str], records: list[dict[str, object]]) -> str:
|
||||
all_headers = list(headers)
|
||||
all_records = list(records)
|
||||
row_xml: list[str] = []
|
||||
header_cells = []
|
||||
for column_number, header in enumerate(all_headers, 1):
|
||||
reference = f"{_column_letter(column_number)}1"
|
||||
header_cells.append(
|
||||
f'<c r="{reference}" t="inlineStr"><is><t>{_xml_escape(header)}</t></is></c>'
|
||||
)
|
||||
row_xml.append(f'<row r="1">{"".join(header_cells)}</row>')
|
||||
|
||||
for row_number, record in enumerate(all_records, 2):
|
||||
cells = []
|
||||
for column_number, header in enumerate(all_headers, 1):
|
||||
value = record.get(header, "")
|
||||
reference = f"{_column_letter(column_number)}{row_number}"
|
||||
if isinstance(value, bool):
|
||||
cells.append(f'<c r="{reference}" t="inlineStr"><is><t>{_xml_escape("是" if value else "否")}</t></is></c>')
|
||||
elif isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
cells.append(f'<c r="{reference}"><v>{value}</v></c>')
|
||||
else:
|
||||
text = "" if value is None else str(value)
|
||||
preserve = ' xml:space="preserve"' if text != text.strip() else ""
|
||||
cells.append(
|
||||
f'<c r="{reference}" t="inlineStr"><is><t{preserve}>{_xml_escape(text)}</t></is></c>'
|
||||
)
|
||||
row_xml.append(f'<row r="{row_number}">{"".join(cells)}</row>')
|
||||
|
||||
column_xml = []
|
||||
for number in range(1, len(all_headers) + 1):
|
||||
column_xml.append(f'<col min="{number}" max="{number}" width="18" customWidth="1"/>')
|
||||
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="{MAIN_NS}">
|
||||
<sheetViews><sheetView workbookViewId="0"/></sheetViews>
|
||||
<cols>{"".join(column_xml)}</cols>
|
||||
<sheetData>{"".join(row_xml)}</sheetData>
|
||||
</worksheet>'''
|
||||
|
||||
|
||||
def write_xlsx(sheets: list[tuple[str, list[str], list[dict[str, object]]]]) -> bytes:
|
||||
"""Create a valid XLSX in memory. Each item is (sheet name, headers, records)."""
|
||||
import posixpath
|
||||
|
||||
used_names = set()
|
||||
safe_names = []
|
||||
for index, (name, _, _) in enumerate(sheets, 1):
|
||||
clean = re.sub(r"[\\/?*\\[\\]:]", "-", name).strip() or f"Sheet{index}"
|
||||
clean = clean[:31]
|
||||
candidate = clean
|
||||
suffix = 2
|
||||
while candidate.lower() in used_names:
|
||||
tail = f"-{suffix}"
|
||||
candidate = clean[: 31 - len(tail)] + tail
|
||||
suffix += 1
|
||||
used_names.add(candidate.lower())
|
||||
safe_names.append(candidate)
|
||||
|
||||
sheet_entries = []
|
||||
workbook_entries = []
|
||||
worksheet_files = []
|
||||
for index, name in enumerate(safe_names, 1):
|
||||
filename = f"xl/worksheets/sheet{index}.xml"
|
||||
worksheet_files.append((filename, _sheet_xml(sheets[index - 1][1], sheets[index - 1][2])))
|
||||
sheet_entries.append(
|
||||
f'<sheet name="{_xml_escape(name)}" sheetId="{index}" '
|
||||
f'r:id="rId{index}"/>'
|
||||
)
|
||||
workbook_entries.append(
|
||||
f'<Relationship Id="rId{index}" '
|
||||
f'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
|
||||
f'Target="worksheets/sheet{index}.xml"/>'
|
||||
)
|
||||
style_relationship_id = len(sheets) + 1
|
||||
|
||||
content_overrides = "".join(
|
||||
f'<Override PartName="/xl/worksheets/sheet{index}.xml" '
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
for index in range(1, len(sheets) + 1)
|
||||
)
|
||||
|
||||
files = {
|
||||
"[Content_Types].xml": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>{content_overrides}
|
||||
</Types>''',
|
||||
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>''',
|
||||
"xl/workbook.xml": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="{MAIN_NS}" xmlns:r="{REL_NS}">
|
||||
<sheets>{"".join(sheet_entries)}</sheets>
|
||||
</workbook>''',
|
||||
"xl/_rels/workbook.xml.rels": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{"".join(workbook_entries)}
|
||||
<Relationship Id="rId{style_relationship_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>''',
|
||||
"xl/styles.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
|
||||
<borders count="1"><border/></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
|
||||
</styleSheet>''',
|
||||
}
|
||||
files.update(worksheet_files)
|
||||
|
||||
import io
|
||||
output = io.BytesIO()
|
||||
with ZipFile(output, "w", ZIP_DEFLATED) as archive:
|
||||
for filename, content in files.items():
|
||||
archive.writestr(filename, content.encode("utf-8"))
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def export_filename(prefix: str, class_name: str | None = None) -> str:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
suffix = f"-{class_name}班" if class_name else ""
|
||||
return f"{prefix}{suffix}-{timestamp}.xlsx"
|
||||
__all__ = ["export_filename", "read_xlsx_rows", "write_xlsx"]
|
||||
|
||||
Reference in New Issue
Block a user