feat(problem): 新增标签管理接口

列表带题目数,重命名撞名时自动合并并返回受影响题数,删除后清标签缓存。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 04:18:41 -06:00
parent ec7cf71ea7
commit c775ae4b22
3 changed files with 74 additions and 2 deletions

View File

@@ -109,6 +109,19 @@ class TagSerializer(serializers.ModelSerializer):
fields = "__all__" fields = "__all__"
class TagAdminSerializer(serializers.ModelSerializer):
problem_count = serializers.IntegerField(read_only=True)
class Meta:
model = ProblemTag
fields = ["id", "name", "problem_count"]
class EditTagSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField(max_length=32)
class BaseProblemSerializer(serializers.ModelSerializer): class BaseProblemSerializer(serializers.ModelSerializer):
tags = serializers.SlugRelatedField(many=True, slug_field="name", read_only=True) tags = serializers.SlugRelatedField(many=True, slug_field="name", read_only=True)
created_by = UsernameSerializer() created_by = UsernameSerializer()

View File

@@ -11,6 +11,7 @@ from ..views.admin import (
SQLTestCasePreviewAPI, SQLTestCasePreviewAPI,
SQLTestCaseScriptsAPI, SQLTestCaseScriptsAPI,
StuckProblemsAPI, StuckProblemsAPI,
TagAdminAPI,
TestCaseAPI, TestCaseAPI,
TopACTrendAPI, TopACTrendAPI,
) )
@@ -25,6 +26,7 @@ urlpatterns = [
path("problem/stuck", StuckProblemsAPI.as_view()), path("problem/stuck", StuckProblemsAPI.as_view()),
path("problem/top_ac_trend", TopACTrendAPI.as_view()), path("problem/top_ac_trend", TopACTrendAPI.as_view()),
path("problem/flowchart", ProblemFlowchartAIGen.as_view()), path("problem/flowchart", ProblemFlowchartAIGen.as_view()),
path("problem/tag", TagAdminAPI.as_view()),
path("contest/problem", ContestProblemAPI.as_view()), path("contest/problem", ContestProblemAPI.as_view()),
path("contest_problem/make_public", MakeContestProblemPublicAPIView.as_view()), path("contest_problem/make_public", MakeContestProblemPublicAPIView.as_view()),
path("contest/add_problem_from_public", AddContestProblemAPI.as_view()), path("contest/add_problem_from_public", AddContestProblemAPI.as_view()),

View File

@@ -19,7 +19,7 @@ from utils.api import APIError, APIView, CSRFExemptAPIView, validate_serializer
from utils.openai import get_ai_client from utils.openai import get_ai_client
from utils.shortcuts import natural_sort_key, rand_str from utils.shortcuts import natural_sort_key, rand_str
from ..models import Problem from ..models import Problem, ProblemTag
from ..serializers import ( from ..serializers import (
AddContestProblemSerializer, AddContestProblemSerializer,
ContestProblemMakePublicSerializer, ContestProblemMakePublicSerializer,
@@ -27,12 +27,14 @@ from ..serializers import (
CreateProblemSerializer, CreateProblemSerializer,
EditContestProblemSerializer, EditContestProblemSerializer,
EditProblemSerializer, EditProblemSerializer,
EditTagSerializer,
ProblemAdminListSerializer, ProblemAdminListSerializer,
ProblemAdminSerializer, ProblemAdminSerializer,
SQLTestCasePreviewSerializer, SQLTestCasePreviewSerializer,
TagAdminSerializer,
TestCaseUploadForm, TestCaseUploadForm,
) )
from ..services import resolve_tags from ..services import clear_tag_cache, resolve_tags
from ..utils import generate_sql_display from ..utils import generate_sql_display
@@ -497,6 +499,61 @@ class AddContestProblemAPI(APIView):
return self.success() 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): class ProblemVisibleAPI(APIView):
@problem_permission_required @problem_permission_required
def put(self, request): def put(self, request):