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)