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
@@ -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}
|
||||
Reference in new issue
Block a user