diff --git a/submission/admin.py b/submission/admin.py index 8c38f3f..583e429 100644 --- a/submission/admin.py +++ b/submission/admin.py @@ -1,3 +1,51 @@ from django.contrib import admin -# Register your models here. +from .models import Award, SubmissionAward + + +@admin.register(Award) +class AwardAdmin(admin.ModelAdmin): + list_display = ("name", "sort_order", "is_active", "item_ordering", "created") + list_filter = ("is_active", "item_ordering") + search_fields = ("name",) + ordering = ("sort_order",) + + +@admin.register(SubmissionAward) +class SubmissionAwardAdmin(admin.ModelAdmin): + list_display = ( + "award_name", + "submission_username", + "submission_task_title", + "submission_score", + "submission_view_count", + "sort_order", + "awarded_at", + ) + list_filter = ("award", "submission__task", "submission__user__classname") + search_fields = ( + "award__name", + "submission__user__username", + "submission__task__title", + ) + raw_id_fields = ("submission",) + + @admin.display(description="奖项") + def award_name(self, obj): + return obj.award.name + + @admin.display(description="提交作者") + def submission_username(self, obj): + return obj.submission.user.username + + @admin.display(description="挑战标题") + def submission_task_title(self, obj): + return obj.submission.task.title + + @admin.display(description="评分") + def submission_score(self, obj): + return obj.submission.score + + @admin.display(description="浏览量") + def submission_view_count(self, obj): + return obj.submission.view_count diff --git a/submission/api.py b/submission/api.py index 6ec9d45..b8190a0 100644 --- a/submission/api.py +++ b/submission/api.py @@ -6,13 +6,27 @@ from ninja.errors import HttpError from ninja.pagination import paginate from django.shortcuts import get_object_or_404 from django.contrib.auth.decorators import login_required -from django.db.models import Avg, Count, F, IntegerField, Max, OuterRef, Q, Subquery +from django.db.models import ( + Avg, + Count, + Exists, + F, + IntegerField, + Max, + OuterRef, + Q, + Subquery, +) from prompt.models import Conversation, Message from .schemas import ( FlagIn, FlagStats, + AwardOut, + PromptRoundOut, + ShowcaseDetailOut, + ShowcaseItemOut, SubmissionCountBucket, SubmissionFilter, SubmissionIn, @@ -24,7 +38,7 @@ from .schemas import ( ) -from .models import Rating, Submission +from .models import Award, ItemOrdering, Rating, Submission, SubmissionAward from task.models import Task from account.models import RoleChoices, User @@ -389,6 +403,132 @@ def get_task_stats(request, task_id: int, classname: Optional[str] = None): ) +@router.get("/showcase/", response=List[AwardOut]) +@login_required +def list_showcase(request): + ordering_map = { + ItemOrdering.MANUAL: "sort_order", + ItemOrdering.AWARDED_AT: "-awarded_at", + ItemOrdering.SCORE: "-submission__score", + ItemOrdering.VIEW_COUNT: "-submission__view_count", + } + awards = Award.objects.filter(is_active=True).order_by("sort_order") + result = [] + + for award in awards: + order_field = ordering_map.get(award.item_ordering, "sort_order") + items_qs = ( + 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(order_field) + ) + items = list(items_qs) + if not items: + continue + result.append( + { + "id": award.id, + "name": award.name, + "description": award.description, + "item_ordering": award.item_ordering, + "items": [ + { + "submission_id": sa.submission_id, + "username": sa.submission.user.username, + "task_title": sa.submission.task.title, + "task_display": sa.submission.task.display, + "score": sa.submission.score, + "view_count": sa.submission.view_count, + "html": sa.submission.html, + "css": sa.submission.css, + "js": sa.submission.js, + "has_prompt_chain": sa.has_prompt_chain, + } + for sa in items + ], + } + ) + + return result + + +@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(): + raise HttpError(404, "作品不存在或未授奖") + + sub = get_object_or_404( + Submission.objects.select_related("user", "task"), + id=submission_id, + ) + has_chain = Message.objects.filter(submission=sub).exists() + award_names = list( + SubmissionAward.objects.filter(submission=sub) + .select_related("award") + .values_list("award__name", flat=True) + ) + + return { + "submission_id": sub.id, + "username": sub.user.username, + "task_title": sub.task.title, + "task_display": sub.task.display, + "score": sub.score, + "view_count": sub.view_count, + "html": sub.html, + "css": sub.css, + "js": sub.js, + "awards": award_names, + "has_prompt_chain": has_chain, + } + + +@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(): + raise HttpError(404, "作品不存在或未授奖") + + sub = get_object_or_404(Submission, id=submission_id) + try: + source_msg = Message.objects.select_related("conversation").get(submission=sub) + except Message.DoesNotExist: + raise HttpError(404, "该作品没有关联提示词链") + + messages = list(source_msg.conversation.messages.all().order_by("created")) + rounds = [] + for i, msg in enumerate(messages): + if msg.role != "user": + continue + html = css = js = None + for reply in messages[i + 1:]: + if reply.role == "user": + break + if reply.role == "assistant": + html = reply.code_html + css = reply.code_css + js = reply.code_js + break + rounds.append( + { + "question": msg.content, + "source": msg.source, + "prompt_level": msg.prompt_level, + "html": html, + "css": css, + "js": js, + } + ) + + return rounds + + @router.get("/{submission_id}", response=SubmissionOut) @login_required def get_submission(request, submission_id: UUID): @@ -462,5 +602,3 @@ def update_flag(request, submission_id: UUID, payload: FlagIn): return {"flag": submission.flag} - - diff --git a/submission/migrations/0011_add_award_submissionaward.py b/submission/migrations/0011_add_award_submissionaward.py new file mode 100644 index 0000000..af40042 --- /dev/null +++ b/submission/migrations/0011_add_award_submissionaward.py @@ -0,0 +1,47 @@ +# Generated by Django 6.0.1 on 2026-04-30 14:55 + +import django.db.models.deletion +import django_extensions.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submission', '0010_remove_conversation_fk'), + ] + + operations = [ + migrations.CreateModel( + name='Award', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='奖项名称')), + ('description', models.TextField(blank=True, default='', verbose_name='奖项简介')), + ('sort_order', models.IntegerField(db_index=True, default=0, verbose_name='排序值')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('item_ordering', models.CharField(choices=[('manual', '手动排序'), ('awarded_at', '授奖时间倒序'), ('score', '评分倒序'), ('view_count', '浏览量倒序')], default='manual', max_length=20, verbose_name='作品排序方式')), + ], + options={ + 'ordering': ('sort_order',), + }, + ), + migrations.CreateModel( + name='SubmissionAward', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('sort_order', models.IntegerField(db_index=True, default=0, verbose_name='手动排序值')), + ('awarded_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='授奖时间')), + ('award', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submission_awards', to='submission.award')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='awards', to='submission.submission')), + ], + options={ + 'ordering': ('sort_order',), + 'unique_together': {('submission', 'award')}, + }, + ), + ] diff --git a/submission/models.py b/submission/models.py index ab328b5..c53484e 100644 --- a/submission/models.py +++ b/submission/models.py @@ -166,3 +166,49 @@ def update_submission_score_on_save(sender, instance, **kwargs): 当Rating保存时,更新对应的Submission的平均分 """ instance.submission.update_score() + + +class ItemOrdering(models.TextChoices): + MANUAL = "manual", "手动排序" + AWARDED_AT = "awarded_at", "授奖时间倒序" + SCORE = "score", "评分倒序" + VIEW_COUNT = "view_count", "浏览量倒序" + + +class Award(TimeStampedModel): + name = models.CharField(max_length=100, unique=True, verbose_name="奖项名称") + description = models.TextField(blank=True, default="", verbose_name="奖项简介") + sort_order = models.IntegerField(default=0, db_index=True, verbose_name="排序值") + is_active = models.BooleanField(default=True, verbose_name="是否启用") + item_ordering = models.CharField( + max_length=20, + choices=ItemOrdering.choices, + default=ItemOrdering.MANUAL, + verbose_name="作品排序方式", + ) + + class Meta: + ordering = ("sort_order",) + + def __str__(self): + return self.name + + +class SubmissionAward(TimeStampedModel): + submission = models.ForeignKey( + Submission, on_delete=models.CASCADE, related_name="awards" + ) + award = models.ForeignKey( + Award, on_delete=models.CASCADE, related_name="submission_awards" + ) + sort_order = models.IntegerField(default=0, db_index=True, verbose_name="手动排序值") + awarded_at = models.DateTimeField( + auto_now_add=True, db_index=True, verbose_name="授奖时间" + ) + + class Meta: + unique_together = ("submission", "award") + ordering = ("sort_order",) + + def __str__(self): + return f"{self.award.name} - {self.submission}" diff --git a/submission/schemas.py b/submission/schemas.py index ac78f9e..625e974 100644 --- a/submission/schemas.py +++ b/submission/schemas.py @@ -158,3 +158,46 @@ class TaskStatsOut(Schema): classes: list[str] top_viewed: list[TopViewedItem] + +class ShowcaseItemOut(Schema): + submission_id: UUID + username: str + task_title: str + task_display: int + score: float + view_count: int + html: Optional[str] = None + css: Optional[str] = None + js: Optional[str] = None + has_prompt_chain: bool + + +class AwardOut(Schema): + id: int + name: str + description: str + item_ordering: str + items: list[ShowcaseItemOut] + + +class ShowcaseDetailOut(Schema): + submission_id: UUID + username: str + task_title: str + task_display: int + score: float + view_count: int + html: Optional[str] = None + css: Optional[str] = None + js: Optional[str] = None + awards: list[str] + has_prompt_chain: bool + + +class PromptRoundOut(Schema): + question: str + source: str + prompt_level: Optional[int] = None + html: Optional[str] = None + css: Optional[str] = None + js: Optional[str] = None diff --git a/submission/tests_showcase.py b/submission/tests_showcase.py new file mode 100644 index 0000000..85e4978 --- /dev/null +++ b/submission/tests_showcase.py @@ -0,0 +1,316 @@ +from django.contrib.auth import get_user_model +from django.db import IntegrityError +from django.test import TestCase + +from submission.models import Award, Submission, SubmissionAward +from task.models import Task + +User = get_user_model() + + +def _make_user(username="student1", role="normal"): + return User.objects.create_user(username=username, password="pw", role=role) + + +def _make_task(display=1): + return Task.objects.create( + title=f"Task {display}", task_type="challenge", display=display, content="" + ) + + +def _make_submission(user, task, score=0.0): + return Submission.objects.create( + user=user, task=task, html="

hi

", css="", js="", score=score + ) + + +def _make_award(name="最佳设计", sort_order=0, is_active=True, item_ordering="manual"): + return Award.objects.create( + name=name, + sort_order=sort_order, + is_active=is_active, + item_ordering=item_ordering, + ) + + +class AwardModelTest(TestCase): + def test_unique_submission_award(self): + user = _make_user() + task = _make_task() + sub = _make_submission(user, task) + award = _make_award() + SubmissionAward.objects.create(submission=sub, award=award) + + with self.assertRaises(IntegrityError): + SubmissionAward.objects.create(submission=sub, award=award) + + def test_submission_can_have_multiple_awards(self): + user = _make_user() + task = _make_task() + sub = _make_submission(user, task) + a1 = _make_award("奖1", sort_order=0) + a2 = _make_award("奖2", sort_order=1) + SubmissionAward.objects.create(submission=sub, award=a1) + SubmissionAward.objects.create(submission=sub, award=a2) + + self.assertEqual(sub.awards.count(), 2) + + def test_award_can_have_multiple_submissions(self): + user = _make_user() + task = _make_task() + sub1 = _make_submission(user, task, score=3.0) + task2 = _make_task(display=2) + sub2 = _make_submission(user, task2, score=4.0) + award = _make_award() + SubmissionAward.objects.create(submission=sub1, award=award) + SubmissionAward.objects.create(submission=sub2, award=award) + + self.assertEqual(award.submission_awards.count(), 2) + + +class ShowcaseListTest(TestCase): + def setUp(self): + self.user = _make_user("student1") + self.task = _make_task() + + def test_unauthenticated_returns_401(self): + resp = self.client.get("/api/submission/showcase/") + self.assertEqual(resp.status_code, 401) + + def test_authenticated_returns_200(self): + self.client.force_login(self.user) + resp = self.client.get("/api/submission/showcase/") + self.assertEqual(resp.status_code, 200) + + def test_inactive_award_not_returned(self): + award = _make_award("停用奖", is_active=False) + sub = _make_submission(self.user, self.task) + SubmissionAward.objects.create(submission=sub, award=award) + self.client.force_login(self.user) + resp = self.client.get("/api/submission/showcase/") + data = resp.json() + self.assertEqual(len(data), 0) + + def test_award_with_no_items_not_returned(self): + _make_award("空奖项") + self.client.force_login(self.user) + resp = self.client.get("/api/submission/showcase/") + data = resp.json() + self.assertEqual(len(data), 0) + + def test_active_award_with_items_returned(self): + award = _make_award("最佳设计") + sub = _make_submission(self.user, self.task) + SubmissionAward.objects.create(submission=sub, award=award) + self.client.force_login(self.user) + resp = self.client.get("/api/submission/showcase/") + data = resp.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["name"], "最佳设计") + self.assertEqual(len(data[0]["items"]), 1) + item = data[0]["items"][0] + self.assertEqual(item["username"], "student1") + self.assertEqual(item["has_prompt_chain"], False) + + def test_manual_ordering_uses_sort_order(self): + award = _make_award("奖", item_ordering="manual") + sub1 = _make_submission(self.user, self.task) + task2 = _make_task(display=2) + sub2 = _make_submission(self.user, task2) + SubmissionAward.objects.create(submission=sub1, award=award, sort_order=2) + SubmissionAward.objects.create(submission=sub2, award=award, sort_order=1) + self.client.force_login(self.user) + resp = self.client.get("/api/submission/showcase/") + items = resp.json()[0]["items"] + self.assertEqual(items[0]["task_display"], task2.display) + self.assertEqual(items[1]["task_display"], self.task.display) + + def test_score_ordering(self): + award = _make_award("奖", item_ordering="score") + sub1 = _make_submission(self.user, self.task, score=2.0) + task2 = _make_task(display=2) + sub2 = _make_submission(self.user, task2, score=4.0) + SubmissionAward.objects.create(submission=sub1, award=award) + SubmissionAward.objects.create(submission=sub2, award=award) + self.client.force_login(self.user) + resp = self.client.get("/api/submission/showcase/") + items = resp.json()[0]["items"] + self.assertGreater(items[0]["score"], items[1]["score"]) + + def test_view_count_ordering(self): + award = _make_award("奖", item_ordering="view_count") + sub1 = _make_submission(self.user, self.task) + sub1.view_count = 5 + sub1.save(update_fields=["view_count"]) + task2 = _make_task(display=2) + sub2 = _make_submission(self.user, task2) + sub2.view_count = 20 + sub2.save(update_fields=["view_count"]) + SubmissionAward.objects.create(submission=sub1, award=award) + SubmissionAward.objects.create(submission=sub2, award=award) + self.client.force_login(self.user) + resp = self.client.get("/api/submission/showcase/") + items = resp.json()[0]["items"] + self.assertGreater(items[0]["view_count"], items[1]["view_count"]) + + def test_has_prompt_chain_true_when_source_message_exists(self): + from prompt.models import Conversation, Message + + award = _make_award("奖") + sub = _make_submission(self.user, self.task) + conv = Conversation.objects.create(user=self.user, task=self.task) + Message.objects.create(conversation=conv, role="user", content="做个按钮") + Message.objects.create( + conversation=conv, + role="assistant", + content="好的", + code_html="", + code_css="", + code_js="", + submission=sub, + ) + SubmissionAward.objects.create(submission=sub, award=award) + self.client.force_login(self.user) + resp = self.client.get("/api/submission/showcase/") + item = resp.json()[0]["items"][0] + self.assertTrue(item["has_prompt_chain"]) + + +class ShowcaseDetailTest(TestCase): + def setUp(self): + self.user = _make_user("student1") + self.task = _make_task() + self.award = _make_award("最佳设计") + self.sub = _make_submission(self.user, self.task, score=4.5) + self.sub.view_count = 10 + self.sub.save(update_fields=["view_count"]) + SubmissionAward.objects.create(submission=self.sub, award=self.award) + + def test_unauthenticated_returns_401(self): + resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/") + self.assertEqual(resp.status_code, 401) + + def test_awarded_submission_accessible(self): + self.client.force_login(self.user) + resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/") + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(data["username"], "student1") + self.assertEqual(data["score"], 4.5) + self.assertEqual(data["view_count"], 10) + self.assertIn("最佳设计", data["awards"]) + self.assertFalse(data["has_prompt_chain"]) + + def test_non_awarded_submission_returns_404(self): + other_sub = _make_submission(self.user, self.task) + self.client.force_login(self.user) + resp = self.client.get(f"/api/submission/showcase/{other_sub.id}/") + self.assertEqual(resp.status_code, 404) + + def test_submission_shows_all_its_awards(self): + award2 = _make_award("最佳游戏", sort_order=1) + SubmissionAward.objects.create(submission=self.sub, award=award2) + self.client.force_login(self.user) + resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/") + data = resp.json() + self.assertIn("最佳设计", data["awards"]) + self.assertIn("最佳游戏", data["awards"]) + + +class ShowcasePromptChainTest(TestCase): + def setUp(self): + from prompt.models import Conversation, Message as Msg + + self.user = _make_user("student1") + self.task = _make_task() + self.award = _make_award("最佳设计") + self.sub = _make_submission(self.user, self.task) + SubmissionAward.objects.create(submission=self.sub, award=self.award) + + self.conv = Conversation.objects.create(user=self.user, task=self.task) + Msg.objects.create( + conversation=self.conv, + role="user", + content="做个按钮", + source="conversation", + prompt_level=3, + ) + Msg.objects.create( + conversation=self.conv, + role="assistant", + content="好的", + code_html="", + code_css="button{color:red}", + code_js="console.log(1)", + submission=self.sub, + ) + + def test_unauthenticated_returns_401(self): + resp = self.client.get( + f"/api/submission/showcase/{self.sub.id}/prompt-chain/" + ) + self.assertEqual(resp.status_code, 401) + + def test_no_source_message_returns_404(self): + other_sub = _make_submission(self.user, self.task) + SubmissionAward.objects.create(submission=other_sub, award=self.award) + self.client.force_login(self.user) + resp = self.client.get( + f"/api/submission/showcase/{other_sub.id}/prompt-chain/" + ) + self.assertEqual(resp.status_code, 404) + + def test_non_awarded_submission_returns_404(self): + from prompt.models import Conversation, Message as Msg + + other_sub = _make_submission(self.user, self.task) + conv = Conversation.objects.create(user=self.user, task=self.task) + Msg.objects.create( + conversation=conv, + role="assistant", + content="x", + submission=other_sub, + ) + self.client.force_login(self.user) + resp = self.client.get( + f"/api/submission/showcase/{other_sub.id}/prompt-chain/" + ) + self.assertEqual(resp.status_code, 404) + + def test_returns_prompt_rounds(self): + self.client.force_login(self.user) + resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/prompt-chain/") + self.assertEqual(resp.status_code, 200) + rounds = resp.json() + self.assertEqual(len(rounds), 1) + r = rounds[0] + self.assertEqual(r["question"], "做个按钮") + self.assertEqual(r["source"], "conversation") + self.assertEqual(r["prompt_level"], 3) + self.assertEqual(r["html"], "") + self.assertEqual(r["css"], "button{color:red}") + self.assertEqual(r["js"], "console.log(1)") + + def test_multiple_rounds(self): + from prompt.models import Message as Msg + + Msg.objects.create( + conversation=self.conv, + role="user", + content="再加个标题", + source="manual", + ) + Msg.objects.create( + conversation=self.conv, + role="assistant", + content="好", + code_html="

标题

", + code_css="", + code_js="", + ) + self.client.force_login(self.user) + resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/prompt-chain/") + rounds = resp.json() + self.assertEqual(len(rounds), 2) + self.assertEqual(rounds[1]["question"], "再加个标题") + self.assertEqual(rounds[1]["source"], "manual")