rewrited logic structure
This commit is contained in:
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 层只依赖这个门面。"""
|
||||
Reference in New Issue
Block a user