Skip to content

Commit fbd3cc1

Browse files
authored
#597 대회 랭킹 페이지 오류 수정 및 디자인 개선 (#605)
* 대회 랭킹 상태 표시 개선 * 대회 랭킹 페이지에서 제출 수 표시 스타일 개선 및 API 타겟 설정 수정 * 대회 참가자 API에서 닉네임 변경된 사용자가 다른 2명으로 보이던 문제 해결 * 대회 랭킹 페이지에서 로딩 상태 표시 개선 및 사용자 데이터가 없을 때의 조건 수정 * 대회 랭킹 페이지에서 제출 상태(WA) 색상 스타일 수정 * 대회 랭킹 페이지 스타일 추가 개선 및 ACM OI 랭킹 디자인 통일 * contest 랭킹 로딩 실패 처리 및 참가자 정보 fallback 보완 * 최신 제출 기준으로 참가자 fallback 조회 및 ACM 랭킹 가드 추가 * 잘못된 코드 수정(index.js) 제거 * 대회 참가자 API contest_id 검증 추가 및 문제 목록 조회 실패 표시 * 대회 참가자 목록 요청 검증 강화 및 응답 이메일 타입 정리
1 parent c3e9f68 commit fbd3cc1

9 files changed

Lines changed: 493 additions & 85 deletions

File tree

backend/contest/serializers.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88

99
class ContestUserSubmissionSummarySerializer(serializers.Serializer):
1010
user_id = serializers.IntegerField()
11-
username = serializers.CharField()
12-
email = serializers.EmailField()
13-
avatar = serializers.CharField()
14-
school = serializers.CharField()
15-
major = serializers.CharField()
11+
username = serializers.CharField(allow_blank=True)
12+
email = serializers.EmailField(allow_blank=True, required=False)
13+
avatar = serializers.CharField(allow_blank=True)
14+
school = serializers.CharField(allow_blank=True)
15+
major = serializers.CharField(allow_blank=True)
1616
submission_count = serializers.IntegerField()
17-
last_submission_ip = serializers.CharField()
17+
last_submission_ip = serializers.CharField(allow_blank=True)
1818

1919

2020
class CreateConetestSeriaizer(serializers.Serializer):

backend/contest/tests.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import copy
22
from datetime import datetime, timedelta
33

4+
from django.conf import settings
45
from django.utils import timezone
56

67
from utils.api.tests import APITestCase
@@ -181,3 +182,111 @@ def setUp(self):
181182
def get_contest_rank(self):
182183
resp = self.client.get(self.url + "?contest_id=" + self.acm_contest.id)
183184
self.assertSuccess(resp)
185+
186+
187+
class ContestParticipantsAPITest(APITestCase):
188+
189+
def setUp(self):
190+
from problem.tests import DEFAULT_PROBLEM_DATA, ProblemCreateTestBase
191+
192+
self.create_school_fixtures(college_id=1, college_name="Test", department_id=1, department_name="Test")
193+
self.admin = self.create_admin()
194+
self.user = self.create_user(email="test@test.com", username="test", password="test1234!", login=False)
195+
self.contest = Contest.objects.create(created_by=self.admin, **DEFAULT_CONTEST_DATA)
196+
self.problem = ProblemCreateTestBase.add_problem(DEFAULT_PROBLEM_DATA, self.admin)
197+
self.problem.contest_id = self.contest.id
198+
self.problem.save()
199+
self.url = self.reverse("contest_participants_api")
200+
201+
def test_participants_are_grouped_by_user_id(self):
202+
from submission.models import JudgeStatus, Submission
203+
204+
Submission.objects.create(
205+
user_id=self.user.id,
206+
username="oldname",
207+
language="C++",
208+
code="test code",
209+
problem_id=self.problem.id,
210+
ip="127.0.0.1",
211+
contest_id=self.contest.id,
212+
result=JudgeStatus.PENDING,
213+
statistic_info={"time_cost": "100", "memory_cost": "1024"},
214+
shared=False,
215+
first_failed_tc_idx=None,
216+
)
217+
Submission.objects.create(
218+
user_id=self.user.id,
219+
username="newname",
220+
language="C++",
221+
code="test code",
222+
problem_id=self.problem.id,
223+
ip="127.0.0.1",
224+
contest_id=self.contest.id,
225+
result=JudgeStatus.PENDING,
226+
statistic_info={"time_cost": "100", "memory_cost": "1024"},
227+
shared=False,
228+
first_failed_tc_idx=None,
229+
)
230+
231+
resp = self.client.get(f"{self.url}?contest_id={self.contest.id}")
232+
self.assertSuccess(resp)
233+
234+
participants = resp.data["data"]
235+
self.assertEqual(len(participants), 1)
236+
self.assertEqual(participants[0]["user_id"], self.user.id)
237+
self.assertEqual(participants[0]["username"], self.user.username)
238+
self.assertEqual(participants[0]["submission_count"], 2)
239+
240+
def test_participants_requires_contest_id(self):
241+
resp = self.client.get(self.url)
242+
self.assertFailed(resp, "Invalid parameter, contest_id is required")
243+
244+
def test_participants_rejects_invalid_contest_id(self):
245+
resp = self.client.get(f"{self.url}?contest_id=abc")
246+
self.assertFailed(resp, "Invalid parameter, contest_id is required")
247+
248+
def test_participants_fallback_to_submission_username_when_user_is_missing(self):
249+
from submission.models import JudgeStatus, Submission
250+
251+
older_submission = Submission.objects.create(
252+
user_id=self.user.id,
253+
username="zzz_user",
254+
language="C++",
255+
code="test code",
256+
problem_id=self.problem.id,
257+
ip="9.9.9.9",
258+
contest_id=self.contest.id,
259+
result=JudgeStatus.PENDING,
260+
statistic_info={"time_cost": "100", "memory_cost": "1024"},
261+
shared=False,
262+
first_failed_tc_idx=None,
263+
)
264+
latest_submission = Submission.objects.create(
265+
user_id=self.user.id,
266+
username="aaa_user",
267+
language="C++",
268+
code="test code",
269+
problem_id=self.problem.id,
270+
ip="1.1.1.1",
271+
contest_id=self.contest.id,
272+
result=JudgeStatus.PENDING,
273+
statistic_info={"time_cost": "100", "memory_cost": "1024"},
274+
shared=False,
275+
first_failed_tc_idx=None,
276+
)
277+
Submission.objects.filter(id=older_submission.id).update(create_time=timezone.now() - timedelta(minutes=1))
278+
Submission.objects.filter(id=latest_submission.id).update(create_time=timezone.now())
279+
280+
self.user.delete()
281+
282+
resp = self.client.get(f"{self.url}?contest_id={self.contest.id}")
283+
self.assertSuccess(resp)
284+
285+
participants = resp.data["data"]
286+
self.assertEqual(len(participants), 1)
287+
self.assertEqual(participants[0]["username"], "aaa_user")
288+
self.assertEqual(participants[0]["email"], "")
289+
self.assertEqual(participants[0]["avatar"], f"{settings.AVATAR_URI_PREFIX}/default.png")
290+
self.assertEqual(participants[0]["school"], "")
291+
self.assertEqual(participants[0]["major"], "")
292+
self.assertEqual(participants[0]["last_submission_ip"], "1.1.1.1")

backend/contest/views/oj.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from django.db.models import OuterRef, Count, Subquery, F, Max
1+
from django.conf import settings
2+
from django.db.models import OuterRef, Count, Subquery
23
from django.http import HttpResponse
34
from django.utils.timezone import now
45
from django.core.cache import cache
@@ -8,7 +9,7 @@
89
from utils.contest_ranking_writer import ContestRankingWriter
910
from utils.shortcuts import datetime2str, check_is_id
1011
from account.models import AdminType, User, UserProfile
11-
from account.decorators import login_required, check_contest_permission, check_contest_password
12+
from account.decorators import login_required, check_contest_permission, check_contest_password, ensure_created_by
1213

1314
from utils.constants import ContestRuleType, ContestStatus
1415
from ..models import ContestAnnouncement, Contest, OIContestRank, ACMContestRank
@@ -154,11 +155,22 @@ class ContestParticipantsAPI(APIView):
154155
@login_required
155156
def get(self, request):
156157
contest_id = request.GET.get("contest_id")
158+
if not contest_id or not check_is_id(contest_id):
159+
return self.error("Invalid parameter, contest_id is required")
160+
161+
try:
162+
contest = Contest.objects.get(id=contest_id)
163+
ensure_created_by(contest, request.user)
164+
except Contest.DoesNotExist:
165+
return self.error("Contest does not exist")
157166

158167
submissions = Submission.objects.filter(contest_id=contest_id)
168+
latest_submission = submissions.filter(user_id=OuterRef('user_id')).order_by('-create_time', '-id')
159169

160-
user_submissions = submissions.values('user_id', 'username').annotate(
161-
submission_count=Count('id'), last_submission_ip=Max('ip')).order_by('user_id')
170+
user_submissions = submissions.values('user_id').annotate(
171+
submission_count=Count('id'),
172+
last_submission_ip=Subquery(latest_submission.values('ip')[:1]),
173+
fallback_username=Subquery(latest_submission.values('username')[:1])).order_by('user_id')
162174

163175
# UserProfile 정보 가져오기
164176
user_profiles = UserProfile.objects.filter(user_id__in=[sub['user_id'] for sub in user_submissions])
@@ -172,15 +184,20 @@ def get(self, request):
172184
for submission in user_submissions:
173185
user = user_dict.get(submission['user_id'])
174186
profile = user_profile_dict.get(submission['user_id'])
187+
username = user.username if user else (submission['fallback_username'] or "")
188+
email = user.email if user and user.email else ""
189+
avatar = profile.avatar if profile else f"{settings.AVATAR_URI_PREFIX}/default.png"
190+
school = profile.school if profile and profile.school else ""
191+
major = profile.major if profile and profile.major else ""
175192
result.append({
176193
'user_id': submission['user_id'],
177-
'username': submission['username'],
178-
'email': user.email if user else None,
179-
'avatar': profile.avatar if profile else None,
180-
'school': profile.school if profile else None,
181-
'major': profile.major if profile else None,
194+
'username': username,
195+
'email': email,
196+
'avatar': avatar,
197+
'school': school,
198+
'major': major,
182199
'submission_count': submission['submission_count'],
183-
'last_submission_ip': submission['last_submission_ip']
200+
'last_submission_ip': submission['last_submission_ip'] or ""
184201
})
185202

186203
serializer = ContestUserSubmissionSummarySerializer(result, many=True)

frontend/src/pages/oj/App.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export default {
4242
},
4343
mounted() {
4444
this.getWebsiteConfig()
45-
this.init()
4645
},
4746
methods: {
4847
...mapActions(["getWebsiteConfig", "changeDomTitle"]),

frontend/src/pages/oj/components/CustomTooltip.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
export default {
1313
name: "custom-tooltip",
1414
props: {
15-
content: HTMLElement,
15+
content: {
16+
type: [String, Number],
17+
default: "",
18+
},
1619
placement: {
1720
type: String,
1821
default: "top",

0 commit comments

Comments
 (0)