Files
OnlineJudge/problem/views/admin.py
yuetsh c775ae4b22 feat(problem): 新增标签管理接口
列表带题目数,重命名撞名时自动合并并返回受影响题数,删除后清标签缓存。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 04:18:41 -06:00

787 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import hashlib
import json
import os
# import shutil
import zipfile
from wsgiref.util import FileWrapper
from django.conf import settings
from django.db.models import Count, Q
from django.db.models.functions import ExtractYear
from django.http import StreamingHttpResponse
from account.decorators import ensure_created_by, problem_permission_required, teacher_admin_required
from contest.models import Contest, ContestStatus
from judge.sql_runner import SQLCaseError, build_display
from submission.models import Submission
from utils.api import APIError, APIView, CSRFExemptAPIView, validate_serializer
from utils.openai import get_ai_client
from utils.shortcuts import natural_sort_key, rand_str
from ..models import Problem, ProblemTag
from ..serializers import (
AddContestProblemSerializer,
ContestProblemMakePublicSerializer,
CreateContestProblemSerializer,
CreateProblemSerializer,
EditContestProblemSerializer,
EditProblemSerializer,
EditTagSerializer,
ProblemAdminListSerializer,
ProblemAdminSerializer,
SQLTestCasePreviewSerializer,
TagAdminSerializer,
TestCaseUploadForm,
)
from ..services import clear_tag_cache, resolve_tags
from ..utils import generate_sql_display
class TestCaseZipProcessor(object):
def process_zip(self, uploaded_zip_file, dir="", sql=False):
try:
zip_file = zipfile.ZipFile(uploaded_zip_file, "r")
except zipfile.BadZipFile:
raise APIError("Bad zip file")
name_list = zip_file.namelist()
if sql:
test_case_list = self.filter_sql_name_list(name_list, dir=dir)
else:
test_case_list = self.filter_name_list(name_list, dir=dir)
if not test_case_list:
raise APIError("Empty file")
# 题目页会展示测试点1的期望结果单测试点时学生可对照硬编码 AC
if sql and len(test_case_list) < 2:
raise APIError("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")
test_case_id = rand_str()
test_case_dir = os.path.join(settings.TEST_CASE_DIR, test_case_id)
os.mkdir(test_case_dir)
os.chmod(test_case_dir, 0o710)
size_cache = {}
md5_cache = {}
for item in test_case_list:
with open(os.path.join(test_case_dir, item), "wb") as f:
content = zip_file.read(f"{dir}{item}").replace(b"\r\n", b"\n")
size_cache[item] = len(content)
if item.endswith(".out"):
md5_cache[item] = hashlib.md5(content.rstrip()).hexdigest()
f.write(content)
test_case_info = {"test_cases": {}}
info = []
if sql:
# SQL 题:每个 N.sql 是一个测试点的建表+数据脚本,没有期望输出(判题时跑标准答案生成)。
# output_name 复用同名、md5 置空,以兼容 CreateTestCaseScoreSerializer 和前端测试点表格。
test_case_info["sql"] = True
for index, item in enumerate(test_case_list):
data = {
"stripped_output_md5": "",
"input_size": size_cache[item],
"output_size": 0,
"input_name": item,
"output_name": item,
}
info.append(data)
test_case_info["test_cases"][str(index + 1)] = data
else:
# ["1.in", "1.out", "2.in", "2.out"] => [("1.in", "1.out"), ("2.in", "2.out")]
test_case_list = zip(*[test_case_list[i::2] for i in range(2)])
for index, item in enumerate(test_case_list):
data = {
"stripped_output_md5": md5_cache[item[1]],
"input_size": size_cache[item[0]],
"output_size": size_cache[item[1]],
"input_name": item[0],
"output_name": item[1],
}
info.append(data)
test_case_info["test_cases"][str(index + 1)] = data
with open(os.path.join(test_case_dir, "info"), "w", encoding="utf-8") as f:
f.write(json.dumps(test_case_info, indent=4))
for item in os.listdir(test_case_dir):
os.chmod(os.path.join(test_case_dir, item), 0o640)
return info, test_case_id
def filter_name_list(self, name_list, dir=""):
ret = []
prefix = 1
while True:
in_name = f"{prefix}.in"
out_name = f"{prefix}.out"
if f"{dir}{in_name}" in name_list and f"{dir}{out_name}" in name_list:
ret.append(in_name)
ret.append(out_name)
prefix += 1
continue
else:
return sorted(ret, key=natural_sort_key)
def filter_sql_name_list(self, name_list, dir=""):
# SQL 题测试点:连续编号的 1.sql, 2.sql, ...
ret = []
prefix = 1
while True:
name = f"{prefix}.sql"
if f"{dir}{name}" in name_list:
ret.append(name)
prefix += 1
continue
else:
return sorted(ret, key=natural_sort_key)
class TestCaseAPI(CSRFExemptAPIView, TestCaseZipProcessor):
request_parsers = ()
def get(self, request):
problem_id = request.GET.get("problem_id")
if not problem_id:
return self.error("Parameter error, problem_id is required")
try:
problem = Problem.objects.get(id=problem_id)
except Problem.DoesNotExist:
return self.error("Problem does not exists")
if problem.contest:
ensure_created_by(problem.contest, request.user)
else:
ensure_created_by(problem, request.user)
test_case_dir = os.path.join(settings.TEST_CASE_DIR, problem.test_case_id)
if not os.path.isdir(test_case_dir):
return self.error("Test case does not exists")
# SQL 题的测试点是 N.sql需按 info 里的类型标记选择文件列表
is_sql = False
info_path = os.path.join(test_case_dir, "info")
if os.path.isfile(info_path):
try:
with open(info_path, encoding="utf-8") as f:
is_sql = bool(json.load(f).get("sql"))
except (OSError, json.JSONDecodeError):
pass
if is_sql:
name_list = self.filter_sql_name_list(os.listdir(test_case_dir))
else:
name_list = self.filter_name_list(os.listdir(test_case_dir))
name_list.append("info")
file_name = os.path.join(test_case_dir, problem.test_case_id + ".zip")
with zipfile.ZipFile(file_name, "w") as file:
for test_case in name_list:
file.write(f"{test_case_dir}/{test_case}", test_case)
response = StreamingHttpResponse(FileWrapper(open(file_name, "rb")), content_type="application/octet-stream")
response["Content-Disposition"] = f"attachment; filename=problem_{problem.id}_test_cases.zip"
response["Content-Length"] = os.path.getsize(file_name)
return response
def post(self, request):
form = TestCaseUploadForm(request.POST, request.FILES)
if form.is_valid():
file = form.cleaned_data["file"]
else:
return self.error("Upload failed")
zip_file = f"/tmp/{rand_str()}.zip"
with open(zip_file, "wb") as f:
for chunk in file:
f.write(chunk)
sql = request.POST.get("sql") in ("1", "true", "True")
info, test_case_id = self.process_zip(zip_file, sql=sql)
os.remove(zip_file)
return self.success({"id": test_case_id, "info": info})
class ProblemBase(APIView):
def common_checks(self, request):
data = request.data
data["languages"] = list(data["languages"])
# SQL 题校验:.sql 测试点与 .in/.out 沙箱判题互斥SQL 必须是唯一语言
if "SQL" in data["languages"]:
if data["languages"] != ["SQL"]:
return "SQL problem cannot be mixed with other languages"
if not data.get("sql_config"):
return "SQL problem requires sql_config"
has_sql_answer = any(item.get("language") == "SQL" and item.get("code", "").strip() for item in (data.get("answers") or []))
if not has_sql_answer:
return "SQL problem requires a SQL reference answer"
return self._build_sql_display(data)
else:
# 序列化器已放宽SQL 题不填这些),非 SQL 题在此保持原有强校验
if not data["input_description"] or not data["output_description"]:
return "Input and output description are required"
if not data["samples"]:
return "Samples are required"
# 防脏数据:非 SQL 题不应携带 SQL 配置
data["sql_config"] = None
data["sql_display"] = None
def _build_sql_display(self, data):
"""SQL 题:生成题目页展示数据。返回错误信息字符串,成功返回 None。"""
display, error = generate_sql_display(data["test_case_id"], data["answers"], data["sql_config"])
if error:
return error
data["sql_display"] = display
class ProblemAPI(ProblemBase):
@problem_permission_required
@validate_serializer(CreateProblemSerializer)
def post(self, request):
data = request.data
_id = data["_id"]
if not _id:
return self.error("Display ID is required")
if Problem.objects.filter(_id=_id, contest_id__isnull=True).exists():
return self.error("Display ID already exists")
error_info = self.common_checks(request)
if error_info:
return self.error(error_info)
# todo check filename and score info
tags = data.pop("tags")
data["created_by"] = request.user
problem = Problem.objects.create(**data)
problem.tags.set(resolve_tags(tags))
return self.success(ProblemAdminSerializer(problem).data)
@problem_permission_required
def get(self, request):
problem_id = request.GET.get("id")
user = request.user
if problem_id:
try:
problem = Problem.objects.get(id=problem_id)
ensure_created_by(problem, request.user)
return self.success(ProblemAdminSerializer(problem).data)
except Problem.DoesNotExist:
return self.error("Problem does not exist")
problems = Problem.objects.filter(contest_id__isnull=True).order_by("-create_time")
author = request.GET.get("author", "")
if author:
problems = problems.filter(created_by__username=author)
keyword = request.GET.get("keyword", "").strip()
if keyword:
problems = problems.filter(Q(title__icontains=keyword) | Q(_id__icontains=keyword))
if not user.can_mgmt_all_problem():
problems = problems.filter(created_by=user)
return self.success(self.paginate_data(request, problems, ProblemAdminListSerializer))
@problem_permission_required
@validate_serializer(EditProblemSerializer)
def put(self, request):
data = request.data
problem_id = data.pop("id")
try:
problem = Problem.objects.get(id=problem_id)
ensure_created_by(problem, request.user)
except Problem.DoesNotExist:
return self.error("Problem does not exist")
_id = data["_id"]
if not _id:
return self.error("Display ID is required")
if Problem.objects.exclude(id=problem_id).filter(_id=_id, contest_id__isnull=True).exists():
return self.error("Display ID already exists")
error_info = self.common_checks(request)
if error_info:
return self.error(error_info)
# todo check filename and score info
tags = data.pop("tags")
data["languages"] = list(data["languages"])
for k, v in data.items():
setattr(problem, k, v)
problem.save()
problem.tags.set(resolve_tags(tags))
return self.success()
@problem_permission_required
def delete(self, request):
id = request.GET.get("id")
if not id:
return self.error("Invalid parameter, id is required")
try:
problem = Problem.objects.get(id=id, contest_id__isnull=True)
except Problem.DoesNotExist:
return self.error("Problem does not exists")
ensure_created_by(problem, request.user)
# d = os.path.join(settings.TEST_CASE_DIR, problem.test_case_id)
# if os.path.isdir(d):
# shutil.rmtree(d, ignore_errors=True)
problem.delete()
return self.success()
class ContestProblemAPI(ProblemBase):
@validate_serializer(CreateContestProblemSerializer)
def post(self, request):
data = request.data
try:
contest = Contest.objects.get(id=data.pop("contest_id"))
ensure_created_by(contest, request.user)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
_id = data["_id"]
if not _id:
return self.error("Display ID is required")
if Problem.objects.filter(_id=_id, contest=contest).exists():
return self.error("Duplicate Display id")
error_info = self.common_checks(request)
if error_info:
return self.error(error_info)
# todo check filename and score info
data["contest"] = contest
tags = data.pop("tags")
data["created_by"] = request.user
problem = Problem.objects.create(**data)
problem.tags.set(resolve_tags(tags))
return self.success(ProblemAdminSerializer(problem).data)
def get(self, request):
problem_id = request.GET.get("id")
contest_id = request.GET.get("contest_id")
user = request.user
if problem_id:
try:
problem = Problem.objects.get(id=problem_id)
ensure_created_by(problem.contest, user)
except Problem.DoesNotExist:
return self.error("Problem does not exist")
return self.success(ProblemAdminSerializer(problem).data)
if not contest_id:
return self.error("Contest id is required")
try:
contest = Contest.objects.get(id=contest_id)
ensure_created_by(contest, user)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
problems = Problem.objects.filter(contest=contest).order_by("-create_time")
if not user.is_super_admin():
problems = problems.filter(contest__created_by=user)
keyword = request.GET.get("keyword")
if keyword:
problems = problems.filter(title__contains=keyword)
return self.success(self.paginate_data(request, problems, ProblemAdminListSerializer))
@validate_serializer(EditContestProblemSerializer)
def put(self, request):
data = request.data
user = request.user
try:
contest = Contest.objects.get(id=data.pop("contest_id"))
ensure_created_by(contest, user)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
problem_id = data.pop("id")
try:
problem = Problem.objects.get(id=problem_id, contest=contest)
except Problem.DoesNotExist:
return self.error("Problem does not exist")
_id = data["_id"]
if not _id:
return self.error("Display ID is required")
if Problem.objects.exclude(id=problem_id).filter(_id=_id, contest=contest).exists():
return self.error("Display ID already exists")
error_info = self.common_checks(request)
if error_info:
return self.error(error_info)
# todo check filename and score info
tags = data.pop("tags")
data["languages"] = list(data["languages"])
for k, v in data.items():
setattr(problem, k, v)
problem.save()
problem.tags.set(resolve_tags(tags))
return self.success()
def delete(self, request):
id = request.GET.get("id")
if not id:
return self.error("Invalid parameter, id is required")
try:
problem = Problem.objects.get(id=id, contest_id__isnull=False)
except Problem.DoesNotExist:
return self.error("Problem does not exists")
ensure_created_by(problem.contest, request.user)
if Submission.objects.filter(problem=problem).exists():
return self.error("Can't delete the problem as it has submissions")
# d = os.path.join(settings.TEST_CASE_DIR, problem.test_case_id)
# if os.path.isdir(d):
# shutil.rmtree(d, ignore_errors=True)
problem.delete()
return self.success()
class MakeContestProblemPublicAPIView(APIView):
@validate_serializer(ContestProblemMakePublicSerializer)
@problem_permission_required
def post(self, request):
data = request.data
display_id = data.get("display_id")
if Problem.objects.filter(_id=display_id, contest_id__isnull=True).exists():
return self.error("Duplicate display ID")
try:
problem = Problem.objects.get(id=data["id"])
except Problem.DoesNotExist:
return self.error("Problem does not exist")
if not problem.contest or problem.is_public:
return self.error("Already be a public problem")
problem.is_public = True
problem.save()
tags = problem.tags.all()
problem.pk = None
problem.contest = None
problem._id = display_id
problem.visible = False
problem.submission_number = problem.accepted_number = 0
problem.statistic_info = {}
problem.save()
problem.tags.set(tags)
return self.success()
class AddContestProblemAPI(APIView):
@validate_serializer(AddContestProblemSerializer)
def post(self, request):
data = request.data
try:
contest = Contest.objects.get(id=data["contest_id"])
problem = Problem.objects.get(id=data["problem_id"])
except (Contest.DoesNotExist, Problem.DoesNotExist):
return self.error("Contest or Problem does not exist")
if contest.status == ContestStatus.CONTEST_ENDED:
return self.error("Contest has ended")
if Problem.objects.filter(contest=contest, _id=data["display_id"]).exists():
return self.error("Duplicate display id in this contest")
tags = problem.tags.all()
problem.pk = None
problem.contest = contest
problem.is_public = True
problem.visible = True
problem._id = request.data["display_id"]
problem.submission_number = problem.accepted_number = 0
problem.statistic_info = {}
problem.save()
problem.tags.set(tags)
return self.success()
class TagAdminAPI(APIView):
@problem_permission_required
def get(self, request):
tags = ProblemTag.objects.annotate(problem_count=Count("problem"))
keyword = request.GET.get("keyword", "").strip()
if keyword:
tags = tags.filter(name__icontains=keyword)
tags = tags.order_by("-problem_count", "name")
return self.success(TagAdminSerializer(tags, many=True).data)
@problem_permission_required
@validate_serializer(EditTagSerializer)
def put(self, request):
data = request.data
try:
tag = ProblemTag.objects.get(id=data["id"])
except ProblemTag.DoesNotExist:
return self.error("标签不存在,请刷新后重试")
name = data["name"].strip()
if not name:
return self.error("标签名不能为空")
target = ProblemTag.objects.filter(name__iexact=name).exclude(id=tag.id).first()
if target is None:
tag.name = name
tag.save()
clear_tag_cache()
return self.success({"merged": False, "id": tag.id, "name": tag.name, "affected_count": 0})
# 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除
affected_count = 0
for problem in Problem.objects.filter(tags=tag):
problem.tags.add(target)
problem.tags.remove(tag)
affected_count += 1
tag.delete()
clear_tag_cache()
return self.success({"merged": True, "id": target.id, "name": target.name, "affected_count": affected_count})
@problem_permission_required
def delete(self, request):
tag_id = request.GET.get("id")
if not tag_id:
return self.error("Invalid parameter, id is required")
try:
tag = ProblemTag.objects.get(id=tag_id)
except ProblemTag.DoesNotExist:
return self.error("标签不存在,请刷新后重试")
# 删除标签行的同时Django 会级联清掉 problem_tags 中间表里的关系
tag.delete()
clear_tag_cache()
return self.success()
class ProblemVisibleAPI(APIView):
@problem_permission_required
def put(self, request):
data = request.data
try:
problem = Problem.objects.get(id=data["id"])
except Problem.DoesNotExist:
self.error("problem does not exists")
problem.visible = not problem.visible
problem.save()
return self.success()
class ProblemFlowchartAIGen(APIView):
@problem_permission_required
def post(self, request):
python_code = request.data.get("python", "")
client = get_ai_client()
response = client.chat.completions.create(
model="deepseek-v4-flash",
messages=[
{
"role": "system",
"content": """你是一个可以将Python代码转换为mermaid的助手。
请将用户提供的Python代码转换为 Mermaid 纯文本。
注意括号内的内容用引号包裹,如果本身就有引号,请注意双引号和单引号的问题。
请只返回 mermaid 代码,连 ``` 都不需要。""",
},
{"role": "user", "content": python_code},
],
temperature=0,
extra_body={"thinking": {"type": "disabled"}},
)
mermaid_code = response.choices[0].message.content
return self.success({"flowchart": mermaid_code})
class StuckProblemsAPI(APIView):
@teacher_admin_required
def get(self, request):
from submission.models import JudgeStatus
failed_q = Q(
result__in=[
JudgeStatus.WRONG_ANSWER,
JudgeStatus.COMPILE_ERROR,
JudgeStatus.RUNTIME_ERROR,
]
)
rows = (
Submission.objects.values("problem_id", "problem___id", "problem__title")
.annotate(
total=Count("id"),
accepted=Count("id", filter=Q(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])),
failed=Count("id", filter=failed_q),
failed_users=Count("user_id", filter=failed_q, distinct=True),
)
.filter(failed_users__gt=0)
.order_by("-failed_users")[:40]
)
result = [
{
"problem_id": r["problem___id"],
"problem_title": r["problem__title"],
"total": r["total"],
"failed": r["failed"],
"failed_users": r["failed_users"],
"ac_rate": round(r["accepted"] / r["total"] * 100, 1) if r["total"] else 0,
}
for r in rows
]
return self.success(result)
class TopACTrendAPI(APIView):
@teacher_admin_required
def get(self, request):
import datetime
from collections import defaultdict
from submission.models import JudgeStatus
current_year = datetime.datetime.now().year
try:
since_year = int(request.GET.get("since_year", 2023))
if since_year < 2022 or since_year > current_year:
since_year = 2023
except (TypeError, ValueError):
since_year = 2023
try:
until_year = int(request.GET.get("until_year", current_year))
if until_year < since_year or until_year > current_year:
until_year = current_year - 1
except (TypeError, ValueError):
until_year = current_year - 1
try:
min_per_year = int(request.GET.get("min_per_year", 100))
if min_per_year not in (50, 100, 200):
min_per_year = 100
except (TypeError, ValueError):
min_per_year = 100
required_years = set(range(since_year, until_year + 1))
yearly_rows = (
Submission.objects.filter(
contest_id__isnull=True,
create_time__year__gte=since_year,
create_time__year__lte=until_year,
)
.annotate(year=ExtractYear("create_time"))
.values("problem_id", "problem___id", "problem__title", "year")
.annotate(
total=Count("id"),
accepted=Count("id", filter=Q(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])),
)
.order_by("problem_id", "year")
)
by_problem: dict[int, list] = defaultdict(list)
problem_meta: dict[int, tuple] = {}
for r in yearly_rows:
pid = r["problem_id"]
if pid not in problem_meta:
problem_meta[pid] = (r["problem___id"], r["problem__title"])
by_problem[pid].append(
{
"year": r["year"],
"total": r["total"],
"accepted": r["accepted"],
"ac_rate": round(r["accepted"] / r["total"] * 100, 1) if r["total"] else 0,
}
)
result = []
for pid, yearly in by_problem.items():
years_present = {row["year"] for row in yearly}
if not required_years.issubset(years_present):
continue
if not all(row["total"] > min_per_year for row in yearly):
continue
result.append(
{
"problem_id": problem_meta[pid][0],
"problem_title": problem_meta[pid][1],
"yearly": sorted(yearly, key=lambda x: x["year"]),
}
)
return self.success(result)
class SQLTestCasePreviewAPI(APIView):
@problem_permission_required
@validate_serializer(SQLTestCasePreviewSerializer)
def post(self, request):
data = request.data
try:
display = build_display(data["init_sql"], data["ref_sql"], data["mode"])
except SQLCaseError as e:
return self.error(e.message)
return self.success(display)
class SQLTestCaseAIGenAPI(APIView):
@problem_permission_required
def post(self, request):
ref_sql = request.data.get("ref_sql", "")
mode = request.data.get("mode", "query")
client = get_ai_client()
response = client.chat.completions.create(
model="deepseek-v4-flash",
messages=[
{
"role": "system",
"content": """你是一个 SQL 出题助手。用户会给你一道 SQL 题的标准答案(查询题的
SELECT 语句,或增删改题的 UPDATE/DELETE/INSERT 语句)和题型。
请你推断出该标准答案所需要的表结构,生成一份自洽的 SQLite 兼容初始化脚本,
包含 CREATE TABLE 和若干条 INSERT 语句,插入的数据要足够让标准答案跑出有意义的结果
(比如查询题要有能被筛选出来和被过滤掉的行;增删改题要有能被改动和不受影响的行)。
请只返回 SQL 脚本本身,连 ``` 都不需要,不要任何解释文字。""",
},
{
"role": "user",
"content": f"题型:{mode}\n标准答案:\n{ref_sql}",
},
],
extra_body={"thinking": {"type": "disabled"}},
)
sql = response.choices[0].message.content
return self.success({"sql": sql})
class SQLTestCaseScriptsAPI(APIView, TestCaseZipProcessor):
@problem_permission_required
def get(self, request):
problem_id = request.GET.get("problem_id")
if not problem_id:
return self.error("Parameter error, problem_id is required")
try:
problem = Problem.objects.get(id=problem_id)
except Problem.DoesNotExist:
return self.error("Problem does not exists")
if problem.contest:
ensure_created_by(problem.contest, request.user)
else:
ensure_created_by(problem, request.user)
test_case_dir = os.path.join(settings.TEST_CASE_DIR, problem.test_case_id)
try:
with open(os.path.join(test_case_dir, "info"), encoding="utf-8") as f:
info = json.load(f)
except (OSError, json.JSONDecodeError):
return self.error("测试点信息读取失败")
if not info.get("sql"):
return self.error("该题的测试点不是 SQL 类型")
scripts = []
for name in self.filter_sql_name_list(os.listdir(test_case_dir)):
try:
with open(os.path.join(test_case_dir, name), encoding="utf-8") as f:
scripts.append({"name": name, "content": f.read()})
except OSError:
return self.error(f"测试点脚本 {name} 读取失败")
return self.success(scripts)