阶段 0 盘点时判为 CUT 的 17 条,当时只在代码里标了 DEPRECATED(2026-05-26),
没删。这次删掉:15 个视图类整体移除,2 条只删 URL。
## 为什么有两条只能删 URL
ProblemSetProblemAPI 和 ProblemSetProgressAPI **各有一条在用的路径**:
ProblemSetProblemAPI /problemset/<id>/problems 在用
/problemset/<id>/problems/<pid> 废弃
ProblemSetProgressAPI /problemset/progress 在用
/problemset/<id>/progress 废弃
删类会直接搞挂"看题单题目"和"加入题单"。清单里记的是**路径**不是视图类,
这个区别不看一眼就动手会出事。
## 核实过程
不敢只信清单 —— 今天已经因为"读过文档还是漏了盲点"栽过一次(前端 /api2 前缀
漏改 5 处)。所以:
1. 用 ast(不是正则)建「视图类 → 它的所有 URL」映射,判定哪些类的全部 URL
都废弃。正则版把 B 组算成 0 个,而我明知至少有 2 个 —— 那种"看起来干净"的
结果最危险。
2. DEPRECATED 检测最初往前多看一行,把上一条的行内注释算到了下一条头上,
于是 /hitokoto、/profile、/reset_password 被误判成废弃。这三个明显在用,
才发现范围错了。
3. 按端点字符串精确搜整个 ojnext(覆盖 http.get、原生 fetch、独立 axios、
模板插值),16/16 确认无调用。**并且用 5 个明确在用的端点反测了检测器本身** ——
未经验证的检测器报"全部无调用"是没有意义的。
## 验证
起本地 Django + PostgreSQL 实跑 33 条断言,全过:
- 保留的端点还能用(13 条,含那两个"类保留"的路径,加入题单确认真的成功)
- 删掉的端点确实 404(17 条)
- reverse() 也解析不到了(3 条)
`ruff check .` 全绿(顺手清掉 44 个因此变成未使用的 import)。
## 一个差点造成的意外
脚本无条件重写所有 urls.py,把 class_pk/ 和 message/ 下三个 **CRLF** 文件
静默转成了 LF —— 内容没变但整文件显示为改动。已原样恢复,最终 diff 是
纯删除,零意外改动。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
8.1 KiB
Python
234 lines
8.1 KiB
Python
import asyncio
|
||
import hashlib
|
||
import os
|
||
import random
|
||
import re
|
||
import shutil
|
||
from datetime import timedelta
|
||
|
||
from asgiref.sync import sync_to_async
|
||
from django.conf import settings
|
||
from django.utils import timezone
|
||
|
||
from account.decorators import super_admin_required
|
||
from account.models import User
|
||
from contest.models import Contest
|
||
from judge.dispatcher import process_pending_task
|
||
from options.options import SysOptions
|
||
from problem.models import Problem
|
||
from submission.models import Submission
|
||
from utils.api import APIView, AsyncAPIView, CSRFExemptAPIView, validate_serializer
|
||
from utils.cache import JsonDataLoader
|
||
from utils.shortcuts import get_env, strip_class_prefix
|
||
from utils.websocket import push_config_update
|
||
from utils.xss_filter import XSSHtml
|
||
|
||
from .models import JudgeServer
|
||
from .serializers import (
|
||
CreateEditWebsiteConfigSerializer,
|
||
EditJudgeServerSerializer,
|
||
JudgeServerHeartbeatSerializer,
|
||
JudgeServerSerializer,
|
||
)
|
||
|
||
|
||
class WebsiteConfigAPI(AsyncAPIView):
|
||
async def get(self, request):
|
||
ret = await SysOptions.aget_many(
|
||
"website_base_url",
|
||
"website_name",
|
||
"website_name_shortcut",
|
||
"website_footer",
|
||
"allow_register",
|
||
"submission_list_show_all",
|
||
"class_list",
|
||
"enable_maxkb",
|
||
)
|
||
return self.success(ret)
|
||
|
||
@super_admin_required
|
||
@validate_serializer(CreateEditWebsiteConfigSerializer)
|
||
async def post(self, request):
|
||
@sync_to_async
|
||
def _update_config(data):
|
||
for k, v in data.items():
|
||
if k == "website_footer":
|
||
with XSSHtml() as parser:
|
||
v = parser.clean(v)
|
||
setattr(SysOptions, k, v)
|
||
push_config_update(k, v)
|
||
|
||
await _update_config(request.data)
|
||
return self.success()
|
||
|
||
|
||
class JudgeServerAPI(APIView):
|
||
@super_admin_required
|
||
def get(self, request):
|
||
servers = JudgeServer.objects.all().order_by("-last_heartbeat")
|
||
return self.success(
|
||
{
|
||
"token": SysOptions.judge_server_token,
|
||
"servers": JudgeServerSerializer(servers, many=True).data,
|
||
}
|
||
)
|
||
|
||
@super_admin_required
|
||
def delete(self, request):
|
||
hostname = request.GET.get("hostname")
|
||
if hostname:
|
||
JudgeServer.objects.filter(hostname=hostname).delete()
|
||
return self.success()
|
||
|
||
@validate_serializer(EditJudgeServerSerializer)
|
||
@super_admin_required
|
||
def put(self, request):
|
||
is_disabled = request.data.get("is_disabled", False)
|
||
JudgeServer.objects.filter(id=request.data["id"]).update(is_disabled=is_disabled)
|
||
if not is_disabled:
|
||
process_pending_task()
|
||
return self.success()
|
||
|
||
|
||
class JudgeServerHeartbeatAPI(CSRFExemptAPIView):
|
||
@validate_serializer(JudgeServerHeartbeatSerializer)
|
||
def post(self, request):
|
||
data = request.data
|
||
client_token = request.META.get("HTTP_X_JUDGE_SERVER_TOKEN")
|
||
if hashlib.sha256(SysOptions.judge_server_token.encode("utf-8")).hexdigest() != client_token:
|
||
return self.error("Invalid token")
|
||
|
||
try:
|
||
server = JudgeServer.objects.get(hostname=data["hostname"])
|
||
server.judger_version = data["judger_version"]
|
||
server.cpu_core = data["cpu_core"]
|
||
server.memory_usage = data["memory"]
|
||
server.cpu_usage = data["cpu"]
|
||
server.service_url = data["service_url"]
|
||
server.ip = request.ip
|
||
server.last_heartbeat = timezone.now()
|
||
server.save(
|
||
update_fields=[
|
||
"judger_version",
|
||
"cpu_core",
|
||
"memory_usage",
|
||
"service_url",
|
||
"ip",
|
||
"last_heartbeat",
|
||
]
|
||
)
|
||
except JudgeServer.DoesNotExist:
|
||
JudgeServer.objects.create(
|
||
hostname=data["hostname"],
|
||
judger_version=data["judger_version"],
|
||
cpu_core=data["cpu_core"],
|
||
memory_usage=data["memory"],
|
||
cpu_usage=data["cpu"],
|
||
ip=request.META["REMOTE_ADDR"],
|
||
service_url=data["service_url"],
|
||
last_heartbeat=timezone.now(),
|
||
)
|
||
# 新server上线 处理队列中的,防止没有新的提交而导致一直waiting
|
||
process_pending_task()
|
||
|
||
return self.success()
|
||
|
||
|
||
class TestCasePruneAPI(APIView):
|
||
@super_admin_required
|
||
def get(self, request):
|
||
"""
|
||
return orphan test_case list
|
||
"""
|
||
ret_data = []
|
||
dir_to_be_removed = self.get_orphan_ids()
|
||
|
||
# return an iterator
|
||
for d in os.scandir(settings.TEST_CASE_DIR):
|
||
if d.name in dir_to_be_removed:
|
||
ret_data.append({"id": d.name, "create_time": d.stat().st_mtime})
|
||
return self.success(ret_data)
|
||
|
||
@super_admin_required
|
||
def delete(self, request):
|
||
test_case_id = request.GET.get("id")
|
||
if test_case_id:
|
||
self.delete_one(test_case_id)
|
||
return self.success()
|
||
for id in self.get_orphan_ids():
|
||
self.delete_one(id)
|
||
return self.success()
|
||
|
||
@staticmethod
|
||
def get_orphan_ids():
|
||
db_ids = Problem.objects.all().values_list("test_case_id", flat=True)
|
||
disk_ids = os.listdir(settings.TEST_CASE_DIR)
|
||
test_case_re = re.compile(r"^[a-zA-Z0-9]{32}$")
|
||
disk_ids = filter(lambda f: test_case_re.match(f), disk_ids)
|
||
return list(set(disk_ids) - set(db_ids))
|
||
|
||
@staticmethod
|
||
def delete_one(id):
|
||
test_case_dir = os.path.join(settings.TEST_CASE_DIR, id)
|
||
if os.path.isdir(test_case_dir):
|
||
shutil.rmtree(test_case_dir, ignore_errors=True)
|
||
|
||
|
||
class DashboardInfoAPI(AsyncAPIView):
|
||
async def get(self, request):
|
||
now = timezone.now()
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
(
|
||
user_count,
|
||
today_submission_count,
|
||
recent_contest_count,
|
||
judge_servers,
|
||
) = await asyncio.gather(
|
||
User.objects.acount(),
|
||
Submission.objects.filter(create_time__gte=today_start).acount(),
|
||
Contest.objects.exclude(end_time__lt=timezone.now()).acount(),
|
||
JudgeServer.objects.filter(last_heartbeat__gte=timezone.now() - timedelta(seconds=6)).acount(),
|
||
)
|
||
return self.success(
|
||
{
|
||
"user_count": user_count,
|
||
"recent_contest_count": recent_contest_count,
|
||
"today_submission_count": today_submission_count,
|
||
"judge_server_count": judge_servers,
|
||
"env": {
|
||
"FORCE_HTTPS": get_env("FORCE_HTTPS", default=False),
|
||
"STATIC_CDN_HOST": get_env("STATIC_CDN_HOST", default=""),
|
||
},
|
||
}
|
||
)
|
||
|
||
|
||
class RandomUsernameAPI(AsyncAPIView):
|
||
async def get(self, request):
|
||
classroom = request.GET.get("classroom", "")
|
||
if not classroom:
|
||
return self.error("需要班级号")
|
||
usernames = [u async for u in User.objects.filter(username__istartswith=classroom).values_list("username", flat=True).order_by("?")[:10]]
|
||
return self.success(usernames)
|
||
|
||
|
||
class HitokotoAPI(AsyncAPIView):
|
||
async def get(self, request):
|
||
try:
|
||
categories = JsonDataLoader.load_data(settings.HITOKOTO_DIR, "categories.json")
|
||
path = random.choice(categories).get("path")
|
||
sentences = JsonDataLoader.load_data(settings.HITOKOTO_DIR, path)
|
||
sentence = random.choice(sentences)
|
||
return self.success(sentence)
|
||
except Exception:
|
||
return self.error("获取一言失败,请稍后再试")
|
||
|
||
|
||
class ClassUsernamesAPI(AsyncAPIView):
|
||
async def get(self, request):
|
||
classroom = request.GET.get("classroom", "")
|
||
if not classroom:
|
||
return self.error("需要班级号")
|
||
names = [strip_class_prefix(user.username, classroom) async for user in User.objects.filter(class_name=classroom).order_by("-create_time")]
|
||
return self.success(names)
|