diff --git a/submission/api.py b/submission/api.py index 9dd357f..35a1a4e 100644 --- a/submission/api.py +++ b/submission/api.py @@ -20,6 +20,7 @@ from django.db.models import ( Q, Subquery, ) +from django.utils import timezone from account.decorators import admin_required from prompt.models import Conversation, Message from .classifier import classify_conversation_messages @@ -44,6 +45,7 @@ from .schemas import ( SubmissionIn, SubmissionOut, RatingScoreIn, + RandomRatingOut, TaskStatsOut, TopViewedItem, UserTag, @@ -288,6 +290,37 @@ def list_by_user_task(request, user_id: int, task_id: int): ) +@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): diff --git a/submission/schemas.py b/submission/schemas.py index 13f60cd..dab2e87 100644 --- a/submission/schemas.py +++ b/submission/schemas.py @@ -296,3 +296,34 @@ class PromptRoundOut(Schema): 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 diff --git a/submission/tests.py b/submission/tests.py index 6faf14e..b09f9b7 100644 --- a/submission/tests.py +++ b/submission/tests.py @@ -10,7 +10,7 @@ from account.models import RoleChoices from prompt.models import Conversation, Message from task.models import Task -from .models import Award, Submission, SubmissionAward +from .models import Award, Rating, Submission, SubmissionAward User = get_user_model() @@ -533,3 +533,72 @@ class GradebookApiTest(TestCase): self.assertEqual(rows[1][2], "alice") self.assertEqual(rows[1][4], "4") self.assertEqual(rows[1][7], "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="
work
"): + 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="
many
") + for rater in raters: + Rating.objects.create(user=rater, submission=many_ratings, score=3) + few_ratings = self._submission(self.author, html="
few
") + 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="
extra
") + for i in range(30): + target = self._submission(self.author, html=f"
{i}
") + 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())