chore: 将后端移动到 api/ 目录,准备合并为单仓库
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e172ffc0b3
commit
0591eda56f
244 files changed
No files matched your search
Whitespace-only changes.
@@ -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
|
||||
@@ -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}
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SubmissionConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'submission'
|
||||
@@ -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)
|
||||
@@ -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."))
|
||||
@@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
+38
@@ -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.
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user