Files
selfgroup/group_system/http/api.py

370 lines
16 KiB
Python

"""学生和教师 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/student-password" and method == "POST":
session, error = self.require_teacher()
if error:
return error
data = self.read_json()
ok, message = self.store.teacher_set_student_password(
int(data.get("student_id", 0)), 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": "接口不存在"})