chore: 将后端移动到 api/ 目录,准备合并为单仓库

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 16:01:25 +08:00
1 parent e172ffc0b3
commit 0591eda56f
244 files changed

No files matched your search

View File
Whitespace-only changes.
+51
View File
@@ -0,0 +1,51 @@
from django.contrib import admin
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
+959
View File
@@ -0,0 +1,959 @@
import csv
import logging
import threading
from typing import List, Literal, Optional
from urllib.parse import quote
from uuid import UUID
from django.http import HttpResponse
from ninja import Router, Query
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,
Exists,
F,
IntegerField,
Max,
OuterRef,
Q,
Subquery,
)
from django.utils import timezone
from account.decorators import admin_required, super_required
from prompt.models import Conversation, Message
from prompt.utils import get_active_conversation, get_preceding_user_message
from .classifier import classify_message
from .schemas import (
AwardItemIn,
AwardItemManageOut,
AwardItemUpdateIn,
AwardManageIn,
AwardManageOut,
FlagIn,
FlagStats,
AwardOut,
GradebookOut,
PromptRoundOut,
ShowcaseDetailOut,
ShowcaseItemOut,
ShowcaseSubmissionLookupOut,
SubmissionCountBucket,
SubmissionFilter,
SubmissionIn,
SubmissionOut,
RatingScoreIn,
RandomRatingOut,
TaskStatsOut,
TopViewedItem,
UserTag,
)
from .models import Award, ItemOrdering, Rating, Submission, SubmissionAward
from .gradebook import GradebookFilters, build_gradebook, gradebook_csv_rows
from task.models import Task, TaskTypeChoices
from account.models import RoleChoices, User
router = Router()
logger = logging.getLogger(__name__)
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,
"is_stale": item.is_stale,
}
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):
"""
创建一个新的提交
"""
task = get_object_or_404(Task, id=payload.task_id)
if task.task_type == TaskTypeChoices.CREATION:
raise HttpError(400, "自由创作不需要提交")
manual_asst_msg = None
linked_msg = None
new_user_msg_id = None
if payload.prompt:
conversation = get_active_conversation(request.user, task.id)
if not conversation:
conversation = Conversation.objects.create(
user=request.user, task=task, is_active=False
)
user_msg = Message.objects.create(
conversation=conversation, role="user", content=payload.prompt, source="manual"
)
new_user_msg_id = user_msg.id
manual_asst_msg = Message.objects.create(
conversation=conversation,
role="assistant",
content="",
code_html=payload.html,
code_css=payload.css,
code_js=payload.js,
source="manual",
)
elif payload.message_id:
linked_msg = Message.objects.filter(
id=payload.message_id,
role="assistant",
conversation__user=request.user,
conversation__task=task,
).first()
if linked_msg is None:
logger.warning(
"create_submission: message_id %s not found for user %s task %s",
payload.message_id, request.user.id, task.id,
)
else:
user_msg = get_preceding_user_message(linked_msg)
if user_msg:
new_user_msg_id = user_msg.id
# Idempotent: re-submitting the same AI message updates its existing submission
# instead of creating an orphaned duplicate.
if linked_msg and linked_msg.submission_id:
submission = linked_msg.submission
submission.html = payload.html
submission.css = payload.css
submission.js = payload.js
submission.save(update_fields=["html", "css", "js"])
else:
submission = Submission.objects.create(
user=request.user,
task=task,
html=payload.html,
css=payload.css,
js=payload.js,
)
# Link assistant message to submission
if manual_asst_msg:
manual_asst_msg.submission = submission
manual_asst_msg.save(update_fields=["submission"])
elif linked_msg:
linked_msg.submission = submission
linked_msg.save(update_fields=["submission"])
# Mark any showcased older submissions from same user+task as stale
SubmissionAward.objects.filter(
submission__user=request.user,
submission__task=task,
is_stale=False,
).exclude(submission=submission).update(is_stale=True)
# Classify only the newly-added prompt message, and only if not already classified
if new_user_msg_id is not None and not Message.objects.filter(
id=new_user_msg_id, prompt_level__isnull=False
).exists():
threading.Thread(target=classify_message, args=(new_user_msg_id,), daemon=True).start()
return {"id": str(submission.id)}
@router.get("/", response=List[SubmissionOut])
@paginate
@login_required
def list_submissions(request, filters: SubmissionFilter = Query(...)):
"""
获取提交列表,支持按任务和用户过滤
"""
submissions = (
Submission.objects.select_related("task", "user")
.defer("html", "css", "js")
)
if filters.task_id:
task = get_object_or_404(Task, id=filters.task_id)
submissions = submissions.filter(task=task)
elif filters.task_type:
submissions = submissions.filter(task__task_type=filters.task_type)
if filters.username:
submissions = submissions.filter(user__username__icontains=filters.username)
if filters.user_id:
submissions = submissions.filter(user_id=filters.user_id)
if filters.flag:
if filters.flag == "any":
submissions = submissions.filter(flag__isnull=False)
else:
submissions = submissions.filter(flag=filters.flag)
if filters.zone:
submissions = submissions.filter(zone=filters.zone)
if filters.score_lt_threshold is not None:
submissions = submissions.filter(score__lt=filters.score_lt_threshold)
else:
if filters.score_min is not None:
submissions = submissions.filter(score__gte=filters.score_min)
if filters.score_max_exclusive is not None:
submissions = submissions.filter(score__lt=filters.score_max_exclusive)
if filters.ordering in ("-score", "score", "-created"):
submissions = submissions.order_by(filters.ordering)
if filters.grouped:
# 分组模式:每个 (user, task) 只保留最新一条
latest_per_group = (
Submission.objects.filter(user=OuterRef("user"), task=OuterRef("task"))
.order_by("-created")
.values("pk")[:1]
)
submissions = submissions.filter(pk=Subquery(latest_per_group))
user_rating_subquery = Subquery(
Rating.objects.filter(user=request.user, submission=OuterRef("pk")).values(
"score"
)[:1],
output_field=IntegerField(),
)
submissions = submissions.annotate(my_score=user_rating_subquery)
# 同一用户同一任务的提交次数
submit_count_subquery = Subquery(
Submission.objects.filter(
user=OuterRef("user"), task=OuterRef("task")
).values("user", "task").annotate(c=Count("id")).values("c")[:1],
output_field=IntegerField(),
)
submissions = submissions.annotate(submit_count=submit_count_subquery)
return submissions
@router.get("/by-user-task", response=List[SubmissionOut])
@login_required
def list_by_user_task(request, user_id: int, task_id: int):
"""
获取某用户某任务的所有提交(不分页)
"""
user_rating_subquery = Subquery(
Rating.objects.filter(user=request.user, submission=OuterRef("pk")).values(
"score"
)[:1],
output_field=IntegerField(),
)
return (
Submission.objects.filter(user_id=user_id, task_id=task_id)
.select_related("task", "user")
.defer("html", "css", "js")
.annotate(my_score=user_rating_subquery)
.order_by("-created")
)
@router.get("/random-for-rating/", response=Optional[RandomRatingOut])
@login_required
def get_random_for_rating(request, exclude_id: Optional[UUID] = None):
"""
随机返回一个待打分的其他同学的提交(用于AI生成期间的随手打分弹窗)
"""
if request.user.role == RoleChoices.NORMAL:
today_start = timezone.now().replace(
hour=0, minute=0, second=0, microsecond=0
)
today_end = today_start + timezone.timedelta(days=1)
rating_count = Rating.objects.filter(
user=request.user, created__range=(today_start, today_end)
).count()
if rating_count >= 30:
return None
candidates = (
Submission.objects.select_related("task", "user")
.exclude(user=request.user)
.exclude(ratings__user=request.user)
)
if exclude_id:
candidates = candidates.exclude(pk=exclude_id)
pending = candidates.annotate(rating_count=Count("ratings")).filter(
rating_count__lt=5
)
return pending.order_by("?").first() or candidates.order_by("?").first()
@router.delete("/flags")
@login_required
def clear_all_flags(request):
"""
清除所有提交的标记(仅管理员和超级管理员可操作)
"""
if request.user.role not in (RoleChoices.SUPER, RoleChoices.ADMIN):
raise HttpError(403, "没有权限")
count = Submission.objects.filter(flag__isnull=False).update(flag=None)
return {"cleared": count}
@router.delete("/{submission_id}")
@login_required
def delete_submission(request, submission_id: UUID):
submission = get_object_or_404(Submission, id=submission_id)
if submission.user != request.user and request.user.role != RoleChoices.SUPER:
raise HttpError(403, "只能删除自己的提交")
if request.user.role != RoleChoices.SUPER:
has_ratings = Rating.objects.filter(submission=submission).exists()
has_awards = SubmissionAward.objects.filter(submission=submission).exists()
if has_ratings or has_awards:
raise HttpError(400, "该提交已被评分或获奖,无法删除")
# 找到关联的助手消息,再找前一条用户消息
asst_msg = Message.objects.filter(submission=submission).first()
user_msg = get_preceding_user_message(asst_msg) if asst_msg else None
submission.delete() # CASCADE 自动删除关联的 asst_msg
if user_msg:
user_msg.delete()
return {"message": "删除成功"}
@router.get("/stats/{task_id}", response=TaskStatsOut)
@login_required
def get_task_stats(request, task_id: int, classname: Optional[str] = None):
"""
获取某个挑战任务的班级提交统计数据(仅管理员)
"""
if request.user.role not in (RoleChoices.SUPER, RoleChoices.ADMIN):
raise HttpError(403, "没有权限")
task = get_object_or_404(Task, id=task_id)
# All distinct classnames (unfiltered, for filter buttons in UI)
all_classes = list(
User.objects.filter(role=RoleChoices.NORMAL)
.exclude(classname="")
.values_list("classname", flat=True)
.distinct()
.order_by("classname")
)
# Student universe: Normal users, optionally filtered by classname
students = User.objects.filter(role=RoleChoices.NORMAL)
if classname:
students = students.filter(classname=classname)
student_ids = list(students.values_list("id", flat=True))
total_students = len(student_ids)
# Submitted student IDs
submitted_ids = set(
Submission.objects.filter(task=task, user_id__in=student_ids)
.values_list("user_id", flat=True)
.distinct()
)
submitted_count = len(submitted_ids)
unsubmitted_count = total_students - submitted_count
# Unsubmitted users
unsubmitted_users = [
UserTag(username=u.username, classname=u.classname)
for u in students.exclude(id__in=submitted_ids).order_by("classname", "username")
]
# Latest submission per submitted user (SQLite-compatible).
# Find each user's max created timestamp, then resolve all matching IDs
# in a single query using OR'd Q objects instead of one query per user.
latest_per_user = list(
Submission.objects.filter(task=task, user_id__in=submitted_ids)
.values("user_id")
.annotate(max_created=Max("created"))
)
latest_sub_ids = []
if latest_per_user:
user_time_filter = Q()
for row in latest_per_user:
user_time_filter |= Q(user_id=row["user_id"], created=row["max_created"])
# Fetch all matching submissions in one query; deduplicate by user_id
seen_users: set = set()
for sub_id, uid in (
Submission.objects.filter(user_time_filter, task=task)
.values_list("id", "user_id")
):
if uid not in seen_users:
seen_users.add(uid)
latest_sub_ids.append(sub_id)
latest_subs = list(Submission.objects.filter(id__in=latest_sub_ids))
# Average score from latest submissions (None if no submissions have score > 0)
avg_result = (
Submission.objects.filter(id__in=latest_sub_ids, score__gt=0)
.aggregate(avg=Avg("score"))["avg"]
)
average_score = round(avg_result, 2) if avg_result is not None else None
# Unrated: submitted but no Rating on any of their submissions for this task
rated_ids = set(
Rating.objects.filter(
submission__task=task, submission__user_id__in=submitted_ids
)
.values_list("submission__user_id", flat=True)
.distinct()
)
unrated_ids = submitted_ids - rated_ids
unrated_count = len(unrated_ids)
unrated_users = [
UserTag(username=u.username, classname=u.classname)
for u in students.filter(id__in=unrated_ids).order_by("classname", "username")
]
# Submission count distribution
sub_counts = dict(
Submission.objects.filter(task=task, user_id__in=submitted_ids)
.values("user_id")
.annotate(c=Count("id"))
.values_list("user_id", "c")
)
dist = {"count_1": 0, "count_2": 0, "count_3": 0, "count_4_plus": 0}
for c in sub_counts.values():
if c == 1:
dist["count_1"] += 1
elif c == 2:
dist["count_2"] += 1
elif c == 3:
dist["count_3"] += 1
else:
dist["count_4_plus"] += 1
# Flag stats (all submissions for this task, not grouped by user)
flag_counts = dict(
Submission.objects.filter(task=task, flag__isnull=False)
.values("flag")
.annotate(c=Count("id"))
.values_list("flag", "c")
)
flag_stats = FlagStats(
red=flag_counts.get("red", 0),
blue=flag_counts.get("blue", 0),
green=flag_counts.get("green", 0),
yellow=flag_counts.get("yellow", 0),
)
# Top 5 submissions by view_count (within filtered student_ids)
top_viewed_qs = (
Submission.objects
.filter(task=task, user_id__in=student_ids)
.select_related("user")
.defer("html", "css", "js")
.order_by("-view_count")[:5]
)
top_viewed = [
TopViewedItem(
username=s.user.username,
classname=s.user.classname,
view_count=s.view_count,
submission_id=s.id,
)
for s in top_viewed_qs
]
return TaskStatsOut(
submitted_count=submitted_count,
unsubmitted_count=unsubmitted_count,
average_score=average_score,
unrated_count=unrated_count,
unsubmitted_users=unsubmitted_users,
unrated_users=unrated_users,
submission_count_distribution=SubmissionCountBucket(**dist),
flag_stats=flag_stats,
classes=all_classes,
top_viewed=top_viewed,
)
@router.get("/gradebook/", response=GradebookOut)
@super_required
def get_gradebook(
request,
classname: str = "",
task_type: Optional[Literal["tutorial", "challenge"]] = None,
username: Optional[str] = None,
include_all_tasks: bool = False,
):
return build_gradebook(
GradebookFilters(
classname=classname,
task_type=task_type,
username=username,
include_all_tasks=include_all_tasks,
)
)
@router.get("/gradebook/export/")
@super_required
def export_gradebook(
request,
classname: str = "",
task_type: Optional[Literal["tutorial", "challenge"]] = None,
username: Optional[str] = None,
include_all_tasks: bool = False,
):
gradebook = build_gradebook(
GradebookFilters(
classname=classname,
task_type=task_type,
username=username,
include_all_tasks=include_all_tasks,
)
)
response = HttpResponse(content_type="text/csv; charset=utf-8")
filename = f"gradebook-{gradebook['classname']}.csv"
response["Content-Disposition"] = (
f"attachment; filename*=UTF-8''{quote(filename)}"
)
response.write("\ufeff")
writer = csv.writer(response)
for row in gradebook_csv_rows(gradebook):
writer.writerow(row)
return response
@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/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.put("/showcase/manage/items/{item_id}/refresh", response=AwardItemManageOut)
@admin_required
def refresh_manage_award_item(request, item_id: int):
"""切换创意工坊条目到该学生该挑战的最新提交"""
item = get_object_or_404(
SubmissionAward.objects.select_related(
"submission", "submission__user", "submission__task"
),
id=item_id,
)
latest = (
Submission.objects.filter(
user=item.submission.user,
task=item.submission.task,
)
.order_by("-created")
.first()
)
if latest and latest.pk != item.submission_id:
item.submission = latest
item.is_stale = False
item.save(update_fields=["submission", "is_stale"])
return _award_item_manage_out(item)
@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,
award__is_active=True,
).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)
.filter(award__is_active=True)
.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,
}
def _build_prompt_rounds(source_msg: Message):
messages = list(source_msg.conversation.messages.all().order_by("created", "id"))
try:
source_index = messages.index(source_msg)
except ValueError:
source_index = len(messages) - 1
messages = messages[: source_index + 1]
rounds = []
for i, msg in enumerate(messages):
if msg.role != "user":
continue
html = css = js = None
assistant_msg_id = None
for reply in messages[i + 1:]:
if reply.role == "user":
break
if reply.role == "assistant":
assistant_msg_id = reply.id
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,
"assistant_msg_id": assistant_msg_id,
"html": html,
"css": css,
"js": js,
}
)
return rounds
@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,
award__is_active=True,
).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, "该作品没有关联提示词链")
return _build_prompt_rounds(source_msg)
@router.get("/{submission_id}/prompt-chain", response=List[PromptRoundOut])
@login_required
def get_submission_prompt_chain(request, submission_id: UUID):
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, "该提交没有关联提示词链")
return _build_prompt_rounds(source_msg)
@router.get("/{submission_id}", response=SubmissionOut)
@login_required
def get_submission(request, submission_id: UUID):
"""
获取单个提交的详细信息
"""
user_rating_subquery = Subquery(
Rating.objects.filter(user=request.user, submission=OuterRef("pk")).values(
"score"
)[:1],
output_field=IntegerField(),
)
submission = get_object_or_404(
Submission.objects.select_related("task", "user").annotate(
my_score=user_rating_subquery
),
id=submission_id,
)
return submission
@router.post("/{submission_id}/view")
@login_required
def increment_view(request, submission_id: UUID):
"""
增加提交的浏览次数(仅在全屏预览时调用)
"""
updated = Submission.objects.filter(pk=submission_id).update(
view_count=F("view_count") + 1
)
if not updated:
raise HttpError(404, "提交不存在")
return {"ok": True}
@router.put("/{submission_id}/score")
@login_required
def update_score(request, submission_id: UUID, payload: RatingScoreIn):
"""
给提交打分
"""
if payload.score <= 0:
raise HttpError(400, "分数不能为零")
submission = get_object_or_404(Submission, id=submission_id)
_, created = Rating.objects.get_or_create(
user=request.user,
submission=submission,
defaults={"score": payload.score},
)
if created:
return {"message": "打分成功"}
else:
return {"message": "你已经给这个提交打过分了"}
@router.put("/{submission_id}/flag")
@login_required
def update_flag(request, submission_id: UUID, payload: FlagIn):
"""
设置或清除提交的标记(仅管理员和超级管理员可操作)
"""
if request.user.role not in (RoleChoices.SUPER, RoleChoices.ADMIN):
raise HttpError(403, "没有权限")
submission = get_object_or_404(Submission, id=submission_id)
submission.flag = payload.flag
submission.save(update_fields=["flag"])
return {"flag": submission.flag}
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SubmissionConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'submission'
+87
View File
@@ -0,0 +1,87 @@
import re
import time
import logging
from uuid import UUID
from django.conf import settings
from openai import OpenAI
logger = logging.getLogger(__name__)
CLASSIFY_SYSTEM_PROMPT = """你是一个教育评估专家。根据布鲁姆认知分类学,分析以下学生在前端学习中发送给AI助手的一条提示词,判断该提示词所体现的认知层级。
层级定义:
- L1 记忆:能背诵HTML标签语法(例:"帮我写一个按钮")
- L2 理解:能解释flex布局原理(例:"为什么这里不居中?")
- L3 应用:能独立搭建页面结构(例:"用flex做导航栏,间距16px")
- L4 分析:能定位跨浏览器兼容性bug(例:"Safari中margin失效,原因?")
- L5 评价:能对比并选择方案(例:"对比Grid与Flex方案优劣")
- L6 创造:能设计并实现原创交互作品(例:"设计夜间/日间切换效果")
只返回一个数字(1-6),不要解释。"""
def _call_llm(content: str) -> int | None:
"""Call LLM to classify a single message content. Returns level 1-6 or None."""
try:
client = OpenAI(
api_key=settings.LLM_API_KEY,
base_url=settings.LLM_BASE_URL,
timeout=30.0,
)
response = client.chat.completions.create(
model=settings.LLM_MODEL,
messages=[
{"role": "system", "content": CLASSIFY_SYSTEM_PROMPT},
{"role": "user", "content": content},
],
max_tokens=10,
stream=False,
extra_body={"thinking": {"type": "disabled"}},
)
text = response.choices[0].message.content or ""
match = re.search(r"[1-6]", text)
if not match:
logger.warning("classify: unexpected LLM response '%s'", text)
return None
return int(match.group())
except Exception as e:
logger.error("classify LLM call failed: %s", e)
return None
def classify_message(message_id: int) -> int | None:
"""Classify a single user Message by ID. Returns level or None."""
from prompt.models import Message
try:
msg = Message.objects.get(id=message_id, role="user")
except Message.DoesNotExist:
return None
level = _call_llm(msg.content)
if level is not None:
Message.objects.filter(id=message_id).update(prompt_level=level)
return level
def classify_conversation_messages(conversation_id: UUID, force: bool = False) -> None:
"""Classify all user messages in a conversation."""
from prompt.models import Message
qs = Message.objects.filter(conversation_id=conversation_id, role="user")
if not force:
qs = qs.filter(prompt_level__isnull=True)
for msg in qs.order_by("created"):
level = _call_llm(msg.content)
if level is not None:
Message.objects.filter(id=msg.id).update(prompt_level=level)
time.sleep(0.3)
def classify_messages_batch(message_ids: list) -> None:
"""Classify a list of messages by ID."""
for mid in message_ids:
classify_message(mid)
time.sleep(0.5)
+321
View File
@@ -0,0 +1,321 @@
from collections import defaultdict
from dataclasses import dataclass
from math import ceil
from typing import Literal
from django.db.models import Count
from ninja.errors import HttpError
from account.models import RoleChoices, User
from task.models import Task, TaskTypeChoices
from .models import Submission
GradebookTaskType = Literal["tutorial", "challenge"]
@dataclass(frozen=True)
class GradebookFilters:
classname: str
task_type: GradebookTaskType | None = None
username: str | None = None
include_all_tasks: bool = False
def _score(value):
rounded = round(float(value or 0), 2)
if rounded == 0:
return 0.0
return rounded
def _grade_for_rank(rank: int, student_count: int) -> str:
if rank <= ceil(0.30 * student_count):
return "A"
if rank <= ceil(0.70 * student_count):
return "B"
if rank <= ceil(0.90 * student_count):
return "C"
if rank <= ceil(0.95 * student_count):
return "D"
return "E"
def _assessment_grade_for_rank(rank: int, student_count: int) -> str:
# 考核等级:A10% B20% C40% D20% E10%,按最高三次提交均分排名
if rank <= ceil(0.10 * student_count):
return "A"
if rank <= ceil(0.30 * student_count):
return "B"
if rank <= ceil(0.70 * student_count):
return "C"
if rank <= ceil(0.90 * student_count):
return "D"
return "E"
# 考核分数:各等级对应的分数段(高分→低分),段内按名次线性铺开
# 最高分封顶 99,不出现满分 100
ASSESSMENT_SCORE_BANDS = {
"A": (92, 99),
"B": (84, 91),
"C": (76, 83),
"D": (68, 75),
"E": (60, 67),
}
def _assessment_score(index_in_grade: int, grade_size: int, grade: str) -> int:
low, high = ASSESSMENT_SCORE_BANDS[grade]
if grade_size <= 1:
return high
# index_in_grade 从 0 开始:段内第 1 名拿段顶,最后 1 名拿段底
return round(high - (index_in_grade / (grade_size - 1)) * (high - low))
def _task_sort_key(task):
type_order = 0 if task.task_type == TaskTypeChoices.TUTORIAL else 1
return (type_order, task.display, task.id)
def _task_type_label(task_type: str) -> str:
return "教程" if task_type == TaskTypeChoices.TUTORIAL else "挑战"
def _csv_number(value):
if value == "":
return ""
number = float(value)
if number.is_integer():
return str(int(number))
return f"{number:.2f}".rstrip("0").rstrip(".")
def _classes():
return list(
User.objects.filter(role__in=(RoleChoices.NORMAL, RoleChoices.ADMIN))
.exclude(classname="")
.values_list("classname", flat=True)
.distinct()
.order_by("classname")
)
def _task_csv_header(task):
return f"{_task_type_label(task['task_type'])}{task['display']}-{task['title']}"
def build_gradebook(filters: GradebookFilters):
classname = filters.classname.strip() if filters.classname else ""
if not classname:
raise HttpError(400, "请选择班级")
if filters.task_type not in (None, "tutorial", "challenge"):
raise HttpError(400, "无效的任务类型")
classes = _classes()
class_students = list(
User.objects.filter(
role__in=(RoleChoices.NORMAL, RoleChoices.ADMIN), classname=classname
)
.order_by("username", "id")
.only("id", "username", "classname")
)
class_student_ids = [student.id for student in class_students]
student_count = len(class_student_ids)
coverage_threshold_count = ceil(student_count * 0.5) if student_count else 0
task_submission_qs = Submission.objects.filter(user_id__in=class_student_ids)
if filters.task_type:
task_submission_qs = task_submission_qs.filter(task__task_type=filters.task_type)
submitted_counts = {
row["task_id"]: row["submitted_count"]
for row in task_submission_qs.values("task_id").annotate(
submitted_count=Count("user_id", distinct=True)
)
}
task_map = {
task.id: task
for task in Task.objects.filter(id__in=submitted_counts.keys()).only(
"id",
"display",
"title",
"task_type",
)
}
all_tasks = []
for task in sorted(task_map.values(), key=_task_sort_key):
submitted_count = submitted_counts[task.id]
included = student_count > 0 and submitted_count >= coverage_threshold_count
all_tasks.append(
{
"id": task.id,
"display": task.display,
"title": task.title,
"task_type": task.task_type,
"submitted_count": submitted_count,
"coverage": _score(submitted_count / student_count)
if student_count
else 0.0,
"included": included,
}
)
tasks = [task for task in all_tasks if filters.include_all_tasks or task["included"]]
visible_task_ids = [task["id"] for task in tasks]
included_task_ids = {task["id"] for task in tasks if task["included"]}
best_by_pair = {}
if class_student_ids and visible_task_ids:
submissions = (
Submission.objects.filter(
user_id__in=class_student_ids,
task_id__in=visible_task_ids,
)
.select_related("task")
.only("id", "user_id", "task_id", "score", "created", "task__task_type")
.order_by("user_id", "task_id", "-score", "-created", "-id")
)
for submission in submissions:
best_by_pair.setdefault((submission.user_id, submission.task_id), submission)
rows = []
for student in class_students:
scores = {}
tutorial_total = 0.0
challenge_total = 0.0
submitted_scores = []
submitted_task_count = 0
missing_task_count = 0
for task in tasks:
submission = best_by_pair.get((student.id, task["id"]))
if submission:
cell_score = _score(submission.score)
scores[task["id"]] = {
"score": cell_score,
"submitted": True,
"submission_id": submission.id,
}
else:
cell_score = 0.0
scores[task["id"]] = {
"score": 0.0,
"submitted": False,
"submission_id": None,
}
if not task["included"]:
continue
if submission:
submitted_task_count += 1
submitted_scores.append(cell_score)
else:
missing_task_count += 1
if task["task_type"] == TaskTypeChoices.TUTORIAL:
tutorial_total += cell_score
else:
challenge_total += cell_score
tutorial_total = _score(tutorial_total)
challenge_total = _score(challenge_total)
top3 = sorted(submitted_scores, reverse=True)[:3]
top3_score = _score(sum(top3) / len(top3)) if top3 else 0.0
total_score = _score(tutorial_total + challenge_total)
average_score = (
_score(total_score / len(included_task_ids)) if included_task_ids else None
)
rows.append(
{
"user_id": student.id,
"username": student.username,
"classname": student.classname,
"rank": 0,
"grade": "E",
"assessment_grade": "E",
"assessment_score": 60,
"top3_score": top3_score,
"scores": scores,
"tutorial_total": tutorial_total,
"challenge_total": challenge_total,
"total_score": total_score,
"average_score": average_score,
"submitted_task_count": submitted_task_count,
"missing_task_count": missing_task_count,
}
)
rows.sort(key=lambda row: (-row["total_score"], row["username"]))
for index, row in enumerate(rows, start=1):
row["rank"] = index
row["grade"] = _grade_for_rank(index, student_count)
# 考核等级:按最高三次提交均分单独排名后定级(不改变上面的总分排名)
assessment_order = sorted(
rows, key=lambda row: (-row["top3_score"], row["username"])
)
for index, row in enumerate(assessment_order, start=1):
row["assessment_grade"] = _assessment_grade_for_rank(index, student_count)
# 考核分数:在各等级段内按名次(沿用最高三次均分排序)线性铺开到对应分数段
grade_groups = defaultdict(list)
for row in assessment_order:
grade_groups[row["assessment_grade"]].append(row)
for grade, group in grade_groups.items():
grade_size = len(group)
for index_in_grade, row in enumerate(group):
row["assessment_score"] = _assessment_score(
index_in_grade, grade_size, grade
)
username = filters.username.strip().lower() if filters.username else ""
if username:
rows = [row for row in rows if username in row["username"].lower()]
return {
"classname": classname,
"classes": classes,
"task_count": len(tasks),
"included_task_count": len(included_task_ids),
"student_count": student_count,
"coverage_threshold_count": coverage_threshold_count,
"tasks": tasks,
"rows": rows,
}
def gradebook_csv_rows(gradebook):
tasks = gradebook["tasks"]
yield [
"排名",
"平时等级",
"考核等级",
"考核分数",
"用户名",
"班级",
*[_task_csv_header(task) for task in tasks],
"教程合计",
"挑战合计",
"总分",
"平均分",
"已提交任务数",
"未提交任务数",
]
for row in gradebook["rows"]:
yield [
row["rank"],
row["grade"],
row["assessment_grade"],
row["assessment_score"],
row["username"],
row["classname"],
*[_csv_number(row["scores"][task["id"]]["score"]) for task in tasks],
_csv_number(row["tutorial_total"]),
_csv_number(row["challenge_total"]),
_csv_number(row["total_score"]),
"" if row["average_score"] is None else _csv_number(row["average_score"]),
row["submitted_task_count"],
row["missing_task_count"],
]
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,28 @@
from django.core.management.base import BaseCommand
from prompt.models import Conversation
from submission.classifier import classify_conversation_messages
class Command(BaseCommand):
help = "为所有缺失 Bloom 等级的用户消息补全分类"
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="重新分类所有消息(包括已有等级的)",
)
def handle(self, *args, **options):
force = options["force"]
convs = Conversation.objects.all()
total = convs.count()
self.stdout.write(f"共 {total} 个对话,开始分类...")
for i, conv in enumerate(convs, 1):
classify_conversation_messages(conv.id, force=force)
self.stdout.write(f"[{i}/{total}] {conv}", ending="\r")
self.stdout.flush()
self.stdout.write(self.style.SUCCESS(f"\n完成,处理了 {total} 个对话。"))
@@ -0,0 +1,35 @@
from django.core.management.base import BaseCommand
from prompt.models import Message
from submission.classifier import classify_message
class Command(BaseCommand):
help = "Classify prompt levels (L1-L6) for user messages using LLM"
def add_arguments(self, parser):
parser.add_argument("--task-id", type=int, help="Only classify messages for this task ID")
parser.add_argument("--force", action="store_true", help="Re-classify already classified messages")
parser.add_argument("--dry-run", action="store_true", help="Show count without classifying")
def handle(self, *args, **options):
qs = Message.objects.filter(role="user")
if options["task_id"]:
qs = qs.filter(conversation__task_id=options["task_id"])
if not options["force"]:
qs = qs.filter(prompt_level__isnull=True)
ids = list(qs.values_list("id", flat=True))
self.stdout.write(f"Found {len(ids)} message(s) to classify.")
if options["dry_run"]:
self.stdout.write("Dry run — no changes made.")
return
for i, mid in enumerate(ids, 1):
level = classify_message(mid)
self.stdout.write(
f"[{i}/{len(ids)}] msg#{mid} → L{level}" if level else f"[{i}/{len(ids)}] msg#{mid} → (skipped)"
)
self.stdout.write(self.style.SUCCESS("Done."))
@@ -0,0 +1,27 @@
from django.core.management.base import BaseCommand
from submission.models import Submission
class Command(BaseCommand):
help = "Recalculate score, raw_score, and zone for all rated submissions"
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true", help="Show what would change without saving")
def handle(self, *args, **options):
dry_run = options["dry_run"]
qs = Submission.objects.filter(ratings__isnull=False).distinct()
total = qs.count()
self.stdout.write(f"Found {total} rated submission(s).")
if dry_run:
self.stdout.write("Dry run — no changes made.")
return
for i, s in enumerate(qs, 1):
old_score = s.score
s.update_score()
self.stdout.write(f"[{i}/{total}] {s.user.username}/{s.task.title}: {old_score:.3f} → {s.score:.3f} zone={s.zone}")
self.stdout.write(self.style.SUCCESS("Done."))
+38
View File
@@ -0,0 +1,38 @@
# Generated by Django 5.1.6 on 2025-03-18 11:36
import django.db.models.deletion
import django_extensions.db.fields
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('task', '0004_alter_task_task_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Submission',
fields=[
('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')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('score', models.IntegerField(default=0, verbose_name='分数')),
('html', models.TextField(blank=True, null=True, verbose_name='HTML代码')),
('css', models.TextField(blank=True, null=True, verbose_name='CSS代码')),
('js', models.TextField(blank=True, null=True, verbose_name='JS代码')),
('referee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='referee_submissions', to=settings.AUTH_USER_MODEL, verbose_name='打分人')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.task')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='my_submissions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-created',),
},
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.1.6 on 2025-03-18 15:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submission', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='submission',
name='referee',
),
migrations.AlterField(
model_name='submission',
name='score',
field=models.FloatField(default=0.0, verbose_name='分数'),
),
migrations.CreateModel(
name='Rating',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('score', models.IntegerField(default=0, verbose_name='分数')),
('created', models.DateTimeField(auto_now_add=True)),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='submission.submission')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'submission')},
},
),
]
@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-03-04 11:03
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('prompt', '0001_initial'),
('submission', '0002_remove_submission_referee_alter_submission_score_and_more'),
]
operations = [
migrations.AddField(
model_name='submission',
name='conversation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submissions', to='prompt.conversation', verbose_name='对话'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-09 09:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submission', '0003_submission_conversation'),
]
operations = [
migrations.AddField(
model_name='submission',
name='flag',
field=models.CharField(blank=True, choices=[('red', '值得展示'), ('blue', '需要讲解'), ('green', '优秀作品'), ('yellow', '需要改进')], default=None, max_length=10, null=True, verbose_name='标记'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-09 10:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submission', '0004_submission_flag'),
]
operations = [
migrations.AlterField(
model_name='submission',
name='flag',
field=models.CharField(blank=True, choices=[('red', '值得展示'), ('blue', '需要讲解'), ('green', '优秀作品'), ('yellow', '需要改进')], db_index=True, default=None, max_length=10, null=True, verbose_name='标记'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-03-16 11:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submission', '0005_add_flag_index'),
]
operations = [
migrations.AddField(
model_name='submission',
name='nominated',
field=models.BooleanField(db_index=True, default=False, verbose_name='参与排名'),
),
migrations.AddField(
model_name='submission',
name='raw_score',
field=models.FloatField(default=0.0, verbose_name='原始加权分'),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("submission", "0006_add_raw_score_nominated"),
]
operations = [
migrations.RemoveField(
model_name="submission",
name="nominated",
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-30 11:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submission', '0007_remove_nominated'),
]
operations = [
migrations.AddField(
model_name='submission',
name='zone',
field=models.CharField(blank=True, choices=[('featured', '精选'), ('low', '待改进'), ('pending', '待评')], db_index=True, default=None, max_length=10, null=True, verbose_name='分区'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-31 00:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submission', '0008_add_zone'),
]
operations = [
migrations.AddField(
model_name='submission',
name='view_count',
field=models.PositiveIntegerField(default=0, verbose_name='查看次数'),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 6.0.1 on 2026-03-31 06:52
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('submission', '0009_add_view_count'),
]
operations = [
migrations.RemoveField(
model_name='submission',
name='conversation',
),
]
@@ -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')},
},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submission", "0011_add_award_submissionaward"),
]
operations = [
migrations.AddField(
model_name="submissionaward",
name="is_stale",
field=models.BooleanField(default=False, verbose_name="有更新提交"),
),
]
Whitespace-only changes.
+215
View File
@@ -0,0 +1,215 @@
import uuid
from django.db import models
from django.db.models import Avg
from django_extensions.db.models import TimeStampedModel
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.db.models.signals import post_save
from django.dispatch import receiver # 导入receiver
from account.models import RoleChoices, User
from task.models import Task
class FlagChoices(models.TextChoices):
RED = "red", "值得展示"
BLUE = "blue", "需要讲解"
GREEN = "green", "优秀作品"
YELLOW = "yellow", "需要改进"
class ZoneChoices(models.TextChoices):
FEATURED = "featured", "精选"
LOW = "low", "待改进"
PENDING = "pending", "待评"
class Submission(TimeStampedModel):
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False,
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="my_submissions",
)
task = models.ForeignKey(Task, on_delete=models.CASCADE)
score = models.FloatField(default=0.0, verbose_name="分数")
html = models.TextField(null=True, blank=True, verbose_name="HTML代码")
css = models.TextField(null=True, blank=True, verbose_name="CSS代码")
js = models.TextField(null=True, blank=True, verbose_name="JS代码")
flag = models.CharField(
max_length=10,
choices=FlagChoices.choices,
null=True,
blank=True,
default=None,
db_index=True,
verbose_name="标记",
)
raw_score = models.FloatField(default=0.0, verbose_name="原始加权分")
zone = models.CharField(
max_length=10,
choices=ZoneChoices.choices,
null=True,
blank=True,
default=None,
db_index=True,
verbose_name="分区",
)
view_count = models.PositiveIntegerField(default=0, verbose_name="查看次数")
class Meta:
ordering = ("-created",)
def __str__(self):
return f"{self.user.username} - {self.task.title}"
def get_task_type(self):
"""
返回任务的具体类型(Challenge 或 Tutorial)
"""
return self.task.task_type
def _update_zone(self, n: int):
if n < 5:
new_zone = ZoneChoices.PENDING
elif self.score >= 4.0:
new_zone = ZoneChoices.FEATURED
elif self.score < 3.0:
new_zone = ZoneChoices.LOW
else:
new_zone = None
if self.zone != new_zone:
self.zone = new_zone
self.save(update_fields=["zone"])
def update_score(self):
ratings = list(self.ratings.select_related("user").all())
n = len(ratings)
if n == 0:
self.raw_score = 0.0
self.score = 0.0
self.save(update_fields=["raw_score", "score"])
self._update_zone(n)
return
role_weights = [
0.5 if r.user.role == RoleChoices.SUPER
else 0.3 if r.user.role == RoleChoices.ADMIN
else 0.2
for r in ratings
]
weighted_sum = sum(r.score * w for r, w in zip(ratings, role_weights))
weight_total = sum(role_weights)
self.raw_score = weighted_sum / weight_total
C = 3
global_mean = (
Submission.objects.filter(raw_score__gt=0)
.exclude(pk=self.pk)
.aggregate(Avg("raw_score"))["raw_score__avg"]
) or self.raw_score
self.score = (C * global_mean + n * self.raw_score) / (C + n)
self.save(update_fields=["raw_score", "score"])
self._update_zone(n)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
class Rating(models.Model):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="ratings",
)
submission = models.ForeignKey(
Submission,
on_delete=models.CASCADE,
related_name="ratings",
)
score = models.IntegerField(default=0, verbose_name="分数")
created = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("user", "submission")
def __str__(self):
return f"{self.user.username} 评分了 {self.submission.id},分数是 {self.score}"
def clean(self):
"""
在保存之前检查用户当天的评分次数
"""
today_start = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timezone.timedelta(days=1)
count = Rating.objects.filter(
user=self.user, created__range=(today_start, today_end)
).count()
if self.user.role == RoleChoices.NORMAL and count >= 30:
raise ValidationError("普通用户每天最多只能评分30次。")
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
@receiver(post_save, sender=Rating)
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="授奖时间"
)
is_stale = models.BooleanField(default=False, verbose_name="有更新提交")
class Meta:
unique_together = ("submission", "award")
ordering = ("sort_order",)
def __str__(self):
return f"{self.award.name} - {self.submission}"
+332
View File
@@ -0,0 +1,332 @@
from datetime import datetime
from typing import Optional, Literal
from ninja import Schema
from uuid import UUID
class SubmissionIn(Schema):
task_id: int
html: Optional[str] = None
css: Optional[str] = None
js: Optional[str] = None
prompt: Optional[str] = None
message_id: Optional[int] = None # 关联的 assistant message pk
class SubmissionOut(Schema):
id: UUID
userid: int
username: str
task_id: int
task_display: int
task_title: str
task_type: Literal["tutorial", "challenge"]
score: float
my_score: int = 0
html: Optional[str] = None
css: Optional[str] = None
js: Optional[str] = None
flag: Optional[str] = None
zone: Optional[str] = None
submit_count: int = 0
view_count: int = 0
created: str
modified: str
@staticmethod
def resolve_userid(obj):
return obj.user.id
@staticmethod
def resolve_username(obj):
return obj.user.username
@staticmethod
def resolve_task_id(obj):
return obj.task.id
@staticmethod
def resolve_task_display(obj):
return obj.task.display
@staticmethod
def resolve_task_title(obj):
return obj.task.title
@staticmethod
def resolve_task_type(obj):
return obj.task.task_type
@staticmethod
def resolve_my_score(obj):
return getattr(obj, "my_score", None) or 0
@staticmethod
def resolve_submit_count(obj):
return getattr(obj, "submit_count", None) or 0
@staticmethod
def resolve_created(obj):
return obj.created.isoformat()
@staticmethod
def resolve_modified(obj):
return obj.modified.isoformat()
@staticmethod
def get(submission, rating):
return {
"id": submission.id,
"userid": submission.user.id,
"username": submission.user.username,
"task_id": submission.task.id,
"task_display": submission.task.display,
"task_title": submission.task.title,
"task_type": submission.task.task_type,
"score": submission.score,
"my_score": rating.score if rating else 0,
"html": submission.html,
"css": submission.css,
"js": submission.js,
"flag": submission.flag,
"created": submission.created.isoformat(),
"modified": submission.modified.isoformat(),
}
class RatingScoreIn(Schema):
score: int
class SubmissionScoreOut(Schema):
id: UUID
score: float
class SubmissionFilter(Schema):
task_id: Optional[int] = None
task_type: Optional[Literal["tutorial", "challenge"]] = None
username: Optional[str] = None
user_id: Optional[int] = None
flag: Optional[Literal["red", "blue", "green", "yellow", "any"]] = None
zone: Optional[Literal["featured", "low", "pending"]] = None
score_min: Optional[float] = None
score_max_exclusive: Optional[float] = None
score_lt_threshold: Optional[float] = None
ordering: Optional[str] = None
grouped: Optional[bool] = True
class FlagIn(Schema):
flag: Optional[Literal["red", "blue", "green", "yellow"]] = None
class UserTag(Schema):
username: str
classname: str
class SubmissionCountBucket(Schema):
count_1: int # users with exactly 1 submission
count_2: int # users with exactly 2 submissions
count_3: int # users with exactly 3 submissions
count_4_plus: int # users with 4+ submissions
class TopViewedItem(Schema):
username: str
classname: str
view_count: int
submission_id: UUID
class FlagStats(Schema):
red: int
blue: int
green: int
yellow: int
class TaskStatsOut(Schema):
submitted_count: int
unsubmitted_count: int
average_score: Optional[float]
unrated_count: int
unsubmitted_users: list[UserTag]
unrated_users: list[UserTag]
submission_count_distribution: SubmissionCountBucket
flag_stats: FlagStats
classes: list[str]
top_viewed: list[TopViewedItem]
class GradebookTask(Schema):
id: int
display: int
title: str
task_type: Literal["tutorial", "challenge"]
submitted_count: int
coverage: float
included: bool
class GradebookCell(Schema):
score: float
submitted: bool
submission_id: Optional[UUID] = None
class GradebookRow(Schema):
user_id: int
username: str
classname: str
rank: int
grade: Literal["A", "B", "C", "D", "E"]
assessment_grade: Literal["A", "B", "C", "D", "E"]
assessment_score: int
top3_score: float
scores: dict[int, GradebookCell]
tutorial_total: float
challenge_total: float
total_score: float
average_score: Optional[float]
submitted_task_count: int
missing_task_count: int
class GradebookOut(Schema):
classname: str
classes: list[str]
task_count: int
included_task_count: int
student_count: int
coverage_threshold_count: int
tasks: list[GradebookTask]
rows: list[GradebookRow]
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 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
is_stale: bool = False
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
assistant_msg_id: Optional[int] = None
html: Optional[str] = None
css: Optional[str] = None
js: Optional[str] = None
class RandomRatingOut(Schema):
submission_id: UUID
username: str
task_title: str
task_display: int
task_type: Literal["tutorial", "challenge"]
html: Optional[str] = None
css: Optional[str] = None
js: Optional[str] = None
@staticmethod
def resolve_submission_id(obj):
return obj.id
@staticmethod
def resolve_username(obj):
return obj.user.username
@staticmethod
def resolve_task_title(obj):
return obj.task.title
@staticmethod
def resolve_task_display(obj):
return obj.task.display
@staticmethod
def resolve_task_type(obj):
return obj.task.task_type
+635
View File
@@ -0,0 +1,635 @@
import csv
import io
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from account.models import RoleChoices
from prompt.models import Conversation, Message
from task.models import Task
from .models import Award, Rating, Submission, SubmissionAward
User = get_user_model()
def _make_user(username, role=RoleChoices.NORMAL, classname=""):
user = User.objects.create_user(username=username, password="pw")
user.role = role
user.classname = classname
user.save(update_fields=["role", "classname"])
return user
def _make_task(
title="Test Challenge",
task_type="challenge",
display=1,
is_public=True,
):
return Task.objects.create(
title=title,
task_type=task_type,
display=display,
content="",
is_public=is_public,
)
class SubmissionPromptChainTest(TestCase):
def setUp(self):
self.viewer = _make_user("viewer")
self.author = _make_user("author")
self.task = _make_task()
viewer_conv = Conversation.objects.create(user=self.viewer, task=self.task)
Message.objects.create(
conversation=viewer_conv,
role="user",
content="viewer prompt",
)
Message.objects.create(
conversation=viewer_conv,
role="assistant",
content="viewer answer",
code_html="<p>viewer</p>",
)
author_conv = Conversation.objects.create(user=self.author, task=self.task)
Message.objects.create(
conversation=author_conv,
role="user",
content="author prompt",
)
self.submission = Submission.objects.create(
user=self.author,
task=self.task,
html="<button>author</button>",
css="button { color: red; }",
js="",
)
Message.objects.create(
conversation=author_conv,
role="assistant",
content="author answer",
code_html="<button>author</button>",
code_css="button { color: red; }",
code_js="",
submission=self.submission,
)
def test_normal_user_can_view_prompt_chain_for_another_users_submission(self):
self.client.force_login(self.viewer)
resp = self.client.get(f"/api/submission/{self.submission.id}/prompt-chain")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["question"], "author prompt")
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)
class GradebookApiTest(TestCase):
def setUp(self):
self.super = _make_user("grade-super", role=RoleChoices.SUPER)
self.admin = _make_user("grade-admin", role=RoleChoices.ADMIN)
self.normal = _make_user("grade-normal", classname="blocked")
def _student(self, username, classname="10A"):
return _make_user(username, classname=classname)
def _submit(self, user, task, score, created=None):
submission = Submission.objects.create(
user=user,
task=task,
score=score,
html="",
css="",
js="",
)
if created is not None:
Submission.objects.filter(pk=submission.pk).update(created=created)
submission.refresh_from_db()
return submission
def test_gradebook_requires_classname(self):
self.client.force_login(self.super)
resp = self.client.get("/api/submission/gradebook/")
self.assertEqual(resp.status_code, 400)
self.assertEqual(resp.json()["detail"], "请选择班级")
def test_normal_user_cannot_access_gradebook(self):
self.client.force_login(self.normal)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
export_resp = self.client.get(
"/api/submission/gradebook/export/?classname=10A"
)
self.assertIn(resp.status_code, (302, 403))
self.assertIn(export_resp.status_code, (302, 403))
def test_admin_user_cannot_access_gradebook(self):
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
export_resp = self.client.get(
"/api/submission/gradebook/export/?classname=10A"
)
self.assertIn(resp.status_code, (302, 403))
self.assertIn(export_resp.status_code, (302, 403))
def test_admin_user_is_counted_in_class_gradebook(self):
self._student("alice")
admin_in_class = _make_user(
"grade-admin-2", role=RoleChoices.ADMIN, classname="10A"
)
task = _make_task(title="Admin Visible", task_type="challenge", display=9)
self._submit(admin_in_class, task, 5.0)
self.client.force_login(self.super)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["student_count"], 2)
usernames = [row["username"] for row in data["rows"]]
self.assertIn("grade-admin-2", usernames)
def test_coverage_includes_tutorial_and_challenge_without_public_requirement(self):
students = [
self._student("alice"),
self._student("bob"),
self._student("carol"),
self._student("dave"),
]
tutorial = _make_task(
title="Intro",
task_type="tutorial",
display=1,
is_public=False,
)
challenge = _make_task(
title="Challenge One",
task_type="challenge",
display=1,
is_public=True,
)
low_coverage = _make_task(
title="Optional",
task_type="challenge",
display=2,
is_public=True,
)
self._submit(students[0], tutorial, 4.0)
self._submit(students[1], tutorial, 5.0)
self._submit(students[0], challenge, 3.0)
self._submit(students[1], challenge, 4.0)
self._submit(students[0], low_coverage, 5.0)
self.client.force_login(self.super)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["student_count"], 4)
self.assertEqual(data["coverage_threshold_count"], 2)
self.assertEqual(data["included_task_count"], 2)
self.assertEqual(
[task["id"] for task in data["tasks"]],
[tutorial.id, challenge.id],
)
self.assertTrue(all(task["included"] for task in data["tasks"]))
alice = next(row for row in data["rows"] if row["username"] == "alice")
carol = next(row for row in data["rows"] if row["username"] == "carol")
self.assertEqual(alice["tutorial_total"], 4.0)
self.assertEqual(alice["challenge_total"], 3.0)
self.assertEqual(alice["total_score"], 7.0)
self.assertEqual(alice["average_score"], 3.5)
self.assertEqual(alice["submitted_task_count"], 2)
self.assertEqual(alice["missing_task_count"], 0)
self.assertEqual(carol["total_score"], 0.0)
self.assertEqual(carol["submitted_task_count"], 0)
self.assertEqual(carol["missing_task_count"], 2)
include_all_resp = self.client.get(
"/api/submission/gradebook/?classname=10A&include_all_tasks=true"
)
include_all = include_all_resp.json()
optional = next(
task for task in include_all["tasks"] if task["id"] == low_coverage.id
)
alice_all = next(
row for row in include_all["rows"] if row["username"] == "alice"
)
self.assertFalse(optional["included"])
self.assertTrue(alice_all["scores"][str(low_coverage.id)]["submitted"])
self.assertEqual(alice_all["scores"][str(low_coverage.id)]["score"], 5.0)
self.assertEqual(alice_all["total_score"], 7.0)
self.assertEqual(alice_all["submitted_task_count"], 2)
def test_best_submission_uses_highest_score_and_latest_equal_score_link(self):
alice = self._student("alice")
bob = self._student("bob")
task = _make_task(title="Best Score", task_type="tutorial", display=3)
older = timezone.now() - timedelta(days=2)
newer = timezone.now() - timedelta(days=1)
self._submit(alice, task, 2.0)
old_best = self._submit(alice, task, 4.5, created=older)
new_best = self._submit(alice, task, 4.5, created=newer)
self._submit(bob, task, 3.0)
self.client.force_login(self.super)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
alice_row = next(
row for row in resp.json()["rows"] if row["username"] == "alice"
)
cell = alice_row["scores"][str(task.id)]
self.assertEqual(cell["score"], 4.5)
self.assertEqual(cell["submission_id"], str(new_best.id))
self.assertNotEqual(cell["submission_id"], str(old_best.id))
def test_task_type_and_username_filters_keep_full_class_rank(self):
alice = self._student("alice")
bob = self._student("bob")
tutorial = _make_task(title="Tutorial", task_type="tutorial", display=1)
challenge = _make_task(title="Challenge", task_type="challenge", display=1)
self._submit(alice, tutorial, 1.0)
self._submit(bob, tutorial, 5.0)
self._submit(alice, challenge, 5.0)
self._submit(bob, challenge, 1.0)
self.client.force_login(self.super)
resp = self.client.get(
"/api/submission/gradebook/?classname=10A&task_type=tutorial&username=alice"
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual([task["task_type"] for task in data["tasks"]], ["tutorial"])
self.assertEqual(len(data["rows"]), 1)
self.assertEqual(data["rows"][0]["username"], "alice")
self.assertEqual(data["rows"][0]["rank"], 2)
def test_missing_class_returns_empty_table_with_class_options(self):
self._student("alice")
self.client.force_login(self.super)
resp = self.client.get("/api/submission/gradebook/?classname=missing")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn("10A", data["classes"])
self.assertEqual(data["student_count"], 0)
self.assertEqual(data["coverage_threshold_count"], 0)
self.assertEqual(data["tasks"], [])
self.assertEqual(data["rows"], [])
def test_no_included_tasks_still_returns_student_rows(self):
students = [
self._student("alice"),
self._student("bob"),
self._student("carol"),
]
optional = _make_task(title="Low Coverage", task_type="challenge", display=8)
self._submit(students[0], optional, 5.0)
self.client.force_login(self.super)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["student_count"], 3)
self.assertEqual(data["coverage_threshold_count"], 2)
self.assertEqual(data["tasks"], [])
self.assertEqual(data["included_task_count"], 0)
self.assertEqual(len(data["rows"]), 3)
alice = next(row for row in data["rows"] if row["username"] == "alice")
self.assertEqual(alice["total_score"], 0.0)
self.assertIsNone(alice["average_score"])
self.assertEqual(alice["submitted_task_count"], 0)
self.assertEqual(alice["missing_task_count"], 0)
include_all_resp = self.client.get(
"/api/submission/gradebook/?classname=10A&include_all_tasks=true"
)
include_all = include_all_resp.json()
self.assertEqual(include_all["task_count"], 1)
self.assertFalse(include_all["tasks"][0]["included"])
alice_all = next(
row for row in include_all["rows"] if row["username"] == "alice"
)
self.assertTrue(alice_all["scores"][str(optional.id)]["submitted"])
self.assertEqual(alice_all["total_score"], 0.0)
def test_grade_boundaries_use_ceil_thresholds(self):
task = _make_task(title="Boundary", task_type="challenge", display=7)
for i in range(1, 21):
student = self._student(f"s{i:02d}", classname="10B")
self._submit(student, task, float(21 - i))
self.client.force_login(self.super)
resp = self.client.get("/api/submission/gradebook/?classname=10B")
self.assertEqual(resp.status_code, 200)
rows_by_name = {row["username"]: row for row in resp.json()["rows"]}
self.assertEqual(rows_by_name["s01"]["grade"], "A")
self.assertEqual(rows_by_name["s06"]["grade"], "A")
self.assertEqual(rows_by_name["s07"]["grade"], "B")
self.assertEqual(rows_by_name["s14"]["grade"], "B")
self.assertEqual(rows_by_name["s15"]["grade"], "C")
self.assertEqual(rows_by_name["s18"]["grade"], "C")
self.assertEqual(rows_by_name["s19"]["grade"], "D")
self.assertEqual(rows_by_name["s20"]["grade"], "E")
def test_export_csv_matches_current_filters(self):
alice = self._student("alice")
bob = self._student("bob")
tutorial = _make_task(title="Intro", task_type="tutorial", display=1)
challenge = _make_task(title="Challenge", task_type="challenge", display=1)
self._submit(alice, tutorial, 4.0)
self._submit(bob, tutorial, 2.0)
self._submit(alice, challenge, 5.0)
self.client.force_login(self.super)
resp = self.client.get(
"/api/submission/gradebook/export/?classname=10A&task_type=tutorial"
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp["Content-Type"], "text/csv; charset=utf-8")
self.assertIn("attachment;", resp["Content-Disposition"])
rows = list(csv.reader(io.StringIO(resp.content.decode("utf-8-sig"))))
self.assertEqual(
rows[0],
[
"排名",
"平时等级",
"考核等级",
"考核分数",
"用户名",
"班级",
"教程1-Intro",
"教程合计",
"挑战合计",
"总分",
"平均分",
"已提交任务数",
"未提交任务数",
],
)
self.assertEqual(rows[1][4], "alice")
self.assertEqual(rows[1][6], "4")
self.assertEqual(rows[1][9], "4")
class RandomForRatingApiTest(TestCase):
def setUp(self):
self.viewer = _make_user("viewer")
self.author = _make_user("author")
self.task = _make_task()
def _submission(self, user, html="<div>work</div>"):
return Submission.objects.create(
user=user, task=self.task, html=html, css="", js=""
)
def test_excludes_own_and_already_rated_submissions(self):
self._submission(self.viewer) # 自己的提交,应排除
rated = self._submission(self.author)
Rating.objects.create(user=self.viewer, submission=rated, score=4)
eligible = self._submission(self.author)
self.client.force_login(self.viewer)
resp = self.client.get("/api/submission/random-for-rating/")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIsNotNone(data)
self.assertEqual(data["submission_id"], str(eligible.id))
self.assertEqual(data["username"], "author")
self.assertEqual(data["task_title"], self.task.title)
self.assertEqual(data["task_display"], self.task.display)
self.assertEqual(data["task_type"], "challenge")
def test_prefers_submission_with_fewer_than_five_ratings(self):
raters = [_make_user(f"rater{i}") for i in range(5)]
many_ratings = self._submission(self.author, html="<div>many</div>")
for rater in raters:
Rating.objects.create(user=rater, submission=many_ratings, score=3)
few_ratings = self._submission(self.author, html="<div>few</div>")
for rater in raters[:2]:
Rating.objects.create(user=rater, submission=few_ratings, score=3)
self.client.force_login(self.viewer)
resp = self.client.get("/api/submission/random-for-rating/")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["submission_id"], str(few_ratings.id))
def test_exclude_id_param_excludes_given_submission(self):
only = self._submission(self.author)
self.client.force_login(self.viewer)
resp = self.client.get(
f"/api/submission/random-for-rating/?exclude_id={only.id}"
)
self.assertEqual(resp.status_code, 200)
self.assertIsNone(resp.json())
def test_normal_user_reaching_daily_cap_returns_none(self):
self._submission(self.author, html="<div>extra</div>")
for i in range(30):
target = self._submission(self.author, html=f"<div>{i}</div>")
Rating.objects.create(user=self.viewer, submission=target, score=3)
self.client.force_login(self.viewer)
resp = self.client.get("/api/submission/random-for-rating/")
self.assertEqual(resp.status_code, 200)
self.assertIsNone(resp.json())