Files
OnlineJudge/problemset/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

405 lines
15 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.
from django.db.models import Count, Q
from account.decorators import ensure_created_by, teacher_admin_required
from problem.models import Problem
from problemset.models import (
ProblemSet,
ProblemSetBadge,
ProblemSetProblem,
ProblemSetProgress,
UserBadge,
)
from problemset.serializers import (
AddProblemToSetSerializer,
CreateProblemSetBadgeSerializer,
CreateProblemSetSerializer,
EditProblemInSetSerializer,
EditProblemSetBadgeSerializer,
EditProblemSetSerializer,
ProblemSetBadgeSerializer,
ProblemSetListSerializer,
ProblemSetProblemSerializer,
ProblemSetProgressSerializer,
ProblemSetSerializer,
ProblemSetUpdateStatusSerializer,
ProblemSetVisibleSerializer,
)
from utils.api import APIView, validate_serializer
class ProblemSetAdminAPI(APIView):
"""题单管理API"""
@teacher_admin_required
def get(self, request):
"""获取题单列表(管理员)"""
# 后台列表**不能**过滤 visible题单列表里有可见性开关一旦关掉
# 题单就从这个列表里消失,教师再也找不到它、也就无法在界面上打开回来。
# 前台列表views/oj.py该过滤后台不该。
problem_sets = ProblemSet.objects.annotate(problems_count=Count("problemsetproblem", distinct=True)).order_by("-create_time")
if not request.user.is_super_admin():
problem_sets = problem_sets.filter(created_by=request.user)
# 过滤条件
keyword = request.GET.get("keyword", "").strip()
if keyword:
problem_sets = problem_sets.filter(Q(title__icontains=keyword) | Q(description__icontains=keyword))
difficulty = request.GET.get("difficulty")
if difficulty:
problem_sets = problem_sets.filter(difficulty=difficulty)
status = request.GET.get("status")
if status:
problem_sets = problem_sets.filter(status=status)
# 使用统一的分页方法
data = self.paginate_data(request, problem_sets, ProblemSetListSerializer)
return self.success(data)
@teacher_admin_required
@validate_serializer(CreateProblemSetSerializer)
def post(self, request):
"""创建题单"""
data = request.data
data["created_by"] = request.user
problem_set = ProblemSet.objects.create(**data)
return self.success(ProblemSetSerializer(problem_set).data)
@teacher_admin_required
@validate_serializer(EditProblemSetSerializer)
def put(self, request):
"""编辑题单"""
data = request.data
problem_set_id = data.pop("id")
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
for key, value in data.items():
setattr(problem_set, key, value)
problem_set.save()
return self.success(ProblemSetSerializer(problem_set).data)
@teacher_admin_required
def delete(self, request):
"""删除题单"""
problem_set_id = request.GET.get("id")
if not problem_set_id:
return self.error("题单ID是必需的")
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
problem_set.delete()
return self.success("题单已删除")
class ProblemSetDetailAdminAPI(APIView):
"""题单详情管理API"""
@teacher_admin_required
def get(self, request, problem_set_id):
"""获取题单详情(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
serializer = ProblemSetSerializer(problem_set, context={"request": request})
return self.success(serializer.data)
class ProblemSetProblemAdminAPI(APIView):
"""题单题目管理API管理员"""
@teacher_admin_required
def get(self, request, problem_set_id):
"""获取题单中的题目列表(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
problems = ProblemSetProblem.objects.filter(problemset=problem_set).order_by("order")
serializer = ProblemSetProblemSerializer(problems, many=True, context={"request": request})
return self.success(serializer.data)
@teacher_admin_required
@validate_serializer(AddProblemToSetSerializer)
def post(self, request, problem_set_id):
"""添加题目到题单(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
data = request.data
try:
problem = Problem.objects.filter(
_id__iexact=data["problem_id"],
visible=True,
contest_id__isnull=True,
).get()
except Problem.DoesNotExist:
return self.error("题目不存在或不可见")
# 检查题目是否已经在题单中
if ProblemSetProblem.objects.filter(problemset=problem_set, problem=problem).exists():
return self.error("题目已在该题单中")
ProblemSetProblem.objects.create(
problemset=problem_set,
problem=problem,
order=data.get("order", 0),
is_required=data.get("is_required", True),
score=data.get("score", 0),
hint=data.get("hint", ""),
)
# 题目数就是进度的分母,加完必须重算:不重算的话已加入的学生进度百分比
# 全是虚高的,而且原本标成"已完成"的人会一直是已完成。
# 手动同步接口ProblemSetSyncAPI前端没有调用点所以只能在这里做。
ProblemSetProgress.sync_all_progress_for_problemset(problem_set)
return self.success("题目已添加到题单")
@teacher_admin_required
@validate_serializer(EditProblemInSetSerializer)
def put(self, request, problem_set_id, problem_set_problem_id):
"""编辑题单中的题目(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
try:
problem_set_problem = ProblemSetProblem.objects.get(id=problem_set_problem_id, problemset=problem_set)
except ProblemSetProblem.DoesNotExist:
return self.error("题目不在该题单中")
data = request.data
# 更新题目属性
if "order" in data:
problem_set_problem.order = data["order"]
if "is_required" in data:
problem_set_problem.is_required = data["is_required"]
if "score" in data:
problem_set_problem.score = data["score"]
if "hint" in data:
problem_set_problem.hint = data["hint"]
problem_set_problem.save()
return self.success("题目已更新")
@teacher_admin_required
def delete(self, request, problem_set_id, problem_set_problem_id):
"""从题单中移除题目(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
try:
problem_set_problem = ProblemSetProblem.objects.get(id=problem_set_problem_id, problemset=problem_set)
problem_set_problem.delete()
# 同上:分母变了,进度要重算
ProblemSetProgress.sync_all_progress_for_problemset(problem_set)
return self.success("题目已从题单中移除")
except ProblemSetProblem.DoesNotExist:
return self.error("题目不在该题单中")
class ProblemSetBadgeAdminAPI(APIView):
"""题单奖章管理API管理员"""
@teacher_admin_required
def get(self, request, problem_set_id):
"""获取题单的奖章列表(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
badges = ProblemSetBadge.objects.filter(problemset=problem_set)
serializer = ProblemSetBadgeSerializer(badges, many=True)
return self.success(serializer.data)
@teacher_admin_required
@validate_serializer(CreateProblemSetBadgeSerializer)
def post(self, request, problem_set_id):
"""创建题单奖章(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
data = request.data
data["problemset"] = problem_set
badge = ProblemSetBadge.objects.create(**data)
return self.success(ProblemSetBadgeSerializer(badge).data)
@teacher_admin_required
@validate_serializer(EditProblemSetBadgeSerializer)
def put(self, request, problem_set_id, badge_id):
"""编辑题单奖章(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
try:
badge = ProblemSetBadge.objects.get(id=badge_id, problemset=problem_set)
except ProblemSetBadge.DoesNotExist:
return self.error("奖章不存在")
data = request.data
# 记录是否修改了条件相关的字段
condition_changed = False
# 更新奖章属性
if "name" in data:
badge.name = data["name"]
if "description" in data:
badge.description = data["description"]
if "icon" in data:
badge.icon = data["icon"]
if "condition_type" in data:
badge.condition_type = data["condition_type"]
condition_changed = True
if "condition_value" in data:
badge.condition_value = data["condition_value"]
condition_changed = True
badge.save() # post_save 信号自动触发 recalculate_user_badges
if condition_changed:
return self.success("奖章已更新,并重新计算了所有用户的徽章资格")
return self.success("奖章已更新")
@teacher_admin_required
def delete(self, request, problem_set_id, badge_id):
"""删除题单奖章(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
try:
badge = ProblemSetBadge.objects.get(id=badge_id, problemset=problem_set)
badge.delete()
return self.success("奖章已删除")
except ProblemSetBadge.DoesNotExist:
return self.error("奖章不存在")
class ProblemSetProgressAdminAPI(APIView):
"""题单进度管理API管理员"""
@teacher_admin_required
def get(self, request, problem_set_id):
"""获取题单的所有用户进度(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
progress_list = ProblemSetProgress.objects.filter(problemset=problem_set).order_by("-join_time")
serializer = ProblemSetProgressSerializer(progress_list, many=True)
return self.success(serializer.data)
@teacher_admin_required
def delete(self, request, problem_set_id, user_id):
"""移除用户从题单(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
try:
progress = ProblemSetProgress.objects.get(problemset=problem_set, user_id=user_id)
progress.delete()
# 奖章是靠这个题单挣的,人移出去了奖章却留着 —— 学生个人页上会一直挂着
# 一个自己已经不在的题单的奖章,而且重新加入时会因唯一约束拿不到第二次。
UserBadge.objects.filter(user_id=user_id, badge__problemset=problem_set).delete()
return self.success("用户已从题单中移除")
except ProblemSetProgress.DoesNotExist:
return self.error("用户未加入该题单")
# DEPRECATED: 前端未调用 (2026-05-26)
class ProblemSetSyncAPI(APIView):
"""题单同步管理API"""
@teacher_admin_required
def post(self, request, problem_set_id):
"""手动同步题单的所有用户进度(管理员)"""
try:
problem_set = ProblemSet.objects.get(id=problem_set_id)
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
# 同步所有用户的进度
synced_count = ProblemSetProgress.sync_all_progress_for_problemset(problem_set)
return self.success(f"已同步 {synced_count} 个用户的进度")
class ProblemSetVisibleAPI(APIView):
"""题单可见性管理API"""
@teacher_admin_required
@validate_serializer(ProblemSetVisibleSerializer)
def put(self, request):
"""切换题单可见性"""
data = request.data
try:
problem_set = ProblemSet.objects.get(id=data["id"])
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
problem_set.visible = not problem_set.visible
problem_set.save()
return self.success()
class ProblemSetStatusAPI(APIView):
"""题单状态管理API"""
@teacher_admin_required
@validate_serializer(ProblemSetUpdateStatusSerializer)
def put(self, request):
"""更新题单状态"""
data = request.data
try:
problem_set = ProblemSet.objects.get(id=data["id"])
ensure_created_by(problem_set, request.user)
except ProblemSet.DoesNotExist:
return self.error("题单不存在")
problem_set.status = data["status"]
problem_set.save()
return self.success()