diff --git a/account/api.py b/account/api.py index 00f6e58..91ef8a1 100644 --- a/account/api.py +++ b/account/api.py @@ -6,6 +6,7 @@ from ninja.pagination import paginate from ninja.errors import HttpError from .schemas import ( BatchUsersIn, + ClassStudentEntry, LeaderboardEntry, UserListSchema, UserRegistrationSchema, @@ -96,7 +97,7 @@ def batch_create(request, payload: BatchUsersIn): # 批量创建用户 for username in usernames: password = generate_password() - user = User(username=username) + user = User(username=username, classname=payload.classname) user.set_password(password) users_to_create.append(user) @@ -135,3 +136,28 @@ def leaderboard(request): LeaderboardEntry(rank=i + 1, username=p.user.username, total_score=p.total_score) for i, p in enumerate(profiles) ] + + +@router.get("/classes", response=List[str]) +def list_classes(request): + """返回所有不重复的非空班级名列表,按字典序升序""" + return ( + User.objects.filter(classname__gt="") + .values_list("classname", flat=True) + .distinct() + .order_by("classname") + ) + + +@router.get("/names", response=List[ClassStudentEntry]) +def list_names_by_class(request, classname: str): + """返回指定班级的学生姓名和用户名列表""" + prefix = "web" + classname + users = User.objects.filter( + classname=classname, + username__startswith=prefix, + ).order_by("username") + return [ + ClassStudentEntry(name=u.username[len(prefix):], username=u.username) + for u in users + ] diff --git a/account/migrations/0003_user_classname.py b/account/migrations/0003_user_classname.py new file mode 100644 index 0000000..cb78f24 --- /dev/null +++ b/account/migrations/0003_user_classname.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-18 02:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0002_alter_profile_total_score'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='classname', + field=models.CharField(blank=True, default='', max_length=50, verbose_name='班级'), + ), + ] diff --git a/account/models.py b/account/models.py index 4ea60a0..8e2a344 100644 --- a/account/models.py +++ b/account/models.py @@ -27,6 +27,12 @@ class User(AbstractUser): blank=True, verbose_name="明文密码", ) + classname = models.CharField( + max_length=50, + blank=True, + default="", + verbose_name="班级", + ) def save(self, *args, **kwargs): if self.username: diff --git a/account/schemas.py b/account/schemas.py index b082c02..a687b6f 100644 --- a/account/schemas.py +++ b/account/schemas.py @@ -52,3 +52,8 @@ class LeaderboardEntry(Schema): rank: int username: str total_score: float + + +class ClassStudentEntry(Schema): + name: str + username: str diff --git a/api/settings.py b/api/settings.py index 8966b0f..8353eaa 100644 --- a/api/settings.py +++ b/api/settings.py @@ -205,4 +205,4 @@ MEDIA_ROOT = BASE_DIR / "media" # LLM Configuration LLM_API_KEY = os.environ.get("LLM_API_KEY", "") LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.deepseek.com") -LLM_MODEL = os.environ.get("LLM_MODEL", "deepseek-chat") +LLM_MODEL = os.environ.get("LLM_MODEL", "deepseek-reasoner") diff --git a/submission/api.py b/submission/api.py index 402b181..5306d0a 100644 --- a/submission/api.py +++ b/submission/api.py @@ -5,7 +5,7 @@ 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 OuterRef, Subquery, IntegerField +from django.db.models import Count, OuterRef, Q, Subquery, IntegerField from .schemas import ( @@ -40,6 +40,11 @@ def create_submission(request, payload: SubmissionIn): ) 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, @@ -47,6 +52,7 @@ def create_submission(request, payload: SubmissionIn): css=payload.css, js=payload.js, conversation=conversation, + nominated=had_nomination, ) @@ -68,8 +74,34 @@ def list_submissions(request, filters: SubmissionFilter = Query(...)): 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: - submissions = submissions.filter(flag=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( @@ -79,6 +111,15 @@ def list_submissions(request, filters: SubmissionFilter = Query(...)): ) 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 @@ -104,6 +145,50 @@ def my_scores(request): +@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("/{submission_id}", response=SubmissionOut) @login_required def get_submission(request, submission_id: UUID): @@ -161,3 +246,27 @@ def update_flag(request, submission_id: UUID, payload: FlagIn): 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} diff --git a/submission/management/__init__.py b/submission/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/submission/migrations/0006_add_raw_score_nominated.py b/submission/migrations/0006_add_raw_score_nominated.py new file mode 100644 index 0000000..ee31ebe --- /dev/null +++ b/submission/migrations/0006_add_raw_score_nominated.py @@ -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='原始加权分'), + ), + ] diff --git a/submission/models.py b/submission/models.py index 1413c95..6fd1a27 100644 --- a/submission/models.py +++ b/submission/models.py @@ -1,5 +1,6 @@ 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 @@ -47,6 +48,8 @@ class Submission(TimeStampedModel): db_index=True, verbose_name="标记", ) + raw_score = models.FloatField(default=0.0, verbose_name="原始加权分") + nominated = models.BooleanField(default=False, db_index=True, verbose_name="参与排名") class Meta: ordering = ("-created",) @@ -61,30 +64,32 @@ class Submission(TimeStampedModel): return self.task.task_type def update_score(self): - """ - 更新当前Submission的分数 - """ ratings = list(self.ratings.select_related("user").all()) + n = len(ratings) - super_score = 0.0 - admin_score = 0.0 - normal_score = 0.0 - - for rating in ratings: - if rating.user.role == RoleChoices.SUPER: - super_score += rating.score - elif rating.user.role == RoleChoices.ADMIN: - admin_score += rating.score - else: - normal_score += rating.score - - if ratings: - total_score = super_score * 0.5 + admin_score * 0.3 + normal_score * 0.2 - self.score = total_score / len(ratings) - else: + if n == 0: + self.raw_score = 0.0 self.score = 0.0 + self.save(update_fields=["raw_score", "score"]) + return - self.save(update_fields=["score"]) + weighted_sum = sum( + r.score * (0.5 if r.user.role == RoleChoices.SUPER + else 0.3 if r.user.role == RoleChoices.ADMIN + else 0.2) + for r in ratings + ) + self.raw_score = weighted_sum / n + + 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"]) def save(self, *args, **kwargs): super().save(*args, **kwargs) diff --git a/submission/schemas.py b/submission/schemas.py index 822667e..1f4941d 100644 --- a/submission/schemas.py +++ b/submission/schemas.py @@ -26,6 +26,8 @@ class SubmissionOut(Schema): js: Optional[str] = None conversation_id: Optional[UUID] = None flag: Optional[str] = None + nominated: bool = False + submit_count: int = 0 created: str modified: str @@ -57,6 +59,10 @@ class SubmissionOut(Schema): 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() @@ -82,6 +88,7 @@ class SubmissionOut(Schema): "js": submission.js, "conversation_id": submission.conversation_id, "flag": submission.flag, + "nominated": submission.nominated, "created": submission.created.isoformat(), "modified": submission.modified.isoformat(), } @@ -100,7 +107,14 @@ class SubmissionFilter(Schema): task_id: Optional[int] = None task_type: Optional[Literal["tutorial", "challenge"]] = None username: Optional[str] = None - flag: Optional[Literal["red", "blue", "green", "yellow"]] = None + user_id: Optional[int] = None + flag: Optional[Literal["red", "blue", "green", "yellow", "any"]] = None + score_min: Optional[float] = None + score_max_exclusive: Optional[float] = None + score_lt_threshold: Optional[float] = None + nominated: Optional[bool] = None + ordering: Optional[str] = None + grouped: Optional[bool] = True class FlagIn(Schema):