Skip to content

Commit 9d93f3d

Browse files
authored
#725 유저 마이페이지 내 문제풀이 페이지에서 풀이 기록을 달력 형식으로 제공 (#726)
* #725 문제풀이 API 응답 확장 * #725 문제풀이 달력 화면 추가 * #725 문제풀이 날짜 기준 KST 고정 * #725 채점 대기 상태 중립 표시
1 parent 3243d32 commit 9d93f3d

6 files changed

Lines changed: 1502 additions & 128 deletions

File tree

backend/profile/serializers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,15 @@ class EditUserProfileSerializer(serializers.Serializer):
3434

3535

3636
class ProfileProblemSerializer(serializers.Serializer):
37+
submissionId = serializers.CharField()
3738
id = serializers.CharField()
3839
title = serializers.CharField()
3940
submitTime = serializers.DateTimeField()
41+
serviceDate = serializers.DateField()
42+
serviceMonth = serializers.CharField()
4043
difficulty = serializers.CharField()
4144
field = serializers.CharField()
45+
status = serializers.IntegerField()
4246

4347

4448
class DashboardUserInfoSerializer(serializers.ModelSerializer):

backend/profile/tests.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,108 @@ def test_activity_day_changes_at_six_am_kst_and_streaks_are_server_calculated(se
133133
{"date": yesterday.isoformat(), "count": 1},
134134
{"date": today.isoformat(), "count": 1},
135135
])
136+
137+
138+
class ProfileProblemAPITest(APITestCase):
139+
140+
def setUp(self):
141+
self.create_school_fixtures(college_id=1, college_name="Test", department_id=1, department_name="Test")
142+
self.user = self.create_user(email="problem@test.com", username="problem", password="test1234!")
143+
problem_data = copy.deepcopy(DEFAULT_PROBLEM_DATA)
144+
problem_data["_id"] = "PROFILE-PROBLEM-1"
145+
self.problem = ProblemCreateTestBase.add_problem(problem_data, self.user)
146+
self.url = self.reverse("profile_problem_api")
147+
148+
def create_submission(self, result, created_at):
149+
submission = Submission.objects.create(
150+
user_id=self.user.id,
151+
username=self.user.username,
152+
problem=self.problem,
153+
code="print(1)",
154+
language="Python3",
155+
result=result,
156+
)
157+
Submission.objects.filter(id=submission.id).update(create_time=created_at)
158+
return submission
159+
160+
def test_returns_all_problem_submissions_in_latest_order(self):
161+
current_timezone = timezone.get_current_timezone()
162+
base_time = timezone.make_aware(datetime.datetime(2026, 6, 25, 12, 0), current_timezone)
163+
older_submission = self.create_submission(JudgeStatus.WRONG_ANSWER, base_time)
164+
latest_submission = self.create_submission(JudgeStatus.ACCEPTED, base_time + datetime.timedelta(minutes=5))
165+
166+
response = self.client.get(self.url, {"username": self.user.username})
167+
168+
self.assertSuccess(response)
169+
data = response.data["data"]
170+
self.assertEqual(len(data), 2)
171+
self.assertEqual(data[0]["submissionId"], latest_submission.id)
172+
self.assertEqual(data[0]["id"], self.problem._id)
173+
self.assertEqual(data[0]["serviceDate"], base_time.date().isoformat())
174+
self.assertEqual(data[0]["serviceMonth"], "2026-06")
175+
self.assertEqual(data[0]["status"], JudgeStatus.ACCEPTED)
176+
self.assertEqual(data[1]["submissionId"], older_submission.id)
177+
self.assertEqual(data[1]["id"], self.problem._id)
178+
self.assertEqual(data[1]["status"], JudgeStatus.WRONG_ANSWER)
179+
180+
def test_returns_service_date_by_six_am_kst_boundary(self):
181+
before_boundary = datetime.datetime(2026, 6, 26, 20, 59, tzinfo=datetime.timezone.utc)
182+
after_boundary = datetime.datetime(2026, 6, 26, 21, 0, tzinfo=datetime.timezone.utc)
183+
self.create_submission(JudgeStatus.ACCEPTED, before_boundary)
184+
self.create_submission(JudgeStatus.ACCEPTED, after_boundary)
185+
186+
response = self.client.get(self.url, {"username": self.user.username})
187+
188+
self.assertSuccess(response)
189+
data = response.data["data"]
190+
self.assertEqual(data[0]["serviceDate"], "2026-06-27")
191+
self.assertEqual(data[0]["serviceMonth"], "2026-06")
192+
self.assertEqual(data[1]["serviceDate"], "2026-06-26")
193+
self.assertEqual(data[1]["serviceMonth"], "2026-06")
194+
195+
def test_filters_problem_field_by_numeric_or_string_value(self):
196+
current_timezone = timezone.get_current_timezone()
197+
created_at = timezone.make_aware(datetime.datetime(2026, 6, 25, 12, 0), current_timezone)
198+
self.create_submission(JudgeStatus.ACCEPTED, created_at)
199+
200+
numeric_response = self.client.get(self.url, {"username": self.user.username, "field": "0"})
201+
string_response = self.client.get(self.url, {"username": self.user.username, "field": "implementation"})
202+
203+
self.assertSuccess(numeric_response)
204+
self.assertSuccess(string_response)
205+
self.assertEqual(numeric_response.data["data"], string_response.data["data"])
206+
self.assertEqual(len(string_response.data["data"]), 1)
207+
208+
def test_invalid_problem_field_filter_returns_api_error(self):
209+
response = self.client.get(self.url, {"username": self.user.username, "field": "unknown"})
210+
211+
self.assertFailed(response, "Invalid field")
212+
213+
def test_missing_username_returns_api_error(self):
214+
response = self.client.get(self.url)
215+
216+
self.assertFailed(response, "username is required")
217+
218+
def test_unknown_username_returns_api_error(self):
219+
response = self.client.get(self.url, {"username": "missing"})
220+
221+
self.assertFailed(response, "User does not exist")
222+
223+
def test_invalid_problem_status_filter_returns_api_error(self):
224+
response = self.client.get(self.url, {"username": self.user.username, "status": "unknown"})
225+
226+
self.assertFailed(response, "Invalid status")
227+
228+
def test_failed_status_filter_excludes_pending_submissions(self):
229+
current_timezone = timezone.get_current_timezone()
230+
base_time = timezone.make_aware(datetime.datetime(2026, 6, 25, 12, 0), current_timezone)
231+
self.create_submission(JudgeStatus.WRONG_ANSWER, base_time)
232+
self.create_submission(JudgeStatus.PENDING, base_time + datetime.timedelta(minutes=1))
233+
self.create_submission(JudgeStatus.JUDGING, base_time + datetime.timedelta(minutes=2))
234+
235+
response = self.client.get(self.url, {"username": self.user.username, "status": "Failed"})
236+
237+
self.assertSuccess(response)
238+
data = response.data["data"]
239+
self.assertEqual(len(data), 1)
240+
self.assertEqual(data[0]["status"], JudgeStatus.WRONG_ANSWER)

backend/profile/views/oj.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import datetime
33

4-
from django.db.models import OuterRef, Subquery, Q, Count, F
4+
from django.db.models import Q, Count, F
55
from django.http import HttpResponseNotFound
66
from django.utils.decorators import method_decorator
77
from django.utils import timezone
@@ -24,13 +24,23 @@
2424
MAX_ACTIVITY_DAYS = 366
2525
ACTIVITY_DAY_START_HOUR = 6
2626
ACTIVITY_DAY_BOUNDARY = "6:00 UTC+9"
27+
KST = datetime.timezone(datetime.timedelta(hours=9))
28+
NON_FAILED_JUDGE_STATUSES = [
29+
JudgeStatus.ACCEPTED,
30+
JudgeStatus.PENDING,
31+
JudgeStatus.JUDGING,
32+
]
2733

2834

2935
def get_activity_service_date(dt, current_timezone):
3036
local_dt = timezone.localtime(dt, current_timezone)
3137
return (local_dt - datetime.timedelta(hours=ACTIVITY_DAY_START_HOUR)).date()
3238

3339

40+
def format_service_month(service_date):
41+
return f"{service_date.year}-{service_date.month:02d}"
42+
43+
3444
def get_activity_day_bounds(days, current_timezone):
3545
end_date = (timezone.localtime(timezone.now(), current_timezone) -
3646
datetime.timedelta(hours=ACTIVITY_DAY_START_HOUR)).date()
@@ -245,9 +255,9 @@ def post(self, request):
245255
class ProfileProblemAPIView(APIView):
246256

247257
def get(self, request):
248-
"""Get the latest submission for each problem of a user.
258+
"""Get submissions of a user.
249259
250-
This API retrieves the latest submission for each problem of a user and returns the details.
260+
This API retrieves submissions of a user and returns the details.
251261
Submissions can be filtered by various parameters like below.
252262
253263
Query Parameters:
@@ -259,38 +269,45 @@ def get(self, request):
259269
endDate (Optional[str]): End date for filtering (YYYY-MM-DD)
260270
261271
Returns:
262-
ProfileProblemSerializer.data: List of latest submissions for each problem.
272+
ProfileProblemSerializer.data: List of submissions.
263273
"""
264274
username = request.GET.get('username')
265275
if not username:
266-
self.error("username is required")
276+
return self.error("username is required")
267277

268278
field = request.GET.get('field')
269279
difficulty = request.GET.get('difficulty')
270280
status = request.GET.get('status')
271281
start_date_str = request.GET.get('startDate')
272282
end_date_str = request.GET.get('endDate')
273283

274-
user_id = User.objects.get(username=username).id
275-
276-
# 각 문제별 최신 제출을 찾는 서브쿼리
277-
latest_submissions = Submission.objects.filter(
278-
user_id=user_id, problem=OuterRef('problem')).order_by('-create_time').values('id')[:1]
284+
user = User.objects.filter(username=username).first()
285+
if not user:
286+
return self.error("User does not exist")
279287

280-
# 메인 쿼리
281-
submissions = Submission.objects.filter(
282-
id__in=Subquery(latest_submissions)).select_related('problem').order_by('-create_time')
288+
submissions = Submission.objects.filter(user_id=user.id).select_related('problem').order_by('-create_time')
283289

284290
# Filtering
285291
if field and field != 'All':
292+
if field in ProblemField.strToInt:
293+
field = ProblemField.strToInt[field]
294+
else:
295+
try:
296+
field = int(field)
297+
except (TypeError, ValueError):
298+
return self.error("Invalid field")
299+
if field not in ProblemField.intToStr:
300+
return self.error("Invalid field")
286301
submissions = submissions.filter(problem__field=field)
287302
if difficulty and difficulty != 'All':
288303
submissions = submissions.filter(problem__difficulty=difficulty)
289304
if status and status != 'All':
290305
if status == "Solved":
291-
submissions = submissions.filter(result=0)
306+
submissions = submissions.filter(result=JudgeStatus.ACCEPTED)
307+
elif status == "Failed":
308+
submissions = submissions.exclude(result__in=NON_FAILED_JUDGE_STATUSES)
292309
else:
293-
submissions = submissions.filter(~Q(result=0))
310+
return self.error("Invalid status")
294311

295312
# Date Filtering
296313
try:
@@ -309,12 +326,17 @@ def get(self, request):
309326

310327
result = []
311328
for submission in submissions:
329+
service_date = get_activity_service_date(submission.create_time, KST)
312330
result.append({
331+
'submissionId': submission.id,
313332
'id': submission.problem._id,
314333
'title': submission.problem.title,
315334
'submitTime': submission.create_time,
335+
'serviceDate': service_date,
336+
'serviceMonth': format_service_month(service_date),
316337
'difficulty': submission.problem.difficulty,
317338
'field': ProblemField.intToStr[submission.problem.field],
339+
'status': submission.result,
318340
})
319341

320342
serializer = ProfileProblemSerializer(data=result, many=True)

frontend/src/i18n/oj/en-US.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,22 @@ export const m = {
408408
Problem_Status: "문제풀이",
409409
Achievement: "업적",
410410
User_Setting: "정보수정",
411+
Calendar_Today: "오늘",
412+
Calendar_Previous_Month: "이전 달",
413+
Calendar_Next_Month: "다음 달",
414+
Calendar_View: "달력",
415+
Calendar_Month_Title: "{year}년 {month}월",
416+
Calendar_Problem_Count: "총 {count}문제",
417+
Calendar_Month_Problem_Count: "이번 달 풀이 {count}개",
418+
Calendar_All_Problems: "월별 목록",
419+
Calendar_All_Problems_Empty: "표시할 문제가 없습니다.",
420+
Calendar_Weekday_Sun: "일",
421+
Calendar_Weekday_Mon: "월",
422+
Calendar_Weekday_Tue: "화",
423+
Calendar_Weekday_Wed: "수",
424+
Calendar_Weekday_Thu: "목",
425+
Calendar_Weekday_Fri: "금",
426+
Calendar_Weekday_Sat: "토",
411427

412428
// UserHome.vue
413429
UserHomeserSubmissions: "Submissions",

0 commit comments

Comments
 (0)