Files
webprewviewapi/submission/api.py
2026-03-18 19:46:27 +08:00

441 lines
14 KiB
Python

from typing import List, Optional
from uuid import UUID
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, IntegerField, Max, OuterRef, Q, Subquery
from .schemas import (
FlagIn,
FlagStats,
SubmissionCountBucket,
SubmissionFilter,
SubmissionIn,
SubmissionOut,
RatingScoreIn,
ScoreBucket,
TaskStatsOut,
TopSubmission,
UserTag,
)
from .models import Rating, Submission
from task.models import Task
from account.models import RoleChoices, User
router = Router()
@router.post("/")
@login_required
def create_submission(request, payload: SubmissionIn):
"""
创建一个新的提交
"""
task = get_object_or_404(Task, id=payload.task_id)
conversation = None
if payload.conversation_id:
from prompt.models import Conversation
conversation = get_object_or_404(
Conversation, id=payload.conversation_id, user=request.user
)
conversation.is_active = False
conversation.save(update_fields=["is_active"])
# 如果用户之前已参与排名,自动转移提名到新提交
had_nomination = Submission.objects.filter(
user=request.user, task=task, nominated=True
).update(nominated=False) > 0
Submission.objects.create(
user=request.user,
task=task,
html=payload.html,
css=payload.css,
js=payload.js,
conversation=conversation,
nominated=had_nomination,
)
@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.nominated is not None:
submissions = submissions.filter(nominated=filters.nominated)
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.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:
raise HttpError(403, "只能删除自己的提交")
submission.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")
]
# Nominated count: distinct users with nominated=True (task-wide, not class-filtered)
nominated_count = (
Submission.objects.filter(task=task, nominated=True)
.values("user_id")
.distinct()
.count()
)
# 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
# Score distribution from latest submissions (exclude unrated score=0).
# Rating scale is 1-5 stars; one bucket per star level.
score_dist = {
"range_1_2": 0, "range_2_3": 0, "range_3_4": 0,
"range_4_5": 0, "range_5": 0,
}
for sub in latest_subs:
if sub.score == 0:
continue
s = sub.score
if s >= 5:
score_dist["range_5"] += 1
elif s >= 4:
score_dist["range_4_5"] += 1
elif s >= 3:
score_dist["range_3_4"] += 1
elif s >= 2:
score_dist["range_2_3"] += 1
else:
score_dist["range_1_2"] += 1
# Top 5 submissions by rating count
top_subs_qs = (
Submission.objects.filter(task=task, user_id__in=student_ids)
.select_related("user")
.annotate(rating_count=Count("ratings"))
.order_by("-rating_count")[:5]
)
top_submissions = [
TopSubmission(
submission_id=str(s.id),
username=s.user.username,
classname=s.user.classname,
score=s.score,
rating_count=s.rating_count,
)
for s in top_subs_qs
]
# 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),
)
return TaskStatsOut(
submitted_count=submitted_count,
unsubmitted_count=unsubmitted_count,
average_score=average_score,
unrated_count=unrated_count,
nominated_count=nominated_count,
unsubmitted_users=unsubmitted_users,
unrated_users=unrated_users,
submission_count_distribution=SubmissionCountBucket(**dist),
score_distribution=ScoreBucket(**score_dist),
top_submissions=top_submissions,
flag_stats=flag_stats,
classes=all_classes,
)
@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.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}
@router.put("/{submission_id}/nominate")
@login_required
def nominate_submission(request, submission_id: UUID):
"""
学生将某条提交标记为"参与排名"
同一用户同一题目只能有一条参与排名,旧的自动取消。
"""
submission = get_object_or_404(Submission, id=submission_id)
if submission.user != request.user:
raise HttpError(403, "只能提名自己的提交")
Submission.objects.filter(
user=request.user,
task=submission.task,
nominated=True,
).exclude(pk=submission.pk).update(nominated=False)
submission.nominated = True
submission.save(update_fields=["nominated"])
return {"nominated": True}