fixed some bugs
This commit is contained in:
110
app.py
110
app.py
@@ -11,6 +11,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -26,7 +27,7 @@ DB_PATH = Path(os.environ.get("GROUP_DB", BASE_DIR / "selfgroup.db"))
|
|||||||
XLSX_PATH = Path(os.environ.get("GROUP_XLSX", BASE_DIR / "2025mt.xlsx"))
|
XLSX_PATH = Path(os.environ.get("GROUP_XLSX", BASE_DIR / "2025mt.xlsx"))
|
||||||
STATIC_DIR = BASE_DIR / "static"
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
TEACHER_USER = os.environ.get("TEACHER_USER", "teacher")
|
TEACHER_USER = os.environ.get("TEACHER_USER", "teacher")
|
||||||
TEACHER_PASSWORD = os.environ.get("TEACHER_PASSWORD", "teacher123")
|
TEACHER_PASSWORD = os.environ.get("TEACHER_PASSWORD", "teacher!@#")
|
||||||
GROUP_LIMIT = 6
|
GROUP_LIMIT = 6
|
||||||
SESSION_DAYS = 7
|
SESSION_DAYS = 7
|
||||||
|
|
||||||
@@ -118,6 +119,10 @@ class GroupStore:
|
|||||||
"INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_password',?)",
|
"INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_password',?)",
|
||||||
(hash_password(TEACHER_PASSWORD),),
|
(hash_password(TEACHER_PASSWORD),),
|
||||||
)
|
)
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO settings(key,value) VALUES('group_limit',?)",
|
||||||
|
(str(GROUP_LIMIT),),
|
||||||
|
)
|
||||||
|
|
||||||
def import_roster(self, path: Path) -> tuple[int, int]:
|
def import_roster(self, path: Path) -> tuple[int, int]:
|
||||||
rows = read_xlsx_rows(path)
|
rows = read_xlsx_rows(path)
|
||||||
@@ -215,6 +220,20 @@ class GroupStore:
|
|||||||
def active_class(self) -> str:
|
def active_class(self) -> str:
|
||||||
return self.get_setting("active_class") or ""
|
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:
|
def is_locked(self, class_name: str) -> bool:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
row = self.connection.execute(
|
row = self.connection.execute(
|
||||||
@@ -292,6 +311,27 @@ class GroupStore:
|
|||||||
token = self.create_session("teacher", expected_user, None)
|
token = self.create_session("teacher", expected_user, None)
|
||||||
return {"token": token, "teacher": {"username": expected_user}}, "登录成功"
|
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(
|
def change_student_password(
|
||||||
self, session: dict[str, Any], old_password: str, new_password: str
|
self, session: dict[str, Any], old_password: str, new_password: str
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
@@ -388,14 +428,14 @@ class GroupStore:
|
|||||||
**dict(row),
|
**dict(row),
|
||||||
"locked": self.is_locked(class_name),
|
"locked": self.is_locked(class_name),
|
||||||
"member_count": len(members[row["id"]]),
|
"member_count": len(members[row["id"]]),
|
||||||
"full": len(members[row["id"]]) >= GROUP_LIMIT,
|
"full": len(members[row["id"]]) >= self.group_limit(),
|
||||||
"members": members[row["id"]],
|
"members": members[row["id"]],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"class_name": class_name,
|
"class_name": class_name,
|
||||||
"locked": self.is_locked(class_name),
|
"locked": self.is_locked(class_name),
|
||||||
"group_limit": GROUP_LIMIT,
|
"group_limit": self.group_limit(),
|
||||||
"groups": group_data,
|
"groups": group_data,
|
||||||
"ungrouped": [dict(row) for row in ungrouped_rows],
|
"ungrouped": [dict(row) for row in ungrouped_rows],
|
||||||
"statistics": {"total_students": total, "grouped_students": grouped, "group_count": len(group_data)},
|
"statistics": {"total_students": total, "grouped_students": grouped, "group_count": len(group_data)},
|
||||||
@@ -454,8 +494,9 @@ class GroupStore:
|
|||||||
member_count = self.connection.execute(
|
member_count = self.connection.execute(
|
||||||
"SELECT COUNT(*) AS n FROM group_members WHERE group_id=?", (group_id,)
|
"SELECT COUNT(*) AS n FROM group_members WHERE group_id=?", (group_id,)
|
||||||
).fetchone()["n"]
|
).fetchone()["n"]
|
||||||
if member_count >= GROUP_LIMIT:
|
group_limit = self.group_limit()
|
||||||
return False, f"该组已有 {GROUP_LIMIT} 人,不能再加入新成员"
|
if member_count >= group_limit:
|
||||||
|
return False, f"该组已有 {group_limit} 人,不能再加入新成员"
|
||||||
if current and not confirmed:
|
if current and not confirmed:
|
||||||
role = "组长,原组会被解散" if current["leader_student_id"] == student_id else "成员"
|
role = "组长,原组会被解散" if current["leader_student_id"] == student_id else "成员"
|
||||||
return False, f"你当前是“{current['name']}”的{role}。加入新组前必须退出原组;确认后才会执行。"
|
return False, f"你当前是“{current['name']}”的{role}。加入新组前必须退出原组;确认后才会执行。"
|
||||||
@@ -613,8 +654,9 @@ class GroupStore:
|
|||||||
count = self.connection.execute(
|
count = self.connection.execute(
|
||||||
"SELECT COUNT(*) AS n FROM group_members WHERE group_id=?", (group_id,)
|
"SELECT COUNT(*) AS n FROM group_members WHERE group_id=?", (group_id,)
|
||||||
).fetchone()["n"]
|
).fetchone()["n"]
|
||||||
if count >= GROUP_LIMIT:
|
group_limit = self.group_limit()
|
||||||
return False, f"该组已有 {GROUP_LIMIT} 人;请先移出一人"
|
if count >= group_limit:
|
||||||
|
return False, f"该组已有 {group_limit} 人;请先移出一人"
|
||||||
if current_group:
|
if current_group:
|
||||||
self.connection.execute(
|
self.connection.execute(
|
||||||
"DELETE FROM group_members WHERE student_id=?", (student_id,)
|
"DELETE FROM group_members WHERE student_id=?", (student_id,)
|
||||||
@@ -914,6 +956,27 @@ class GroupRequestHandler:
|
|||||||
status = HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST
|
status = HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST
|
||||||
return json_response(status, {"ok": ok, "message": message})
|
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 = 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 = 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": STORE.group_limit()
|
||||||
|
})
|
||||||
|
|
||||||
if action == "groups" and method == "POST":
|
if action == "groups" and method == "POST":
|
||||||
session, error = self.require_student()
|
session, error = self.require_student()
|
||||||
if error:
|
if error:
|
||||||
@@ -1138,6 +1201,32 @@ def send_text(handler, status: int, text: str):
|
|||||||
handler.wfile.write(body)
|
handler.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def local_ipv4_addresses() -> list[str]:
|
||||||
|
addresses = 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
|
||||||
|
|
||||||
|
# Connecting a UDP socket reveals the address used for the default route.
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
route(self)
|
route(self)
|
||||||
@@ -1165,7 +1254,12 @@ def main() -> None:
|
|||||||
port = int(os.environ.get("PORT", "8000"))
|
port = int(os.environ.get("PORT", "8000"))
|
||||||
server = http.server.ThreadingHTTPServer((host, port), Handler)
|
server = http.server.ThreadingHTTPServer((host, port), Handler)
|
||||||
LOG.info("学生分组系统已启动:http://0.0.0.0:%d", port)
|
LOG.info("学生分组系统已启动:http://0.0.0.0:%d", port)
|
||||||
LOG.info("学生入口:http://<老师电脑IP>:%d/ 教师入口:http://<老师电脑IP>:%d/teacher", port, 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:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -26,11 +26,33 @@
|
|||||||
<input id="password" type="password" autocomplete="current-password" required>
|
<input id="password" type="password" autocomplete="current-password" required>
|
||||||
<button class="full" data-working type="submit">登录后台</button>
|
<button class="full" data-working type="submit">登录后台</button>
|
||||||
<div class="notice" id="loginNotice"></div>
|
<div class="notice" id="loginNotice"></div>
|
||||||
<p class="hint">默认账号 teacher,默认密码 teacher123。正式使用前请通过环境变量修改。</p>
|
<p class="hint">默认账号 teacher,默认密码 teacher!@#。首次使用后请及时修改密码。</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mainArea" class="hidden">
|
<div id="mainArea" class="hidden">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head"><h2>账号安全</h2></div>
|
||||||
|
<form class="form-line" id="teacherPasswordForm">
|
||||||
|
<input id="teacherOldPassword" type="password" autocomplete="current-password" placeholder="原密码" required>
|
||||||
|
<input id="teacherNewPassword" type="password" autocomplete="new-password" minlength="8" placeholder="新密码(至少 8 位)" required>
|
||||||
|
<input id="teacherConfirmPassword" type="password" autocomplete="new-password" minlength="8" placeholder="确认新密码" required>
|
||||||
|
<button data-working type="submit">修改密码</button>
|
||||||
|
</form>
|
||||||
|
<div class="notice" id="teacherPasswordNotice"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head"><h2>分组设置</h2></div>
|
||||||
|
<form class="form-line" id="groupLimitForm">
|
||||||
|
<input id="groupLimit" type="number" min="2" max="100" required>
|
||||||
|
<span>人</span>
|
||||||
|
<button data-working type="submit">保存上限</button>
|
||||||
|
</form>
|
||||||
|
<p class="hint">调整后立即对所有班级生效;已有小组不会被自动拆分。</p>
|
||||||
|
<div class="notice" id="groupLimitNotice"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>班级管理</h2>
|
<h2>班级管理</h2>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ function renderState() {
|
|||||||
el('mainArea').classList.remove('hidden');
|
el('mainArea').classList.remove('hidden');
|
||||||
el('teacherToolbar').classList.remove('hidden');
|
el('teacherToolbar').classList.remove('hidden');
|
||||||
el('teacherChip').textContent = `教师:${state.username}`;
|
el('teacherChip').textContent = `教师:${state.username}`;
|
||||||
|
el('groupLimit').value = state.snapshot.group_limit;
|
||||||
renderClassTabs();
|
renderClassTabs();
|
||||||
renderStatistics();
|
renderStatistics();
|
||||||
renderGroups();
|
renderGroups();
|
||||||
@@ -166,6 +167,41 @@ async function submitLogin(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitTeacherPassword(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const oldPassword = el('teacherOldPassword').value;
|
||||||
|
const newPassword = el('teacherNewPassword').value;
|
||||||
|
const confirmPassword = el('teacherConfirmPassword').value;
|
||||||
|
const notice = el('teacherPasswordNotice');
|
||||||
|
notice.textContent = '';
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
notice.textContent = '两次输入的新密码不一致';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setButtonsDisabled(el('teacherPasswordForm'), true);
|
||||||
|
const result = await api('teacher/change-password', {
|
||||||
|
method: 'POST', json: { old_password: oldPassword, new_password: newPassword }
|
||||||
|
});
|
||||||
|
setButtonsDisabled(el('teacherPasswordForm'), false);
|
||||||
|
notice.textContent = result.ok ? '' : result.message;
|
||||||
|
toast(result.message, !result.ok);
|
||||||
|
if (result.ok) {
|
||||||
|
el('teacherPasswordForm').reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitGroupLimit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setButtonsDisabled(el('groupLimitForm'), true);
|
||||||
|
const result = await api('teacher/group-limit', {
|
||||||
|
method: 'POST', json: { limit: Number(el('groupLimit').value) }
|
||||||
|
});
|
||||||
|
setButtonsDisabled(el('groupLimitForm'), false);
|
||||||
|
el('groupLimitNotice').textContent = result.ok ? '' : result.message;
|
||||||
|
toast(result.message, !result.ok);
|
||||||
|
if (result.ok) await loadState();
|
||||||
|
}
|
||||||
|
|
||||||
async function switchClass(className) {
|
async function switchClass(className) {
|
||||||
const result = await api('teacher/class', { method: 'POST', json: { class_name: className } });
|
const result = await api('teacher/class', { method: 'POST', json: { class_name: className } });
|
||||||
toast(result.message, !result.ok);
|
toast(result.message, !result.ok);
|
||||||
@@ -211,6 +247,8 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
el('loginForm').addEventListener('submit', submitLogin);
|
el('loginForm').addEventListener('submit', submitLogin);
|
||||||
|
el('teacherPasswordForm').addEventListener('submit', submitTeacherPassword);
|
||||||
|
el('groupLimitForm').addEventListener('submit', submitGroupLimit);
|
||||||
el('logoutButton').addEventListener('click', logout);
|
el('logoutButton').addEventListener('click', logout);
|
||||||
el('lockButton').addEventListener('click', toggleLock);
|
el('lockButton').addEventListener('click', toggleLock);
|
||||||
el('createGroupForm').addEventListener('submit', submitCreateGroup);
|
el('createGroupForm').addEventListener('submit', submitCreateGroup);
|
||||||
|
|||||||
Reference in New Issue
Block a user