Update Space: schedule, MySQL persistence, registration codes, registration flow
Browse files- README.md +221 -221
- ca.pem +25 -0
- core/config.py +38 -3
- core/db.py +799 -368
- core/task_manager.py +67 -2
- requirements.txt +8 -7
- space_app.py +185 -15
- templates/admin_dashboard.html +376 -278
- templates/login.html +7 -7
- templates/register.html +58 -0
README.md
CHANGED
|
@@ -1,222 +1,222 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Advanced SCU Course Catcher
|
| 3 |
-
emoji: 🐳
|
| 4 |
-
colorFrom: green
|
| 5 |
-
colorTo: yellow
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 7860
|
| 8 |
-
pinned: false
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
# Advanced SCU Course Catcher
|
| 12 |
-
|
| 13 |
-
这是一个面向 Hugging Face Space 的四川大学选课系统 Web 版抢课面板。当前版本已经从单机脚本改造成了可部署、可并行、可多用户管理的 Flask + Selenium 服务,支持普通用户端和管理员端分离运行。
|
| 14 |
-
|
| 15 |
-
## 当前能力
|
| 16 |
-
|
| 17 |
-
- 普通用户入口:`/login`
|
| 18 |
-
用户使用 `学号 + 密码` 登录,只能看到自己的课程、任务状态和实时日志。
|
| 19 |
-
- 管理员入口:`/admin`
|
| 20 |
-
管理员使用 `账号 + 密码` 登录。此入口不会在用户登录页展示。
|
| 21 |
-
- 多管理员体系
|
| 22 |
-
支持多位普通管理员,支持 1 位超级管理员。超级管理员账号和密码由环境变量 `ADMIN`、`PASSWORD` 提供。
|
| 23 |
-
- 多用户管理
|
| 24 |
-
管理员可以手动录入用户账号、重置密码、启用/禁用用户、查看所有课程目标。
|
| 25 |
-
- 课程录入
|
| 26 |
-
用户自己填写 `课程号` 和 `课序号`,管理员可见全部内容,也可以代为录入和修改。
|
| 27 |
-
- 实时日志
|
| 28 |
-
用户和管理员都可以实时看到程序日志,前端通过 SSE 持续推送。
|
| 29 |
-
- 并行调度
|
| 30 |
-
多用户任务由后台任务调度器并行执行,管理员可以在后台动态设置并行数。
|
| 31 |
-
- Hugging Face Space 适配
|
| 32 |
-
使用 Docker Space 运行,内置 Chromium、chromedriver、中文字体和无头浏览器配置。
|
| 33 |
-
- 登录验证码与提交验证码 OCR
|
| 34 |
-
登录阶段和提交选课阶段都支持验证码 OCR 自动识别。
|
| 35 |
-
- Selenium 自恢复
|
| 36 |
-
单个 Selenium 会话连续错误达到 5 次时,会自动重建浏览器;连续重建 5 个会话仍失败时,任务才会终止。
|
| 37 |
-
|
| 38 |
-
## 运行结构
|
| 39 |
-
|
| 40 |
-
当前实际运行入口和核心模块如下:
|
| 41 |
-
|
| 42 |
-
- `app.py`
|
| 43 |
-
Hugging Face / gunicorn 使用的 WSGI 入口。
|
| 44 |
-
- `space_app.py`
|
| 45 |
-
Flask 路由、页面渲染、登录鉴权、SSE 日志流。
|
| 46 |
-
- `core/config.py`
|
| 47 |
-
运行配置与内部密钥管理。
|
| 48 |
-
- `core/db.py`
|
| 49 |
-
SQLite 数据层。
|
| 50 |
-
- `core/task_manager.py`
|
| 51 |
-
并行调度、任务生命周期管理。
|
| 52 |
-
- `core/course_bot.py`
|
| 53 |
-
Selenium 抢课核心逻辑、验证码识别、重试与重建策略。
|
| 54 |
-
- `webdriver_utils.py`
|
| 55 |
-
Chromium / chromedriver 初始化。
|
| 56 |
-
- `templates/` + `static/`
|
| 57 |
-
用户端、管理员
|
| 58 |
-
|
| 59 |
-
## 环境变量
|
| 60 |
-
|
| 61 |
-
为了减少 Space 配置复杂度,当前只保留少量必要环境变量。
|
| 62 |
-
|
| 63 |
-
### 必填
|
| 64 |
-
|
| 65 |
-
- `ADMIN`
|
| 66 |
-
超级管理员账号。
|
| 67 |
-
- `PASSWORD`
|
| 68 |
-
超级管理员密码。
|
| 69 |
-
|
| 70 |
-
### 可选
|
| 71 |
-
|
| 72 |
-
- `DATA_DIR`
|
| 73 |
-
数据目录,默认会使用仓库下的 `data/`。在 Hugging Face Space 中建议保持为 `/data`。
|
| 74 |
-
- `CHROME_BIN`
|
| 75 |
-
自定义 Chromium 路径。默认会自动尝试 `/usr/bin/chromium`。
|
| 76 |
-
- `CHROMEDRIVER_PATH`
|
| 77 |
-
自定义 chromedriver 路径。默认会自动尝试 `/usr/bin/chromedriver`。
|
| 78 |
-
|
| 79 |
-
### 不需要再手动配置的内容
|
| 80 |
-
|
| 81 |
-
以下内容已经改为自动生成或写入程序默认值,不需要再手工配置环境变量:
|
| 82 |
-
|
| 83 |
-
- Flask session secret
|
| 84 |
-
- 用户密码加密密钥
|
| 85 |
-
- 默认并行数
|
| 86 |
-
- 登录重试次数
|
| 87 |
-
- Selenium 会话错误阈值
|
| 88 |
-
- Selenium 会话重建阈值
|
| 89 |
-
- 提交验证码重试次数
|
| 90 |
-
- 页面超时时间
|
| 91 |
-
- 日志页容量
|
| 92 |
-
|
| 93 |
-
程序会在 `DATA_DIR/.app_secrets.json` 中自动持久化内部密钥。这样即使以后修改 `ADMIN` 或 `PASSWORD`,也不会导致历史用户密码全部失效。
|
| 94 |
-
|
| 95 |
-
## Hugging Face Space 部署
|
| 96 |
-
|
| 97 |
-
### 1. 创建 Space
|
| 98 |
-
|
| 99 |
-
在 Hugging Face 上创建一个新的 Space:
|
| 100 |
-
|
| 101 |
-
- SDK 选择 `Docker`
|
| 102 |
-
- 端口使用 `7860`
|
| 103 |
-
- 仓库内容直接推送本项目即可
|
| 104 |
-
|
| 105 |
-
本仓库根目录已经提供好 `README.md` 的 Space metadata 和 `Dockerfile`。
|
| 106 |
-
|
| 107 |
-
### 2. 设置 Space Secrets
|
| 108 |
-
|
| 109 |
-
进入 `Settings -> Variables and secrets`,至少配置:
|
| 110 |
-
|
| 111 |
-
- `ADMIN=你的超级管理员账号`
|
| 112 |
-
- `PASSWORD=你的超级管理员密码`
|
| 113 |
-
|
| 114 |
-
通常不需要再设置其他环境变量。
|
| 115 |
-
|
| 116 |
-
如果你想显式指定数据目录,也可以增加:
|
| 117 |
-
|
| 118 |
-
- `DATA_DIR=/data`
|
| 119 |
-
|
| 120 |
-
### 3. 开启持久化存储
|
| 121 |
-
|
| 122 |
-
如果你希望以下数据在 Space 重启后仍然保留,建议在 Space 设置里开启 Persistent Storage:
|
| 123 |
-
|
| 124 |
-
- 用户账号
|
| 125 |
-
- 管理员账号
|
| 126 |
-
- 课程目标
|
| 127 |
-
- 任务历史
|
| 128 |
-
- 运行日志
|
| 129 |
-
- 自动生成的内部密钥文件
|
| 130 |
-
|
| 131 |
-
当前 Dockerfile 已按 `/data` 目录进行适配,并预先处理了目录权限,方便在 Space 中直接持久化。
|
| 132 |
-
|
| 133 |
-
### 4. 推送部署
|
| 134 |
-
|
| 135 |
-
如果你使用 git 推送到 Hugging Face Space,可以参考:
|
| 136 |
-
|
| 137 |
-
```bash
|
| 138 |
-
git remote add space https://huggingface.co/spaces/<your-name>/<your-space>
|
| 139 |
-
git push space main
|
| 140 |
-
```
|
| 141 |
-
|
| 142 |
-
### 5. 启动方式
|
| 143 |
-
|
| 144 |
-
容器启动命令已经写入 `Dockerfile`:
|
| 145 |
-
|
| 146 |
-
```bash
|
| 147 |
-
gunicorn --bind 0.0.0.0:${PORT:-7860} --workers 1 --threads 8 --timeout 180 app:app
|
| 148 |
-
```
|
| 149 |
-
|
| 150 |
-
这里使用 `1` 个 gunicorn worker,是为了避免多进程情况下每个进程都各自启动一套本地任务调度器。并发能力由应用内部的线程调度和管理员可配置的任务并行数控制。
|
| 151 |
-
|
| 152 |
-
## Space 端建议配置
|
| 153 |
-
|
| 154 |
-
推荐的初始运行策略:
|
| 155 |
-
|
| 156 |
-
- 后台并行数先设置为 `1` 或 `2`
|
| 157 |
-
- 只有在 Space CPU / 内存足够稳定时,再逐步提升
|
| 158 |
-
- 在真实高峰前,先做一次小规模联调
|
| 159 |
-
|
| 160 |
-
对于 CPU 较小的 Space,同时拉起过多 Chromium 会明显增加失败率,因此管理员后台提供了并行数动态调整功能。
|
| 161 |
-
|
| 162 |
-
## 管理员联调清单
|
| 163 |
-
|
| 164 |
-
部署到 Space 后,建议按以下顺序联调:
|
| 165 |
-
|
| 166 |
-
1. 访问 `/admin`,使用 `ADMIN` / `PASSWORD` 登录超级管理员。
|
| 167 |
-
2. 在管理员后台创建 1 个普通管理员、2 个测试用户。
|
| 168 |
-
3. 为两个测试用户分别录入不同课程号和课序号。
|
| 169 |
-
4. 将并行数设置为 `2`。
|
| 170 |
-
5. 分别触发两个用户任务,确认两条任务都能进入队列,并按并行数启动。
|
| 171 |
-
6. 在管理员日志面板确认日志持续刷新,且可以看到不同学号的执行记录。
|
| 172 |
-
7. 使用用户账号访问 `/login`,确认用户只能看到自己的课程和日志。
|
| 173 |
-
8. 在任务运行过程中测试停止任务,确认状态能变为“停止中”并最终收敛。
|
| 174 |
-
9. 如果学校页面出现提交验证码,确认日志中能看到提交阶段验证码识别提示。
|
| 175 |
-
|
| 176 |
-
## 自动化测试
|
| 177 |
-
|
| 178 |
-
仓库当前已补充以下自动化测试:
|
| 179 |
-
|
| 180 |
-
- `tests/test_config.py`
|
| 181 |
-
验证内部密钥在超级管理员账号变更后依然保持稳定。
|
| 182 |
-
- `tests/test_course_bot.py`
|
| 183 |
-
验证 Selenium 会话错误累计后会触发浏览器重建,并验证提交阶段验证码分支。
|
| 184 |
-
- `tests/test_task_manager.py`
|
| 185 |
-
验证任务调度器会严格遵守管理员设置的并行上限。
|
| 186 |
-
- `.github/workflows/linux-tests.yml`
|
| 187 |
-
在 `ubuntu-latest` 上自动运行上述测试,避免关键逻辑只在 Windows 本地验证。
|
| 188 |
-
|
| 189 |
-
本地运行测试:
|
| 190 |
-
|
| 191 |
-
```bash
|
| 192 |
-
python -m unittest discover -s tests -v
|
| 193 |
-
```
|
| 194 |
-
|
| 195 |
-
## 本地运行
|
| 196 |
-
|
| 197 |
-
### 直接运行
|
| 198 |
-
|
| 199 |
-
```bash
|
| 200 |
-
pip install -r requirements.txt
|
| 201 |
-
set ADMIN=admin
|
| 202 |
-
set PASSWORD=change-me
|
| 203 |
-
python main.py
|
| 204 |
-
```
|
| 205 |
-
|
| 206 |
-
### Docker 运行
|
| 207 |
-
|
| 208 |
-
```bash
|
| 209 |
-
docker build -t advanced-scu-course-catcher .
|
| 210 |
-
docker run --rm -p 7860:7860 \
|
| 211 |
-
-e ADMIN=admin \
|
| 212 |
-
-e PASSWORD=change-me \
|
| 213 |
-
advanced-scu-course-catcher
|
| 214 |
-
```
|
| 215 |
-
|
| 216 |
-
## 重要说明
|
| 217 |
-
|
| 218 |
-
- 本项目当前的任务
|
| 219 |
-
- 用户密码会以应用内部密钥加密后保存在数据库中,用于后台自动登录教务系统。
|
| 220 |
-
- 管理员可以看到全部用户、课程和日志,请在实际使用前明确权限边界。
|
| 221 |
-
- 如果学校选课页面 DOM 结构变化较大,需要同步更新 `core/course_bot.py` 中的选择器以及 `javascript/` 下的辅助脚本。
|
| 222 |
- `course_catcher/` 目录是仓库中保留的旧实现,当前运行链路以 `space_app.py + core/*` 为准。
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Advanced SCU Course Catcher
|
| 3 |
+
emoji: 🐳
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Advanced SCU Course Catcher
|
| 12 |
+
|
| 13 |
+
这是一个面向 Hugging Face Space 的四川大学选课系统 Web 版抢课面板。当前版本已经从单机脚本改造成了可部署、可并行、可多用户管理的 Flask + Selenium 服务,支持普通用户端和管理员端分离运行。
|
| 14 |
+
|
| 15 |
+
## 当前能力
|
| 16 |
+
|
| 17 |
+
- 普通用户入口:`/login`
|
| 18 |
+
用户使用 `学号 + 密码` 登录,只能看到自己的课程、任务状态和实时日志。
|
| 19 |
+
- 管理员入口:`/admin`
|
| 20 |
+
管理员使用 `账号 + 密码` 登录。此入口不会在用户登录页展示。
|
| 21 |
+
- 多管理员体系
|
| 22 |
+
支持多位普通管理员,支持 1 位超级管理员。超级管理员账号和密码由环境变量 `ADMIN`、`PASSWORD` 提供。
|
| 23 |
+
- 多用户管理
|
| 24 |
+
管理员可以手动录入用户账号、重置密码、启用/禁用用户、查看所有课程目标。
|
| 25 |
+
- 课程录入
|
| 26 |
+
用户自己填写 `课程号` 和 `课序号`,管理员可见全部内容,也可以代为录入和修改。
|
| 27 |
+
- 实时日志
|
| 28 |
+
用户和管理员都可以实时看到程序日志,前端通过 SSE 持续推送。
|
| 29 |
+
- 并行调度
|
| 30 |
+
多用户任务由后台任务调度器并行执行,管理员可以在后台动态设置并行数。
|
| 31 |
+
- Hugging Face Space 适配
|
| 32 |
+
使用 Docker Space 运行,内置 Chromium、chromedriver、中文字体和无头浏览器配置。
|
| 33 |
+
- 登录验证码与提交验证码 OCR
|
| 34 |
+
登录阶段和提交选课阶段都支持验证码 OCR 自动识别。
|
| 35 |
+
- Selenium 自恢复
|
| 36 |
+
单个 Selenium 会话连续错误达到 5 次时,会自动重建浏览器;连续重建 5 个会话仍失败时,任务才会终止。
|
| 37 |
+
|
| 38 |
+
## 运行结构
|
| 39 |
+
|
| 40 |
+
当前实际运行入口和核心模块如下:
|
| 41 |
+
|
| 42 |
+
- `app.py`
|
| 43 |
+
Hugging Face / gunicorn 使用的 WSGI 入口。
|
| 44 |
+
- `space_app.py`
|
| 45 |
+
Flask 路由、页面渲染、登录鉴权、SSE 日志流。
|
| 46 |
+
- `core/config.py`
|
| 47 |
+
运行配置与内部密钥管理。
|
| 48 |
+
- `core/db.py`
|
| 49 |
+
SQLite 数据层。
|
| 50 |
+
- `core/task_manager.py`
|
| 51 |
+
并行调度、任务生命周期管理。
|
| 52 |
+
- `core/course_bot.py`
|
| 53 |
+
Selenium 抢课核心逻辑、验证码识别、重试与重建策略。
|
| 54 |
+
- `webdriver_utils.py`
|
| 55 |
+
Chromium / chromedriver 初始化。
|
| 56 |
+
- `templates/` + `static/`
|
| 57 |
+
用户端、管理员���和响应式前端页面。
|
| 58 |
+
|
| 59 |
+
## 环境变量
|
| 60 |
+
|
| 61 |
+
为了减少 Space 配置复杂度,当前只保留少量必要环境变量。
|
| 62 |
+
|
| 63 |
+
### 必填
|
| 64 |
+
|
| 65 |
+
- `ADMIN`
|
| 66 |
+
超级管理员账号。
|
| 67 |
+
- `PASSWORD`
|
| 68 |
+
超级管理员密码。
|
| 69 |
+
|
| 70 |
+
### 可选
|
| 71 |
+
|
| 72 |
+
- `DATA_DIR`
|
| 73 |
+
数据目录,默认会使用仓库下的 `data/`。在 Hugging Face Space 中建议保持为 `/data`。
|
| 74 |
+
- `CHROME_BIN`
|
| 75 |
+
自定义 Chromium 路径。默认会自动尝试 `/usr/bin/chromium`。
|
| 76 |
+
- `CHROMEDRIVER_PATH`
|
| 77 |
+
自定义 chromedriver 路径。默认会自动尝试 `/usr/bin/chromedriver`。
|
| 78 |
+
|
| 79 |
+
### 不需要再手动配置的内容
|
| 80 |
+
|
| 81 |
+
以下内容已经改为自动生成或写入程序默认值,不需要再手工配置环境变量:
|
| 82 |
+
|
| 83 |
+
- Flask session secret
|
| 84 |
+
- 用户密码加密密钥
|
| 85 |
+
- 默认并行数
|
| 86 |
+
- 登录重试次数
|
| 87 |
+
- Selenium 会话错误阈值
|
| 88 |
+
- Selenium 会话重建阈值
|
| 89 |
+
- 提交验证码重试次数
|
| 90 |
+
- 页面超时时间
|
| 91 |
+
- 日志页容量
|
| 92 |
+
|
| 93 |
+
程序会在 `DATA_DIR/.app_secrets.json` 中自动持久化内部密钥。这样即使以后修改 `ADMIN` 或 `PASSWORD`,也不会导致历史用户密码全部失效。
|
| 94 |
+
|
| 95 |
+
## Hugging Face Space 部署
|
| 96 |
+
|
| 97 |
+
### 1. 创建 Space
|
| 98 |
+
|
| 99 |
+
在 Hugging Face 上创建一个新的 Space:
|
| 100 |
+
|
| 101 |
+
- SDK 选择 `Docker`
|
| 102 |
+
- 端口使用 `7860`
|
| 103 |
+
- 仓库内容直接推送本项目即可
|
| 104 |
+
|
| 105 |
+
本仓库根目录已经提供好 `README.md` 的 Space metadata 和 `Dockerfile`。
|
| 106 |
+
|
| 107 |
+
### 2. 设置 Space Secrets
|
| 108 |
+
|
| 109 |
+
进入 `Settings -> Variables and secrets`,至少配置:
|
| 110 |
+
|
| 111 |
+
- `ADMIN=你的超级管理员账号`
|
| 112 |
+
- `PASSWORD=你的超级管理员密码`
|
| 113 |
+
|
| 114 |
+
通常不需要再设置其他环境变量。
|
| 115 |
+
|
| 116 |
+
如果你想显式指定数据目录,也可以增加:
|
| 117 |
+
|
| 118 |
+
- `DATA_DIR=/data`
|
| 119 |
+
|
| 120 |
+
### 3. 开启持久化存储
|
| 121 |
+
|
| 122 |
+
如果你希望以下数据在 Space 重启后仍然保留,建议在 Space 设置里开启 Persistent Storage:
|
| 123 |
+
|
| 124 |
+
- 用户账号
|
| 125 |
+
- 管理员账号
|
| 126 |
+
- 课程目标
|
| 127 |
+
- 任务历史
|
| 128 |
+
- 运行日志
|
| 129 |
+
- 自动生成的内部密钥文件
|
| 130 |
+
|
| 131 |
+
当前 Dockerfile 已按 `/data` 目录进行适配,并预先处理了目录权限,方便在 Space 中直接持久化。
|
| 132 |
+
|
| 133 |
+
### 4. 推送部署
|
| 134 |
+
|
| 135 |
+
如果你使用 git 推送到 Hugging Face Space,可以参考:
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
git remote add space https://huggingface.co/spaces/<your-name>/<your-space>
|
| 139 |
+
git push space main
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
### 5. 启动方式
|
| 143 |
+
|
| 144 |
+
容器启动命令已经写入 `Dockerfile`:
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
gunicorn --bind 0.0.0.0:${PORT:-7860} --workers 1 --threads 8 --timeout 180 app:app
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
这里使用 `1` 个 gunicorn worker,是为了避免多进程情况下每个进程都各自启动一套本地任务调度器。并发能力由应用内部的线程调度和管理员可配置的任务并行数控制。
|
| 151 |
+
|
| 152 |
+
## Space 端建议配置
|
| 153 |
+
|
| 154 |
+
推荐的初始运行策略:
|
| 155 |
+
|
| 156 |
+
- 后台并行数先设置为 `1` 或 `2`
|
| 157 |
+
- 只有在 Space CPU / 内存足够稳定时,再逐步提升
|
| 158 |
+
- 在真实高峰前,先做一次小规模联调
|
| 159 |
+
|
| 160 |
+
对于 CPU 较小的 Space,同时拉起过多 Chromium 会明显增加失败率,因此管理员后台提供了并行数动态调整功能。
|
| 161 |
+
|
| 162 |
+
## 管理员联调清单
|
| 163 |
+
|
| 164 |
+
部署到 Space 后,建议按以下顺序联调:
|
| 165 |
+
|
| 166 |
+
1. 访问 `/admin`,使用 `ADMIN` / `PASSWORD` 登录超级管理员。
|
| 167 |
+
2. 在管理员后台创建 1 个普通管理员、2 个测试用户。
|
| 168 |
+
3. 为两个测试用户分别录入不同课程号和课序号。
|
| 169 |
+
4. 将并行数设置为 `2`。
|
| 170 |
+
5. 分别触发两个用户任务,确认两条任务都能进入队列,并按并行数启动。
|
| 171 |
+
6. 在管理员日志面板确认日志持续刷新,且可以看到不同学号的执行记录。
|
| 172 |
+
7. 使用用户账号访问 `/login`,确认用户只能看到自己的课程和日志。
|
| 173 |
+
8. 在任务运行过程中测试停止任务,确认状态能变为“停止中”并最终收敛。
|
| 174 |
+
9. 如果学校页面出现提交验证码,确认日志中能看到提交阶段验证码识别提示。
|
| 175 |
+
|
| 176 |
+
## 自动化测试
|
| 177 |
+
|
| 178 |
+
仓库当前已补充以下自动化测试:
|
| 179 |
+
|
| 180 |
+
- `tests/test_config.py`
|
| 181 |
+
验证内部密钥在超级管理员账号变更后依然保持稳定。
|
| 182 |
+
- `tests/test_course_bot.py`
|
| 183 |
+
验证 Selenium 会话错误累计后会触发浏览器重建,并验证提交阶段验证码分支。
|
| 184 |
+
- `tests/test_task_manager.py`
|
| 185 |
+
验证任务调度器会严格遵守管理员设置的并行上限。
|
| 186 |
+
- `.github/workflows/linux-tests.yml`
|
| 187 |
+
在 `ubuntu-latest` 上自动运行上述测试,避免关键逻辑只在 Windows 本地验证。
|
| 188 |
+
|
| 189 |
+
本地运行测试:
|
| 190 |
+
|
| 191 |
+
```bash
|
| 192 |
+
python -m unittest discover -s tests -v
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
## 本地运行
|
| 196 |
+
|
| 197 |
+
### 直接运行
|
| 198 |
+
|
| 199 |
+
```bash
|
| 200 |
+
pip install -r requirements.txt
|
| 201 |
+
set ADMIN=admin
|
| 202 |
+
set PASSWORD=change-me
|
| 203 |
+
python main.py
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
### Docker 运行
|
| 207 |
+
|
| 208 |
+
```bash
|
| 209 |
+
docker build -t advanced-scu-course-catcher .
|
| 210 |
+
docker run --rm -p 7860:7860 \
|
| 211 |
+
-e ADMIN=admin \
|
| 212 |
+
-e PASSWORD=change-me \
|
| 213 |
+
advanced-scu-course-catcher
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
## 重要说明
|
| 217 |
+
|
| 218 |
+
- 本项目当前的任务���型是“持续轮询直到成功、手动停止或达到错误上限”。
|
| 219 |
+
- 用户密码会以应用内部密钥加密后保存在数据库中,用于后台自动登录教务系统。
|
| 220 |
+
- 管理员可以看到全部用户、课程和日志,请在实际使用前明确权限边界。
|
| 221 |
+
- 如果学校选课页面 DOM 结构变化较大,需要同步更新 `core/course_bot.py` 中的选择器以及 `javascript/` 下的辅助脚本。
|
| 222 |
- `course_catcher/` 目录是仓库中保留的旧实现,当前运行链路以 `space_app.py + core/*` 为准。
|
ca.pem
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-----BEGIN CERTIFICATE-----
|
| 2 |
+
MIIERDCCAqygAwIBAgIUIZeUQD0xvEAJNu72uWDNezVfx/cwDQYJKoZIhvcNAQEM
|
| 3 |
+
BQAwOjE4MDYGA1UEAwwvOTYzMmFjZDktZjBhOC00NjQ4LTg3M2QtNTRkYTAxNWEz
|
| 4 |
+
NzllIFByb2plY3QgQ0EwHhcNMjYwMzA1MDgzMTU3WhcNMzYwMzAyMDgzMTU3WjA6
|
| 5 |
+
MTgwNgYDVQQDDC85NjMyYWNkOS1mMGE4LTQ2NDgtODczZC01NGRhMDE1YTM3OWUg
|
| 6 |
+
UHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAMSAckGu
|
| 7 |
+
d3mDTOTdgrL/mCfF7WkNWx6CKNGU/R5WaKdJ0Ub0Fbsu3erzQ7Qi877qcPSQg06G
|
| 8 |
+
qH9umxOvq6hZG2lrNXg+8AJihezMME1zmODu+OEOX96jsUn5Pr8OSa68zzRkoiOl
|
| 9 |
+
fXtVDYwTaIhexLJT/U6ELKqUxkBIVHTSmY/hp0SFOuEtMdra6dbJdMOXrGhI+IQK
|
| 10 |
+
KmbRh9H208NpfzjQBos1g27D4YHBe1p55CfihDFEso22i98Wxu2kRqv6hz2n35Qi
|
| 11 |
+
PNdJEi38ascZCGjx24VuuhXGcghHC1GxuIcArxWExNt880HtGztSIu6mBeSW+k9m
|
| 12 |
+
HQiORu9TGuaErD/Xa33wM+sLKFbjBspCjkejwWWp7Kz4T+yup1lOxBDZlqaBgUdD
|
| 13 |
+
MqIcIwBlG/kEUqTPHHKiTcGzg/8KUVDtTTofEAEi5MSiu/7EBQkN/jcAmEfwm4E4
|
| 14 |
+
eOCINwkv53IL5ZRKVg7+sPg0a3mFe2nC0jpO8SskAe00ny3glN+uVzG+2wIDAQAB
|
| 15 |
+
o0IwQDAdBgNVHQ4EFgQUecg1zbigo7JiWmgY17t7E4L8cFIwEgYDVR0TAQH/BAgw
|
| 16 |
+
BgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGBAFSdhJQ5fCO7
|
| 17 |
+
FC4I7ri5Gh93iBzjFplqTpbKYZft1RCRZy/ddvwSh4RMylb4MAQbDNs5D/c45E0u
|
| 18 |
+
CUSc449KkVmoZsrtRwQY7Z9BLGaSDlWNca8FO+cGNqrM7vhXOeMYiGADIeg2M/yU
|
| 19 |
+
QMmNxR+Y6nQO6wHP9r3BG1rNMBrjNcOjIXoo65qXv3PGLmNnjlLWyHwTmkfm6E4o
|
| 20 |
+
RIPPWQNo7zVzUnxNiGEDUOpMpeA/JxK4BY44JZTRy4TNVCoM+Qn6PoY4IYyFVjFo
|
| 21 |
+
CG0dBZPHdSeeJGgqJjOVhQQj2hq7BMVdgMSIv1jhUezHQeyfW8XF42C80qG09psI
|
| 22 |
+
CLuxu2geZC/4Y+I5NR21EecUf8nDHNcBHObAOsrPM7oOd7iPpiEMKh2s6kRa4yGt
|
| 23 |
+
LWOL391h5HgNnNcancVe8fXuZ4q7ul4NmQYYt8vtj9e82VpppKPwCPtIZGi4XEv5
|
| 24 |
+
qrYctjxBQUnw+Wp/aV+Q4MN1CD188XEJyo0re0n5Lzi/VBj8suXzcg==
|
| 25 |
+
-----END CERTIFICATE-----
|
core/config.py
CHANGED
|
@@ -6,11 +6,12 @@ import secrets
|
|
| 6 |
import shutil
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from pathlib import Path
|
|
|
|
| 9 |
|
| 10 |
from cryptography.fernet import Fernet
|
| 11 |
|
| 12 |
|
| 13 |
-
DEFAULT_PARALLEL_LIMIT =
|
| 14 |
POLL_INTERVAL_SECONDS = 10
|
| 15 |
LOGIN_RETRY_LIMIT = 6
|
| 16 |
TASK_BACKOFF_SECONDS = 6
|
|
@@ -19,6 +20,8 @@ LOGS_PAGE_SIZE = 180
|
|
| 19 |
SELENIUM_ERROR_LIMIT = 5
|
| 20 |
SELENIUM_RESTART_LIMIT = 5
|
| 21 |
SUBMIT_CAPTCHA_RETRY_LIMIT = 5
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def _pick_binary(env_name: str, *fallbacks: str) -> str:
|
|
@@ -79,11 +82,38 @@ def _load_or_create_internal_secrets(data_dir: Path) -> tuple[str, str]:
|
|
| 79 |
return session_secret, encryption_key
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
@dataclass(slots=True)
|
| 83 |
class AppConfig:
|
| 84 |
root_dir: Path
|
| 85 |
data_dir: Path
|
| 86 |
-
db_path: Path
|
|
|
|
|
|
|
| 87 |
session_secret: str
|
| 88 |
encryption_key: str
|
| 89 |
super_admin_username: str
|
|
@@ -99,6 +129,7 @@ class AppConfig:
|
|
| 99 |
submit_captcha_retry_limit: int
|
| 100 |
chrome_binary: str
|
| 101 |
chromedriver_path: str
|
|
|
|
| 102 |
|
| 103 |
@classmethod
|
| 104 |
def load(cls) -> "AppConfig":
|
|
@@ -109,11 +140,14 @@ class AppConfig:
|
|
| 109 |
super_admin_username = os.getenv("ADMIN", "superadmin")
|
| 110 |
super_admin_password = os.getenv("PASSWORD", "change-me-in-hf-space")
|
| 111 |
session_secret, encryption_key = _load_or_create_internal_secrets(data_dir)
|
|
|
|
| 112 |
|
| 113 |
return cls(
|
| 114 |
root_dir=root_dir,
|
| 115 |
data_dir=data_dir,
|
| 116 |
-
db_path=
|
|
|
|
|
|
|
| 117 |
session_secret=session_secret,
|
| 118 |
encryption_key=encryption_key,
|
| 119 |
super_admin_username=super_admin_username,
|
|
@@ -139,4 +173,5 @@ class AppConfig:
|
|
| 139 |
"/usr/bin/chromedriver",
|
| 140 |
"chromedriver",
|
| 141 |
),
|
|
|
|
| 142 |
)
|
|
|
|
| 6 |
import shutil
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from pathlib import Path
|
| 9 |
+
from urllib.parse import quote
|
| 10 |
|
| 11 |
from cryptography.fernet import Fernet
|
| 12 |
|
| 13 |
|
| 14 |
+
DEFAULT_PARALLEL_LIMIT = 4
|
| 15 |
POLL_INTERVAL_SECONDS = 10
|
| 16 |
LOGIN_RETRY_LIMIT = 6
|
| 17 |
TASK_BACKOFF_SECONDS = 6
|
|
|
|
| 20 |
SELENIUM_ERROR_LIMIT = 5
|
| 21 |
SELENIUM_RESTART_LIMIT = 5
|
| 22 |
SUBMIT_CAPTCHA_RETRY_LIMIT = 5
|
| 23 |
+
SCHEDULE_TIMEZONE = "Asia/Shanghai"
|
| 24 |
+
AIVEN_MYSQL_TEMPLATE = "mysql://avnadmin:{password}@mysql-2bace9cd-cacode.i.aivencloud.com:21260/SACC?ssl-mode=REQUIRED"
|
| 25 |
|
| 26 |
|
| 27 |
def _pick_binary(env_name: str, *fallbacks: str) -> str:
|
|
|
|
| 82 |
return session_secret, encryption_key
|
| 83 |
|
| 84 |
|
| 85 |
+
def _build_database_target(root_dir: Path, data_dir: Path) -> tuple[str | Path, str, Path | None]:
|
| 86 |
+
explicit_database_url = os.getenv("DATABASE_URL", "").strip()
|
| 87 |
+
if explicit_database_url:
|
| 88 |
+
normalized_url = explicit_database_url
|
| 89 |
+
else:
|
| 90 |
+
sql_password = os.getenv("SQL_PASSWORD", "").strip()
|
| 91 |
+
if sql_password:
|
| 92 |
+
normalized_url = AIVEN_MYSQL_TEMPLATE.format(password=quote(sql_password, safe=""))
|
| 93 |
+
else:
|
| 94 |
+
return (data_dir / "course_catcher.db").resolve(), "sqlite", None
|
| 95 |
+
|
| 96 |
+
if normalized_url.startswith("mysql://"):
|
| 97 |
+
normalized_url = f"mysql+pymysql://{normalized_url[len('mysql://') :]}"
|
| 98 |
+
|
| 99 |
+
if normalized_url.startswith("mysql+pymysql://"):
|
| 100 |
+
mysql_ssl_ca_path = Path(os.getenv("MYSQL_SSL_CA", str(root_dir / "ca.pem"))).resolve()
|
| 101 |
+
return normalized_url, "mysql", mysql_ssl_ca_path
|
| 102 |
+
|
| 103 |
+
if normalized_url.startswith("sqlite:///"):
|
| 104 |
+
sqlite_path = Path(normalized_url[len("sqlite:///") :]).resolve()
|
| 105 |
+
return sqlite_path, "sqlite", None
|
| 106 |
+
|
| 107 |
+
return Path(normalized_url).resolve(), "sqlite", None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
@dataclass(slots=True)
|
| 111 |
class AppConfig:
|
| 112 |
root_dir: Path
|
| 113 |
data_dir: Path
|
| 114 |
+
db_path: str | Path
|
| 115 |
+
database_backend: str
|
| 116 |
+
mysql_ssl_ca_path: Path | None
|
| 117 |
session_secret: str
|
| 118 |
encryption_key: str
|
| 119 |
super_admin_username: str
|
|
|
|
| 129 |
submit_captcha_retry_limit: int
|
| 130 |
chrome_binary: str
|
| 131 |
chromedriver_path: str
|
| 132 |
+
schedule_timezone: str
|
| 133 |
|
| 134 |
@classmethod
|
| 135 |
def load(cls) -> "AppConfig":
|
|
|
|
| 140 |
super_admin_username = os.getenv("ADMIN", "superadmin")
|
| 141 |
super_admin_password = os.getenv("PASSWORD", "change-me-in-hf-space")
|
| 142 |
session_secret, encryption_key = _load_or_create_internal_secrets(data_dir)
|
| 143 |
+
db_path, database_backend, mysql_ssl_ca_path = _build_database_target(root_dir, data_dir)
|
| 144 |
|
| 145 |
return cls(
|
| 146 |
root_dir=root_dir,
|
| 147 |
data_dir=data_dir,
|
| 148 |
+
db_path=db_path,
|
| 149 |
+
database_backend=database_backend,
|
| 150 |
+
mysql_ssl_ca_path=mysql_ssl_ca_path,
|
| 151 |
session_secret=session_secret,
|
| 152 |
encryption_key=encryption_key,
|
| 153 |
super_admin_username=super_admin_username,
|
|
|
|
| 173 |
"/usr/bin/chromedriver",
|
| 174 |
"chromedriver",
|
| 175 |
),
|
| 176 |
+
schedule_timezone=os.getenv("APP_TIMEZONE", SCHEDULE_TIMEZONE),
|
| 177 |
)
|
core/db.py
CHANGED
|
@@ -1,16 +1,28 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import sqlite3
|
|
|
|
| 4 |
from contextlib import contextmanager
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
from pathlib import Path
|
| 7 |
from typing import Any, Iterator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
ACTIVE_TASK_STATUSES = {"pending", "running", "cancel_requested"}
|
| 11 |
DEFAULT_REFRESH_INTERVAL_SECONDS = 10
|
| 12 |
MIN_REFRESH_INTERVAL_SECONDS = 1
|
| 13 |
MAX_REFRESH_INTERVAL_SECONDS = 120
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
def utc_now() -> str:
|
|
@@ -25,21 +37,103 @@ def clamp_refresh_interval_seconds(value: int | str | None, default: int = DEFAU
|
|
| 25 |
return max(MIN_REFRESH_INTERVAL_SECONDS, min(MAX_REFRESH_INTERVAL_SECONDS, normalized))
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
class Database:
|
| 29 |
-
def __init__(
|
| 30 |
-
self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
self.default_parallel_limit = default_parallel_limit
|
| 32 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
@contextmanager
|
| 42 |
-
def _cursor(self) -> Iterator[tuple[
|
| 43 |
connection = self._connect()
|
| 44 |
try:
|
| 45 |
cursor = connection.cursor()
|
|
@@ -48,18 +142,38 @@ class Database:
|
|
| 48 |
finally:
|
| 49 |
connection.close()
|
| 50 |
|
|
|
|
|
|
|
|
|
|
| 51 |
@staticmethod
|
| 52 |
-
def _rows_to_dicts(rows: list[
|
| 53 |
return [dict(row) for row in rows]
|
| 54 |
|
| 55 |
@staticmethod
|
| 56 |
def _normalize_course_identity(value: str) -> str:
|
| 57 |
return str(value or "").strip().upper()
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
def _ensure_column(self, table_name: str, column_name: str, definition: str) -> None:
|
| 65 |
with self._cursor() as (_connection, cursor):
|
|
@@ -67,105 +181,248 @@ class Database:
|
|
| 67 |
return
|
| 68 |
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {definition}")
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def init_db(self) -> None:
|
| 71 |
with self._cursor() as (_connection, cursor):
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
student_id TEXT NOT NULL UNIQUE,
|
| 77 |
-
password_encrypted TEXT NOT NULL,
|
| 78 |
-
display_name TEXT NOT NULL DEFAULT '',
|
| 79 |
-
is_active INTEGER NOT NULL DEFAULT 1,
|
| 80 |
-
refresh_interval_seconds INTEGER NOT NULL DEFAULT 10,
|
| 81 |
-
created_at TEXT NOT NULL,
|
| 82 |
-
updated_at TEXT NOT NULL
|
| 83 |
-
);
|
| 84 |
-
|
| 85 |
-
CREATE TABLE IF NOT EXISTS course_targets (
|
| 86 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 87 |
-
user_id INTEGER NOT NULL,
|
| 88 |
-
category TEXT NOT NULL DEFAULT 'free',
|
| 89 |
-
course_id TEXT NOT NULL,
|
| 90 |
-
course_index TEXT NOT NULL,
|
| 91 |
-
created_at TEXT NOT NULL,
|
| 92 |
-
UNIQUE(user_id, category, course_id, course_index),
|
| 93 |
-
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 94 |
-
);
|
| 95 |
-
|
| 96 |
-
CREATE TABLE IF NOT EXISTS admins (
|
| 97 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 98 |
-
username TEXT NOT NULL UNIQUE,
|
| 99 |
-
password_hash TEXT NOT NULL,
|
| 100 |
-
created_at TEXT NOT NULL,
|
| 101 |
-
updated_at TEXT NOT NULL
|
| 102 |
-
);
|
| 103 |
-
|
| 104 |
-
CREATE TABLE IF NOT EXISTS app_settings (
|
| 105 |
-
key TEXT PRIMARY KEY,
|
| 106 |
-
value TEXT NOT NULL,
|
| 107 |
-
updated_at TEXT NOT NULL
|
| 108 |
-
);
|
| 109 |
-
|
| 110 |
-
CREATE TABLE IF NOT EXISTS tasks (
|
| 111 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 112 |
-
user_id INTEGER NOT NULL,
|
| 113 |
-
status TEXT NOT NULL,
|
| 114 |
-
requested_by TEXT NOT NULL,
|
| 115 |
-
requested_by_role TEXT NOT NULL,
|
| 116 |
-
last_error TEXT NOT NULL DEFAULT '',
|
| 117 |
-
total_attempts INTEGER NOT NULL DEFAULT 0,
|
| 118 |
-
total_errors INTEGER NOT NULL DEFAULT 0,
|
| 119 |
-
created_at TEXT NOT NULL,
|
| 120 |
-
started_at TEXT,
|
| 121 |
-
finished_at TEXT,
|
| 122 |
-
updated_at TEXT NOT NULL,
|
| 123 |
-
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 124 |
-
);
|
| 125 |
-
|
| 126 |
-
CREATE TABLE IF NOT EXISTS logs (
|
| 127 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 128 |
-
task_id INTEGER,
|
| 129 |
-
user_id INTEGER,
|
| 130 |
-
scope TEXT NOT NULL,
|
| 131 |
-
level TEXT NOT NULL,
|
| 132 |
-
message TEXT NOT NULL,
|
| 133 |
-
created_at TEXT NOT NULL,
|
| 134 |
-
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
| 135 |
-
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 136 |
-
);
|
| 137 |
-
"""
|
| 138 |
-
)
|
| 139 |
|
| 140 |
self._ensure_column(
|
| 141 |
"users",
|
| 142 |
"refresh_interval_seconds",
|
| 143 |
-
f"
|
| 144 |
)
|
| 145 |
-
self._ensure_column("tasks", "total_attempts", "
|
| 146 |
-
self._ensure_column("tasks", "total_errors", "
|
| 147 |
|
| 148 |
if self.get_setting("parallel_limit") is None:
|
| 149 |
self.set_setting("parallel_limit", str(self.default_parallel_limit))
|
| 150 |
|
| 151 |
def get_setting(self, key: str) -> str | None:
|
|
|
|
| 152 |
with self._cursor() as (_connection, cursor):
|
| 153 |
-
|
| 154 |
-
|
|
|
|
| 155 |
|
| 156 |
def set_setting(self, key: str, value: str) -> None:
|
| 157 |
now = utc_now()
|
| 158 |
with self._cursor() as (_connection, cursor):
|
| 159 |
cursor.execute(
|
| 160 |
-
""
|
| 161 |
-
|
| 162 |
-
VALUES (?, ?, ?)
|
| 163 |
-
ON CONFLICT(key) DO UPDATE SET
|
| 164 |
-
value = excluded.value,
|
| 165 |
-
updated_at = excluded.updated_at
|
| 166 |
-
""",
|
| 167 |
-
(key, value, now),
|
| 168 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
def get_parallel_limit(self) -> int:
|
| 171 |
raw_value = self.get_setting("parallel_limit")
|
|
@@ -191,22 +448,78 @@ class Database:
|
|
| 191 |
refresh_interval = clamp_refresh_interval_seconds(refresh_interval_seconds)
|
| 192 |
with self._cursor() as (_connection, cursor):
|
| 193 |
cursor.execute(
|
| 194 |
-
""
|
| 195 |
-
|
| 196 |
-
student_id,
|
| 197 |
-
password_encrypted,
|
| 198 |
-
display_name,
|
| 199 |
-
is_active,
|
| 200 |
-
refresh_interval_seconds,
|
| 201 |
-
created_at,
|
| 202 |
-
updated_at
|
| 203 |
-
|
| 204 |
-
VALUES (?, ?, ?, 1, ?, ?, ?)
|
| 205 |
-
""",
|
| 206 |
-
(student_id.strip(), password_encrypted, display_name.strip(), refresh_interval, now, now),
|
| 207 |
)
|
| 208 |
return int(cursor.lastrowid)
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
def update_user(
|
| 211 |
self,
|
| 212 |
user_id: int,
|
|
@@ -217,28 +530,34 @@ class Database:
|
|
| 217 |
refresh_interval_seconds: int | None = None,
|
| 218 |
) -> None:
|
| 219 |
assignments: list[str] = []
|
| 220 |
-
values:
|
| 221 |
if password_encrypted is not None:
|
| 222 |
-
assignments.append("password_encrypted =
|
| 223 |
-
values
|
| 224 |
if display_name is not None:
|
| 225 |
-
assignments.append("display_name =
|
| 226 |
-
values
|
| 227 |
if is_active is not None:
|
| 228 |
-
assignments.append("is_active =
|
| 229 |
-
values
|
| 230 |
if refresh_interval_seconds is not None:
|
| 231 |
-
assignments.append("refresh_interval_seconds =
|
| 232 |
-
values
|
| 233 |
if not assignments:
|
| 234 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
|
| 237 |
-
values.append(utc_now())
|
| 238 |
-
values.append(user_id)
|
| 239 |
-
|
| 240 |
with self._cursor() as (_connection, cursor):
|
| 241 |
-
cursor.execute(
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
def toggle_user_active(self, user_id: int) -> dict[str, Any] | None:
|
| 244 |
user = self.get_user(user_id)
|
|
@@ -249,42 +568,35 @@ class Database:
|
|
| 249 |
|
| 250 |
def get_user(self, user_id: int) -> dict[str, Any] | None:
|
| 251 |
with self._cursor() as (_connection, cursor):
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
return None if row is None else dict(row)
|
| 254 |
|
| 255 |
def get_user_by_student_id(self, student_id: str) -> dict[str, Any] | None:
|
| 256 |
with self._cursor() as (_connection, cursor):
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
return None if row is None else dict(row)
|
| 259 |
|
| 260 |
def list_users(self) -> list[dict[str, Any]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
with self._cursor() as (_connection, cursor):
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
SELECT
|
| 265 |
-
u.*,
|
| 266 |
-
COUNT(c.id) AS course_count,
|
| 267 |
-
(
|
| 268 |
-
SELECT t.status
|
| 269 |
-
FROM tasks t
|
| 270 |
-
WHERE t.user_id = u.id
|
| 271 |
-
ORDER BY t.created_at DESC
|
| 272 |
-
LIMIT 1
|
| 273 |
-
) AS latest_task_status,
|
| 274 |
-
(
|
| 275 |
-
SELECT t.updated_at
|
| 276 |
-
FROM tasks t
|
| 277 |
-
WHERE t.user_id = u.id
|
| 278 |
-
ORDER BY t.created_at DESC
|
| 279 |
-
LIMIT 1
|
| 280 |
-
) AS latest_task_updated_at
|
| 281 |
-
FROM users u
|
| 282 |
-
LEFT JOIN course_targets c ON c.user_id = u.id
|
| 283 |
-
GROUP BY u.id
|
| 284 |
-
ORDER BY u.student_id ASC
|
| 285 |
-
"""
|
| 286 |
-
).fetchall()
|
| 287 |
-
return self._rows_to_dicts(rows)
|
| 288 |
|
| 289 |
def add_course(self, user_id: int, category: str, course_id: str, course_index: str) -> int | None:
|
| 290 |
now = utc_now()
|
|
@@ -293,210 +605,199 @@ class Database:
|
|
| 293 |
normalized_course_index = self._normalize_course_identity(course_index)
|
| 294 |
with self._cursor() as (_connection, cursor):
|
| 295 |
cursor.execute(
|
| 296 |
-
""
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
)
|
| 302 |
-
return int(cursor.lastrowid)
|
| 303 |
|
| 304 |
def delete_course(self, course_target_id: int) -> None:
|
| 305 |
with self._cursor() as (_connection, cursor):
|
| 306 |
-
cursor.execute(
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
def remove_course_by_identity(self, user_id: int, category: str, course_id: str, course_index: str) -> None:
|
| 309 |
with self._cursor() as (_connection, cursor):
|
| 310 |
cursor.execute(
|
| 311 |
-
""
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
self._normalize_course_identity(course_id),
|
| 319 |
-
self._normalize_course_identity(course_index),
|
| 320 |
-
),
|
| 321 |
)
|
| 322 |
|
| 323 |
def list_courses_for_user(self, user_id: int) -> list[dict[str, Any]]:
|
| 324 |
with self._cursor() as (_connection, cursor):
|
| 325 |
-
|
| 326 |
-
""
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
ORDER BY category ASC, course_id ASC, course_index ASC
|
| 331 |
-
""",
|
| 332 |
-
(user_id,),
|
| 333 |
-
).fetchall()
|
| 334 |
-
return self._rows_to_dicts(rows)
|
| 335 |
|
| 336 |
def create_admin(self, username: str, password_hash: str) -> int:
|
| 337 |
now = utc_now()
|
| 338 |
with self._cursor() as (_connection, cursor):
|
| 339 |
cursor.execute(
|
| 340 |
-
""
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
| 345 |
)
|
| 346 |
return int(cursor.lastrowid)
|
| 347 |
|
| 348 |
def get_admin_by_username(self, username: str) -> dict[str, Any] | None:
|
| 349 |
with self._cursor() as (_connection, cursor):
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
return None if row is None else dict(row)
|
| 352 |
|
| 353 |
def list_admins(self) -> list[dict[str, Any]]:
|
| 354 |
with self._cursor() as (_connection, cursor):
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
).fetchall()
|
| 358 |
-
return self._rows_to_dicts(rows)
|
| 359 |
|
| 360 |
def find_active_task_for_user(self, user_id: int) -> dict[str, Any] | None:
|
| 361 |
with self._cursor() as (_connection, cursor):
|
| 362 |
-
|
| 363 |
-
""
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
ORDER BY created_at DESC
|
| 368 |
-
LIMIT 1
|
| 369 |
-
""",
|
| 370 |
-
(user_id,),
|
| 371 |
-
).fetchone()
|
| 372 |
return None if row is None else dict(row)
|
| 373 |
|
| 374 |
def create_task(self, user_id: int, requested_by: str, requested_by_role: str) -> int:
|
| 375 |
now = utc_now()
|
| 376 |
with self._cursor() as (_connection, cursor):
|
| 377 |
cursor.execute(
|
| 378 |
-
""
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
)
|
| 384 |
return int(cursor.lastrowid)
|
| 385 |
|
| 386 |
def get_task(self, task_id: int) -> dict[str, Any] | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
with self._cursor() as (_connection, cursor):
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
SELECT
|
| 391 |
-
t.*,
|
| 392 |
-
u.student_id,
|
| 393 |
-
u.display_name,
|
| 394 |
-
u.refresh_interval_seconds
|
| 395 |
-
FROM tasks t
|
| 396 |
-
JOIN users u ON u.id = t.user_id
|
| 397 |
-
WHERE t.id = ?
|
| 398 |
-
""",
|
| 399 |
-
(task_id,),
|
| 400 |
-
).fetchone()
|
| 401 |
return None if row is None else dict(row)
|
| 402 |
|
| 403 |
def get_latest_task_for_user(self, user_id: int) -> dict[str, Any] | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
with self._cursor() as (_connection, cursor):
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
SELECT
|
| 408 |
-
t.*,
|
| 409 |
-
u.student_id,
|
| 410 |
-
u.display_name,
|
| 411 |
-
u.refresh_interval_seconds
|
| 412 |
-
FROM tasks t
|
| 413 |
-
JOIN users u ON u.id = t.user_id
|
| 414 |
-
WHERE t.user_id = ?
|
| 415 |
-
ORDER BY t.created_at DESC
|
| 416 |
-
LIMIT 1
|
| 417 |
-
""",
|
| 418 |
-
(user_id,),
|
| 419 |
-
).fetchone()
|
| 420 |
return None if row is None else dict(row)
|
| 421 |
|
| 422 |
def list_pending_tasks(self, limit: int) -> list[dict[str, Any]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
with self._cursor() as (_connection, cursor):
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
SELECT
|
| 427 |
-
t.*,
|
| 428 |
-
u.student_id,
|
| 429 |
-
u.display_name,
|
| 430 |
-
u.password_encrypted,
|
| 431 |
-
u.is_active,
|
| 432 |
-
u.refresh_interval_seconds
|
| 433 |
-
FROM tasks t
|
| 434 |
-
JOIN users u ON u.id = t.user_id
|
| 435 |
-
WHERE t.status = 'pending'
|
| 436 |
-
ORDER BY t.created_at ASC
|
| 437 |
-
LIMIT ?
|
| 438 |
-
""",
|
| 439 |
-
(limit,),
|
| 440 |
-
).fetchall()
|
| 441 |
-
return self._rows_to_dicts(rows)
|
| 442 |
|
| 443 |
def list_recent_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
with self._cursor() as (_connection, cursor):
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
SELECT
|
| 448 |
-
t.*,
|
| 449 |
-
u.student_id,
|
| 450 |
-
u.display_name,
|
| 451 |
-
u.refresh_interval_seconds
|
| 452 |
-
FROM tasks t
|
| 453 |
-
JOIN users u ON u.id = t.user_id
|
| 454 |
-
ORDER BY t.created_at DESC
|
| 455 |
-
LIMIT ?
|
| 456 |
-
""",
|
| 457 |
-
(limit,),
|
| 458 |
-
).fetchall()
|
| 459 |
-
return self._rows_to_dicts(rows)
|
| 460 |
-
|
| 461 |
def mark_task_running(self, task_id: int) -> None:
|
| 462 |
now = utc_now()
|
| 463 |
with self._cursor() as (_connection, cursor):
|
| 464 |
cursor.execute(
|
| 465 |
-
""
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
started_at
|
| 469 |
-
updated_at
|
| 470 |
-
last_error
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
(now, now, task_id),
|
| 474 |
)
|
| 475 |
|
| 476 |
def finish_task(self, task_id: int, status: str, last_error: str = "") -> None:
|
| 477 |
now = utc_now()
|
| 478 |
with self._cursor() as (_connection, cursor):
|
| 479 |
cursor.execute(
|
| 480 |
-
""
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
finished_at
|
| 484 |
-
updated_at
|
| 485 |
-
last_error
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
(status, now, now, last_error, task_id),
|
| 489 |
)
|
| 490 |
|
| 491 |
def update_task_status(self, task_id: int, status: str, last_error: str = "") -> None:
|
| 492 |
with self._cursor() as (_connection, cursor):
|
| 493 |
cursor.execute(
|
| 494 |
-
""
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
|
|
|
| 500 |
)
|
| 501 |
|
| 502 |
def increment_task_attempts(self, task_id: int, delta: int = 1) -> None:
|
|
@@ -505,13 +806,8 @@ class Database:
|
|
| 505 |
return
|
| 506 |
with self._cursor() as (_connection, cursor):
|
| 507 |
cursor.execute(
|
| 508 |
-
""
|
| 509 |
-
|
| 510 |
-
SET total_attempts = COALESCE(total_attempts, 0) + ?,
|
| 511 |
-
updated_at = ?
|
| 512 |
-
WHERE id = ?
|
| 513 |
-
""",
|
| 514 |
-
(increment, utc_now(), task_id),
|
| 515 |
)
|
| 516 |
|
| 517 |
def increment_task_errors(self, task_id: int, delta: int = 1) -> None:
|
|
@@ -520,13 +816,8 @@ class Database:
|
|
| 520 |
return
|
| 521 |
with self._cursor() as (_connection, cursor):
|
| 522 |
cursor.execute(
|
| 523 |
-
""
|
| 524 |
-
|
| 525 |
-
SET total_errors = COALESCE(total_errors, 0) + ?,
|
| 526 |
-
updated_at = ?
|
| 527 |
-
WHERE id = ?
|
| 528 |
-
""",
|
| 529 |
-
(increment, utc_now(), task_id),
|
| 530 |
)
|
| 531 |
|
| 532 |
def request_task_stop(self, task_id: int) -> bool:
|
|
@@ -535,12 +826,8 @@ class Database:
|
|
| 535 |
return False
|
| 536 |
with self._cursor() as (_connection, cursor):
|
| 537 |
cursor.execute(
|
| 538 |
-
""
|
| 539 |
-
|
| 540 |
-
SET status = 'cancel_requested', updated_at = ?
|
| 541 |
-
WHERE id = ?
|
| 542 |
-
""",
|
| 543 |
-
(utc_now(), task_id),
|
| 544 |
)
|
| 545 |
return True
|
| 546 |
|
|
@@ -548,102 +835,246 @@ class Database:
|
|
| 548 |
now = utc_now()
|
| 549 |
with self._cursor() as (_connection, cursor):
|
| 550 |
cursor.execute(
|
| 551 |
-
""
|
| 552 |
-
|
| 553 |
-
SET status = 'stopped',
|
| 554 |
-
finished_at = COALESCE(finished_at, ?),
|
| 555 |
-
updated_at = ?
|
| 556 |
-
WHERE status IN ('pending', 'running', 'cancel_requested')
|
| 557 |
-
""",
|
| 558 |
-
(now, now),
|
| 559 |
)
|
| 560 |
|
| 561 |
def add_log(self, task_id: int | None, user_id: int | None, scope: str, level: str, message: str) -> int:
|
| 562 |
now = utc_now()
|
| 563 |
with self._cursor() as (_connection, cursor):
|
| 564 |
cursor.execute(
|
| 565 |
-
""
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
)
|
| 571 |
return int(cursor.lastrowid)
|
| 572 |
|
| 573 |
def list_recent_logs(self, *, user_id: int | None = None, limit: int = 120) -> list[dict[str, Any]]:
|
| 574 |
if user_id is None:
|
| 575 |
-
query = """
|
| 576 |
-
SELECT
|
| 577 |
-
l.*,
|
| 578 |
-
u.student_id,
|
| 579 |
-
u.display_name
|
| 580 |
FROM logs l
|
| 581 |
LEFT JOIN users u ON u.id = l.user_id
|
| 582 |
ORDER BY l.id DESC
|
| 583 |
-
LIMIT
|
| 584 |
"""
|
| 585 |
-
params = (limit
|
| 586 |
else:
|
| 587 |
-
query = """
|
| 588 |
-
SELECT
|
| 589 |
-
l.*,
|
| 590 |
-
u.student_id,
|
| 591 |
-
u.display_name
|
| 592 |
FROM logs l
|
| 593 |
LEFT JOIN users u ON u.id = l.user_id
|
| 594 |
-
WHERE l.user_id =
|
| 595 |
ORDER BY l.id DESC
|
| 596 |
-
LIMIT
|
| 597 |
"""
|
| 598 |
-
params =
|
| 599 |
-
|
| 600 |
with self._cursor() as (_connection, cursor):
|
| 601 |
-
|
|
|
|
| 602 |
return list(reversed(self._rows_to_dicts(rows)))
|
| 603 |
|
| 604 |
def list_logs_after(self, after_id: int, *, user_id: int | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
| 605 |
if user_id is None:
|
| 606 |
-
query = """
|
| 607 |
-
SELECT
|
| 608 |
-
l.*,
|
| 609 |
-
u.student_id,
|
| 610 |
-
u.display_name
|
| 611 |
FROM logs l
|
| 612 |
LEFT JOIN users u ON u.id = l.user_id
|
| 613 |
-
WHERE l.id >
|
| 614 |
ORDER BY l.id ASC
|
| 615 |
-
LIMIT
|
| 616 |
"""
|
| 617 |
-
params =
|
| 618 |
else:
|
| 619 |
-
query = """
|
| 620 |
-
SELECT
|
| 621 |
-
l.*,
|
| 622 |
-
u.student_id,
|
| 623 |
-
u.display_name
|
| 624 |
FROM logs l
|
| 625 |
LEFT JOIN users u ON u.id = l.user_id
|
| 626 |
-
WHERE l.id >
|
| 627 |
ORDER BY l.id ASC
|
| 628 |
-
LIMIT
|
| 629 |
"""
|
| 630 |
-
params =
|
| 631 |
-
|
| 632 |
with self._cursor() as (_connection, cursor):
|
| 633 |
-
|
| 634 |
-
return self._rows_to_dicts(
|
| 635 |
|
| 636 |
def get_admin_stats(self) -> dict[str, int]:
|
| 637 |
with self._cursor() as (_connection, cursor):
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
return {
|
| 644 |
-
"users_count":
|
| 645 |
-
"courses_count":
|
| 646 |
-
"admins_count":
|
| 647 |
-
"running_count":
|
| 648 |
-
"pending_count":
|
|
|
|
|
|
|
| 649 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import secrets
|
| 4 |
import sqlite3
|
| 5 |
+
import string
|
| 6 |
from contextlib import contextmanager
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Any, Iterator
|
| 10 |
+
from urllib.parse import parse_qs, unquote, urlparse
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
import pymysql
|
| 14 |
+
from pymysql.cursors import DictCursor as MySQLDictCursor
|
| 15 |
+
except ImportError: # pragma: no cover - optional dependency in local SQLite mode
|
| 16 |
+
pymysql = None
|
| 17 |
+
MySQLDictCursor = None
|
| 18 |
|
| 19 |
|
| 20 |
ACTIVE_TASK_STATUSES = {"pending", "running", "cancel_requested"}
|
| 21 |
DEFAULT_REFRESH_INTERVAL_SECONDS = 10
|
| 22 |
MIN_REFRESH_INTERVAL_SECONDS = 1
|
| 23 |
MAX_REFRESH_INTERVAL_SECONDS = 120
|
| 24 |
+
DEFAULT_REGISTRATION_CODE_MAX_USES = 1
|
| 25 |
+
REGISTRATION_CODE_ALPHABET = string.ascii_uppercase + string.digits
|
| 26 |
|
| 27 |
|
| 28 |
def utc_now() -> str:
|
|
|
|
| 37 |
return max(MIN_REFRESH_INTERVAL_SECONDS, min(MAX_REFRESH_INTERVAL_SECONDS, normalized))
|
| 38 |
|
| 39 |
|
| 40 |
+
def normalize_registration_code(value: str) -> str:
|
| 41 |
+
return str(value or "").strip().upper()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
class Database:
|
| 45 |
+
def __init__(
|
| 46 |
+
self,
|
| 47 |
+
path: Path | str,
|
| 48 |
+
default_parallel_limit: int = 4,
|
| 49 |
+
*,
|
| 50 |
+
mysql_ssl_ca_path: Path | str | None = None,
|
| 51 |
+
) -> None:
|
| 52 |
self.default_parallel_limit = default_parallel_limit
|
| 53 |
+
self._mysql_ssl_ca_path = Path(mysql_ssl_ca_path).resolve() if mysql_ssl_ca_path else None
|
| 54 |
+
self._dialect = "sqlite"
|
| 55 |
+
self._sqlite_path: Path | None = None
|
| 56 |
+
self._mysql_options: dict[str, Any] = {}
|
| 57 |
+
self._mysql_database_name = ""
|
| 58 |
+
|
| 59 |
+
raw_path = str(path)
|
| 60 |
+
if isinstance(path, Path) or "://" not in raw_path:
|
| 61 |
+
self._sqlite_path = Path(path).resolve()
|
| 62 |
+
self._sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
| 63 |
+
self.path = self._sqlite_path
|
| 64 |
+
elif raw_path.startswith("sqlite:///"):
|
| 65 |
+
self._sqlite_path = Path(raw_path[len("sqlite:///") :]).resolve()
|
| 66 |
+
self._sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
| 67 |
+
self.path = self._sqlite_path
|
| 68 |
+
elif raw_path.startswith("mysql://") or raw_path.startswith("mysql+pymysql://"):
|
| 69 |
+
self._dialect = "mysql"
|
| 70 |
+
self._mysql_options = self._parse_mysql_options(raw_path)
|
| 71 |
+
self._mysql_database_name = str(self._mysql_options["database"])
|
| 72 |
+
self.path = self._mysql_display_label()
|
| 73 |
+
else:
|
| 74 |
+
raise ValueError(f"Unsupported database target: {raw_path}")
|
| 75 |
|
| 76 |
+
@property
|
| 77 |
+
def is_mysql(self) -> bool:
|
| 78 |
+
return self._dialect == "mysql"
|
| 79 |
+
|
| 80 |
+
def _mysql_display_label(self) -> str:
|
| 81 |
+
if not self._mysql_options:
|
| 82 |
+
return "mysql://unknown"
|
| 83 |
+
return (
|
| 84 |
+
f"mysql://{self._mysql_options.get('user', '')}@"
|
| 85 |
+
f"{self._mysql_options.get('host', '')}:{self._mysql_options.get('port', '')}/"
|
| 86 |
+
f"{self._mysql_options.get('database', '')}"
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
@staticmethod
|
| 90 |
+
def _parse_mysql_options(raw_url: str) -> dict[str, Any]:
|
| 91 |
+
normalized_url = raw_url.replace("mysql+pymysql://", "mysql://", 1)
|
| 92 |
+
parsed = urlparse(normalized_url)
|
| 93 |
+
return {
|
| 94 |
+
"host": parsed.hostname or "localhost",
|
| 95 |
+
"port": parsed.port or 3306,
|
| 96 |
+
"user": unquote(parsed.username or ""),
|
| 97 |
+
"password": unquote(parsed.password or ""),
|
| 98 |
+
"database": (parsed.path or "/").lstrip("/"),
|
| 99 |
+
"query": parse_qs(parsed.query),
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
def _connect(self):
|
| 103 |
+
if not self.is_mysql:
|
| 104 |
+
connection = sqlite3.connect(self._sqlite_path, timeout=30, check_same_thread=False)
|
| 105 |
+
connection.row_factory = sqlite3.Row
|
| 106 |
+
connection.execute("PRAGMA foreign_keys = ON")
|
| 107 |
+
connection.execute("PRAGMA journal_mode = WAL")
|
| 108 |
+
return connection
|
| 109 |
+
|
| 110 |
+
if pymysql is None or MySQLDictCursor is None:
|
| 111 |
+
raise RuntimeError("PyMySQL is required when DATABASE_URL/SQL_PASSWORD enables MySQL persistence.")
|
| 112 |
+
|
| 113 |
+
ssl_config: dict[str, str] | None = None
|
| 114 |
+
ssl_mode = str(self._mysql_options.get("query", {}).get("ssl-mode", [""])[0]).upper()
|
| 115 |
+
if ssl_mode == "REQUIRED" or self._mysql_ssl_ca_path is not None:
|
| 116 |
+
if self._mysql_ssl_ca_path is None or not self._mysql_ssl_ca_path.exists():
|
| 117 |
+
raise RuntimeError("MySQL SSL is enabled but ca.pem was not found.")
|
| 118 |
+
ssl_config = {"ca": str(self._mysql_ssl_ca_path)}
|
| 119 |
+
|
| 120 |
+
return pymysql.connect(
|
| 121 |
+
host=self._mysql_options["host"],
|
| 122 |
+
port=int(self._mysql_options["port"]),
|
| 123 |
+
user=self._mysql_options["user"],
|
| 124 |
+
password=self._mysql_options["password"],
|
| 125 |
+
database=self._mysql_options["database"],
|
| 126 |
+
charset="utf8mb4",
|
| 127 |
+
cursorclass=MySQLDictCursor,
|
| 128 |
+
autocommit=False,
|
| 129 |
+
ssl=ssl_config,
|
| 130 |
+
connect_timeout=10,
|
| 131 |
+
read_timeout=30,
|
| 132 |
+
write_timeout=30,
|
| 133 |
+
)
|
| 134 |
|
| 135 |
@contextmanager
|
| 136 |
+
def _cursor(self) -> Iterator[tuple[Any, Any]]:
|
| 137 |
connection = self._connect()
|
| 138 |
try:
|
| 139 |
cursor = connection.cursor()
|
|
|
|
| 142 |
finally:
|
| 143 |
connection.close()
|
| 144 |
|
| 145 |
+
def _placeholder(self, name: str) -> str:
|
| 146 |
+
return f":{name}" if not self.is_mysql else f"%({name})s"
|
| 147 |
+
|
| 148 |
@staticmethod
|
| 149 |
+
def _rows_to_dicts(rows: list[Any]) -> list[dict[str, Any]]:
|
| 150 |
return [dict(row) for row in rows]
|
| 151 |
|
| 152 |
@staticmethod
|
| 153 |
def _normalize_course_identity(value: str) -> str:
|
| 154 |
return str(value or "").strip().upper()
|
| 155 |
|
| 156 |
+
def _column_exists(self, cursor: Any, table_name: str, column_name: str) -> bool:
|
| 157 |
+
if not self.is_mysql:
|
| 158 |
+
rows = cursor.execute(f"PRAGMA table_info({table_name})").fetchall()
|
| 159 |
+
return any(str(row[1]) == column_name for row in rows)
|
| 160 |
+
|
| 161 |
+
query = (
|
| 162 |
+
f"SELECT COUNT(*) AS total FROM information_schema.columns "
|
| 163 |
+
f"WHERE table_schema = {self._placeholder('schema_name')} "
|
| 164 |
+
f"AND table_name = {self._placeholder('table_name')} "
|
| 165 |
+
f"AND column_name = {self._placeholder('column_name')}"
|
| 166 |
+
)
|
| 167 |
+
cursor.execute(
|
| 168 |
+
query,
|
| 169 |
+
{
|
| 170 |
+
"schema_name": self._mysql_database_name,
|
| 171 |
+
"table_name": table_name,
|
| 172 |
+
"column_name": column_name,
|
| 173 |
+
},
|
| 174 |
+
)
|
| 175 |
+
row = cursor.fetchone()
|
| 176 |
+
return int(dict(row).get("total", 0)) > 0
|
| 177 |
|
| 178 |
def _ensure_column(self, table_name: str, column_name: str, definition: str) -> None:
|
| 179 |
with self._cursor() as (_connection, cursor):
|
|
|
|
| 181 |
return
|
| 182 |
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {definition}")
|
| 183 |
|
| 184 |
+
def _create_tables_sqlite(self, cursor: Any) -> None:
|
| 185 |
+
cursor.executescript(
|
| 186 |
+
"""
|
| 187 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 188 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 189 |
+
student_id TEXT NOT NULL UNIQUE,
|
| 190 |
+
password_encrypted TEXT NOT NULL,
|
| 191 |
+
display_name TEXT NOT NULL DEFAULT '',
|
| 192 |
+
is_active INTEGER NOT NULL DEFAULT 1,
|
| 193 |
+
refresh_interval_seconds INTEGER NOT NULL DEFAULT 10,
|
| 194 |
+
created_at TEXT NOT NULL,
|
| 195 |
+
updated_at TEXT NOT NULL
|
| 196 |
+
);
|
| 197 |
+
CREATE TABLE IF NOT EXISTS course_targets (
|
| 198 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 199 |
+
user_id INTEGER NOT NULL,
|
| 200 |
+
category TEXT NOT NULL DEFAULT 'free',
|
| 201 |
+
course_id TEXT NOT NULL,
|
| 202 |
+
course_index TEXT NOT NULL,
|
| 203 |
+
created_at TEXT NOT NULL,
|
| 204 |
+
UNIQUE(user_id, category, course_id, course_index),
|
| 205 |
+
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 206 |
+
);
|
| 207 |
+
CREATE TABLE IF NOT EXISTS admins (
|
| 208 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 209 |
+
username TEXT NOT NULL UNIQUE,
|
| 210 |
+
password_hash TEXT NOT NULL,
|
| 211 |
+
created_at TEXT NOT NULL,
|
| 212 |
+
updated_at TEXT NOT NULL
|
| 213 |
+
);
|
| 214 |
+
CREATE TABLE IF NOT EXISTS app_settings (
|
| 215 |
+
`key` TEXT PRIMARY KEY,
|
| 216 |
+
value TEXT NOT NULL,
|
| 217 |
+
updated_at TEXT NOT NULL
|
| 218 |
+
);
|
| 219 |
+
CREATE TABLE IF NOT EXISTS tasks (
|
| 220 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 221 |
+
user_id INTEGER NOT NULL,
|
| 222 |
+
status TEXT NOT NULL,
|
| 223 |
+
requested_by TEXT NOT NULL,
|
| 224 |
+
requested_by_role TEXT NOT NULL,
|
| 225 |
+
last_error TEXT NOT NULL DEFAULT '',
|
| 226 |
+
total_attempts INTEGER NOT NULL DEFAULT 0,
|
| 227 |
+
total_errors INTEGER NOT NULL DEFAULT 0,
|
| 228 |
+
created_at TEXT NOT NULL,
|
| 229 |
+
started_at TEXT,
|
| 230 |
+
finished_at TEXT,
|
| 231 |
+
updated_at TEXT NOT NULL,
|
| 232 |
+
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 233 |
+
);
|
| 234 |
+
CREATE TABLE IF NOT EXISTS logs (
|
| 235 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 236 |
+
task_id INTEGER,
|
| 237 |
+
user_id INTEGER,
|
| 238 |
+
scope TEXT NOT NULL,
|
| 239 |
+
level TEXT NOT NULL,
|
| 240 |
+
message TEXT NOT NULL,
|
| 241 |
+
created_at TEXT NOT NULL,
|
| 242 |
+
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
| 243 |
+
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 244 |
+
);
|
| 245 |
+
CREATE TABLE IF NOT EXISTS user_schedules (
|
| 246 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 247 |
+
user_id INTEGER NOT NULL UNIQUE,
|
| 248 |
+
is_enabled INTEGER NOT NULL DEFAULT 0,
|
| 249 |
+
start_date TEXT,
|
| 250 |
+
end_date TEXT,
|
| 251 |
+
daily_start_time TEXT,
|
| 252 |
+
daily_stop_time TEXT,
|
| 253 |
+
last_auto_start_on TEXT NOT NULL DEFAULT '',
|
| 254 |
+
last_auto_stop_on TEXT NOT NULL DEFAULT '',
|
| 255 |
+
created_at TEXT NOT NULL,
|
| 256 |
+
updated_at TEXT NOT NULL,
|
| 257 |
+
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 258 |
+
);
|
| 259 |
+
CREATE TABLE IF NOT EXISTS registration_codes (
|
| 260 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 261 |
+
code TEXT NOT NULL UNIQUE,
|
| 262 |
+
note TEXT NOT NULL DEFAULT '',
|
| 263 |
+
is_active INTEGER NOT NULL DEFAULT 1,
|
| 264 |
+
max_uses INTEGER NOT NULL DEFAULT 1,
|
| 265 |
+
used_count INTEGER NOT NULL DEFAULT 0,
|
| 266 |
+
created_by TEXT NOT NULL DEFAULT '',
|
| 267 |
+
used_by_user_id INTEGER,
|
| 268 |
+
used_at TEXT,
|
| 269 |
+
created_at TEXT NOT NULL,
|
| 270 |
+
updated_at TEXT NOT NULL,
|
| 271 |
+
FOREIGN KEY(used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
| 272 |
+
);
|
| 273 |
+
"""
|
| 274 |
+
)
|
| 275 |
+
def _create_tables_mysql(self, cursor: Any) -> None:
|
| 276 |
+
statements = [
|
| 277 |
+
"""
|
| 278 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 279 |
+
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
| 280 |
+
student_id VARCHAR(32) NOT NULL UNIQUE,
|
| 281 |
+
password_encrypted TEXT NOT NULL,
|
| 282 |
+
display_name VARCHAR(255) NOT NULL DEFAULT '',
|
| 283 |
+
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
| 284 |
+
refresh_interval_seconds INT NOT NULL DEFAULT 10,
|
| 285 |
+
created_at VARCHAR(32) NOT NULL,
|
| 286 |
+
updated_at VARCHAR(32) NOT NULL
|
| 287 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 288 |
+
""",
|
| 289 |
+
"""
|
| 290 |
+
CREATE TABLE IF NOT EXISTS course_targets (
|
| 291 |
+
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
| 292 |
+
user_id BIGINT NOT NULL,
|
| 293 |
+
category VARCHAR(32) NOT NULL DEFAULT 'free',
|
| 294 |
+
course_id VARCHAR(64) NOT NULL,
|
| 295 |
+
course_index VARCHAR(32) NOT NULL,
|
| 296 |
+
created_at VARCHAR(32) NOT NULL,
|
| 297 |
+
UNIQUE KEY uq_course_targets_identity (user_id, category, course_id, course_index),
|
| 298 |
+
CONSTRAINT fk_course_targets_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 299 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 300 |
+
""",
|
| 301 |
+
"""
|
| 302 |
+
CREATE TABLE IF NOT EXISTS admins (
|
| 303 |
+
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
| 304 |
+
username VARCHAR(255) NOT NULL UNIQUE,
|
| 305 |
+
password_hash TEXT NOT NULL,
|
| 306 |
+
created_at VARCHAR(32) NOT NULL,
|
| 307 |
+
updated_at VARCHAR(32) NOT NULL
|
| 308 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 309 |
+
""",
|
| 310 |
+
"""
|
| 311 |
+
CREATE TABLE IF NOT EXISTS app_settings (
|
| 312 |
+
`key` VARCHAR(191) PRIMARY KEY,
|
| 313 |
+
value TEXT NOT NULL,
|
| 314 |
+
updated_at VARCHAR(32) NOT NULL
|
| 315 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 316 |
+
""",
|
| 317 |
+
"""
|
| 318 |
+
CREATE TABLE IF NOT EXISTS tasks (
|
| 319 |
+
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
| 320 |
+
user_id BIGINT NOT NULL,
|
| 321 |
+
status VARCHAR(32) NOT NULL,
|
| 322 |
+
requested_by VARCHAR(255) NOT NULL,
|
| 323 |
+
requested_by_role VARCHAR(32) NOT NULL,
|
| 324 |
+
last_error TEXT NOT NULL,
|
| 325 |
+
total_attempts INT NOT NULL DEFAULT 0,
|
| 326 |
+
total_errors INT NOT NULL DEFAULT 0,
|
| 327 |
+
created_at VARCHAR(32) NOT NULL,
|
| 328 |
+
started_at VARCHAR(32),
|
| 329 |
+
finished_at VARCHAR(32),
|
| 330 |
+
updated_at VARCHAR(32) NOT NULL,
|
| 331 |
+
CONSTRAINT fk_tasks_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 332 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 333 |
+
""",
|
| 334 |
+
"""
|
| 335 |
+
CREATE TABLE IF NOT EXISTS logs (
|
| 336 |
+
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
| 337 |
+
task_id BIGINT NULL,
|
| 338 |
+
user_id BIGINT NULL,
|
| 339 |
+
scope VARCHAR(64) NOT NULL,
|
| 340 |
+
level VARCHAR(32) NOT NULL,
|
| 341 |
+
message TEXT NOT NULL,
|
| 342 |
+
created_at VARCHAR(32) NOT NULL,
|
| 343 |
+
CONSTRAINT fk_logs_task FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
| 344 |
+
CONSTRAINT fk_logs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 345 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 346 |
+
""",
|
| 347 |
+
"""
|
| 348 |
+
CREATE TABLE IF NOT EXISTS user_schedules (
|
| 349 |
+
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
| 350 |
+
user_id BIGINT NOT NULL UNIQUE,
|
| 351 |
+
is_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
| 352 |
+
start_date VARCHAR(10) NULL,
|
| 353 |
+
end_date VARCHAR(10) NULL,
|
| 354 |
+
daily_start_time VARCHAR(5) NULL,
|
| 355 |
+
daily_stop_time VARCHAR(5) NULL,
|
| 356 |
+
last_auto_start_on VARCHAR(10) NOT NULL DEFAULT '',
|
| 357 |
+
last_auto_stop_on VARCHAR(10) NOT NULL DEFAULT '',
|
| 358 |
+
created_at VARCHAR(32) NOT NULL,
|
| 359 |
+
updated_at VARCHAR(32) NOT NULL,
|
| 360 |
+
CONSTRAINT fk_user_schedules_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 361 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 362 |
+
""",
|
| 363 |
+
"""
|
| 364 |
+
CREATE TABLE IF NOT EXISTS registration_codes (
|
| 365 |
+
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
| 366 |
+
code VARCHAR(64) NOT NULL UNIQUE,
|
| 367 |
+
note TEXT NOT NULL,
|
| 368 |
+
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
| 369 |
+
max_uses INT NOT NULL DEFAULT 1,
|
| 370 |
+
used_count INT NOT NULL DEFAULT 0,
|
| 371 |
+
created_by VARCHAR(255) NOT NULL DEFAULT '',
|
| 372 |
+
used_by_user_id BIGINT NULL,
|
| 373 |
+
used_at VARCHAR(32) NULL,
|
| 374 |
+
created_at VARCHAR(32) NOT NULL,
|
| 375 |
+
updated_at VARCHAR(32) NOT NULL,
|
| 376 |
+
CONSTRAINT fk_registration_codes_user FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
| 377 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 378 |
+
""",
|
| 379 |
+
]
|
| 380 |
+
for statement in statements:
|
| 381 |
+
cursor.execute(statement)
|
| 382 |
+
|
| 383 |
def init_db(self) -> None:
|
| 384 |
with self._cursor() as (_connection, cursor):
|
| 385 |
+
if self.is_mysql:
|
| 386 |
+
self._create_tables_mysql(cursor)
|
| 387 |
+
else:
|
| 388 |
+
self._create_tables_sqlite(cursor)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
|
| 390 |
self._ensure_column(
|
| 391 |
"users",
|
| 392 |
"refresh_interval_seconds",
|
| 393 |
+
f"INT NOT NULL DEFAULT {DEFAULT_REFRESH_INTERVAL_SECONDS}",
|
| 394 |
)
|
| 395 |
+
self._ensure_column("tasks", "total_attempts", "INT NOT NULL DEFAULT 0")
|
| 396 |
+
self._ensure_column("tasks", "total_errors", "INT NOT NULL DEFAULT 0")
|
| 397 |
|
| 398 |
if self.get_setting("parallel_limit") is None:
|
| 399 |
self.set_setting("parallel_limit", str(self.default_parallel_limit))
|
| 400 |
|
| 401 |
def get_setting(self, key: str) -> str | None:
|
| 402 |
+
query = f"SELECT value FROM app_settings WHERE `key` = {self._placeholder('key')}"
|
| 403 |
with self._cursor() as (_connection, cursor):
|
| 404 |
+
cursor.execute(query, {"key": key})
|
| 405 |
+
row = cursor.fetchone()
|
| 406 |
+
return None if row is None else str(dict(row)["value"])
|
| 407 |
|
| 408 |
def set_setting(self, key: str, value: str) -> None:
|
| 409 |
now = utc_now()
|
| 410 |
with self._cursor() as (_connection, cursor):
|
| 411 |
cursor.execute(
|
| 412 |
+
f"SELECT value FROM app_settings WHERE `key` = {self._placeholder('key')}",
|
| 413 |
+
{"key": key},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
)
|
| 415 |
+
existing = cursor.fetchone()
|
| 416 |
+
if existing is None:
|
| 417 |
+
cursor.execute(
|
| 418 |
+
f"INSERT INTO app_settings (`key`, value, updated_at) VALUES ({self._placeholder('key')}, {self._placeholder('value')}, {self._placeholder('updated_at')})",
|
| 419 |
+
{"key": key, "value": value, "updated_at": now},
|
| 420 |
+
)
|
| 421 |
+
else:
|
| 422 |
+
cursor.execute(
|
| 423 |
+
f"UPDATE app_settings SET value = {self._placeholder('value')}, updated_at = {self._placeholder('updated_at')} WHERE `key` = {self._placeholder('key')}",
|
| 424 |
+
{"key": key, "value": value, "updated_at": now},
|
| 425 |
+
)
|
| 426 |
|
| 427 |
def get_parallel_limit(self) -> int:
|
| 428 |
raw_value = self.get_setting("parallel_limit")
|
|
|
|
| 448 |
refresh_interval = clamp_refresh_interval_seconds(refresh_interval_seconds)
|
| 449 |
with self._cursor() as (_connection, cursor):
|
| 450 |
cursor.execute(
|
| 451 |
+
f"INSERT INTO users (student_id, password_encrypted, display_name, is_active, refresh_interval_seconds, created_at, updated_at) VALUES ({self._placeholder('student_id')}, {self._placeholder('password_encrypted')}, {self._placeholder('display_name')}, {self._placeholder('is_active')}, {self._placeholder('refresh_interval_seconds')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
|
| 452 |
+
{
|
| 453 |
+
"student_id": student_id.strip(),
|
| 454 |
+
"password_encrypted": password_encrypted,
|
| 455 |
+
"display_name": display_name.strip(),
|
| 456 |
+
"is_active": 1,
|
| 457 |
+
"refresh_interval_seconds": refresh_interval,
|
| 458 |
+
"created_at": now,
|
| 459 |
+
"updated_at": now,
|
| 460 |
+
},
|
|
|
|
|
|
|
|
|
|
| 461 |
)
|
| 462 |
return int(cursor.lastrowid)
|
| 463 |
|
| 464 |
+
def register_user_with_code(
|
| 465 |
+
self,
|
| 466 |
+
registration_code: str,
|
| 467 |
+
student_id: str,
|
| 468 |
+
password_encrypted: str,
|
| 469 |
+
display_name: str = "",
|
| 470 |
+
*,
|
| 471 |
+
refresh_interval_seconds: int = DEFAULT_REFRESH_INTERVAL_SECONDS,
|
| 472 |
+
) -> int:
|
| 473 |
+
now = utc_now()
|
| 474 |
+
normalized_code = normalize_registration_code(registration_code)
|
| 475 |
+
normalized_student_id = str(student_id or "").strip()
|
| 476 |
+
refresh_interval = clamp_refresh_interval_seconds(refresh_interval_seconds)
|
| 477 |
+
with self._cursor() as (_connection, cursor):
|
| 478 |
+
cursor.execute(
|
| 479 |
+
f"SELECT * FROM registration_codes WHERE code = {self._placeholder('code')}",
|
| 480 |
+
{"code": normalized_code},
|
| 481 |
+
)
|
| 482 |
+
code_row = cursor.fetchone()
|
| 483 |
+
if code_row is None:
|
| 484 |
+
raise ValueError("注册码不存在。")
|
| 485 |
+
code_payload = dict(code_row)
|
| 486 |
+
if not bool(code_payload.get("is_active", 0)):
|
| 487 |
+
raise ValueError("注册码已停用。")
|
| 488 |
+
if int(code_payload.get("used_count", 0)) >= int(code_payload.get("max_uses", 1)):
|
| 489 |
+
raise ValueError("注册码已使用完毕。")
|
| 490 |
+
cursor.execute(
|
| 491 |
+
f"SELECT id FROM users WHERE student_id = {self._placeholder('student_id')}",
|
| 492 |
+
{"student_id": normalized_student_id},
|
| 493 |
+
)
|
| 494 |
+
if cursor.fetchone() is not None:
|
| 495 |
+
raise ValueError("该学号已经注册。")
|
| 496 |
+
cursor.execute(
|
| 497 |
+
f"INSERT INTO users (student_id, password_encrypted, display_name, is_active, refresh_interval_seconds, created_at, updated_at) VALUES ({self._placeholder('student_id')}, {self._placeholder('password_encrypted')}, {self._placeholder('display_name')}, {self._placeholder('is_active')}, {self._placeholder('refresh_interval_seconds')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
|
| 498 |
+
{
|
| 499 |
+
"student_id": normalized_student_id,
|
| 500 |
+
"password_encrypted": password_encrypted,
|
| 501 |
+
"display_name": display_name.strip(),
|
| 502 |
+
"is_active": 1,
|
| 503 |
+
"refresh_interval_seconds": refresh_interval,
|
| 504 |
+
"created_at": now,
|
| 505 |
+
"updated_at": now,
|
| 506 |
+
},
|
| 507 |
+
)
|
| 508 |
+
user_id = int(cursor.lastrowid)
|
| 509 |
+
used_count = int(code_payload.get("used_count", 0)) + 1
|
| 510 |
+
max_uses = int(code_payload.get("max_uses", 1))
|
| 511 |
+
cursor.execute(
|
| 512 |
+
f"UPDATE registration_codes SET used_count = {self._placeholder('used_count')}, used_by_user_id = {self._placeholder('used_by_user_id')}, used_at = {self._placeholder('used_at')}, is_active = {self._placeholder('is_active')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('id')}",
|
| 513 |
+
{
|
| 514 |
+
"id": int(code_payload["id"]),
|
| 515 |
+
"used_count": used_count,
|
| 516 |
+
"used_by_user_id": user_id,
|
| 517 |
+
"used_at": now,
|
| 518 |
+
"is_active": 0 if used_count >= max_uses else 1,
|
| 519 |
+
"updated_at": now,
|
| 520 |
+
},
|
| 521 |
+
)
|
| 522 |
+
return user_id
|
| 523 |
def update_user(
|
| 524 |
self,
|
| 525 |
user_id: int,
|
|
|
|
| 530 |
refresh_interval_seconds: int | None = None,
|
| 531 |
) -> None:
|
| 532 |
assignments: list[str] = []
|
| 533 |
+
values: dict[str, Any] = {"user_id": user_id, "updated_at": utc_now()}
|
| 534 |
if password_encrypted is not None:
|
| 535 |
+
assignments.append(f"password_encrypted = {self._placeholder('password_encrypted')}")
|
| 536 |
+
values["password_encrypted"] = password_encrypted
|
| 537 |
if display_name is not None:
|
| 538 |
+
assignments.append(f"display_name = {self._placeholder('display_name')}")
|
| 539 |
+
values["display_name"] = display_name.strip()
|
| 540 |
if is_active is not None:
|
| 541 |
+
assignments.append(f"is_active = {self._placeholder('is_active')}")
|
| 542 |
+
values["is_active"] = 1 if is_active else 0
|
| 543 |
if refresh_interval_seconds is not None:
|
| 544 |
+
assignments.append(f"refresh_interval_seconds = {self._placeholder('refresh_interval_seconds')}")
|
| 545 |
+
values["refresh_interval_seconds"] = clamp_refresh_interval_seconds(refresh_interval_seconds)
|
| 546 |
if not assignments:
|
| 547 |
return
|
| 548 |
+
assignments.append(f"updated_at = {self._placeholder('updated_at')}")
|
| 549 |
+
with self._cursor() as (_connection, cursor):
|
| 550 |
+
cursor.execute(
|
| 551 |
+
f"UPDATE users SET {', '.join(assignments)} WHERE id = {self._placeholder('user_id')}",
|
| 552 |
+
values,
|
| 553 |
+
)
|
| 554 |
|
| 555 |
+
def delete_user(self, user_id: int) -> None:
|
|
|
|
|
|
|
|
|
|
| 556 |
with self._cursor() as (_connection, cursor):
|
| 557 |
+
cursor.execute(
|
| 558 |
+
f"DELETE FROM users WHERE id = {self._placeholder('user_id')}",
|
| 559 |
+
{"user_id": user_id},
|
| 560 |
+
)
|
| 561 |
|
| 562 |
def toggle_user_active(self, user_id: int) -> dict[str, Any] | None:
|
| 563 |
user = self.get_user(user_id)
|
|
|
|
| 568 |
|
| 569 |
def get_user(self, user_id: int) -> dict[str, Any] | None:
|
| 570 |
with self._cursor() as (_connection, cursor):
|
| 571 |
+
cursor.execute(
|
| 572 |
+
f"SELECT * FROM users WHERE id = {self._placeholder('user_id')}",
|
| 573 |
+
{"user_id": user_id},
|
| 574 |
+
)
|
| 575 |
+
row = cursor.fetchone()
|
| 576 |
return None if row is None else dict(row)
|
| 577 |
|
| 578 |
def get_user_by_student_id(self, student_id: str) -> dict[str, Any] | None:
|
| 579 |
with self._cursor() as (_connection, cursor):
|
| 580 |
+
cursor.execute(
|
| 581 |
+
f"SELECT * FROM users WHERE student_id = {self._placeholder('student_id')}",
|
| 582 |
+
{"student_id": student_id.strip()},
|
| 583 |
+
)
|
| 584 |
+
row = cursor.fetchone()
|
| 585 |
return None if row is None else dict(row)
|
| 586 |
|
| 587 |
def list_users(self) -> list[dict[str, Any]]:
|
| 588 |
+
query = f"""
|
| 589 |
+
SELECT
|
| 590 |
+
u.*,
|
| 591 |
+
(SELECT COUNT(*) FROM course_targets c WHERE c.user_id = u.id) AS course_count,
|
| 592 |
+
(SELECT t.status FROM tasks t WHERE t.user_id = u.id ORDER BY t.created_at DESC LIMIT 1) AS latest_task_status,
|
| 593 |
+
(SELECT t.updated_at FROM tasks t WHERE t.user_id = u.id ORDER BY t.created_at DESC LIMIT 1) AS latest_task_updated_at
|
| 594 |
+
FROM users u
|
| 595 |
+
ORDER BY u.student_id ASC
|
| 596 |
+
"""
|
| 597 |
with self._cursor() as (_connection, cursor):
|
| 598 |
+
cursor.execute(query)
|
| 599 |
+
return self._rows_to_dicts(cursor.fetchall())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
|
| 601 |
def add_course(self, user_id: int, category: str, course_id: str, course_index: str) -> int | None:
|
| 602 |
now = utc_now()
|
|
|
|
| 605 |
normalized_course_index = self._normalize_course_identity(course_index)
|
| 606 |
with self._cursor() as (_connection, cursor):
|
| 607 |
cursor.execute(
|
| 608 |
+
f"SELECT id FROM course_targets WHERE user_id = {self._placeholder('user_id')} AND category = {self._placeholder('category')} AND course_id = {self._placeholder('course_id')} AND course_index = {self._placeholder('course_index')}",
|
| 609 |
+
{
|
| 610 |
+
"user_id": user_id,
|
| 611 |
+
"category": normalized_category,
|
| 612 |
+
"course_id": normalized_course_id,
|
| 613 |
+
"course_index": normalized_course_index,
|
| 614 |
+
},
|
| 615 |
+
)
|
| 616 |
+
if cursor.fetchone() is not None:
|
| 617 |
+
return None
|
| 618 |
+
cursor.execute(
|
| 619 |
+
f"INSERT INTO course_targets (user_id, category, course_id, course_index, created_at) VALUES ({self._placeholder('user_id')}, {self._placeholder('category')}, {self._placeholder('course_id')}, {self._placeholder('course_index')}, {self._placeholder('created_at')})",
|
| 620 |
+
{
|
| 621 |
+
"user_id": user_id,
|
| 622 |
+
"category": normalized_category,
|
| 623 |
+
"course_id": normalized_course_id,
|
| 624 |
+
"course_index": normalized_course_index,
|
| 625 |
+
"created_at": now,
|
| 626 |
+
},
|
| 627 |
)
|
| 628 |
+
return int(cursor.lastrowid)
|
| 629 |
|
| 630 |
def delete_course(self, course_target_id: int) -> None:
|
| 631 |
with self._cursor() as (_connection, cursor):
|
| 632 |
+
cursor.execute(
|
| 633 |
+
f"DELETE FROM course_targets WHERE id = {self._placeholder('course_target_id')}",
|
| 634 |
+
{"course_target_id": course_target_id},
|
| 635 |
+
)
|
| 636 |
|
| 637 |
def remove_course_by_identity(self, user_id: int, category: str, course_id: str, course_index: str) -> None:
|
| 638 |
with self._cursor() as (_connection, cursor):
|
| 639 |
cursor.execute(
|
| 640 |
+
f"DELETE FROM course_targets WHERE user_id = {self._placeholder('user_id')} AND category = {self._placeholder('category')} AND course_id = {self._placeholder('course_id')} AND course_index = {self._placeholder('course_index')}",
|
| 641 |
+
{
|
| 642 |
+
"user_id": user_id,
|
| 643 |
+
"category": category,
|
| 644 |
+
"course_id": self._normalize_course_identity(course_id),
|
| 645 |
+
"course_index": self._normalize_course_identity(course_index),
|
| 646 |
+
},
|
|
|
|
|
|
|
|
|
|
| 647 |
)
|
| 648 |
|
| 649 |
def list_courses_for_user(self, user_id: int) -> list[dict[str, Any]]:
|
| 650 |
with self._cursor() as (_connection, cursor):
|
| 651 |
+
cursor.execute(
|
| 652 |
+
f"SELECT * FROM course_targets WHERE user_id = {self._placeholder('user_id')} ORDER BY category ASC, course_id ASC, course_index ASC",
|
| 653 |
+
{"user_id": user_id},
|
| 654 |
+
)
|
| 655 |
+
return self._rows_to_dicts(cursor.fetchall())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
|
| 657 |
def create_admin(self, username: str, password_hash: str) -> int:
|
| 658 |
now = utc_now()
|
| 659 |
with self._cursor() as (_connection, cursor):
|
| 660 |
cursor.execute(
|
| 661 |
+
f"INSERT INTO admins (username, password_hash, created_at, updated_at) VALUES ({self._placeholder('username')}, {self._placeholder('password_hash')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
|
| 662 |
+
{
|
| 663 |
+
"username": username.strip(),
|
| 664 |
+
"password_hash": password_hash,
|
| 665 |
+
"created_at": now,
|
| 666 |
+
"updated_at": now,
|
| 667 |
+
},
|
| 668 |
)
|
| 669 |
return int(cursor.lastrowid)
|
| 670 |
|
| 671 |
def get_admin_by_username(self, username: str) -> dict[str, Any] | None:
|
| 672 |
with self._cursor() as (_connection, cursor):
|
| 673 |
+
cursor.execute(
|
| 674 |
+
f"SELECT * FROM admins WHERE username = {self._placeholder('username')}",
|
| 675 |
+
{"username": username.strip()},
|
| 676 |
+
)
|
| 677 |
+
row = cursor.fetchone()
|
| 678 |
return None if row is None else dict(row)
|
| 679 |
|
| 680 |
def list_admins(self) -> list[dict[str, Any]]:
|
| 681 |
with self._cursor() as (_connection, cursor):
|
| 682 |
+
cursor.execute("SELECT id, username, created_at, updated_at FROM admins ORDER BY username ASC")
|
| 683 |
+
return self._rows_to_dicts(cursor.fetchall())
|
|
|
|
|
|
|
| 684 |
|
| 685 |
def find_active_task_for_user(self, user_id: int) -> dict[str, Any] | None:
|
| 686 |
with self._cursor() as (_connection, cursor):
|
| 687 |
+
cursor.execute(
|
| 688 |
+
f"SELECT * FROM tasks WHERE user_id = {self._placeholder('user_id')} AND status IN ('pending', 'running', 'cancel_requested') ORDER BY created_at DESC LIMIT 1",
|
| 689 |
+
{"user_id": user_id},
|
| 690 |
+
)
|
| 691 |
+
row = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
return None if row is None else dict(row)
|
| 693 |
|
| 694 |
def create_task(self, user_id: int, requested_by: str, requested_by_role: str) -> int:
|
| 695 |
now = utc_now()
|
| 696 |
with self._cursor() as (_connection, cursor):
|
| 697 |
cursor.execute(
|
| 698 |
+
f"INSERT INTO tasks (user_id, status, requested_by, requested_by_role, last_error, total_attempts, total_errors, created_at, updated_at) VALUES ({self._placeholder('user_id')}, {self._placeholder('status')}, {self._placeholder('requested_by')}, {self._placeholder('requested_by_role')}, {self._placeholder('last_error')}, {self._placeholder('total_attempts')}, {self._placeholder('total_errors')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
|
| 699 |
+
{
|
| 700 |
+
"user_id": user_id,
|
| 701 |
+
"status": "pending",
|
| 702 |
+
"requested_by": requested_by,
|
| 703 |
+
"requested_by_role": requested_by_role,
|
| 704 |
+
"last_error": "",
|
| 705 |
+
"total_attempts": 0,
|
| 706 |
+
"total_errors": 0,
|
| 707 |
+
"created_at": now,
|
| 708 |
+
"updated_at": now,
|
| 709 |
+
},
|
| 710 |
)
|
| 711 |
return int(cursor.lastrowid)
|
| 712 |
|
| 713 |
def get_task(self, task_id: int) -> dict[str, Any] | None:
|
| 714 |
+
query = f"""
|
| 715 |
+
SELECT t.*, u.student_id, u.display_name, u.refresh_interval_seconds
|
| 716 |
+
FROM tasks t
|
| 717 |
+
JOIN users u ON u.id = t.user_id
|
| 718 |
+
WHERE t.id = {self._placeholder('task_id')}
|
| 719 |
+
"""
|
| 720 |
with self._cursor() as (_connection, cursor):
|
| 721 |
+
cursor.execute(query, {"task_id": task_id})
|
| 722 |
+
row = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
return None if row is None else dict(row)
|
| 724 |
|
| 725 |
def get_latest_task_for_user(self, user_id: int) -> dict[str, Any] | None:
|
| 726 |
+
query = f"""
|
| 727 |
+
SELECT t.*, u.student_id, u.display_name, u.refresh_interval_seconds
|
| 728 |
+
FROM tasks t
|
| 729 |
+
JOIN users u ON u.id = t.user_id
|
| 730 |
+
WHERE t.user_id = {self._placeholder('user_id')}
|
| 731 |
+
ORDER BY t.created_at DESC
|
| 732 |
+
LIMIT 1
|
| 733 |
+
"""
|
| 734 |
with self._cursor() as (_connection, cursor):
|
| 735 |
+
cursor.execute(query, {"user_id": user_id})
|
| 736 |
+
row = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
return None if row is None else dict(row)
|
| 738 |
|
| 739 |
def list_pending_tasks(self, limit: int) -> list[dict[str, Any]]:
|
| 740 |
+
query = f"""
|
| 741 |
+
SELECT t.*, u.student_id, u.display_name, u.password_encrypted, u.is_active, u.refresh_interval_seconds
|
| 742 |
+
FROM tasks t
|
| 743 |
+
JOIN users u ON u.id = t.user_id
|
| 744 |
+
WHERE t.status = 'pending'
|
| 745 |
+
ORDER BY t.created_at ASC
|
| 746 |
+
LIMIT {self._placeholder('limit')}
|
| 747 |
+
"""
|
| 748 |
with self._cursor() as (_connection, cursor):
|
| 749 |
+
cursor.execute(query, {"limit": int(limit)})
|
| 750 |
+
return self._rows_to_dicts(cursor.fetchall())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
|
| 752 |
def list_recent_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
|
| 753 |
+
query = f"""
|
| 754 |
+
SELECT t.*, u.student_id, u.display_name, u.refresh_interval_seconds
|
| 755 |
+
FROM tasks t
|
| 756 |
+
JOIN users u ON u.id = t.user_id
|
| 757 |
+
ORDER BY t.created_at DESC
|
| 758 |
+
LIMIT {self._placeholder('limit')}
|
| 759 |
+
"""
|
| 760 |
with self._cursor() as (_connection, cursor):
|
| 761 |
+
cursor.execute(query, {"limit": int(limit)})
|
| 762 |
+
return self._rows_to_dicts(cursor.fetchall())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
def mark_task_running(self, task_id: int) -> None:
|
| 764 |
now = utc_now()
|
| 765 |
with self._cursor() as (_connection, cursor):
|
| 766 |
cursor.execute(
|
| 767 |
+
f"UPDATE tasks SET status = {self._placeholder('status')}, started_at = COALESCE(started_at, {self._placeholder('started_at')}), updated_at = {self._placeholder('updated_at')}, last_error = {self._placeholder('last_error')} WHERE id = {self._placeholder('task_id')}",
|
| 768 |
+
{
|
| 769 |
+
"status": "running",
|
| 770 |
+
"started_at": now,
|
| 771 |
+
"updated_at": now,
|
| 772 |
+
"last_error": "",
|
| 773 |
+
"task_id": task_id,
|
| 774 |
+
},
|
|
|
|
| 775 |
)
|
| 776 |
|
| 777 |
def finish_task(self, task_id: int, status: str, last_error: str = "") -> None:
|
| 778 |
now = utc_now()
|
| 779 |
with self._cursor() as (_connection, cursor):
|
| 780 |
cursor.execute(
|
| 781 |
+
f"UPDATE tasks SET status = {self._placeholder('status')}, finished_at = {self._placeholder('finished_at')}, updated_at = {self._placeholder('updated_at')}, last_error = {self._placeholder('last_error')} WHERE id = {self._placeholder('task_id')}",
|
| 782 |
+
{
|
| 783 |
+
"status": status,
|
| 784 |
+
"finished_at": now,
|
| 785 |
+
"updated_at": now,
|
| 786 |
+
"last_error": last_error,
|
| 787 |
+
"task_id": task_id,
|
| 788 |
+
},
|
|
|
|
| 789 |
)
|
| 790 |
|
| 791 |
def update_task_status(self, task_id: int, status: str, last_error: str = "") -> None:
|
| 792 |
with self._cursor() as (_connection, cursor):
|
| 793 |
cursor.execute(
|
| 794 |
+
f"UPDATE tasks SET status = {self._placeholder('status')}, updated_at = {self._placeholder('updated_at')}, last_error = {self._placeholder('last_error')} WHERE id = {self._placeholder('task_id')}",
|
| 795 |
+
{
|
| 796 |
+
"status": status,
|
| 797 |
+
"updated_at": utc_now(),
|
| 798 |
+
"last_error": last_error,
|
| 799 |
+
"task_id": task_id,
|
| 800 |
+
},
|
| 801 |
)
|
| 802 |
|
| 803 |
def increment_task_attempts(self, task_id: int, delta: int = 1) -> None:
|
|
|
|
| 806 |
return
|
| 807 |
with self._cursor() as (_connection, cursor):
|
| 808 |
cursor.execute(
|
| 809 |
+
f"UPDATE tasks SET total_attempts = total_attempts + {self._placeholder('delta')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('task_id')}",
|
| 810 |
+
{"delta": increment, "updated_at": utc_now(), "task_id": task_id},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 811 |
)
|
| 812 |
|
| 813 |
def increment_task_errors(self, task_id: int, delta: int = 1) -> None:
|
|
|
|
| 816 |
return
|
| 817 |
with self._cursor() as (_connection, cursor):
|
| 818 |
cursor.execute(
|
| 819 |
+
f"UPDATE tasks SET total_errors = total_errors + {self._placeholder('delta')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('task_id')}",
|
| 820 |
+
{"delta": increment, "updated_at": utc_now(), "task_id": task_id},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 821 |
)
|
| 822 |
|
| 823 |
def request_task_stop(self, task_id: int) -> bool:
|
|
|
|
| 826 |
return False
|
| 827 |
with self._cursor() as (_connection, cursor):
|
| 828 |
cursor.execute(
|
| 829 |
+
f"UPDATE tasks SET status = {self._placeholder('status')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('task_id')}",
|
| 830 |
+
{"status": "cancel_requested", "updated_at": utc_now(), "task_id": task_id},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
)
|
| 832 |
return True
|
| 833 |
|
|
|
|
| 835 |
now = utc_now()
|
| 836 |
with self._cursor() as (_connection, cursor):
|
| 837 |
cursor.execute(
|
| 838 |
+
f"UPDATE tasks SET status = {self._placeholder('status')}, finished_at = COALESCE(finished_at, {self._placeholder('finished_at')}), updated_at = {self._placeholder('updated_at')} WHERE status IN ('pending', 'running', 'cancel_requested')",
|
| 839 |
+
{"status": "stopped", "finished_at": now, "updated_at": now},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
)
|
| 841 |
|
| 842 |
def add_log(self, task_id: int | None, user_id: int | None, scope: str, level: str, message: str) -> int:
|
| 843 |
now = utc_now()
|
| 844 |
with self._cursor() as (_connection, cursor):
|
| 845 |
cursor.execute(
|
| 846 |
+
f"INSERT INTO logs (task_id, user_id, scope, level, message, created_at) VALUES ({self._placeholder('task_id')}, {self._placeholder('user_id')}, {self._placeholder('scope')}, {self._placeholder('level')}, {self._placeholder('message')}, {self._placeholder('created_at')})",
|
| 847 |
+
{
|
| 848 |
+
"task_id": task_id,
|
| 849 |
+
"user_id": user_id,
|
| 850 |
+
"scope": scope,
|
| 851 |
+
"level": level.upper(),
|
| 852 |
+
"message": message,
|
| 853 |
+
"created_at": now,
|
| 854 |
+
},
|
| 855 |
)
|
| 856 |
return int(cursor.lastrowid)
|
| 857 |
|
| 858 |
def list_recent_logs(self, *, user_id: int | None = None, limit: int = 120) -> list[dict[str, Any]]:
|
| 859 |
if user_id is None:
|
| 860 |
+
query = f"""
|
| 861 |
+
SELECT l.*, u.student_id, u.display_name
|
|
|
|
|
|
|
|
|
|
| 862 |
FROM logs l
|
| 863 |
LEFT JOIN users u ON u.id = l.user_id
|
| 864 |
ORDER BY l.id DESC
|
| 865 |
+
LIMIT {self._placeholder('limit')}
|
| 866 |
"""
|
| 867 |
+
params = {"limit": int(limit)}
|
| 868 |
else:
|
| 869 |
+
query = f"""
|
| 870 |
+
SELECT l.*, u.student_id, u.display_name
|
|
|
|
|
|
|
|
|
|
| 871 |
FROM logs l
|
| 872 |
LEFT JOIN users u ON u.id = l.user_id
|
| 873 |
+
WHERE l.user_id = {self._placeholder('user_id')}
|
| 874 |
ORDER BY l.id DESC
|
| 875 |
+
LIMIT {self._placeholder('limit')}
|
| 876 |
"""
|
| 877 |
+
params = {"user_id": user_id, "limit": int(limit)}
|
|
|
|
| 878 |
with self._cursor() as (_connection, cursor):
|
| 879 |
+
cursor.execute(query, params)
|
| 880 |
+
rows = cursor.fetchall()
|
| 881 |
return list(reversed(self._rows_to_dicts(rows)))
|
| 882 |
|
| 883 |
def list_logs_after(self, after_id: int, *, user_id: int | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
| 884 |
if user_id is None:
|
| 885 |
+
query = f"""
|
| 886 |
+
SELECT l.*, u.student_id, u.display_name
|
|
|
|
|
|
|
|
|
|
| 887 |
FROM logs l
|
| 888 |
LEFT JOIN users u ON u.id = l.user_id
|
| 889 |
+
WHERE l.id > {self._placeholder('after_id')}
|
| 890 |
ORDER BY l.id ASC
|
| 891 |
+
LIMIT {self._placeholder('limit')}
|
| 892 |
"""
|
| 893 |
+
params = {"after_id": after_id, "limit": int(limit)}
|
| 894 |
else:
|
| 895 |
+
query = f"""
|
| 896 |
+
SELECT l.*, u.student_id, u.display_name
|
|
|
|
|
|
|
|
|
|
| 897 |
FROM logs l
|
| 898 |
LEFT JOIN users u ON u.id = l.user_id
|
| 899 |
+
WHERE l.id > {self._placeholder('after_id')} AND l.user_id = {self._placeholder('user_id')}
|
| 900 |
ORDER BY l.id ASC
|
| 901 |
+
LIMIT {self._placeholder('limit')}
|
| 902 |
"""
|
| 903 |
+
params = {"after_id": after_id, "user_id": user_id, "limit": int(limit)}
|
|
|
|
| 904 |
with self._cursor() as (_connection, cursor):
|
| 905 |
+
cursor.execute(query, params)
|
| 906 |
+
return self._rows_to_dicts(cursor.fetchall())
|
| 907 |
|
| 908 |
def get_admin_stats(self) -> dict[str, int]:
|
| 909 |
with self._cursor() as (_connection, cursor):
|
| 910 |
+
cursor.execute("SELECT COUNT(*) AS total FROM users")
|
| 911 |
+
users_count = int(dict(cursor.fetchone())["total"])
|
| 912 |
+
cursor.execute("SELECT COUNT(*) AS total FROM course_targets")
|
| 913 |
+
courses_count = int(dict(cursor.fetchone())["total"])
|
| 914 |
+
cursor.execute("SELECT COUNT(*) AS total FROM admins")
|
| 915 |
+
admins_count = int(dict(cursor.fetchone())["total"])
|
| 916 |
+
cursor.execute("SELECT COUNT(*) AS total FROM tasks WHERE status = 'running'")
|
| 917 |
+
running_count = int(dict(cursor.fetchone())["total"])
|
| 918 |
+
cursor.execute("SELECT COUNT(*) AS total FROM tasks WHERE status = 'pending'")
|
| 919 |
+
pending_count = int(dict(cursor.fetchone())["total"])
|
| 920 |
+
cursor.execute("SELECT COUNT(*) AS total FROM registration_codes")
|
| 921 |
+
registration_code_count = int(dict(cursor.fetchone())["total"])
|
| 922 |
+
cursor.execute("SELECT COUNT(*) AS total FROM user_schedules WHERE is_enabled = 1")
|
| 923 |
+
active_schedule_count = int(dict(cursor.fetchone())["total"])
|
| 924 |
return {
|
| 925 |
+
"users_count": users_count,
|
| 926 |
+
"courses_count": courses_count,
|
| 927 |
+
"admins_count": admins_count + 1,
|
| 928 |
+
"running_count": running_count,
|
| 929 |
+
"pending_count": pending_count,
|
| 930 |
+
"registration_code_count": registration_code_count,
|
| 931 |
+
"active_schedule_count": active_schedule_count,
|
| 932 |
}
|
| 933 |
+
|
| 934 |
+
def get_user_schedule(self, user_id: int) -> dict[str, Any] | None:
|
| 935 |
+
with self._cursor() as (_connection, cursor):
|
| 936 |
+
cursor.execute(
|
| 937 |
+
f"SELECT * FROM user_schedules WHERE user_id = {self._placeholder('user_id')}",
|
| 938 |
+
{"user_id": user_id},
|
| 939 |
+
)
|
| 940 |
+
row = cursor.fetchone()
|
| 941 |
+
return None if row is None else dict(row)
|
| 942 |
+
|
| 943 |
+
def upsert_user_schedule(
|
| 944 |
+
self,
|
| 945 |
+
user_id: int,
|
| 946 |
+
*,
|
| 947 |
+
is_enabled: bool,
|
| 948 |
+
start_date: str | None,
|
| 949 |
+
end_date: str | None,
|
| 950 |
+
daily_start_time: str | None,
|
| 951 |
+
daily_stop_time: str | None,
|
| 952 |
+
) -> None:
|
| 953 |
+
now = utc_now()
|
| 954 |
+
current = self.get_user_schedule(user_id)
|
| 955 |
+
payload = {
|
| 956 |
+
"user_id": user_id,
|
| 957 |
+
"is_enabled": 1 if is_enabled else 0,
|
| 958 |
+
"start_date": start_date,
|
| 959 |
+
"end_date": end_date,
|
| 960 |
+
"daily_start_time": daily_start_time,
|
| 961 |
+
"daily_stop_time": daily_stop_time,
|
| 962 |
+
"last_auto_start_on": "",
|
| 963 |
+
"last_auto_stop_on": "",
|
| 964 |
+
"created_at": now,
|
| 965 |
+
"updated_at": now,
|
| 966 |
+
"id": int(current["id"]) if current else None,
|
| 967 |
+
}
|
| 968 |
+
with self._cursor() as (_connection, cursor):
|
| 969 |
+
if current is None:
|
| 970 |
+
cursor.execute(
|
| 971 |
+
f"INSERT INTO user_schedules (user_id, is_enabled, start_date, end_date, daily_start_time, daily_stop_time, last_auto_start_on, last_auto_stop_on, created_at, updated_at) VALUES ({self._placeholder('user_id')}, {self._placeholder('is_enabled')}, {self._placeholder('start_date')}, {self._placeholder('end_date')}, {self._placeholder('daily_start_time')}, {self._placeholder('daily_stop_time')}, {self._placeholder('last_auto_start_on')}, {self._placeholder('last_auto_stop_on')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
|
| 972 |
+
payload,
|
| 973 |
+
)
|
| 974 |
+
else:
|
| 975 |
+
cursor.execute(
|
| 976 |
+
f"UPDATE user_schedules SET is_enabled = {self._placeholder('is_enabled')}, start_date = {self._placeholder('start_date')}, end_date = {self._placeholder('end_date')}, daily_start_time = {self._placeholder('daily_start_time')}, daily_stop_time = {self._placeholder('daily_stop_time')}, last_auto_start_on = {self._placeholder('last_auto_start_on')}, last_auto_stop_on = {self._placeholder('last_auto_stop_on')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('id')}",
|
| 977 |
+
payload,
|
| 978 |
+
)
|
| 979 |
+
|
| 980 |
+
def list_enabled_user_schedules(self) -> list[dict[str, Any]]:
|
| 981 |
+
query = """
|
| 982 |
+
SELECT s.*, u.student_id, u.display_name, u.is_active AS user_is_active
|
| 983 |
+
FROM user_schedules s
|
| 984 |
+
JOIN users u ON u.id = s.user_id
|
| 985 |
+
WHERE s.is_enabled = 1
|
| 986 |
+
ORDER BY s.user_id ASC
|
| 987 |
+
"""
|
| 988 |
+
with self._cursor() as (_connection, cursor):
|
| 989 |
+
cursor.execute(query)
|
| 990 |
+
return self._rows_to_dicts(cursor.fetchall())
|
| 991 |
+
|
| 992 |
+
def mark_schedule_auto_start(self, user_id: int, day_text: str) -> None:
|
| 993 |
+
with self._cursor() as (_connection, cursor):
|
| 994 |
+
cursor.execute(
|
| 995 |
+
f"UPDATE user_schedules SET last_auto_start_on = {self._placeholder('day_text')}, updated_at = {self._placeholder('updated_at')} WHERE user_id = {self._placeholder('user_id')}",
|
| 996 |
+
{"day_text": day_text, "updated_at": utc_now(), "user_id": user_id},
|
| 997 |
+
)
|
| 998 |
+
|
| 999 |
+
def mark_schedule_auto_stop(self, user_id: int, day_text: str) -> None:
|
| 1000 |
+
with self._cursor() as (_connection, cursor):
|
| 1001 |
+
cursor.execute(
|
| 1002 |
+
f"UPDATE user_schedules SET last_auto_stop_on = {self._placeholder('day_text')}, updated_at = {self._placeholder('updated_at')} WHERE user_id = {self._placeholder('user_id')}",
|
| 1003 |
+
{"day_text": day_text, "updated_at": utc_now(), "user_id": user_id},
|
| 1004 |
+
)
|
| 1005 |
+
def create_registration_code(
|
| 1006 |
+
self,
|
| 1007 |
+
*,
|
| 1008 |
+
created_by: str,
|
| 1009 |
+
note: str = "",
|
| 1010 |
+
max_uses: int = DEFAULT_REGISTRATION_CODE_MAX_USES,
|
| 1011 |
+
) -> dict[str, Any]:
|
| 1012 |
+
normalized_max_uses = max(1, int(max_uses or DEFAULT_REGISTRATION_CODE_MAX_USES))
|
| 1013 |
+
now = utc_now()
|
| 1014 |
+
for _ in range(24):
|
| 1015 |
+
code = "SACC-" + "".join(secrets.choice(REGISTRATION_CODE_ALPHABET) for _ in range(8))
|
| 1016 |
+
if self.get_registration_code_by_code(code) is not None:
|
| 1017 |
+
continue
|
| 1018 |
+
with self._cursor() as (_connection, cursor):
|
| 1019 |
+
cursor.execute(
|
| 1020 |
+
f"INSERT INTO registration_codes (code, note, is_active, max_uses, used_count, created_by, used_by_user_id, used_at, created_at, updated_at) VALUES ({self._placeholder('code')}, {self._placeholder('note')}, {self._placeholder('is_active')}, {self._placeholder('max_uses')}, {self._placeholder('used_count')}, {self._placeholder('created_by')}, {self._placeholder('used_by_user_id')}, {self._placeholder('used_at')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
|
| 1021 |
+
{
|
| 1022 |
+
"code": code,
|
| 1023 |
+
"note": note.strip(),
|
| 1024 |
+
"is_active": 1,
|
| 1025 |
+
"max_uses": normalized_max_uses,
|
| 1026 |
+
"used_count": 0,
|
| 1027 |
+
"created_by": created_by,
|
| 1028 |
+
"used_by_user_id": None,
|
| 1029 |
+
"used_at": None,
|
| 1030 |
+
"created_at": now,
|
| 1031 |
+
"updated_at": now,
|
| 1032 |
+
},
|
| 1033 |
+
)
|
| 1034 |
+
return self.get_registration_code_by_id(int(cursor.lastrowid)) or {"code": code}
|
| 1035 |
+
raise RuntimeError("生成注册码失败,请重试。")
|
| 1036 |
+
|
| 1037 |
+
def get_registration_code_by_code(self, code: str) -> dict[str, Any] | None:
|
| 1038 |
+
with self._cursor() as (_connection, cursor):
|
| 1039 |
+
cursor.execute(
|
| 1040 |
+
f"SELECT * FROM registration_codes WHERE code = {self._placeholder('code')}",
|
| 1041 |
+
{"code": normalize_registration_code(code)},
|
| 1042 |
+
)
|
| 1043 |
+
row = cursor.fetchone()
|
| 1044 |
+
return None if row is None else dict(row)
|
| 1045 |
+
|
| 1046 |
+
def get_registration_code_by_id(self, registration_code_id: int) -> dict[str, Any] | None:
|
| 1047 |
+
with self._cursor() as (_connection, cursor):
|
| 1048 |
+
cursor.execute(
|
| 1049 |
+
f"SELECT * FROM registration_codes WHERE id = {self._placeholder('registration_code_id')}",
|
| 1050 |
+
{"registration_code_id": registration_code_id},
|
| 1051 |
+
)
|
| 1052 |
+
row = cursor.fetchone()
|
| 1053 |
+
return None if row is None else dict(row)
|
| 1054 |
+
|
| 1055 |
+
def list_registration_codes(self, limit: int = 100) -> list[dict[str, Any]]:
|
| 1056 |
+
query = f"""
|
| 1057 |
+
SELECT rc.*, u.student_id AS used_by_student_id, u.display_name AS used_by_display_name
|
| 1058 |
+
FROM registration_codes rc
|
| 1059 |
+
LEFT JOIN users u ON u.id = rc.used_by_user_id
|
| 1060 |
+
ORDER BY rc.created_at DESC
|
| 1061 |
+
LIMIT {self._placeholder('limit')}
|
| 1062 |
+
"""
|
| 1063 |
+
with self._cursor() as (_connection, cursor):
|
| 1064 |
+
cursor.execute(query, {"limit": int(limit)})
|
| 1065 |
+
return self._rows_to_dicts(cursor.fetchall())
|
| 1066 |
+
|
| 1067 |
+
def toggle_registration_code_active(self, registration_code_id: int) -> dict[str, Any] | None:
|
| 1068 |
+
code = self.get_registration_code_by_id(registration_code_id)
|
| 1069 |
+
if code is None:
|
| 1070 |
+
return None
|
| 1071 |
+
with self._cursor() as (_connection, cursor):
|
| 1072 |
+
cursor.execute(
|
| 1073 |
+
f"UPDATE registration_codes SET is_active = {self._placeholder('is_active')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('registration_code_id')}",
|
| 1074 |
+
{
|
| 1075 |
+
"is_active": 0 if bool(code.get('is_active')) else 1,
|
| 1076 |
+
"updated_at": utc_now(),
|
| 1077 |
+
"registration_code_id": registration_code_id,
|
| 1078 |
+
},
|
| 1079 |
+
)
|
| 1080 |
+
return self.get_registration_code_by_id(registration_code_id)
|
core/task_manager.py
CHANGED
|
@@ -4,6 +4,8 @@ import logging
|
|
| 4 |
import threading
|
| 5 |
import time
|
| 6 |
from dataclasses import dataclass
|
|
|
|
|
|
|
| 7 |
|
| 8 |
from core.course_bot import CourseBot, TaskResult
|
| 9 |
from core.db import Database
|
|
@@ -32,6 +34,7 @@ class TaskManager:
|
|
| 32 |
self._dispatcher_thread: threading.Thread | None = None
|
| 33 |
self._running: dict[int, RunningTask] = {}
|
| 34 |
self._last_dispatch_snapshot: tuple[int, int, int] | None = None
|
|
|
|
| 35 |
|
| 36 |
def start(self) -> None:
|
| 37 |
with self._startup_lock:
|
|
@@ -39,9 +42,10 @@ class TaskManager:
|
|
| 39 |
LOGGER.info("Task manager start skipped because it is already running")
|
| 40 |
return
|
| 41 |
LOGGER.info(
|
| 42 |
-
"Task manager starting | db_path=%s default_parallel_limit=%s",
|
| 43 |
self.store.path,
|
| 44 |
getattr(self.config, "default_parallel_limit", "-"),
|
|
|
|
| 45 |
)
|
| 46 |
self.store.reset_inflight_tasks()
|
| 47 |
self._dispatcher_thread = threading.Thread(target=self._dispatch_loop, name="task-dispatcher", daemon=True)
|
|
@@ -92,10 +96,71 @@ class TaskManager:
|
|
| 92 |
running_task.stop_event.set()
|
| 93 |
return True
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
def _dispatch_loop(self) -> None:
|
| 96 |
LOGGER.info("Dispatcher loop started")
|
| 97 |
while not self._shutdown_event.is_set():
|
| 98 |
self._cleanup_running_registry()
|
|
|
|
| 99 |
parallel_limit = self.store.get_parallel_limit()
|
| 100 |
running_count = self._running_count()
|
| 101 |
available_slots = max(0, parallel_limit - running_count)
|
|
@@ -232,4 +297,4 @@ class TaskManager:
|
|
| 232 |
with self._running_lock:
|
| 233 |
removed = self._running.pop(task_id, None)
|
| 234 |
if removed is not None:
|
| 235 |
-
LOGGER.info("Task removed from running registry | task_id=%s", task_id)
|
|
|
|
| 4 |
import threading
|
| 5 |
import time
|
| 6 |
from dataclasses import dataclass
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from zoneinfo import ZoneInfo
|
| 9 |
|
| 10 |
from core.course_bot import CourseBot, TaskResult
|
| 11 |
from core.db import Database
|
|
|
|
| 34 |
self._dispatcher_thread: threading.Thread | None = None
|
| 35 |
self._running: dict[int, RunningTask] = {}
|
| 36 |
self._last_dispatch_snapshot: tuple[int, int, int] | None = None
|
| 37 |
+
self._schedule_timezone = ZoneInfo(getattr(self.config, "schedule_timezone", "Asia/Shanghai"))
|
| 38 |
|
| 39 |
def start(self) -> None:
|
| 40 |
with self._startup_lock:
|
|
|
|
| 42 |
LOGGER.info("Task manager start skipped because it is already running")
|
| 43 |
return
|
| 44 |
LOGGER.info(
|
| 45 |
+
"Task manager starting | db_path=%s default_parallel_limit=%s schedule_timezone=%s",
|
| 46 |
self.store.path,
|
| 47 |
getattr(self.config, "default_parallel_limit", "-"),
|
| 48 |
+
getattr(self.config, "schedule_timezone", "Asia/Shanghai"),
|
| 49 |
)
|
| 50 |
self.store.reset_inflight_tasks()
|
| 51 |
self._dispatcher_thread = threading.Thread(target=self._dispatch_loop, name="task-dispatcher", daemon=True)
|
|
|
|
| 96 |
running_task.stop_event.set()
|
| 97 |
return True
|
| 98 |
|
| 99 |
+
def _current_schedule_now(self) -> datetime:
|
| 100 |
+
return datetime.now(self._schedule_timezone)
|
| 101 |
+
|
| 102 |
+
def _within_schedule_date_window(self, schedule: dict, today_text: str) -> bool:
|
| 103 |
+
start_date = str(schedule.get("start_date") or "").strip()
|
| 104 |
+
end_date = str(schedule.get("end_date") or "").strip()
|
| 105 |
+
if start_date and today_text < start_date:
|
| 106 |
+
return False
|
| 107 |
+
if end_date and today_text > end_date:
|
| 108 |
+
return False
|
| 109 |
+
return True
|
| 110 |
+
|
| 111 |
+
def _apply_user_schedules(self) -> None:
|
| 112 |
+
now = self._current_schedule_now()
|
| 113 |
+
today_text = now.date().isoformat()
|
| 114 |
+
current_time_text = now.strftime("%H:%M")
|
| 115 |
+
|
| 116 |
+
for schedule in self.store.list_enabled_user_schedules():
|
| 117 |
+
if not bool(schedule.get("user_is_active", 0)):
|
| 118 |
+
continue
|
| 119 |
+
if not self._within_schedule_date_window(schedule, today_text):
|
| 120 |
+
continue
|
| 121 |
+
|
| 122 |
+
start_time = str(schedule.get("daily_start_time") or "").strip()
|
| 123 |
+
stop_time = str(schedule.get("daily_stop_time") or "").strip()
|
| 124 |
+
if not start_time or not stop_time:
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
user_id = int(schedule["user_id"])
|
| 128 |
+
active_task = self.store.find_active_task_for_user(user_id)
|
| 129 |
+
|
| 130 |
+
if current_time_text >= stop_time and str(schedule.get("last_auto_stop_on") or "") != today_text:
|
| 131 |
+
if active_task and self.stop_task(int(active_task["id"])):
|
| 132 |
+
self._log(int(active_task["id"]), user_id, "SYSTEM", "INFO", "已按管理员定时设置发送停止请求。")
|
| 133 |
+
else:
|
| 134 |
+
self._log(None, user_id, "SYSTEM", "INFO", "已到定时停止时间,当前没有运行中的任务。")
|
| 135 |
+
self.store.mark_schedule_auto_stop(user_id, today_text)
|
| 136 |
+
continue
|
| 137 |
+
|
| 138 |
+
if not (start_time <= current_time_text < stop_time):
|
| 139 |
+
continue
|
| 140 |
+
if str(schedule.get("last_auto_start_on") or "") == today_text:
|
| 141 |
+
continue
|
| 142 |
+
|
| 143 |
+
user = self.store.get_user(user_id)
|
| 144 |
+
if user is None:
|
| 145 |
+
self.store.mark_schedule_auto_start(user_id, today_text)
|
| 146 |
+
continue
|
| 147 |
+
if not self.store.list_courses_for_user(user_id):
|
| 148 |
+
self._log(None, user_id, "SYSTEM", "INFO", "已到定时启动时间,但当前没有课程目标,今天不自动启动。")
|
| 149 |
+
self.store.mark_schedule_auto_start(user_id, today_text)
|
| 150 |
+
continue
|
| 151 |
+
|
| 152 |
+
task, created = self.queue_task(user_id, requested_by="scheduler", requested_by_role="system")
|
| 153 |
+
if created:
|
| 154 |
+
self._log(task["id"], user_id, "SYSTEM", "INFO", "已按管理员定时设置自动加入任务队列。")
|
| 155 |
+
else:
|
| 156 |
+
self._log(task["id"], user_id, "SYSTEM", "INFO", "已到定时启动时间,但当前已有任务在运行或排队。")
|
| 157 |
+
self.store.mark_schedule_auto_start(user_id, today_text)
|
| 158 |
+
|
| 159 |
def _dispatch_loop(self) -> None:
|
| 160 |
LOGGER.info("Dispatcher loop started")
|
| 161 |
while not self._shutdown_event.is_set():
|
| 162 |
self._cleanup_running_registry()
|
| 163 |
+
self._apply_user_schedules()
|
| 164 |
parallel_limit = self.store.get_parallel_limit()
|
| 165 |
running_count = self._running_count()
|
| 166 |
available_slots = max(0, parallel_limit - running_count)
|
|
|
|
| 297 |
with self._running_lock:
|
| 298 |
removed = self._running.pop(task_id, None)
|
| 299 |
if removed is not None:
|
| 300 |
+
LOGGER.info("Task removed from running registry | task_id=%s", task_id)
|
requirements.txt
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
-
Flask>=3.0.0
|
| 2 |
-
gunicorn>=23.0.0
|
| 3 |
-
selenium>=4.35.0
|
| 4 |
-
onnxruntime>=1.18.0
|
| 5 |
-
numpy>=1.24.0
|
| 6 |
-
Pillow>=10.0.0
|
| 7 |
-
cryptography>=43.0.0
|
|
|
|
|
|
| 1 |
+
Flask>=3.0.0
|
| 2 |
+
gunicorn>=23.0.0
|
| 3 |
+
selenium>=4.35.0
|
| 4 |
+
onnxruntime>=1.18.0
|
| 5 |
+
numpy>=1.24.0
|
| 6 |
+
Pillow>=10.0.0
|
| 7 |
+
cryptography>=43.0.0
|
| 8 |
+
PyMySQL>=1.1.1
|
space_app.py
CHANGED
|
@@ -1,26 +1,37 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
|
| 3 |
import atexit
|
| 4 |
import json
|
| 5 |
import re
|
| 6 |
import time
|
|
|
|
| 7 |
from functools import wraps
|
| 8 |
from typing import Callable
|
| 9 |
|
| 10 |
from flask import Flask, Response, flash, g, jsonify, redirect, render_template, request, session, stream_with_context, url_for
|
| 11 |
|
| 12 |
from core.config import AppConfig
|
| 13 |
-
from core.db import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from core.security import SecretBox, hash_password, verify_password
|
| 15 |
from core.task_manager import TaskManager
|
| 16 |
-
from core.runtime_logging import configure_logging, get_logger
|
| 17 |
|
| 18 |
|
| 19 |
configure_logging()
|
| 20 |
APP_LOGGER = get_logger("sacc.web")
|
| 21 |
|
| 22 |
config = AppConfig.load()
|
| 23 |
-
store = Database(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
store.init_db()
|
| 25 |
secret_box = SecretBox(config.encryption_key)
|
| 26 |
task_manager = TaskManager(config=config, store=store, secret_box=secret_box)
|
|
@@ -61,12 +72,14 @@ app = Flask(__name__, template_folder="templates", static_folder="static")
|
|
| 61 |
app.secret_key = config.session_secret
|
| 62 |
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax")
|
| 63 |
APP_LOGGER.info(
|
| 64 |
-
"Application bootstrap complete | data_dir=%s db_path=%s chrome_binary=%s chromedriver_path=%s default_parallel_limit=%s",
|
| 65 |
config.data_dir,
|
| 66 |
config.db_path,
|
|
|
|
| 67 |
config.chrome_binary,
|
| 68 |
config.chromedriver_path,
|
| 69 |
config.default_parallel_limit,
|
|
|
|
| 70 |
)
|
| 71 |
|
| 72 |
CATEGORY_LABELS = {
|
|
@@ -82,7 +95,6 @@ TASK_LABELS = {
|
|
| 82 |
"failed": "失败",
|
| 83 |
}
|
| 84 |
|
| 85 |
-
|
| 86 |
SKIPPED_REQUEST_LOG_PREFIXES = (
|
| 87 |
"/static/",
|
| 88 |
"/api/",
|
|
@@ -90,6 +102,9 @@ SKIPPED_REQUEST_LOG_PREFIXES = (
|
|
| 90 |
SKIPPED_REQUEST_LOG_PATHS = {
|
| 91 |
"/favicon.ico",
|
| 92 |
}
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
def _current_actor_label() -> str:
|
|
@@ -137,6 +152,7 @@ def inject_globals() -> dict:
|
|
| 137 |
"refresh_interval_min": MIN_REFRESH_INTERVAL_SECONDS,
|
| 138 |
"refresh_interval_max": MAX_REFRESH_INTERVAL_SECONDS,
|
| 139 |
"default_refresh_interval_seconds": config.poll_interval_seconds,
|
|
|
|
| 140 |
}
|
| 141 |
|
| 142 |
|
|
@@ -172,10 +188,6 @@ def _get_admin_identity() -> dict:
|
|
| 172 |
}
|
| 173 |
|
| 174 |
|
| 175 |
-
COURSE_ID_PATTERN = re.compile(r"^[0-9A-Za-z]{1,32}$")
|
| 176 |
-
COURSE_INDEX_PATTERN = re.compile(r"^[0-9A-Za-z]{1,8}$")
|
| 177 |
-
|
| 178 |
-
|
| 179 |
def _normalize_course_token(raw_value: str) -> str:
|
| 180 |
return re.sub(r"\s+", "", str(raw_value or "")).upper()
|
| 181 |
|
|
@@ -203,6 +215,72 @@ def _parse_refresh_interval(raw_value: str | None, *, default: int) -> int:
|
|
| 203 |
return interval
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def _user_owns_course(user_id: int, course_target_id: int) -> bool:
|
| 207 |
return any(course["id"] == course_target_id for course in store.list_courses_for_user(user_id))
|
| 208 |
|
|
@@ -212,6 +290,7 @@ def _build_user_dashboard_context(user: dict) -> dict:
|
|
| 212 |
"current_user": user,
|
| 213 |
"courses": store.list_courses_for_user(user["id"]),
|
| 214 |
"task": store.get_latest_task_for_user(user["id"]),
|
|
|
|
| 215 |
"recent_logs": store.list_recent_logs(user_id=user["id"], limit=config.logs_page_size),
|
| 216 |
}
|
| 217 |
|
|
@@ -221,10 +300,12 @@ def _build_admin_dashboard_context() -> dict:
|
|
| 221 |
for user in users:
|
| 222 |
user["courses"] = store.list_courses_for_user(user["id"])
|
| 223 |
user["latest_task"] = store.get_latest_task_for_user(user["id"])
|
|
|
|
| 224 |
admin_identity = _get_admin_identity()
|
| 225 |
return {
|
| 226 |
"users": users,
|
| 227 |
"admins": store.list_admins(),
|
|
|
|
| 228 |
"stats": store.get_admin_stats(),
|
| 229 |
"recent_tasks": store.list_recent_tasks(limit=18),
|
| 230 |
"recent_logs": store.list_recent_logs(limit=config.logs_page_size),
|
|
@@ -261,7 +342,7 @@ def login():
|
|
| 261 |
password = request.form.get("password", "")
|
| 262 |
user = store.get_user_by_student_id(student_id)
|
| 263 |
if user is None:
|
| 264 |
-
flash("没有找到该学号对应的账号,请
|
| 265 |
return render_template("login.html")
|
| 266 |
if not user["is_active"]:
|
| 267 |
flash("该账号已被管理员禁用。", "danger")
|
|
@@ -283,6 +364,39 @@ def login():
|
|
| 283 |
return render_template("login.html")
|
| 284 |
|
| 285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
@app.post("/logout")
|
| 287 |
def logout():
|
| 288 |
session.clear()
|
|
@@ -313,8 +427,7 @@ def admin_login():
|
|
| 313 |
@app.post("/admin/logout")
|
| 314 |
def admin_logout():
|
| 315 |
session.clear()
|
| 316 |
-
return redirect(url_for("admin_login"))
|
| 317 |
-
|
| 318 |
|
| 319 |
@app.get("/dashboard")
|
| 320 |
@_login_required("user")
|
|
@@ -441,7 +554,7 @@ def admin_dashboard():
|
|
| 441 |
@_login_required("admin")
|
| 442 |
def update_parallel_limit():
|
| 443 |
try:
|
| 444 |
-
parallel_limit = max(1, min(8, int(request.form.get("parallel_limit",
|
| 445 |
except ValueError:
|
| 446 |
flash("并行数必须是 1 到 8 的整数。", "danger")
|
| 447 |
return redirect(url_for("admin_dashboard"))
|
|
@@ -521,6 +634,38 @@ def toggle_user(user_id: int):
|
|
| 521 |
return redirect(url_for("admin_dashboard"))
|
| 522 |
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
@app.post("/admin/users/<int:user_id>/courses")
|
| 525 |
@_login_required("admin")
|
| 526 |
def admin_add_course(user_id: int):
|
|
@@ -591,6 +736,31 @@ def create_admin():
|
|
| 591 |
return redirect(url_for("admin_dashboard"))
|
| 592 |
|
| 593 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
@app.get("/api/user/status")
|
| 595 |
@_login_required("user")
|
| 596 |
def user_status():
|
|
@@ -671,4 +841,4 @@ def stream_admin_logs():
|
|
| 671 |
response = Response(generate(), mimetype="text/event-stream")
|
| 672 |
response.headers["Cache-Control"] = "no-cache"
|
| 673 |
response.headers["X-Accel-Buffering"] = "no"
|
| 674 |
-
return response
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
|
| 3 |
import atexit
|
| 4 |
import json
|
| 5 |
import re
|
| 6 |
import time
|
| 7 |
+
from datetime import date as date_cls, time as time_cls
|
| 8 |
from functools import wraps
|
| 9 |
from typing import Callable
|
| 10 |
|
| 11 |
from flask import Flask, Response, flash, g, jsonify, redirect, render_template, request, session, stream_with_context, url_for
|
| 12 |
|
| 13 |
from core.config import AppConfig
|
| 14 |
+
from core.db import (
|
| 15 |
+
DEFAULT_REGISTRATION_CODE_MAX_USES,
|
| 16 |
+
Database,
|
| 17 |
+
MAX_REFRESH_INTERVAL_SECONDS,
|
| 18 |
+
MIN_REFRESH_INTERVAL_SECONDS,
|
| 19 |
+
normalize_registration_code,
|
| 20 |
+
)
|
| 21 |
+
from core.runtime_logging import configure_logging, get_logger
|
| 22 |
from core.security import SecretBox, hash_password, verify_password
|
| 23 |
from core.task_manager import TaskManager
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
configure_logging()
|
| 27 |
APP_LOGGER = get_logger("sacc.web")
|
| 28 |
|
| 29 |
config = AppConfig.load()
|
| 30 |
+
store = Database(
|
| 31 |
+
config.db_path,
|
| 32 |
+
default_parallel_limit=config.default_parallel_limit,
|
| 33 |
+
mysql_ssl_ca_path=config.mysql_ssl_ca_path,
|
| 34 |
+
)
|
| 35 |
store.init_db()
|
| 36 |
secret_box = SecretBox(config.encryption_key)
|
| 37 |
task_manager = TaskManager(config=config, store=store, secret_box=secret_box)
|
|
|
|
| 72 |
app.secret_key = config.session_secret
|
| 73 |
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax")
|
| 74 |
APP_LOGGER.info(
|
| 75 |
+
"Application bootstrap complete | data_dir=%s db_path=%s backend=%s chrome_binary=%s chromedriver_path=%s default_parallel_limit=%s schedule_timezone=%s",
|
| 76 |
config.data_dir,
|
| 77 |
config.db_path,
|
| 78 |
+
config.database_backend,
|
| 79 |
config.chrome_binary,
|
| 80 |
config.chromedriver_path,
|
| 81 |
config.default_parallel_limit,
|
| 82 |
+
config.schedule_timezone,
|
| 83 |
)
|
| 84 |
|
| 85 |
CATEGORY_LABELS = {
|
|
|
|
| 95 |
"failed": "失败",
|
| 96 |
}
|
| 97 |
|
|
|
|
| 98 |
SKIPPED_REQUEST_LOG_PREFIXES = (
|
| 99 |
"/static/",
|
| 100 |
"/api/",
|
|
|
|
| 102 |
SKIPPED_REQUEST_LOG_PATHS = {
|
| 103 |
"/favicon.ico",
|
| 104 |
}
|
| 105 |
+
COURSE_ID_PATTERN = re.compile(r"^[0-9A-Za-z]{1,32}$")
|
| 106 |
+
COURSE_INDEX_PATTERN = re.compile(r"^[0-9A-Za-z]{1,8}$")
|
| 107 |
+
REGISTRATION_CODE_PATTERN = re.compile(r"^[A-Z0-9-]{6,64}$")
|
| 108 |
|
| 109 |
|
| 110 |
def _current_actor_label() -> str:
|
|
|
|
| 152 |
"refresh_interval_min": MIN_REFRESH_INTERVAL_SECONDS,
|
| 153 |
"refresh_interval_max": MAX_REFRESH_INTERVAL_SECONDS,
|
| 154 |
"default_refresh_interval_seconds": config.poll_interval_seconds,
|
| 155 |
+
"default_registration_code_max_uses": DEFAULT_REGISTRATION_CODE_MAX_USES,
|
| 156 |
}
|
| 157 |
|
| 158 |
|
|
|
|
| 188 |
}
|
| 189 |
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
def _normalize_course_token(raw_value: str) -> str:
|
| 192 |
return re.sub(r"\s+", "", str(raw_value or "")).upper()
|
| 193 |
|
|
|
|
| 215 |
return interval
|
| 216 |
|
| 217 |
|
| 218 |
+
def _parse_registration_code_max_uses(raw_value: str | None) -> int:
|
| 219 |
+
raw_text = str(raw_value or "").strip()
|
| 220 |
+
if not raw_text:
|
| 221 |
+
return DEFAULT_REGISTRATION_CODE_MAX_USES
|
| 222 |
+
try:
|
| 223 |
+
value = int(raw_text)
|
| 224 |
+
except ValueError as exc:
|
| 225 |
+
raise ValueError("注册码可用次数必须是 1 到 99 之间的整数。") from exc
|
| 226 |
+
if value < 1 or value > 99:
|
| 227 |
+
raise ValueError("注册码可用次数必须在 1 到 99 之间。")
|
| 228 |
+
return value
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _parse_iso_date(raw_value: str | None, label: str) -> str:
|
| 232 |
+
raw_text = str(raw_value or "").strip()
|
| 233 |
+
if not raw_text:
|
| 234 |
+
raise ValueError(f"{label}不能为空。")
|
| 235 |
+
try:
|
| 236 |
+
return date_cls.fromisoformat(raw_text).isoformat()
|
| 237 |
+
except ValueError as exc:
|
| 238 |
+
raise ValueError(f"{label}格式无效,请使用 YYYY-MM-DD。") from exc
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _parse_clock_time(raw_value: str | None, label: str) -> str:
|
| 242 |
+
raw_text = str(raw_value or "").strip()
|
| 243 |
+
if not raw_text:
|
| 244 |
+
raise ValueError(f"{label}不能为空。")
|
| 245 |
+
try:
|
| 246 |
+
return time_cls.fromisoformat(raw_text).strftime("%H:%M")
|
| 247 |
+
except ValueError as exc:
|
| 248 |
+
raise ValueError(f"{label}格式无效,请使用 HH:MM。") from exc
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _parse_schedule_form(form) -> dict:
|
| 252 |
+
enabled = str(form.get("schedule_enabled", "")).lower() in {"1", "true", "on", "yes"}
|
| 253 |
+
start_date_raw = form.get("start_date", "")
|
| 254 |
+
end_date_raw = form.get("end_date", "")
|
| 255 |
+
daily_start_time_raw = form.get("daily_start_time", "")
|
| 256 |
+
daily_stop_time_raw = form.get("daily_stop_time", "")
|
| 257 |
+
has_any_value = enabled or any(str(value or "").strip() for value in (start_date_raw, end_date_raw, daily_start_time_raw, daily_stop_time_raw))
|
| 258 |
+
if not has_any_value:
|
| 259 |
+
return {
|
| 260 |
+
"is_enabled": False,
|
| 261 |
+
"start_date": None,
|
| 262 |
+
"end_date": None,
|
| 263 |
+
"daily_start_time": None,
|
| 264 |
+
"daily_stop_time": None,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
start_date = _parse_iso_date(start_date_raw, "开始日期")
|
| 268 |
+
end_date = _parse_iso_date(end_date_raw, "结束日期")
|
| 269 |
+
daily_start_time = _parse_clock_time(daily_start_time_raw, "每日启动时间")
|
| 270 |
+
daily_stop_time = _parse_clock_time(daily_stop_time_raw, "每日停止时间")
|
| 271 |
+
if end_date < start_date:
|
| 272 |
+
raise ValueError("结束日期不能早于开始日期。")
|
| 273 |
+
if daily_stop_time <= daily_start_time:
|
| 274 |
+
raise ValueError("每日停止时间必须晚于每日启动时间。")
|
| 275 |
+
return {
|
| 276 |
+
"is_enabled": enabled,
|
| 277 |
+
"start_date": start_date,
|
| 278 |
+
"end_date": end_date,
|
| 279 |
+
"daily_start_time": daily_start_time,
|
| 280 |
+
"daily_stop_time": daily_stop_time,
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
|
| 284 |
def _user_owns_course(user_id: int, course_target_id: int) -> bool:
|
| 285 |
return any(course["id"] == course_target_id for course in store.list_courses_for_user(user_id))
|
| 286 |
|
|
|
|
| 290 |
"current_user": user,
|
| 291 |
"courses": store.list_courses_for_user(user["id"]),
|
| 292 |
"task": store.get_latest_task_for_user(user["id"]),
|
| 293 |
+
"schedule": store.get_user_schedule(user["id"]),
|
| 294 |
"recent_logs": store.list_recent_logs(user_id=user["id"], limit=config.logs_page_size),
|
| 295 |
}
|
| 296 |
|
|
|
|
| 300 |
for user in users:
|
| 301 |
user["courses"] = store.list_courses_for_user(user["id"])
|
| 302 |
user["latest_task"] = store.get_latest_task_for_user(user["id"])
|
| 303 |
+
user["schedule"] = store.get_user_schedule(user["id"])
|
| 304 |
admin_identity = _get_admin_identity()
|
| 305 |
return {
|
| 306 |
"users": users,
|
| 307 |
"admins": store.list_admins(),
|
| 308 |
+
"registration_codes": store.list_registration_codes(limit=60),
|
| 309 |
"stats": store.get_admin_stats(),
|
| 310 |
"recent_tasks": store.list_recent_tasks(limit=18),
|
| 311 |
"recent_logs": store.list_recent_logs(limit=config.logs_page_size),
|
|
|
|
| 342 |
password = request.form.get("password", "")
|
| 343 |
user = store.get_user_by_student_id(student_id)
|
| 344 |
if user is None:
|
| 345 |
+
flash("没有找到该学号对应的账号。如果你有注册码,请先完成注册。", "danger")
|
| 346 |
return render_template("login.html")
|
| 347 |
if not user["is_active"]:
|
| 348 |
flash("该账号已被管理员禁用。", "danger")
|
|
|
|
| 364 |
return render_template("login.html")
|
| 365 |
|
| 366 |
|
| 367 |
+
@app.route("/register", methods=["GET", "POST"])
|
| 368 |
+
def register():
|
| 369 |
+
if request.method == "POST":
|
| 370 |
+
registration_code = normalize_registration_code(request.form.get("registration_code", ""))
|
| 371 |
+
student_id = request.form.get("student_id", "").strip()
|
| 372 |
+
password = request.form.get("password", "").strip()
|
| 373 |
+
display_name = request.form.get("display_name", "").strip()
|
| 374 |
+
|
| 375 |
+
if not REGISTRATION_CODE_PATTERN.fullmatch(registration_code):
|
| 376 |
+
flash("请输入有效的注册码。", "danger")
|
| 377 |
+
return render_template("register.html")
|
| 378 |
+
if not student_id.isdigit() or not password:
|
| 379 |
+
flash("请填写学号和教务处密码。", "danger")
|
| 380 |
+
return render_template("register.html")
|
| 381 |
+
|
| 382 |
+
try:
|
| 383 |
+
store.register_user_with_code(
|
| 384 |
+
registration_code,
|
| 385 |
+
student_id,
|
| 386 |
+
secret_box.encrypt(password),
|
| 387 |
+
display_name,
|
| 388 |
+
refresh_interval_seconds=config.poll_interval_seconds,
|
| 389 |
+
)
|
| 390 |
+
except ValueError as exc:
|
| 391 |
+
flash(str(exc), "danger")
|
| 392 |
+
return render_template("register.html")
|
| 393 |
+
|
| 394 |
+
flash("注册成功,请使用学号和教务处密码登录。", "success")
|
| 395 |
+
return redirect(url_for("login"))
|
| 396 |
+
|
| 397 |
+
return render_template("register.html")
|
| 398 |
+
|
| 399 |
+
|
| 400 |
@app.post("/logout")
|
| 401 |
def logout():
|
| 402 |
session.clear()
|
|
|
|
| 427 |
@app.post("/admin/logout")
|
| 428 |
def admin_logout():
|
| 429 |
session.clear()
|
| 430 |
+
return redirect(url_for("admin_login"))
|
|
|
|
| 431 |
|
| 432 |
@app.get("/dashboard")
|
| 433 |
@_login_required("user")
|
|
|
|
| 554 |
@_login_required("admin")
|
| 555 |
def update_parallel_limit():
|
| 556 |
try:
|
| 557 |
+
parallel_limit = max(1, min(8, int(request.form.get("parallel_limit", str(config.default_parallel_limit)))))
|
| 558 |
except ValueError:
|
| 559 |
flash("并行数必须是 1 到 8 的整数。", "danger")
|
| 560 |
return redirect(url_for("admin_dashboard"))
|
|
|
|
| 634 |
return redirect(url_for("admin_dashboard"))
|
| 635 |
|
| 636 |
|
| 637 |
+
@app.post("/admin/users/<int:user_id>/delete")
|
| 638 |
+
@_login_required("admin")
|
| 639 |
+
def delete_user_by_admin(user_id: int):
|
| 640 |
+
user = store.get_user(user_id)
|
| 641 |
+
if user is None:
|
| 642 |
+
flash("用户不存在。", "danger")
|
| 643 |
+
return redirect(url_for("admin_dashboard"))
|
| 644 |
+
active_task = store.find_active_task_for_user(user_id)
|
| 645 |
+
if active_task is not None:
|
| 646 |
+
flash("请先停止该用户当前任务,再删除用户。", "danger")
|
| 647 |
+
return redirect(url_for("admin_dashboard"))
|
| 648 |
+
store.delete_user(user_id)
|
| 649 |
+
flash("用户及其课程、日志、定时设置已删除。", "success")
|
| 650 |
+
return redirect(url_for("admin_dashboard"))
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
@app.post("/admin/users/<int:user_id>/schedule")
|
| 654 |
+
@_login_required("admin")
|
| 655 |
+
def update_user_schedule(user_id: int):
|
| 656 |
+
if store.get_user(user_id) is None:
|
| 657 |
+
flash("用户不存在。", "danger")
|
| 658 |
+
return redirect(url_for("admin_dashboard"))
|
| 659 |
+
try:
|
| 660 |
+
schedule_payload = _parse_schedule_form(request.form)
|
| 661 |
+
except ValueError as exc:
|
| 662 |
+
flash(str(exc), "danger")
|
| 663 |
+
return redirect(url_for("admin_dashboard"))
|
| 664 |
+
store.upsert_user_schedule(user_id, **schedule_payload)
|
| 665 |
+
flash("定时启动终止设置已更新。", "success")
|
| 666 |
+
return redirect(url_for("admin_dashboard"))
|
| 667 |
+
|
| 668 |
+
|
| 669 |
@app.post("/admin/users/<int:user_id>/courses")
|
| 670 |
@_login_required("admin")
|
| 671 |
def admin_add_course(user_id: int):
|
|
|
|
| 736 |
return redirect(url_for("admin_dashboard"))
|
| 737 |
|
| 738 |
|
| 739 |
+
@app.post("/admin/registration-codes")
|
| 740 |
+
@_login_required("admin")
|
| 741 |
+
def create_registration_code():
|
| 742 |
+
note = request.form.get("note", "").strip()
|
| 743 |
+
try:
|
| 744 |
+
max_uses = _parse_registration_code_max_uses(request.form.get("max_uses"))
|
| 745 |
+
except ValueError as exc:
|
| 746 |
+
flash(str(exc), "danger")
|
| 747 |
+
return redirect(url_for("admin_dashboard"))
|
| 748 |
+
admin_identity = _get_admin_identity()
|
| 749 |
+
created = store.create_registration_code(created_by=admin_identity["username"], note=note, max_uses=max_uses)
|
| 750 |
+
flash(f"注册码已创建:{created['code']}", "success")
|
| 751 |
+
return redirect(url_for("admin_dashboard"))
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
@app.post("/admin/registration-codes/<int:registration_code_id>/toggle")
|
| 755 |
+
@_login_required("admin")
|
| 756 |
+
def toggle_registration_code(registration_code_id: int):
|
| 757 |
+
updated = store.toggle_registration_code_active(registration_code_id)
|
| 758 |
+
if updated is None:
|
| 759 |
+
flash("注册码不存在。", "danger")
|
| 760 |
+
else:
|
| 761 |
+
flash("注册码状态已更新。", "success")
|
| 762 |
+
return redirect(url_for("admin_dashboard"))
|
| 763 |
+
|
| 764 |
@app.get("/api/user/status")
|
| 765 |
@_login_required("user")
|
| 766 |
def user_status():
|
|
|
|
| 841 |
response = Response(generate(), mimetype="text/event-stream")
|
| 842 |
response.headers["Cache-Control"] = "no-cache"
|
| 843 |
response.headers["X-Accel-Buffering"] = "no"
|
| 844 |
+
return response
|
templates/admin_dashboard.html
CHANGED
|
@@ -1,279 +1,377 @@
|
|
| 1 |
-
{% extends "base.html" %}
|
| 2 |
-
{% block title %}管理后台 | SCU 选课控制台{% endblock %}
|
| 3 |
-
{% block body_class %}admin-theme{% endblock %}
|
| 4 |
-
{% block content %}
|
| 5 |
-
<section class="dashboard-shell admin-dashboard" data-log-stream-url="{{ url_for('stream_admin_logs', last_id=recent_logs[-1].id if recent_logs else 0) }}" data-status-url="{{ url_for('admin_status') }}">
|
| 6 |
-
<header class="topbar reveal-up">
|
| 7 |
-
<div>
|
| 8 |
-
<span class="eyebrow">Admin Console</span>
|
| 9 |
-
<h1>管理员后台</h1>
|
| 10 |
-
<p>当前管理员:{{ admin_identity.username }}{% if is_super_admin %} · 超级管理员{% endif %}</p>
|
| 11 |
-
</div>
|
| 12 |
-
<form method="post" action="{{ url_for('admin_logout') }}">
|
| 13 |
-
<button type="submit" class="btn btn-ghost">退出后台</button>
|
| 14 |
-
</form>
|
| 15 |
-
</header>
|
| 16 |
-
|
| 17 |
-
<section class="metric-grid reveal-up delay-1">
|
| 18 |
-
<article class="metric-card">
|
| 19 |
-
<span>用户数</span>
|
| 20 |
-
<strong id="stat-users">{{ stats.users_count }}</strong>
|
| 21 |
-
<small>已录入的学生账号</small>
|
| 22 |
-
</article>
|
| 23 |
-
<article class="metric-card">
|
| 24 |
-
<span>运行中任务</span>
|
| 25 |
-
<strong id="stat-running">{{ stats.running_count }}</strong>
|
| 26 |
-
<small>排队中:<span id="stat-pending">{{ stats.pending_count }}</span></small>
|
| 27 |
-
</article>
|
| 28 |
-
<article class="metric-card">
|
| 29 |
-
<span>总课程目标</span>
|
| 30 |
-
<strong>{{ stats.courses_count }}</strong>
|
| 31 |
-
<small>管理员可见全部课程号与课序号</small>
|
| 32 |
-
</article>
|
| 33 |
-
<article class="metric-card">
|
| 34 |
-
<span>
|
| 35 |
-
<strong>{{ stats.
|
| 36 |
-
<small>
|
| 37 |
-
</article>
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
<
|
| 48 |
-
<
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
<
|
| 63 |
-
<
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
<
|
| 70 |
-
|
| 71 |
-
<label
|
| 72 |
-
|
| 73 |
-
<
|
| 74 |
-
|
| 75 |
-
<label
|
| 76 |
-
|
| 77 |
-
<
|
| 78 |
-
|
| 79 |
-
<
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
<
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
<
|
| 90 |
-
|
| 91 |
-
<
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
<label class="field">
|
| 96 |
-
<span>
|
| 97 |
-
<input type="
|
| 98 |
-
</label>
|
| 99 |
-
<
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
<
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
<
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
<
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
<
|
| 149 |
-
|
| 150 |
-
</
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
<
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
</
|
| 194 |
-
<
|
| 195 |
-
|
| 196 |
-
</
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
<
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
<
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
<
|
| 220 |
-
</
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
{% endblock %}
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}管理后台 | SCU 选课控制台{% endblock %}
|
| 3 |
+
{% block body_class %}admin-theme{% endblock %}
|
| 4 |
+
{% block content %}
|
| 5 |
+
<section class="dashboard-shell admin-dashboard" data-log-stream-url="{{ url_for('stream_admin_logs', last_id=recent_logs[-1].id if recent_logs else 0) }}" data-status-url="{{ url_for('admin_status') }}">
|
| 6 |
+
<header class="topbar reveal-up">
|
| 7 |
+
<div>
|
| 8 |
+
<span class="eyebrow">Admin Console</span>
|
| 9 |
+
<h1>管理员后台</h1>
|
| 10 |
+
<p>当前管理员:{{ admin_identity.username }}{% if is_super_admin %} · 超级管理员{% endif %}</p>
|
| 11 |
+
</div>
|
| 12 |
+
<form method="post" action="{{ url_for('admin_logout') }}">
|
| 13 |
+
<button type="submit" class="btn btn-ghost">退出后台</button>
|
| 14 |
+
</form>
|
| 15 |
+
</header>
|
| 16 |
+
|
| 17 |
+
<section class="metric-grid reveal-up delay-1">
|
| 18 |
+
<article class="metric-card">
|
| 19 |
+
<span>用户数</span>
|
| 20 |
+
<strong id="stat-users">{{ stats.users_count }}</strong>
|
| 21 |
+
<small>已录入的学生账号</small>
|
| 22 |
+
</article>
|
| 23 |
+
<article class="metric-card">
|
| 24 |
+
<span>运行中任务</span>
|
| 25 |
+
<strong id="stat-running">{{ stats.running_count }}</strong>
|
| 26 |
+
<small>排队中:<span id="stat-pending">{{ stats.pending_count }}</span></small>
|
| 27 |
+
</article>
|
| 28 |
+
<article class="metric-card">
|
| 29 |
+
<span>总课程目标</span>
|
| 30 |
+
<strong>{{ stats.courses_count }}</strong>
|
| 31 |
+
<small>管理员可见全部课程号与课序号</small>
|
| 32 |
+
</article>
|
| 33 |
+
<article class="metric-card">
|
| 34 |
+
<span>有效定时任务</span>
|
| 35 |
+
<strong>{{ stats.active_schedule_count }}</strong>
|
| 36 |
+
<small>管理员配置的每日自动启动与停止</small>
|
| 37 |
+
</article>
|
| 38 |
+
<article class="metric-card">
|
| 39 |
+
<span>注册码总数</span>
|
| 40 |
+
<strong>{{ stats.registration_code_count }}</strong>
|
| 41 |
+
<small>支持用户按注册码自助注册</small>
|
| 42 |
+
</article>
|
| 43 |
+
</section>
|
| 44 |
+
|
| 45 |
+
<section class="content-grid admin-grid">
|
| 46 |
+
<article class="card reveal-up delay-2">
|
| 47 |
+
<div class="card-head">
|
| 48 |
+
<span class="kicker">调度设置</span>
|
| 49 |
+
<h2>并行数</h2>
|
| 50 |
+
<p>默认并行数已调整为 4,建议根据 Hugging Face Space 的资源情况适当调节。</p>
|
| 51 |
+
</div>
|
| 52 |
+
<form method="post" action="{{ url_for('update_parallel_limit') }}" class="form-grid form-grid-compact">
|
| 53 |
+
<label class="field">
|
| 54 |
+
<span>当前并行数</span>
|
| 55 |
+
<input type="number" id="parallel-limit-input" name="parallel_limit" min="1" max="8" value="{{ parallel_limit }}" required>
|
| 56 |
+
</label>
|
| 57 |
+
<button type="submit" class="btn btn-primary">更新并行数</button>
|
| 58 |
+
</form>
|
| 59 |
+
</article>
|
| 60 |
+
|
| 61 |
+
<article class="card reveal-up delay-2">
|
| 62 |
+
<div class="card-head">
|
| 63 |
+
<span class="kicker">新增用户</span>
|
| 64 |
+
<h2>手动录入用户信息</h2>
|
| 65 |
+
<p>管理员可以直接录入学生账号,也可以只发注册码让学生自行注册。</p>
|
| 66 |
+
</div>
|
| 67 |
+
<form method="post" action="{{ url_for('create_user') }}" class="form-grid form-grid-compact">
|
| 68 |
+
<label class="field">
|
| 69 |
+
<span>学号</span>
|
| 70 |
+
<input type="text" name="student_id" inputmode="numeric" placeholder="13 位学号" required>
|
| 71 |
+
</label>
|
| 72 |
+
<label class="field">
|
| 73 |
+
<span>显示名称</span>
|
| 74 |
+
<input type="text" name="display_name" placeholder="可选备注">
|
| 75 |
+
</label>
|
| 76 |
+
<label class="field">
|
| 77 |
+
<span>刷新间隔</span>
|
| 78 |
+
<input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ default_refresh_interval_seconds }}" required>
|
| 79 |
+
</label>
|
| 80 |
+
<label class="field span-2">
|
| 81 |
+
<span>密码</span>
|
| 82 |
+
<input type="password" name="password" placeholder="教务处密码" required>
|
| 83 |
+
</label>
|
| 84 |
+
<button type="submit" class="btn btn-secondary">创建用户</button>
|
| 85 |
+
</form>
|
| 86 |
+
</article>
|
| 87 |
+
|
| 88 |
+
<article class="card reveal-up delay-2">
|
| 89 |
+
<div class="card-head">
|
| 90 |
+
<span class="kicker">注册码</span>
|
| 91 |
+
<h2>创建注册码</h2>
|
| 92 |
+
<p>学生拿到注册码后即可在 <code>/register</code> 页面使用学号和教务处密码完成注册。</p>
|
| 93 |
+
</div>
|
| 94 |
+
<form method="post" action="{{ url_for('create_registration_code') }}" class="form-grid form-grid-compact">
|
| 95 |
+
<label class="field span-2">
|
| 96 |
+
<span>备注</span>
|
| 97 |
+
<input type="text" name="note" placeholder="例如 2025 春季新用户批次">
|
| 98 |
+
</label>
|
| 99 |
+
<label class="field">
|
| 100 |
+
<span>可用次数</span>
|
| 101 |
+
<input type="number" name="max_uses" min="1" max="99" value="{{ default_registration_code_max_uses }}" required>
|
| 102 |
+
</label>
|
| 103 |
+
<button type="submit" class="btn btn-secondary">生成注册码</button>
|
| 104 |
+
</form>
|
| 105 |
+
</article>
|
| 106 |
+
|
| 107 |
+
{% if is_super_admin %}
|
| 108 |
+
<article class="card reveal-up delay-2">
|
| 109 |
+
<div class="card-head">
|
| 110 |
+
<span class="kicker">管理员管理</span>
|
| 111 |
+
<h2>新增管理员</h2>
|
| 112 |
+
<p>只有超级管理员可以继续创建普通管理员。</p>
|
| 113 |
+
</div>
|
| 114 |
+
<form method="post" action="{{ url_for('create_admin') }}" class="form-grid form-grid-compact">
|
| 115 |
+
<label class="field">
|
| 116 |
+
<span>管理员账号</span>
|
| 117 |
+
<input type="text" name="username" placeholder="输入管理员账号" required>
|
| 118 |
+
</label>
|
| 119 |
+
<label class="field">
|
| 120 |
+
<span>管理员密码</span>
|
| 121 |
+
<input type="password" name="password" placeholder="输入管理员密码" required>
|
| 122 |
+
</label>
|
| 123 |
+
<button type="submit" class="btn btn-ghost">创建管理员</button>
|
| 124 |
+
</form>
|
| 125 |
+
<div class="chip-row">
|
| 126 |
+
<span class="chip highlight">超级管理员:{{ admin_identity.username }}</span>
|
| 127 |
+
{% for admin in admins %}
|
| 128 |
+
<span class="chip">{{ admin.username }}</span>
|
| 129 |
+
{% endfor %}
|
| 130 |
+
</div>
|
| 131 |
+
</article>
|
| 132 |
+
{% endif %}
|
| 133 |
+
|
| 134 |
+
<article class="card reveal-up delay-3 span-2">
|
| 135 |
+
<div class="card-head split">
|
| 136 |
+
<div>
|
| 137 |
+
<span class="kicker">任务总览</span>
|
| 138 |
+
<h2>最近任务</h2>
|
| 139 |
+
<p>用于快速确认任务是否正在排队、执行、停止或失败。</p>
|
| 140 |
+
</div>
|
| 141 |
+
<span class="status-pill status-running">实时刷新</span>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="course-table-wrap">
|
| 144 |
+
<table class="data-table">
|
| 145 |
+
<thead>
|
| 146 |
+
<tr>
|
| 147 |
+
<th>任务</th>
|
| 148 |
+
<th>学号</th>
|
| 149 |
+
<th>状态</th>
|
| 150 |
+
<th>尝试</th>
|
| 151 |
+
<th>错误</th>
|
| 152 |
+
<th>刷新间隔</th>
|
| 153 |
+
<th>触发者</th>
|
| 154 |
+
<th>更新时间</th>
|
| 155 |
+
</tr>
|
| 156 |
+
</thead>
|
| 157 |
+
<tbody>
|
| 158 |
+
{% if recent_tasks %}
|
| 159 |
+
{% for task in recent_tasks %}
|
| 160 |
+
<tr>
|
| 161 |
+
<td>#{{ task.id }}</td>
|
| 162 |
+
<td>{{ task.student_id }}</td>
|
| 163 |
+
<td><span class="status-pill status-{{ task.status }}">{{ task_labels.get(task.status, task.status) }}</span></td>
|
| 164 |
+
<td>{{ task.total_attempts }}</td>
|
| 165 |
+
<td>{{ task.total_errors }}</td>
|
| 166 |
+
<td>{{ task.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</td>
|
| 167 |
+
<td>{{ task.requested_by_role }}:{{ task.requested_by }}</td>
|
| 168 |
+
<td>{{ task.updated_at }}</td>
|
| 169 |
+
</tr>
|
| 170 |
+
{% endfor %}
|
| 171 |
+
{% else %}
|
| 172 |
+
<tr>
|
| 173 |
+
<td colspan="8" class="empty-cell">还没有任务记录。</td>
|
| 174 |
+
</tr>
|
| 175 |
+
{% endif %}
|
| 176 |
+
</tbody>
|
| 177 |
+
</table>
|
| 178 |
+
</div>
|
| 179 |
+
</article>
|
| 180 |
+
|
| 181 |
+
<article class="card reveal-up delay-3 span-2">
|
| 182 |
+
<div class="card-head split">
|
| 183 |
+
<div>
|
| 184 |
+
<span class="kicker">注册码清单</span>
|
| 185 |
+
<h2>注册码状态</h2>
|
| 186 |
+
<p>可以查看注册码是否启用、可用次数、已用次数以及最近一次使用情况。</p>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
<div class="course-table-wrap">
|
| 190 |
+
<table class="data-table">
|
| 191 |
+
<thead>
|
| 192 |
+
<tr>
|
| 193 |
+
<th>注册码</th>
|
| 194 |
+
<th>备注</th>
|
| 195 |
+
<th>状态</th>
|
| 196 |
+
<th>使用</th>
|
| 197 |
+
<th>最近使用者</th>
|
| 198 |
+
<th>操作</th>
|
| 199 |
+
</tr>
|
| 200 |
+
</thead>
|
| 201 |
+
<tbody>
|
| 202 |
+
{% if registration_codes %}
|
| 203 |
+
{% for code in registration_codes %}
|
| 204 |
+
<tr>
|
| 205 |
+
<td><code>{{ code.code }}</code></td>
|
| 206 |
+
<td>{{ code.note or '无' }}</td>
|
| 207 |
+
<td>{{ '启用' if code.is_active else '停用' }}</td>
|
| 208 |
+
<td>{{ code.used_count }}/{{ code.max_uses }}</td>
|
| 209 |
+
<td>{{ code.used_by_student_id or '暂无' }}</td>
|
| 210 |
+
<td>
|
| 211 |
+
<form method="post" action="{{ url_for('toggle_registration_code', registration_code_id=code.id) }}">
|
| 212 |
+
<button type="submit" class="inline-action">{{ '停用' if code.is_active else '启用' }}</button>
|
| 213 |
+
</form>
|
| 214 |
+
</td>
|
| 215 |
+
</tr>
|
| 216 |
+
{% endfor %}
|
| 217 |
+
{% else %}
|
| 218 |
+
<tr>
|
| 219 |
+
<td colspan="6" class="empty-cell">还没有创建注册码。</td>
|
| 220 |
+
</tr>
|
| 221 |
+
{% endif %}
|
| 222 |
+
</tbody>
|
| 223 |
+
</table>
|
| 224 |
+
</div>
|
| 225 |
+
</article>
|
| 226 |
+
|
| 227 |
+
<article class="card reveal-up delay-3 span-2">
|
| 228 |
+
<div class="card-head split">
|
| 229 |
+
<div>
|
| 230 |
+
<span class="kicker">全局日志</span>
|
| 231 |
+
<h2>所有用户的运行日志</h2>
|
| 232 |
+
<p>日志会持续流入,便于管理员确认登录、查课、提交结果、定时启动终止与错误信息。</p>
|
| 233 |
+
</div>
|
| 234 |
+
<span class="live-dot">LIVE</span>
|
| 235 |
+
</div>
|
| 236 |
+
<div class="log-console" id="log-console">
|
| 237 |
+
{% if recent_logs %}
|
| 238 |
+
{% for log in recent_logs %}
|
| 239 |
+
<div class="log-line level-{{ log.level|lower }}">
|
| 240 |
+
<span class="log-meta">{{ log.created_at }} · {{ log.student_id or 'system' }} · {{ log.scope }} · {{ log.level }}</span>
|
| 241 |
+
<span>{{ log.message }}</span>
|
| 242 |
+
</div>
|
| 243 |
+
{% endfor %}
|
| 244 |
+
{% else %}
|
| 245 |
+
<div class="log-line level-info muted">暂无日志,用户启动任务后这里会自动刷新。</div>
|
| 246 |
+
{% endif %}
|
| 247 |
+
</div>
|
| 248 |
+
</article>
|
| 249 |
+
|
| 250 |
+
<article class="card reveal-up delay-4 span-2">
|
| 251 |
+
<div class="card-head">
|
| 252 |
+
<span class="kicker">用户管理</span>
|
| 253 |
+
<h2>所有用户与课程详情</h2>
|
| 254 |
+
<p>可以直接修改用户信息、配置定时任务、增减课程,或代替用户启动和停止任务。</p>
|
| 255 |
+
</div>
|
| 256 |
+
<div class="user-card-grid">
|
| 257 |
+
{% for user in users %}
|
| 258 |
+
<section class="user-card">
|
| 259 |
+
<div class="user-card-head">
|
| 260 |
+
<div>
|
| 261 |
+
<h3>{{ user.display_name or user.student_id }}</h3>
|
| 262 |
+
<p>{{ user.student_id }}</p>
|
| 263 |
+
</div>
|
| 264 |
+
<span class="status-pill status-{{ user.latest_task.status if user.latest_task else 'idle' }}">
|
| 265 |
+
{{ task_labels.get(user.latest_task.status, '未启动') if user.latest_task else '未启动' }}
|
| 266 |
+
</span>
|
| 267 |
+
</div>
|
| 268 |
+
|
| 269 |
+
<div class="chip-row tight">
|
| 270 |
+
<span class="chip {% if user.is_active %}highlight{% endif %}">{{ '启用中' if user.is_active else '已禁用' }}</span>
|
| 271 |
+
<span class="chip">课程 {{ user.course_count }}</span>
|
| 272 |
+
<span class="chip">最近任务 {{ user.latest_task.id if user.latest_task else '--' }}</span>
|
| 273 |
+
<span class="chip">刷新 {{ user.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</span>
|
| 274 |
+
<span class="chip">尝试 {{ user.latest_task.total_attempts if user.latest_task else 0 }}</span>
|
| 275 |
+
<span class="chip">错误 {{ user.latest_task.total_errors if user.latest_task else 0 }}</span>
|
| 276 |
+
<span class="chip">定时 {{ '开启' if user.schedule and user.schedule.is_enabled else '关闭' }}</span>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<form method="post" action="{{ url_for('update_user', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
|
| 280 |
+
<label class="field span-2">
|
| 281 |
+
<span>显示名称</span>
|
| 282 |
+
<input type="text" name="display_name" value="{{ user.display_name }}" placeholder="备注名称">
|
| 283 |
+
</label>
|
| 284 |
+
<label class="field">
|
| 285 |
+
<span>刷新间隔</span>
|
| 286 |
+
<input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ user.refresh_interval_seconds or default_refresh_interval_seconds }}" required>
|
| 287 |
+
</label>
|
| 288 |
+
<label class="field span-2">
|
| 289 |
+
<span>重置密码</span>
|
| 290 |
+
<input type="password" name="password" placeholder="留空表示不修改">
|
| 291 |
+
</label>
|
| 292 |
+
<button type="submit" class="btn btn-ghost">保存用户</button>
|
| 293 |
+
</form>
|
| 294 |
+
|
| 295 |
+
<form method="post" action="{{ url_for('update_user_schedule', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
|
| 296 |
+
<label class="field">
|
| 297 |
+
<span>启用定时</span>
|
| 298 |
+
<input type="checkbox" name="schedule_enabled" value="1" {% if user.schedule and user.schedule.is_enabled %}checked{% endif %}>
|
| 299 |
+
</label>
|
| 300 |
+
<label class="field">
|
| 301 |
+
<span>开始日期</span>
|
| 302 |
+
<input type="date" name="start_date" value="{{ user.schedule.start_date if user.schedule else '' }}">
|
| 303 |
+
</label>
|
| 304 |
+
<label class="field">
|
| 305 |
+
<span>结束日期</span>
|
| 306 |
+
<input type="date" name="end_date" value="{{ user.schedule.end_date if user.schedule else '' }}">
|
| 307 |
+
</label>
|
| 308 |
+
<label class="field">
|
| 309 |
+
<span>每日启动</span>
|
| 310 |
+
<input type="time" name="daily_start_time" value="{{ user.schedule.daily_start_time if user.schedule else '' }}">
|
| 311 |
+
</label>
|
| 312 |
+
<label class="field">
|
| 313 |
+
<span>每日停止</span>
|
| 314 |
+
<input type="time" name="daily_stop_time" value="{{ user.schedule.daily_stop_time if user.schedule else '' }}">
|
| 315 |
+
</label>
|
| 316 |
+
<button type="submit" class="btn btn-secondary">保存定时设置</button>
|
| 317 |
+
</form>
|
| 318 |
+
|
| 319 |
+
<div class="button-row wrap-row">
|
| 320 |
+
<form method="post" action="{{ url_for('toggle_user', user_id=user.id) }}">
|
| 321 |
+
<button type="submit" class="btn btn-ghost {% if not user.is_active %}danger{% endif %}">{{ '禁用' if user.is_active else '启用' }}</button>
|
| 322 |
+
</form>
|
| 323 |
+
<form method="post" action="{{ url_for('admin_start_user_task', user_id=user.id) }}">
|
| 324 |
+
<button type="submit" class="btn btn-primary">代启动任务</button>
|
| 325 |
+
</form>
|
| 326 |
+
<form method="post" action="{{ url_for('admin_stop_user_task', user_id=user.id) }}">
|
| 327 |
+
<button type="submit" class="btn btn-ghost danger">代停止任务</button>
|
| 328 |
+
</form>
|
| 329 |
+
<form method="post" action="{{ url_for('delete_user_by_admin', user_id=user.id) }}">
|
| 330 |
+
<button type="submit" class="btn btn-ghost danger">删除用户</button>
|
| 331 |
+
</form>
|
| 332 |
+
</div>
|
| 333 |
+
|
| 334 |
+
<form method="post" action="{{ url_for('admin_add_course', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
|
| 335 |
+
<label class="field">
|
| 336 |
+
<span>类型</span>
|
| 337 |
+
<select name="category">
|
| 338 |
+
<option value="free">自由选课</option>
|
| 339 |
+
<option value="plan">方案选课</option>
|
| 340 |
+
</select>
|
| 341 |
+
</label>
|
| 342 |
+
<label class="field">
|
| 343 |
+
<span>课程号</span>
|
| 344 |
+
<input type="text" name="course_id" placeholder="例如 888005010A59" autocapitalize="characters" required>
|
| 345 |
+
</label>
|
| 346 |
+
<label class="field">
|
| 347 |
+
<span>课序号</span>
|
| 348 |
+
<input type="text" name="course_index" placeholder="例如 01 或 666" autocapitalize="characters" required>
|
| 349 |
+
</label>
|
| 350 |
+
<button type="submit" class="btn btn-secondary">为该用户加课</button>
|
| 351 |
+
</form>
|
| 352 |
+
|
| 353 |
+
<div class="course-list">
|
| 354 |
+
{% if user.courses %}
|
| 355 |
+
{% for course in user.courses %}
|
| 356 |
+
<div class="course-chip-row">
|
| 357 |
+
<span>{{ category_labels.get(course.category, course.category) }} · {{ course.course_id }}_{{ course.course_index }}</span>
|
| 358 |
+
<form method="post" action="{{ url_for('admin_delete_course', course_target_id=course.id) }}">
|
| 359 |
+
<button type="submit" class="inline-action">删除</button>
|
| 360 |
+
</form>
|
| 361 |
+
</div>
|
| 362 |
+
{% endfor %}
|
| 363 |
+
{% else %}
|
| 364 |
+
<div class="empty-mini">当前没有课程目标。</div>
|
| 365 |
+
{% endif %}
|
| 366 |
+
</div>
|
| 367 |
+
</section>
|
| 368 |
+
{% else %}
|
| 369 |
+
<div class="empty-state-card">
|
| 370 |
+
还没有录入任何用户,请先通过上方表单创建或发放注册码。
|
| 371 |
+
</div>
|
| 372 |
+
{% endfor %}
|
| 373 |
+
</div>
|
| 374 |
+
</article>
|
| 375 |
+
</section>
|
| 376 |
+
</section>
|
| 377 |
{% endblock %}
|
templates/login.html
CHANGED
|
@@ -15,12 +15,12 @@
|
|
| 15 |
<span>每位用户独立队列与日志流</span>
|
| 16 |
</article>
|
| 17 |
<article>
|
| 18 |
-
<strong>
|
| 19 |
-
<span>
|
| 20 |
</article>
|
| 21 |
<article>
|
| 22 |
-
<strong>
|
| 23 |
-
<span>
|
| 24 |
</article>
|
| 25 |
</div>
|
| 26 |
</div>
|
|
@@ -34,16 +34,16 @@
|
|
| 34 |
<form method="post" class="form-grid">
|
| 35 |
<label class="field">
|
| 36 |
<span>学号</span>
|
| 37 |
-
<input type="text" name="student_id" inputmode="numeric" autocomplete="username" placeholder="例如
|
| 38 |
</label>
|
| 39 |
<label class="field">
|
| 40 |
<span>密码</span>
|
| 41 |
-
<input type="password" name="password" autocomplete="current-password" placeholder="输入教务
|
| 42 |
</label>
|
| 43 |
<button type="submit" class="btn btn-primary btn-lg">进入用户控制台</button>
|
| 44 |
</form>
|
| 45 |
<div class="auth-footnote">
|
| 46 |
-
|
| 47 |
</div>
|
| 48 |
</div>
|
| 49 |
</section>
|
|
|
|
| 15 |
<span>每位用户独立队列与日志流</span>
|
| 16 |
</article>
|
| 17 |
<article>
|
| 18 |
+
<strong>定时启动终止</strong>
|
| 19 |
+
<span>管理员可配置每日自动开始与停止</span>
|
| 20 |
</article>
|
| 21 |
<article>
|
| 22 |
+
<strong>远程持久化</strong>
|
| 23 |
+
<span>用户、课程与定时配置会保存在数据库中</span>
|
| 24 |
</article>
|
| 25 |
</div>
|
| 26 |
</div>
|
|
|
|
| 34 |
<form method="post" class="form-grid">
|
| 35 |
<label class="field">
|
| 36 |
<span>学号</span>
|
| 37 |
+
<input type="text" name="student_id" inputmode="numeric" autocomplete="username" placeholder="例如 2025XXXXXXXXX" required>
|
| 38 |
</label>
|
| 39 |
<label class="field">
|
| 40 |
<span>密码</span>
|
| 41 |
+
<input type="password" name="password" autocomplete="current-password" placeholder="输入教务处密码" required>
|
| 42 |
</label>
|
| 43 |
<button type="submit" class="btn btn-primary btn-lg">进入用户控制台</button>
|
| 44 |
</form>
|
| 45 |
<div class="auth-footnote">
|
| 46 |
+
如果你已经拿到注册码,请先前往 <a href="{{ url_for('register') }}">用户注册</a>,注册时请使用学号和教务处密码。
|
| 47 |
</div>
|
| 48 |
</div>
|
| 49 |
</section>
|
templates/register.html
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}用户注册 | SCU 选课控制台{% endblock %}
|
| 3 |
+
{% block body_class %}auth-body{% endblock %}
|
| 4 |
+
{% block content %}
|
| 5 |
+
<section class="auth-layout">
|
| 6 |
+
<div class="hero-panel reveal-up">
|
| 7 |
+
<span class="eyebrow">Registration</span>
|
| 8 |
+
<h1>使用注册码创建你的抢课账号。</h1>
|
| 9 |
+
<p>
|
| 10 |
+
注册成功后,后续就可以直接使用学号和教务处密码登录,不需要重复输入注册码。
|
| 11 |
+
</p>
|
| 12 |
+
<div class="hero-metrics">
|
| 13 |
+
<article>
|
| 14 |
+
<strong>注册码校验</strong>
|
| 15 |
+
<span>注册码由管理员在后台创建和管理</span>
|
| 16 |
+
</article>
|
| 17 |
+
<article>
|
| 18 |
+
<strong>使用学号注册</strong>
|
| 19 |
+
<span>请填写你的学号和教务处密码</span>
|
| 20 |
+
</article>
|
| 21 |
+
<article>
|
| 22 |
+
<strong>自动进入用户体系</strong>
|
| 23 |
+
<span>注册后即可管理课程目标与查看日志</span>
|
| 24 |
+
</article>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div class="auth-card reveal-up delay-1">
|
| 29 |
+
<div class="card-head compact">
|
| 30 |
+
<span class="kicker">学生注册</span>
|
| 31 |
+
<h2>注册新账号</h2>
|
| 32 |
+
<p>请使用管理员发放的注册码,并填写学号与教务处密码。</p>
|
| 33 |
+
</div>
|
| 34 |
+
<form method="post" class="form-grid">
|
| 35 |
+
<label class="field">
|
| 36 |
+
<span>注册码</span>
|
| 37 |
+
<input type="text" name="registration_code" placeholder="例如 SACC-AB12CD34" autocapitalize="characters" required>
|
| 38 |
+
</label>
|
| 39 |
+
<label class="field">
|
| 40 |
+
<span>显示名称</span>
|
| 41 |
+
<input type="text" name="display_name" placeholder="可选昵称或备注">
|
| 42 |
+
</label>
|
| 43 |
+
<label class="field">
|
| 44 |
+
<span>学号</span>
|
| 45 |
+
<input type="text" name="student_id" inputmode="numeric" placeholder="请输入学号" required>
|
| 46 |
+
</label>
|
| 47 |
+
<label class="field">
|
| 48 |
+
<span>教务处密码</span>
|
| 49 |
+
<input type="password" name="password" placeholder="请输入教务处密码" required>
|
| 50 |
+
</label>
|
| 51 |
+
<button type="submit" class="btn btn-primary btn-lg">完成注册</button>
|
| 52 |
+
</form>
|
| 53 |
+
<div class="auth-footnote">
|
| 54 |
+
已有账号?直接返回 <a href="{{ url_for('login') }}">用户登录</a>。
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</section>
|
| 58 |
+
{% endblock %}
|