add showcase manage

This commit is contained in:
2026-05-01 08:24:30 -06:00
parent 17948a69f3
commit 99ec8cdf02
3 changed files with 432 additions and 4 deletions

View File

@@ -17,16 +17,23 @@ from django.db.models import (
Q, Q,
Subquery, Subquery,
) )
from account.decorators import admin_required
from prompt.models import Conversation, Message from prompt.models import Conversation, Message
from .schemas import ( from .schemas import (
AwardItemIn,
AwardItemManageOut,
AwardItemUpdateIn,
AwardManageIn,
AwardManageOut,
FlagIn, FlagIn,
FlagStats, FlagStats,
AwardOut, AwardOut,
PromptRoundOut, PromptRoundOut,
ShowcaseDetailOut, ShowcaseDetailOut,
ShowcaseItemOut, ShowcaseItemOut,
ShowcaseSubmissionLookupOut,
SubmissionCountBucket, SubmissionCountBucket,
SubmissionFilter, SubmissionFilter,
SubmissionIn, SubmissionIn,
@@ -45,6 +52,67 @@ from account.models import RoleChoices, User
router = Router() 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("/") @router.post("/")
@login_required @login_required
def create_submission(request, payload: SubmissionIn): def create_submission(request, payload: SubmissionIn):
@@ -457,10 +525,141 @@ def list_showcase(request):
return result 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) @router.get("/showcase/{submission_id}/", response=ShowcaseDetailOut)
@login_required @login_required
def get_showcase_detail(request, submission_id: UUID): 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, "作品不存在或未授奖") raise HttpError(404, "作品不存在或未授奖")
sub = get_object_or_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() has_chain = Message.objects.filter(submission=sub).exists()
award_names = list( award_names = list(
SubmissionAward.objects.filter(submission=sub) SubmissionAward.objects.filter(submission=sub)
.filter(award__is_active=True)
.select_related("award") .select_related("award")
.values_list("award__name", flat=True) .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]) @router.get("/showcase/{submission_id}/prompt-chain/", response=List[PromptRoundOut])
@login_required @login_required
def get_showcase_prompt_chain(request, submission_id: UUID): 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, "作品不存在或未授奖") raise HttpError(404, "作品不存在或未授奖")
sub = get_object_or_404(Submission, id=submission_id) 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.flag = payload.flag
submission.save(update_fields=["flag"]) submission.save(update_fields=["flag"])
return {"flag": submission.flag} return {"flag": submission.flag}

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional, Literal from typing import Optional, Literal
from ninja import Schema from ninja import Schema
from uuid import UUID from uuid import UUID
@@ -180,6 +181,56 @@ class AwardOut(Schema):
items: list[ShowcaseItemOut] 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): class ShowcaseDetailOut(Schema):
submission_id: UUID submission_id: UUID
username: str username: str

View File

@@ -1,10 +1,11 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from account.models import RoleChoices
from prompt.models import Conversation, Message from prompt.models import Conversation, Message
from task.models import Task from task.models import Task
from .models import Submission from .models import Award, Submission, SubmissionAward
User = get_user_model() User = get_user_model()
@@ -74,3 +75,177 @@ class SubmissionPromptChainTest(TestCase):
self.assertEqual(len(data), 1) self.assertEqual(len(data), 1)
self.assertEqual(data[0]["question"], "author prompt") self.assertEqual(data[0]["question"], "author prompt")
self.assertEqual(data[0]["html"], "<button>author</button>") self.assertEqual(data[0]["html"], "<button>author</button>")
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="<main>work</main>",
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)