feat: add showcase backend

This commit is contained in:
2026-04-30 08:59:14 -06:00
parent c5f46de80a
commit f99c2c8033
6 changed files with 643 additions and 5 deletions

View File

@@ -1,3 +1,51 @@
from django.contrib import admin 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

View File

@@ -6,13 +6,27 @@ from ninja.errors import HttpError
from ninja.pagination import paginate from ninja.pagination import paginate
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.contrib.auth.decorators import login_required 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 prompt.models import Conversation, Message
from .schemas import ( from .schemas import (
FlagIn, FlagIn,
FlagStats, FlagStats,
AwardOut,
PromptRoundOut,
ShowcaseDetailOut,
ShowcaseItemOut,
SubmissionCountBucket, SubmissionCountBucket,
SubmissionFilter, SubmissionFilter,
SubmissionIn, 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 task.models import Task
from account.models import RoleChoices, User 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) @router.get("/{submission_id}", response=SubmissionOut)
@login_required @login_required
def get_submission(request, submission_id: UUID): def get_submission(request, submission_id: UUID):
@@ -462,5 +602,3 @@ def update_flag(request, submission_id: UUID, payload: FlagIn):
return {"flag": submission.flag} return {"flag": submission.flag}

View File

@@ -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')},
},
),
]

View File

@@ -166,3 +166,49 @@ def update_submission_score_on_save(sender, instance, **kwargs):
当Rating保存时更新对应的Submission的平均分 当Rating保存时更新对应的Submission的平均分
""" """
instance.submission.update_score() 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}"

View File

@@ -158,3 +158,46 @@ class TaskStatsOut(Schema):
classes: list[str] classes: list[str]
top_viewed: list[TopViewedItem] 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

View File

@@ -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="<p>hi</p>", 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="<button>OK</button>",
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="<button>OK</button>",
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"], "<button>OK</button>")
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="<h1>标题</h1>",
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")