Files
OnlineJudge/contest/views/admin.py
yuetsh 4ebcecfab4 fix: 修掉重写期间在旧后端发现的 6 个问题
这些是做 Bun 重写时逐行对照发现的,旧后端现在还在线上跑,学生今天就在踩。
每条都在本地起 Django + postgres 实跑验证过,**正反两面都验**:越权要挡住、
正常操作要还能用。合计 14 条断言全过。

## 越权(跨教师)

**ACMContestHelper.put** — 只按 pk 取 rank,任何老师都能改**别人比赛**里的
检查标记。而 `contest_id` 明明在 ACMContesHelperSerializer 里、客户端一直在传,
视图完全没用它。改成按 (pk, contest_id) 取 + ensure_created_by。
同类的 get 本来就有 ensure_created_by,是 put 漏了。

**AddContestProblemAPI.post** — 一个权限装饰器都没有,导致两件事:
  - 少 problem_permission_required:problem_permission=None 的学生管理员也能建题
  - 少 ensure_created_by:任何管理员都能往**别人的比赛**里塞题
另外源题只按 id 取,能把别人比赛里的赛题(或别人未公开的草稿)拷进自己的比赛,
连 answers 参考答案一起拿到。改成源题必须是公开题库题,不可见的要求归属。
(中间件 AdminRoleRequiredMiddleware 只保证"是管理员身份",挡不住这些。)

## 会 500

**ProblemVisibleAPI.put** — `self.error(...)` 漏写 return,题不存在时继续执行到
未赋值的 problem,抛 UnboundLocalError → 500。

## 数据正确性

**ProblemSetAdminAPI.get** — 后台列表写死 filter(visible=True),而列表里就有
可见性开关:一旦关掉,题单从后台消失,教师再也找不到、也无法在界面上打开回来。
后台列表不该过滤 visible,前台该。

**增删题单题目不重算进度** — 题目数是进度的分母。不重算的话学生进度百分比全是
虚高的,原本标"已完成"的人会一直是已完成。更麻烦的是那个手动同步接口
(ProblemSetSyncAPI)前端没有调用点,等于**界面上根本没办法修**。
改成增删之后直接 sync_all_progress_for_problemset。

**移除用户留下孤儿奖章** — 只删了 ProblemSetProgress,UserBadge 还在。学生个人页
会一直挂着一个自己已不在的题单的奖章,而且重新加入时因唯一约束拿不到第二次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 06:50:47 -06:00

306 lines
12 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 copy
import os
import zipfile
from datetime import datetime, timedelta
from ipaddress import ip_network
from django.http import FileResponse
from django.utils.timezone import now
from account.decorators import ensure_created_by, super_admin_required, teacher_admin_required
from account.models import User
from problem.models import Problem
from submission.models import JudgeStatus, Submission
from utils.api import APIView, validate_serializer
from utils.shortcuts import rand_str
from utils.tasks import delete_files
from ..models import ACMContestRank, Contest, ContestAnnouncement
from ..serializers import (
ACMContesHelperSerializer,
ContestAdminSerializer,
ContestAnnouncementSerializer,
ContestCloneSerializer,
CreateConetestSeriaizer,
CreateContestAnnouncementSerializer,
EditConetestSeriaizer,
EditContestAnnouncementSerializer,
)
class ContestAPI(APIView):
@validate_serializer(CreateConetestSeriaizer)
@teacher_admin_required
def post(self, request):
data = request.data
# DRF 的 DateTimeField 已经校验并规范化过,这里拿到的必定是 ISO 8601 字符串
data["start_time"] = datetime.fromisoformat(data["start_time"])
data["end_time"] = datetime.fromisoformat(data["end_time"])
data["created_by"] = request.user
if data["end_time"] <= data["start_time"]:
return self.error("Start time must occur earlier than end time")
if data.get("password") and data["password"] == "":
data["password"] = None
for ip_range in data["allowed_ip_ranges"]:
try:
ip_network(ip_range, strict=False)
except ValueError:
return self.error(f"{ip_range} is not a valid cidr network")
contest = Contest.objects.create(**data)
return self.success(ContestAdminSerializer(contest).data)
@validate_serializer(EditConetestSeriaizer)
@teacher_admin_required
def put(self, request):
data = request.data
try:
contest = Contest.objects.get(id=data.pop("id"))
except Contest.DoesNotExist:
return self.error("Contest does not exist")
ensure_created_by(contest, request.user)
# DRF 的 DateTimeField 已经校验并规范化过,这里拿到的必定是 ISO 8601 字符串
data["start_time"] = datetime.fromisoformat(data["start_time"])
data["end_time"] = datetime.fromisoformat(data["end_time"])
if data["end_time"] <= data["start_time"]:
return self.error("Start time must occur earlier than end time")
if not data["password"]:
data["password"] = None
for ip_range in data["allowed_ip_ranges"]:
try:
ip_network(ip_range, strict=False)
except ValueError:
return self.error(f"{ip_range} is not a valid cidr network")
for k, v in data.items():
setattr(contest, k, v)
contest.save()
return self.success(ContestAdminSerializer(contest).data)
@teacher_admin_required
def get(self, request):
contest_id = request.GET.get("id")
if contest_id:
try:
contest = Contest.objects.get(id=contest_id)
ensure_created_by(contest, request.user)
return self.success(ContestAdminSerializer(contest).data)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
contests = Contest.objects.all().order_by("-create_time")
if not request.user.is_super_admin():
contests = contests.filter(created_by=request.user)
keyword = request.GET.get("keyword")
if keyword:
contests = contests.filter(title__contains=keyword)
return self.success(self.paginate_data(request, contests, ContestAdminSerializer))
# DEPRECATED: 前端未调用 (2026-05-26)
class ContestAnnouncementAPI(APIView):
@validate_serializer(CreateContestAnnouncementSerializer)
@super_admin_required
def post(self, request):
"""
Create one contest_announcement.
"""
data = request.data
try:
contest = Contest.objects.get(id=data.pop("contest_id"))
data["contest"] = contest
data["created_by"] = request.user
except Contest.DoesNotExist:
return self.error("Contest does not exist")
announcement = ContestAnnouncement.objects.create(**data)
return self.success(ContestAnnouncementSerializer(announcement).data)
@validate_serializer(EditContestAnnouncementSerializer)
@super_admin_required
def put(self, request):
"""
update contest_announcement
"""
data = request.data
try:
contest_announcement = ContestAnnouncement.objects.get(id=data.pop("id"))
except ContestAnnouncement.DoesNotExist:
return self.error("Contest announcement does not exist")
for k, v in data.items():
setattr(contest_announcement, k, v)
contest_announcement.save()
return self.success()
@super_admin_required
def delete(self, request):
"""
Delete one contest_announcement.
"""
contest_announcement_id = request.GET.get("id")
if contest_announcement_id:
ContestAnnouncement.objects.filter(id=contest_announcement_id).delete()
return self.success()
@super_admin_required
def get(self, request):
"""
Get one contest_announcement or contest_announcement list.
"""
contest_announcement_id = request.GET.get("id")
if contest_announcement_id:
try:
contest_announcement = ContestAnnouncement.objects.get(id=contest_announcement_id)
return self.success(ContestAnnouncementSerializer(contest_announcement).data)
except ContestAnnouncement.DoesNotExist:
return self.error("Contest announcement does not exist")
contest_id = request.GET.get("contest_id")
if not contest_id:
return self.error("Parameter error")
contest_announcements = ContestAnnouncement.objects.filter(contest_id=contest_id)
keyword = request.GET.get("keyword")
if keyword:
contest_announcements = contest_announcements.filter(title__contains=keyword)
return self.success(ContestAnnouncementSerializer(contest_announcements, many=True).data)
class ACMContestHelper(APIView):
@teacher_admin_required
def get(self, request):
contest_id = request.GET.get("contest_id")
if not contest_id:
return self.error("Parameter error, contest_id is required")
try:
contest = Contest.objects.get(id=contest_id, visible=True)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
ensure_created_by(contest, request.user)
problems = Problem.objects.filter(contest=contest).values("id", "_id")
problem_id_map = {str(p["id"]): p["_id"] for p in problems}
ranks = ACMContestRank.objects.filter(contest=contest, accepted_number__gt=0).values("id", "user__username", "user__userprofile__real_name", "submission_info")
results = []
for rank in ranks:
for problem_id, info in rank["submission_info"].items():
if info["is_ac"]:
results.append(
{
"id": rank["id"],
"username": rank["user__username"],
"real_name": rank["user__userprofile__real_name"],
"problem_id": problem_id,
"problem_display_id": problem_id_map.get(problem_id, problem_id),
"ac_info": info,
"checked": info.get("checked", False),
}
)
results.sort(key=lambda x: -x["ac_info"]["ac_time"])
return self.success(results)
@teacher_admin_required
@validate_serializer(ACMContesHelperSerializer)
def put(self, request):
data = request.data
# 原来只按 pk 取contest_id 明明在序列化器里、客户端一直在传,却完全没用上。
# 于是任何老师都能改别人比赛里的检查标记 —— teacher_admin_required 只保证
# "是老师"。同类的 get 是有 ensure_created_by 的,这里补齐。
try:
rank = ACMContestRank.objects.get(pk=data["rank_id"], contest_id=data["contest_id"])
except ACMContestRank.DoesNotExist:
return self.error("Rank id does not exist")
ensure_created_by(rank.contest, request.user)
problem_rank_status = rank.submission_info.get(data["problem_id"])
if not problem_rank_status:
return self.error("Problem id does not exist")
problem_rank_status["checked"] = data["checked"]
rank.save(update_fields=("submission_info",))
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class DownloadContestSubmissions(APIView):
def _dump_submissions(self, contest, exclude_admin=True):
problem_ids = contest.problem_set.all().values_list("id", "_id")
id2display_id = {k[0]: k[1] for k in problem_ids}
ac_map = {k[0]: False for k in problem_ids}
submissions = Submission.objects.filter(contest=contest, result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]).order_by("-create_time")
user_ids = submissions.values_list("user_id", flat=True)
users = User.objects.filter(id__in=user_ids)
path = f"/tmp/{rand_str()}.zip"
with zipfile.ZipFile(path, "w") as zip_file:
for user in users:
if user.is_admin_role() and exclude_admin:
continue
user_ac_map = copy.deepcopy(ac_map)
user_submissions = submissions.filter(user_id=user.id)
for submission in user_submissions:
problem_id = submission.problem_id
if user_ac_map[problem_id]:
continue
file_name = f"{user.username}_{id2display_id[submission.problem_id]}.txt"
compression = zipfile.ZIP_DEFLATED
zip_file.writestr(
zinfo_or_arcname=f"{file_name}",
data=submission.code,
compress_type=compression,
)
user_ac_map[problem_id] = True
return path
@super_admin_required
def get(self, request):
contest_id = request.GET.get("contest_id")
if not contest_id:
return self.error("Parameter error")
try:
contest = Contest.objects.get(id=contest_id)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
exclude_admin = request.GET.get("exclude_admin") == "1"
zip_path = self._dump_submissions(contest, exclude_admin)
delete_files.send_with_options(args=(zip_path,), delay=300_000)
resp = FileResponse(open(zip_path, "rb"))
resp["Content-Type"] = "application/zip"
resp["Content-Disposition"] = f"attachment;filename={os.path.basename(zip_path)}"
return resp
class ContestCloneAPI(APIView):
@validate_serializer(ContestCloneSerializer)
@teacher_admin_required
def post(self, request):
try:
original = Contest.objects.get(id=request.data["contest_id"])
except Contest.DoesNotExist:
return self.error("Contest does not exist")
duration = original.end_time - original.start_time
new_start = now() + timedelta(minutes=10)
new_end = new_start + duration
new_contest = Contest.objects.create(
title=original.title,
description=original.description,
tag=original.tag,
password=original.password,
visible=False,
allowed_ip_ranges=original.allowed_ip_ranges,
start_time=new_start,
end_time=new_end,
created_by=request.user,
)
for problem in Problem.objects.filter(contest=original):
tags = problem.tags.all()
problem.pk = None
problem.contest = new_contest
problem.submission_number = 0
problem.accepted_number = 0
problem.statistic_info = {}
problem.created_by = request.user
problem.save()
problem.tags.set(tags)
return self.success(ContestAdminSerializer(new_contest).data)