import threading 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, Exists, F, IntegerField, Max, OuterRef, Q, Subquery, ) from account.decorators import admin_required from prompt.models import Conversation, Message from .schemas import ( AwardItemIn, AwardItemManageOut, AwardItemUpdateIn, AwardManageIn, AwardManageOut, FlagIn, FlagStats, AwardOut, PromptRoundOut, ShowcaseDetailOut, ShowcaseItemOut, ShowcaseSubmissionLookupOut, SubmissionCountBucket, SubmissionFilter, SubmissionIn, SubmissionOut, RatingScoreIn, TaskStatsOut, TopViewedItem, UserTag, ) from .models import Award, ItemOrdering, Rating, Submission, SubmissionAward from task.models import Task from account.models import RoleChoices, User router = Router() 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, } 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) manual_asst_msg = None if payload.prompt: conversation = ( Conversation.objects.filter(user=request.user, task=task) .annotate(msg_count=Count("messages")) .order_by("-msg_count", "-created") .first() ) if not conversation: conversation = Conversation.objects.create( user=request.user, task=task, is_active=False ) Message.objects.create( conversation=conversation, role="user", content=payload.prompt, source="manual" ) 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", ) from .classifier import classify_conversation_messages threading.Thread(target=classify_conversation_messages, args=(conversation.id,), daemon=True).start() else: conversation = ( Conversation.objects.filter(user=request.user, task=task) .annotate(msg_count=Count("messages")) .order_by("-msg_count", "-created") .first() ) if conversation: from .classifier import classify_conversation_messages threading.Thread(target=classify_conversation_messages, args=(conversation.id,), daemon=True).start() 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 payload.message_id: try: msg = Message.objects.get( id=payload.message_id, role="assistant", conversation__user=request.user, conversation__task=task, ) msg.submission = submission msg.save(update_fields=["submission"]) except Message.DoesNotExist: pass # invalid message_id — submission already created, silently skip 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.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, "只能删除自己的提交") # 找到关联的助手消息,再找前一条用户消息 asst_msg = Message.objects.filter(submission=submission).first() user_msg = None if asst_msg: user_msg = ( Message.objects.filter( conversation=asst_msg.conversation, created__lt=asst_msg.created, role="user", ) .order_by("-created") .first() ) 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("/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.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}