From 99ec8cdf025ead80b5842ee0c23fc246d4c8875e Mon Sep 17 00:00:00 2001
From: yuetsh <517252939@qq.com>
Date: Fri, 1 May 2026 08:24:30 -0600
Subject: [PATCH] add showcase manage
---
submission/api.py | 208 +++++++++++++++++++++++++++++++++++++++++-
submission/schemas.py | 51 +++++++++++
submission/tests.py | 177 ++++++++++++++++++++++++++++++++++-
3 files changed, 432 insertions(+), 4 deletions(-)
diff --git a/submission/api.py b/submission/api.py
index 8f08c23..37d424a 100644
--- a/submission/api.py
+++ b/submission/api.py
@@ -17,16 +17,23 @@ from django.db.models import (
Q,
Subquery,
)
+from account.decorators import admin_required
from prompt.models import Conversation, Message
from .schemas import (
+ AwardItemIn,
+ AwardItemManageOut,
+ AwardItemUpdateIn,
+ AwardManageIn,
+ AwardManageOut,
FlagIn,
FlagStats,
AwardOut,
PromptRoundOut,
ShowcaseDetailOut,
ShowcaseItemOut,
+ ShowcaseSubmissionLookupOut,
SubmissionCountBucket,
SubmissionFilter,
SubmissionIn,
@@ -45,6 +52,67 @@ from account.models import RoleChoices, User
router = Router()
+def _validate_item_ordering(value: str):
+ if value not in ItemOrdering.values:
+ raise HttpError(400, "无效的作品排序方式")
+
+
+def _award_manage_out(award: Award):
+ return {
+ "id": award.id,
+ "name": award.name,
+ "description": award.description,
+ "sort_order": award.sort_order,
+ "is_active": award.is_active,
+ "item_ordering": award.item_ordering,
+ "item_count": getattr(award, "item_count", None)
+ if getattr(award, "item_count", None) is not None
+ else award.submission_awards.count(),
+ }
+
+
+def _award_item_ordering(award: Award):
+ ordering_map = {
+ ItemOrdering.MANUAL: ("sort_order", "id"),
+ ItemOrdering.AWARDED_AT: ("-awarded_at", "sort_order", "id"),
+ ItemOrdering.SCORE: ("-submission__score", "sort_order", "id"),
+ ItemOrdering.VIEW_COUNT: ("-submission__view_count", "sort_order", "id"),
+ }
+ return ordering_map.get(award.item_ordering, ("sort_order", "id"))
+
+
+def _award_item_manage_out(item: SubmissionAward):
+ has_prompt_chain = getattr(item, "has_prompt_chain", None)
+ if has_prompt_chain is None:
+ has_prompt_chain = Message.objects.filter(
+ submission_id=item.submission_id
+ ).exists()
+ return {
+ "id": item.id,
+ "submission_id": item.submission_id,
+ "username": item.submission.user.username,
+ "task_title": item.submission.task.title,
+ "task_display": item.submission.task.display,
+ "score": item.submission.score,
+ "view_count": item.submission.view_count,
+ "sort_order": item.sort_order,
+ "awarded_at": item.awarded_at,
+ "has_prompt_chain": has_prompt_chain,
+ }
+
+
+def _showcase_submission_lookup_out(submission: Submission):
+ return {
+ "submission_id": submission.id,
+ "username": submission.user.username,
+ "task_title": submission.task.title,
+ "task_display": submission.task.display,
+ "score": submission.score,
+ "view_count": submission.view_count,
+ "has_prompt_chain": Message.objects.filter(submission=submission).exists(),
+ }
+
+
@router.post("/")
@login_required
def create_submission(request, payload: SubmissionIn):
@@ -457,10 +525,141 @@ def list_showcase(request):
return result
+@router.get("/showcase/manage/awards", response=List[AwardManageOut])
+@admin_required
+def list_manage_awards(request):
+ awards = Award.objects.annotate(
+ item_count=Count("submission_awards")
+ ).order_by("sort_order", "id")
+ return [_award_manage_out(award) for award in awards]
+
+
+@router.post("/showcase/manage/awards", response=AwardManageOut)
+@admin_required
+def create_manage_award(request, payload: AwardManageIn):
+ _validate_item_ordering(payload.item_ordering)
+ award = Award.objects.create(**payload.dict())
+ award.item_count = 0
+ return _award_manage_out(award)
+
+
+@router.put("/showcase/manage/awards/{award_id}", response=AwardManageOut)
+@admin_required
+def update_manage_award(request, award_id: int, payload: AwardManageIn):
+ _validate_item_ordering(payload.item_ordering)
+ award = get_object_or_404(Award, id=award_id)
+ award.name = payload.name
+ award.description = payload.description
+ award.sort_order = payload.sort_order
+ award.is_active = payload.is_active
+ award.item_ordering = payload.item_ordering
+ award.save(
+ update_fields=[
+ "name",
+ "description",
+ "sort_order",
+ "is_active",
+ "item_ordering",
+ ]
+ )
+ award.item_count = award.submission_awards.count()
+ return _award_manage_out(award)
+
+
+@router.delete("/showcase/manage/awards/{award_id}")
+@admin_required
+def delete_manage_award(request, award_id: int):
+ award = get_object_or_404(Award, id=award_id)
+ award.delete()
+ return {"message": "删除成功"}
+
+
+@router.get(
+ "/showcase/manage/submissions/{submission_id}",
+ response=ShowcaseSubmissionLookupOut,
+)
+@admin_required
+def get_manage_submission(request, submission_id: UUID):
+ submission = get_object_or_404(
+ Submission.objects.select_related("user", "task"),
+ id=submission_id,
+ )
+ return _showcase_submission_lookup_out(submission)
+
+
+@router.get(
+ "/showcase/manage/awards/{award_id}/items",
+ response=List[AwardItemManageOut],
+)
+@admin_required
+def list_manage_award_items(request, award_id: int):
+ award = get_object_or_404(Award, id=award_id)
+ items = (
+ SubmissionAward.objects.filter(award=award)
+ .select_related("submission", "submission__user", "submission__task")
+ .annotate(
+ has_prompt_chain=Exists(
+ Message.objects.filter(submission_id=OuterRef("submission_id"))
+ )
+ )
+ .order_by(*_award_item_ordering(award))
+ )
+ return [_award_item_manage_out(item) for item in items]
+
+
+@router.post(
+ "/showcase/manage/awards/{award_id}/items",
+ response=AwardItemManageOut,
+)
+@admin_required
+def create_manage_award_item(request, award_id: int, payload: AwardItemIn):
+ award = get_object_or_404(Award, id=award_id)
+ submission = get_object_or_404(
+ Submission.objects.select_related("user", "task"),
+ id=payload.submission_id,
+ )
+ item, created = SubmissionAward.objects.get_or_create(
+ award=award,
+ submission=submission,
+ defaults={"sort_order": payload.sort_order},
+ )
+ if not created:
+ raise HttpError(400, "该作品已在奖项中")
+ item.submission = submission
+ return _award_item_manage_out(item)
+
+
+@router.put("/showcase/manage/items/{item_id}", response=AwardItemManageOut)
+@admin_required
+def update_manage_award_item(request, item_id: int, payload: AwardItemUpdateIn):
+ item = get_object_or_404(
+ SubmissionAward.objects.select_related(
+ "submission",
+ "submission__user",
+ "submission__task",
+ ),
+ id=item_id,
+ )
+ item.sort_order = payload.sort_order
+ item.save(update_fields=["sort_order"])
+ return _award_item_manage_out(item)
+
+
+@router.delete("/showcase/manage/items/{item_id}")
+@admin_required
+def delete_manage_award_item(request, item_id: int):
+ item = get_object_or_404(SubmissionAward, id=item_id)
+ item.delete()
+ return {"message": "删除成功"}
+
+
@router.get("/showcase/{submission_id}/", response=ShowcaseDetailOut)
@login_required
def get_showcase_detail(request, submission_id: UUID):
- if not SubmissionAward.objects.filter(submission_id=submission_id).exists():
+ if not SubmissionAward.objects.filter(
+ submission_id=submission_id,
+ award__is_active=True,
+ ).exists():
raise HttpError(404, "作品不存在或未授奖")
sub = get_object_or_404(
@@ -470,6 +669,7 @@ def get_showcase_detail(request, submission_id: UUID):
has_chain = Message.objects.filter(submission=sub).exists()
award_names = list(
SubmissionAward.objects.filter(submission=sub)
+ .filter(award__is_active=True)
.select_related("award")
.values_list("award__name", flat=True)
)
@@ -529,7 +729,10 @@ def _build_prompt_rounds(source_msg: Message):
@router.get("/showcase/{submission_id}/prompt-chain/", response=List[PromptRoundOut])
@login_required
def get_showcase_prompt_chain(request, submission_id: UUID):
- if not SubmissionAward.objects.filter(submission_id=submission_id).exists():
+ if not SubmissionAward.objects.filter(
+ submission_id=submission_id,
+ award__is_active=True,
+ ).exists():
raise HttpError(404, "作品不存在或未授奖")
sub = get_object_or_404(Submission, id=submission_id)
@@ -624,4 +827,3 @@ def update_flag(request, submission_id: UUID, payload: FlagIn):
submission.flag = payload.flag
submission.save(update_fields=["flag"])
return {"flag": submission.flag}
-
diff --git a/submission/schemas.py b/submission/schemas.py
index 8285699..ac4aadb 100644
--- a/submission/schemas.py
+++ b/submission/schemas.py
@@ -1,3 +1,4 @@
+from datetime import datetime
from typing import Optional, Literal
from ninja import Schema
from uuid import UUID
@@ -180,6 +181,56 @@ class AwardOut(Schema):
items: list[ShowcaseItemOut]
+class AwardManageIn(Schema):
+ name: str
+ description: str = ""
+ sort_order: int = 0
+ is_active: bool = True
+ item_ordering: str = "manual"
+
+
+class AwardManageOut(Schema):
+ id: int
+ name: str
+ description: str
+ sort_order: int
+ is_active: bool
+ item_ordering: str
+ item_count: int
+
+
+class AwardItemIn(Schema):
+ submission_id: UUID
+ sort_order: int = 0
+
+
+class AwardItemUpdateIn(Schema):
+ sort_order: int = 0
+
+
+class ShowcaseSubmissionLookupOut(Schema):
+ submission_id: UUID
+ username: str
+ task_title: str
+ task_display: int
+ score: float
+ view_count: int
+ has_prompt_chain: bool
+
+
+class AwardItemManageOut(Schema):
+ id: int
+ submission_id: UUID
+ username: str
+ task_title: str
+ task_display: int
+ score: float
+ view_count: int
+ sort_order: int
+ awarded_at: datetime
+ has_prompt_chain: bool
+
+
class ShowcaseDetailOut(Schema):
submission_id: UUID
username: str
diff --git a/submission/tests.py b/submission/tests.py
index c882ecd..f5695ea 100644
--- a/submission/tests.py
+++ b/submission/tests.py
@@ -1,10 +1,11 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
+from account.models import RoleChoices
from prompt.models import Conversation, Message
from task.models import Task
-from .models import Submission
+from .models import Award, Submission, SubmissionAward
User = get_user_model()
@@ -74,3 +75,177 @@ class SubmissionPromptChainTest(TestCase):
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["question"], "author prompt")
self.assertEqual(data[0]["html"], "")
+
+
+class ShowcaseManagementApiTest(TestCase):
+ def setUp(self):
+ self.admin = _make_user("admin")
+ self.admin.role = RoleChoices.ADMIN
+ self.admin.save(update_fields=["role"])
+ self.student = _make_user("student")
+ self.task = _make_task()
+ self.award = Award.objects.create(name="最佳视觉", sort_order=10)
+ self.submission = Submission.objects.create(
+ user=self.student,
+ task=self.task,
+ html="work",
+ css="main { color: red; }",
+ js="",
+ score=4.5,
+ view_count=8,
+ )
+
+ def test_normal_user_cannot_access_management_api(self):
+ self.client.force_login(self.student)
+
+ resp = self.client.get("/api/submission/showcase/manage/awards")
+ lookup_resp = self.client.get(
+ f"/api/submission/showcase/manage/submissions/{self.submission.id}"
+ )
+
+ self.assertIn(resp.status_code, (302, 403))
+ self.assertIn(lookup_resp.status_code, (302, 403))
+
+ def test_admin_can_find_submission_by_id_for_showcase_management(self):
+ self.client.force_login(self.admin)
+
+ resp = self.client.get(
+ f"/api/submission/showcase/manage/submissions/{self.submission.id}"
+ )
+
+ self.assertEqual(resp.status_code, 200)
+ data = resp.json()
+ self.assertEqual(data["submission_id"], str(self.submission.id))
+ self.assertEqual(data["username"], "student")
+ self.assertEqual(data["task_title"], "Test Challenge")
+ self.assertEqual(data["task_display"], 1)
+ self.assertEqual(data["score"], 4.5)
+ self.assertEqual(data["view_count"], 8)
+ self.assertFalse(data["has_prompt_chain"])
+ self.assertNotIn("html", data)
+
+ def test_admin_can_create_and_update_award(self):
+ self.client.force_login(self.admin)
+
+ create_resp = self.client.post(
+ "/api/submission/showcase/manage/awards",
+ data={
+ "name": "最佳互动",
+ "description": "交互完整",
+ "sort_order": 3,
+ "is_active": True,
+ "item_ordering": "score",
+ },
+ content_type="application/json",
+ )
+ self.assertEqual(create_resp.status_code, 200)
+ created = create_resp.json()
+ self.assertEqual(created["name"], "最佳互动")
+ self.assertEqual(created["item_count"], 0)
+
+ update_resp = self.client.put(
+ f"/api/submission/showcase/manage/awards/{created['id']}",
+ data={
+ "name": "最佳交互",
+ "description": "操作体验完整",
+ "sort_order": 1,
+ "is_active": False,
+ "item_ordering": "view_count",
+ },
+ content_type="application/json",
+ )
+ self.assertEqual(update_resp.status_code, 200)
+ updated = update_resp.json()
+ self.assertEqual(updated["name"], "最佳交互")
+ self.assertEqual(updated["description"], "操作体验完整")
+ self.assertEqual(updated["sort_order"], 1)
+ self.assertFalse(updated["is_active"])
+ self.assertEqual(updated["item_ordering"], "view_count")
+
+ def test_admin_cannot_add_same_submission_twice(self):
+ self.client.force_login(self.admin)
+ payload = {"submission_id": str(self.submission.id), "sort_order": 2}
+
+ first_resp = self.client.post(
+ f"/api/submission/showcase/manage/awards/{self.award.id}/items",
+ data=payload,
+ content_type="application/json",
+ )
+ self.assertEqual(first_resp.status_code, 200)
+ self.assertEqual(first_resp.json()["submission_id"], str(self.submission.id))
+
+ duplicate_resp = self.client.post(
+ f"/api/submission/showcase/manage/awards/{self.award.id}/items",
+ data=payload,
+ content_type="application/json",
+ )
+
+ self.assertEqual(duplicate_resp.status_code, 400)
+ self.assertEqual(
+ SubmissionAward.objects.filter(
+ award=self.award,
+ submission=self.submission,
+ ).count(),
+ 1,
+ )
+
+ def test_public_showcase_hides_removed_or_inactive_items(self):
+ self.client.force_login(self.admin)
+ add_resp = self.client.post(
+ f"/api/submission/showcase/manage/awards/{self.award.id}/items",
+ data={"submission_id": str(self.submission.id), "sort_order": 0},
+ content_type="application/json",
+ )
+ item_id = add_resp.json()["id"]
+
+ self.client.force_login(self.student)
+ visible_resp = self.client.get("/api/submission/showcase/")
+ self.assertEqual(visible_resp.status_code, 200)
+ self.assertEqual(len(visible_resp.json()), 1)
+ detail_resp = self.client.get(
+ f"/api/submission/showcase/{self.submission.id}/"
+ )
+ self.assertEqual(detail_resp.status_code, 200)
+
+ self.client.force_login(self.admin)
+ delete_resp = self.client.delete(
+ f"/api/submission/showcase/manage/items/{item_id}"
+ )
+ self.assertEqual(delete_resp.status_code, 200)
+
+ self.client.force_login(self.student)
+ removed_resp = self.client.get("/api/submission/showcase/")
+ self.assertEqual(removed_resp.status_code, 200)
+ self.assertEqual(removed_resp.json(), [])
+ removed_detail_resp = self.client.get(
+ f"/api/submission/showcase/{self.submission.id}/"
+ )
+ self.assertEqual(removed_detail_resp.status_code, 404)
+
+ self.client.force_login(self.admin)
+ self.client.post(
+ f"/api/submission/showcase/manage/awards/{self.award.id}/items",
+ data={"submission_id": str(self.submission.id), "sort_order": 0},
+ content_type="application/json",
+ )
+ deactivate_resp = self.client.put(
+ f"/api/submission/showcase/manage/awards/{self.award.id}",
+ data={
+ "name": self.award.name,
+ "description": self.award.description,
+ "sort_order": self.award.sort_order,
+ "is_active": False,
+ "item_ordering": self.award.item_ordering,
+ },
+ content_type="application/json",
+ )
+ self.assertEqual(deactivate_resp.status_code, 200)
+
+ self.client.force_login(self.student)
+ inactive_resp = self.client.get("/api/submission/showcase/")
+ self.assertEqual(inactive_resp.status_code, 200)
+ self.assertEqual(inactive_resp.json(), [])
+ inactive_detail_resp = self.client.get(
+ f"/api/submission/showcase/{self.submission.id}/"
+ )
+ self.assertEqual(inactive_detail_resp.status_code, 404)