diff --git a/.gitignore b/.gitignore index 1bfed4c14..2c7b2702c 100644 --- a/.gitignore +++ b/.gitignore @@ -188,3 +188,6 @@ typings/ # devcontainer .devcontainer/data + +# MacOS file +.DS_Store* diff --git a/backend/.DS_Store~1787bcb (feat: Add test for user-contest-api) b/backend/.DS_Store~1787bcb (feat: Add test for user-contest-api) new file mode 100644 index 000000000..9d2e2a117 Binary files /dev/null and b/backend/.DS_Store~1787bcb (feat: Add test for user-contest-api) differ diff --git a/backend/contest/models.py b/backend/contest/models.py index b160120c9..258bf2a8a 100644 --- a/backend/contest/models.py +++ b/backend/contest/models.py @@ -8,6 +8,8 @@ from group.models import Group from utils.models import RichTextField +from account.models import AdminType + class Contest(models.Model): title = models.TextField() @@ -15,6 +17,7 @@ class Contest(models.Model): requirements = JSONField(default=list) constraints = JSONField(default=list) allowed_groups = models.ManyToManyField(Group, blank=True) + # allowed_groups = models.ManyToManyField(Group, blank=True) scoring = models.TextField(default="ACM-ICPC style") # show real time rank or cached rank real_time_rank = models.BooleanField() @@ -92,6 +95,17 @@ class ACMContestRank(AbstractContestRank): # key is problem id submission_info = JSONField(default=dict) + @property + def rank(self): + qs_contest = Contest.objects.get(id=self.contest.id) + if qs_contest.status == ContestStatus.CONTEST_ENDED: + qs_participants = ACMContestRank.objects.filter(contest=self.contest.id, user__admin_type=AdminType.REGULAR_USER, user__is_disabled=False).\ + select_related("user").order_by("-accepted_number", "total_penalty", "total_time") + for i in range(qs_participants.count()): + if qs_participants[i].user.id == self.user.id: + return i+1 + return -1 + class Meta: db_table = "acm_contest_rank" unique_together = (("user", "contest"),) diff --git a/backend/contest/serializers.py b/backend/contest/serializers.py index 8e52ab21a..57bf6e74b 100644 --- a/backend/contest/serializers.py +++ b/backend/contest/serializers.py @@ -174,3 +174,11 @@ class ACMContesHelperSerializer(serializers.Serializer): problem_id = serializers.CharField() rank_id = serializers.IntegerField() checked = serializers.BooleanField() + + +class ProfileContestSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField(max_length=128) + start_time = serializers.DateTimeField() + rank = serializers.IntegerField() + percentage = serializers.FloatField() diff --git a/backend/contest/tests.py b/backend/contest/tests.py index 8655e4783..ebbf082eb 100644 --- a/backend/contest/tests.py +++ b/backend/contest/tests.py @@ -6,9 +6,9 @@ from utils.api.tests import APITestCase -from .models import ContestAnnouncement, ContestRuleType, Contest - -from problem.models import ProblemIOMode +from .models import ContestAnnouncement, ContestRuleType, Contest, ACMContestRank +from submission.models import Submission +from problem.models import Problem, ProblemIOMode, ProblemTag DEFAULT_PROBLEM_DATA = {"_id": "A-110", "title": "test", "description": "

test

", "input_description": "test", "output_description": "test", "time_limit": 1000, "memory_limit": 256, "difficulty": "Level1", @@ -39,6 +39,22 @@ "bank_filter": [], "visible": True, "real_time_rank": True, "rank_penalty_visible": True} +DEFAULT_SUBMISSION_DATA = { + "problem_id": "1", + "user_id": 1, + "username": "test", + "code": "xxxxxxxxxxxxxx", + "result": -2, + "info": {}, + "language": "C", + "statistic_info": {} +} + + +DEFAULT_ACMCONTESTRANK_DATA = {"submission_number": 1, "accepted_number": 1, "total_time": 123, "total_penalty": 123, + "submission_info": {"1": {"is_ac": True, "ac_time": 123, "penalty": 123, "problem_submission": 1}}, + "contest": 1} + class ContestAdminAPITest(APITestCase): def setUp(self): @@ -252,3 +268,48 @@ def test_create_problem_bank(self): url = self.reverse("contest_bank_api") response = self.client.post(url, data={"contest_id": contest["id"]}) self.assertSuccess(response) + + +class UserContestAPITest(APITestCase): + def setUp(self): + # create contest + admin = self.create_admin() + data = copy.deepcopy(DEFAULT_CONTEST_DATA) + data.pop("allowed_groups") + data.pop("prizes") + self.contest = Contest.objects.create(created_by=admin, **data) + + # create problem in contest + data = copy.deepcopy(DEFAULT_PROBLEM_DATA) + data["contest_id"] = self.contest.id + tags = data.pop("tags") + problem = Problem.objects.create(created_by=admin, **data) + + for item in tags: + try: + tag = ProblemTag.objects.get(name=item) + except ProblemTag.DoesNotExist: + tag = ProblemTag.objects.create(name=item) + problem.tags.add(tag) + + self.problem = problem + # user submit problem + user = self.create_user("test", "test123") + data = copy.deepcopy(DEFAULT_SUBMISSION_DATA) + data["contest_id"] = self.contest.id + data["problem_id"] = self.problem.id + data["user_id"] = user.id + self.submission = Submission.objects.create(**data) + + # create ACMContestRank + data = copy.deepcopy(DEFAULT_ACMCONTESTRANK_DATA) + data["user"] = user + data["contest"] = self.contest + self.rank = ACMContestRank.objects.create(**data) + + self.url = self.reverse("contest_user_api") + + # test UserContestAPI : can user get contest info which he participated and rank? + def test_get_participated_contest_list(self): + response = self.client.get(self.url) + self.assertSuccess(response) diff --git a/backend/contest/urls/oj.py b/backend/contest/urls/oj.py index 398f0ab68..0a5ddcbfe 100644 --- a/backend/contest/urls/oj.py +++ b/backend/contest/urls/oj.py @@ -3,6 +3,7 @@ from ..views.oj import ContestAnnouncementListAPI from ..views.oj import ContestPasswordVerifyAPI, ContestAccessAPI from ..views.oj import ContestListAPI, ContestAPI, ContestRankAPI, ProblemBankAPI +from ..views.oj import UserContestAPI urlpatterns = [ path("contests/", ContestListAPI.as_view(), name="contest_list_api"), @@ -12,4 +13,5 @@ path("contest/access/", ContestAccessAPI.as_view(), name="contest_access_api"), path("contest/rank/", ContestRankAPI.as_view(), name="contest_rank_api"), path("contest/bank/", ProblemBankAPI.as_view(), name="contest_bank_api"), + path("contest/user/", UserContestAPI.as_view(), name="contest_user_api"), ] diff --git a/backend/contest/views/oj.py b/backend/contest/views/oj.py index a2c3b7661..f8741c2fb 100644 --- a/backend/contest/views/oj.py +++ b/backend/contest/views/oj.py @@ -16,6 +16,7 @@ from ..serializers import ACMContestRankNoPenaltySerializer, ContestAnnouncementSerializer from ..serializers import ContestSerializer, ContestPasswordVerifySerializer from ..serializers import ACMContestRankSerializer +from ..serializers import ProfileContestSerializer import random import json @@ -277,3 +278,29 @@ def get(self, request): except ProblemBank.DoesNotExist: return self.success(False) return self.success(True) + + +class UserContestAPI(APIView): + def get(self, request): + user = request.user + # queryset for all problems information which user submitted + qs_problems = ACMContestRank.objects.filter(user=user.id, user__admin_type=AdminType.REGULAR_USER, user__is_disabled=False) + contests = [] + for problem in qs_problems: + contest_id = problem.contest.id + try: + contest = Contest.objects.get(id=contest_id, visible=True) + contest = ContestSerializer(contest).data + total_participants = ACMContestRank.objects.filter(contest=contest_id, user__admin_type=AdminType.REGULAR_USER, user__is_disabled=False).count() + contest["rank"] = problem.rank + contest["percentage"] = round(contest["rank"]/total_participants*100, 2) + contests.append(contest) + except Contest.DoesNotExist: + return self.error("Contest does not exist") + + # priority + priority = request.GET.get("priority") + if priority: + contests = sorted(contests, key=lambda c: c[priority]) + + return self.success(ProfileContestSerializer(contests, many=True).data) diff --git a/backend/submission/migrations/0017_auto_20220302_1508.py b/backend/submission/migrations/0017_auto_20220302_1508.py new file mode 100644 index 000000000..a4a912204 --- /dev/null +++ b/backend/submission/migrations/0017_auto_20220302_1508.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.12 on 2022-03-02 06:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submission', '0016_auto_20211226_1458'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='code_length', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='submission', + name='title', + field=models.TextField(null=True), + ), + ] diff --git a/backend/submission/models.py b/backend/submission/models.py index 809b0d7f3..0f27489d9 100644 --- a/backend/submission/models.py +++ b/backend/submission/models.py @@ -29,10 +29,12 @@ class Submission(models.Model): contest = models.ForeignKey(Contest, null=True, on_delete=models.CASCADE) problem = models.ForeignKey(Problem, on_delete=models.CASCADE) assignment = models.ForeignKey(Assignment, null=True, on_delete=models.CASCADE, related_name="submissions") + title = models.TextField(null=True) create_time = models.DateTimeField(auto_now_add=True) user_id = models.IntegerField(db_index=True) username = models.TextField() code = models.TextField() + code_length = models.IntegerField(default=0) result = models.IntegerField(db_index=True, default=JudgeStatus.PENDING) # Judgment details returned from JudgeServer info = JSONField(default=dict) diff --git a/backend/submission/urls.py b/backend/submission/urls.py index 11134cd03..dd88cac4a 100644 --- a/backend/submission/urls.py +++ b/backend/submission/urls.py @@ -2,6 +2,7 @@ from .views import (SubmissionAPI, SubmissionListAPI, ContestSubmissionListAPI, AssignmentSubmissionListAPI, AssignmentSubmissionListProfessorAPI, SubmissionExistsAPI, EditSubmissionScoreAPI) +from .views import ProfileSubmissionListAPI urlpatterns = [ path("submission/", SubmissionAPI.as_view(), name="submission_api"), @@ -11,4 +12,5 @@ path("assignment_submissions/", AssignmentSubmissionListAPI.as_view(), name="assignment_submission_list_api"), path("assignment_submissions_professor/", AssignmentSubmissionListProfessorAPI.as_view(), name="assignment_submission_list_professor_api"), path("edit_submission_score/", EditSubmissionScoreAPI.as_view(), name="edit_submission_score_api"), + path("profile_submissions/", ProfileSubmissionListAPI.as_view(), name="profile_submission_list_api"), ] diff --git a/backend/submission/views.py b/backend/submission/views.py index 3d2b5186f..827f25e4f 100644 --- a/backend/submission/views.py +++ b/backend/submission/views.py @@ -10,6 +10,7 @@ # from judge.dispatcher import JudgeDispatcher from problem.models import Problem, ProblemRuleType from account.models import User, AdminType +from django.db.models import Q from utils.api import APIView, validate_serializer from utils.constants import AssignmentStatus from utils.cache import cache @@ -86,7 +87,9 @@ def post(self, request): username=request.user.username, language=data["language"], code=data["code"], + code_length=len(data["code"].encode("utf-8")), problem_id=problem.id, + title=problem.title, ip=request.session["ip"], contest_id=data.get("contest_id"), assignment_id=data.get("assignment_id")) @@ -473,3 +476,22 @@ def get(self, request): return self.success(request.user.is_authenticated and Submission.objects.filter(problem_id=request.GET["problem_id"], user_id=request.user.id).exists()) + + +class ProfileSubmissionListAPI(APIView): + def get(self, request): + submissions = Submission.objects.filter(user_id=request.user.id) + # keyword search, 문제 번호로 검색 안됨 + keyword = request.GET.get("keyword", "").strip() + if keyword: + submissions = submissions.filter(Q(title__icontains=keyword)) + # result search + result = request.GET.get("result") + if result: + if result == "AC": + submissions = submissions.filter(result=0) + elif result == "NAC": + submissions = submissions.exclude(result=0) + data = self.paginate_data(request, submissions) + data["results"] = SubmissionListSerializer(data["results"], many=True, user=request.user).data + return self.success(data) diff --git a/frontend/package.json b/frontend/package.json index 1516333bc..061627cc9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "axios": "^0.24.0", "bootstrap-vue": "^2.21.2", "browser-detect": "^0.2.28", + "chart.js": "^3.7.0", "core-js": "^3.19.3", "highlight.js": "10.7.3", "iview": "2", diff --git a/frontend/skku-coding-platform b/frontend/skku-coding-platform new file mode 160000 index 000000000..62983c3fb --- /dev/null +++ b/frontend/skku-coding-platform @@ -0,0 +1 @@ +Subproject commit 62983c3fbf9d61165931872359a44b8649d7b942 diff --git a/frontend/src/pages/oj/api.js b/frontend/src/pages/oj/api.js index 1844b6e82..90e4ef59c 100644 --- a/frontend/src/pages/oj/api.js +++ b/frontend/src/pages/oj/api.js @@ -208,6 +208,13 @@ export default { } }) }, + getUserContestInfo (offset, limit, params) { + params.limit = limit + params.offset = offset + return ajax('contest/user/', 'get', { + params + }) + }, submitCode (data) { return ajax('submission/', 'post', { data @@ -227,6 +234,13 @@ export default { params }) }, + getProfileSubmissionList (offset, limit, params) { + params.limit = limit + params.offset = offset + return ajax('profile_submissions/', 'get', { + params + }) + }, getSubmission (id) { return ajax('submission/', 'get', { params: { diff --git a/frontend/src/pages/oj/components/Header.vue b/frontend/src/pages/oj/components/Header.vue index b1b6f0252..6c61a6f09 100644 --- a/frontend/src/pages/oj/components/Header.vue +++ b/frontend/src/pages/oj/components/Header.vue @@ -27,7 +27,7 @@ @@ -77,6 +77,11 @@ export default { } else { window.open('/admin/') } + }, + async goProfile () { + await this.$router.push({ + name: 'profile' + }) } }, computed: { diff --git a/frontend/src/pages/oj/components/Pagination.vue b/frontend/src/pages/oj/components/Pagination.vue new file mode 100644 index 000000000..3475c767a --- /dev/null +++ b/frontend/src/pages/oj/components/Pagination.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/pages/oj/components/user/ProfileContest.vue b/frontend/src/pages/oj/components/user/ProfileContest.vue new file mode 100644 index 000000000..aa04ac2b6 --- /dev/null +++ b/frontend/src/pages/oj/components/user/ProfileContest.vue @@ -0,0 +1,181 @@ + + + + + + diff --git a/frontend/src/pages/oj/components/user/ProfileGroup.vue b/frontend/src/pages/oj/components/user/ProfileGroup.vue new file mode 100644 index 000000000..f54f94b97 --- /dev/null +++ b/frontend/src/pages/oj/components/user/ProfileGroup.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/frontend/src/pages/oj/components/user/ProfileSubmission.vue b/frontend/src/pages/oj/components/user/ProfileSubmission.vue new file mode 100644 index 000000000..1038a6eaf --- /dev/null +++ b/frontend/src/pages/oj/components/user/ProfileSubmission.vue @@ -0,0 +1,494 @@ + + + + + diff --git a/frontend/src/pages/oj/components/user/TabSplitInTwo.vue b/frontend/src/pages/oj/components/user/TabSplitInTwo.vue new file mode 100644 index 000000000..f24a6efc8 --- /dev/null +++ b/frontend/src/pages/oj/components/user/TabSplitInTwo.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/frontend/src/pages/oj/components/user/UserInfo.vue b/frontend/src/pages/oj/components/user/UserInfo.vue new file mode 100644 index 000000000..5d7f19cef --- /dev/null +++ b/frontend/src/pages/oj/components/user/UserInfo.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/pages/oj/views/user/ProfileSetting.vue b/frontend/src/pages/oj/components/user/UserSetting.vue similarity index 98% rename from frontend/src/pages/oj/views/user/ProfileSetting.vue rename to frontend/src/pages/oj/components/user/UserSetting.vue index dcc5bd8af..7f8ab3397 100644 --- a/frontend/src/pages/oj/views/user/ProfileSetting.vue +++ b/frontend/src/pages/oj/components/user/UserSetting.vue @@ -1,7 +1,7 @@