Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add User Contest in Profile Page & Add Pagination component #291

Merged
merged 24 commits into from
Mar 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0b7e58a
refactor: Rebase
goo314 Jan 10, 2022
bc6c7ec
fix: Delete routers of profile-contest
goo314 Jan 11, 2022
07e6875
feat: Add ProfileContest vue
goo314 Jan 11, 2022
b9b44af
feat: Publish user contest page
goo314 Jan 14, 2022
365c56f
add: Add file in gitignore
goo314 Jan 14, 2022
1867d42
feat: Add Pagination component
goo314 Jan 21, 2022
59987a7
feat: Add methods for each button in pagination vue
goo314 Jan 24, 2022
606cd19
chore: Remove chart.js outside frontend folder
goo314 Jan 28, 2022
cdba955
fix: Show at most limit pages
goo314 Jan 28, 2022
0e421b4
style: Shorten style code
goo314 Jan 28, 2022
6694e96
fix: Add component communication(props, event)
goo314 Jan 28, 2022
fc89247
style: Apply Pagination vue
goo314 Jan 28, 2022
eea45dd
style: Edit code style
goo314 Feb 6, 2022
91e7d1e
feat: Create USerContestAPI
goo314 Feb 8, 2022
9a04321
add: Calate and return user rank in UserContestAPI
goo314 Feb 9, 2022
79abf47
style: Change code style
goo314 Feb 10, 2022
2d03088
add: Add rank field in ACMContestRank & Shorten UserContestAPI
goo314 Feb 10, 2022
61456b6
add: Connect UserContestAPI to ProfileContest page
goo314 Feb 14, 2022
8ee4300
feat: Add sorting contests function in ProfileContest
goo314 Feb 20, 2022
b8b6528
fix: Connect sorting api to ProfileContest
goo314 Feb 20, 2022
8facf45
feat: Add test for user-contest-api
goo314 Feb 26, 2022
26489aa
feat: add contestprize model
jimin9038 Feb 25, 2022
b7adf68
style: Change code style
goo314 Mar 1, 2022
03e7fe9
migrate model change
jimin9038 Mar 2, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,6 @@ typings/

# devcontainer
.devcontainer/data

# MacOS file
.DS_Store
Binary file added backend/.DS_Store
Binary file not shown.
49 changes: 49 additions & 0 deletions backend/contest/migrations/0013_auto_20220302_1413.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 3.2.12 on 2022-03-02 05:13

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('contest', '0012_rename_total_score_acmcontestrank_total_penalty'),
]

operations = [
migrations.AddField(
model_name='contest',
name='constraints',
field=models.JSONField(default=list),
),
migrations.AddField(
model_name='contest',
name='requirements',
field=models.JSONField(default=list),
),
migrations.AddField(
model_name='contest',
name='scoring',
field=models.TextField(default='ACM-ICPC style'),
),
migrations.CreateModel(
name='ContestPrize',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('color', models.TextField()),
('name', models.TextField()),
('reward', models.TextField()),
('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contest.contest')),
],
),
migrations.AddField(
model_name='acmcontestrank',
name='prize',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contest.contestprize'),
),
migrations.AddField(
model_name='oicontestrank',
name='prize',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contest.contestprize'),
),
]
25 changes: 25 additions & 0 deletions backend/contest/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@
from account.models import User
from utils.models import RichTextField

from account.models import AdminType


class Contest(models.Model):
title = models.TextField()
description = RichTextField()
requirements = JSONField(default=list)
constraints = JSONField(default=list)
# 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()
password = models.TextField(null=True)
Expand Down Expand Up @@ -55,10 +61,18 @@ class Meta:
ordering = ("-start_time",)


class ContestPrize(models.Model):
contest = models.ForeignKey(Contest, on_delete=models.CASCADE)
color = models.TextField()
name = models.TextField()
reward = models.TextField()


class AbstractContestRank(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
contest = models.ForeignKey(Contest, on_delete=models.CASCADE)
submission_number = models.IntegerField(default=0)
prize = models.ForeignKey(ContestPrize, on_delete=models.SET_NULL, blank=True, null=True)

class Meta:
abstract = True
Expand All @@ -73,6 +87,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"),)
Expand Down
8 changes: 8 additions & 0 deletions backend/contest/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,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()
65 changes: 64 additions & 1 deletion backend/contest/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

from utils.api.tests import APITestCase

from .models import ContestAnnouncement, ContestRuleType, Contest
from .models import ContestAnnouncement, ContestRuleType, Contest, ACMContestRank
from submission.models import Submission
from problem.models import Problem, ProblemIOMode

DEFAULT_CONTEST_DATA = {"title": "test title", "description": "test description",
"start_time": timezone.localtime(timezone.now()),
Expand All @@ -15,6 +17,34 @@
"allowed_ip_ranges": [],
"visible": True, "real_time_rank": True}

DEFAULT_PROBLEM_DATA = {"_id": "A-110", "title": "test", "description": "<p>test</p>", "input_description": "test",
"output_description": "test", "time_limit": 1000, "memory_limit": 256, "difficulty": "Level1",
"visible": True, "languages": ["C", "C++", "Java", "Python2"], "template": {},
"samples": [{"input": "test", "output": "test"}], "spj": False, "spj_language": "C",
"spj_code": "", "spj_compile_ok": True, "test_case_id": "499b26290cc7994e0b497212e842ea85",
"test_case_score": [{"output_name": "1.out", "input_name": "1.in", "output_size": 0,
"stripped_output_md5": "d41d8cd98f00b204e9800998ecf8427e",
"input_size": 0, "score": 0}],
"io_mode": {"io_mode": ProblemIOMode.standard, "input": "input.txt", "output": "output.txt"},
"share_submission": False,
"rule_type": "ACM", "hint": "<p>test</p>", "source": "test"}

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):
Expand Down Expand Up @@ -148,3 +178,36 @@ def test_get_contest_announcement_list(self):
contest_id = self.create_contest_announcements()
response = self.client.get(self.url, data={"contest_id": contest_id})
self.assertSuccess(response)


class UserContestAPITest(APITestCase):
def setUp(self):
# create contest
admin = self.create_admin()
self.contest = Contest.objects.create(created_by=admin, **DEFAULT_CONTEST_DATA)

# create problem in contest
data = copy.deepcopy(DEFAULT_PROBLEM_DATA)
data["contest_id"] = self.contest.id
self.problem = Problem.objects.create(created_by=admin, **data)

# 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)
2 changes: 2 additions & 0 deletions backend/contest/urls/oj.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from ..views.oj import ContestAnnouncementListAPI
from ..views.oj import ContestPasswordVerifyAPI, ContestAccessAPI
from ..views.oj import ContestListAPI, ContestAPI, ContestRankAPI
from ..views.oj import UserContestAPI

urlpatterns = [
path("contests/", ContestListAPI.as_view(), name="contest_list_api"),
Expand All @@ -11,4 +12,5 @@
path("contest/announcement/", ContestAnnouncementListAPI.as_view(), name="contest_announcement_api"),
path("contest/access/", ContestAccessAPI.as_view(), name="contest_access_api"),
path("contest/rank/", ContestRankAPI.as_view(), name="contest_rank_api"),
path("contest/user/", UserContestAPI.as_view(), name="contest_user_api"),
]
27 changes: 27 additions & 0 deletions backend/contest/views/oj.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ..serializers import ContestAnnouncementSerializer
from ..serializers import ContestSerializer, ContestPasswordVerifySerializer
from ..serializers import ACMContestRankSerializer
from ..serializers import ProfileContestSerializer


class ContestAnnouncementListAPI(APIView):
Expand Down Expand Up @@ -229,3 +230,29 @@ def get(self, request):
page_qs["results"] = serializer(page_qs["results"], many=True, is_contest_admin=is_contest_admin).data
page_qs["results"].append(self.contest.id)
return self.success(page_qs)


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)
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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",
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/pages/oj/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,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
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/pages/oj/components/Pagination.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<template>
<div class="page-itm">
<div @click="changePage(1)" class="page-btn leftedge">«</div>
<div @click="changePage(currentPage-1)" class="page-btn">&lt;</div>
<div
v-for="page in pageList"
:key="page"
@click="changePage(page)"
:class="[ page==currentPage? 'page-btn select': 'page-btn' ]">
{{page}}
</div>
<div @click="changePage(currentPage+1)" class="page-btn">&gt;</div>
<div @click="changePage(numberOfPages)" class="page-btn rightedge">»</div>
</div>
</template>

<script>
export default {
name: 'Pagination',
props: {
totalRows: { // number of total rows in table
type: Number
},
perPage: { // number of rows in table per one page
type: Number
},
limit: {
type: String
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit은 어떤 목적으로 사용되는 변수인가요? String type으로 되어있는데, 아래에는 숫자 연산(/, *)이 있어서 확인 부탁드려요.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pagination에 페이지 번호가 최대 몇개 보여질지 나타내는 변수입니다.
총 페이지가 10이고, limit가 3일때 방향키를 누를때마다 (1,2,3), (4,5,6), (7,8,9), (10) 의 페이지가 차례대로 보여요.
b-pagination과 호환되게 코드를 작성하라고 하셔서,
b-pagination의 경우 props를 :limit="3" 가 아닌 limit="3" 문자열으로 받기에 이렇게 코드를 작성했습니다.
javascript상에서 문자열 3과 숫자 3과 동일하다고 들어서 코드의 실행에는 상관이 없지만 혹시 문제가 된다면 바꾸겠습니다 😃

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BootstrapVue 문서 확인해보니까 Number or String으로 명시되어있는데, 여기도 그렇게 하는 게 나을 것 같아요.
https://bootstrap-vue.org/docs/components/pagination#__BVID__579__row_limit

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BootstrapVue 코드도 확인해봤는데, 이렇게 integer로 명시적으로(explicit) 변환하는 함수가 있더라고요.
https://github.com/bootstrap-vue/bootstrap-vue/blob/c645a33790ccaa0e4695dc7b74f9c9d7a812aa8d/src/mixins/pagination.js#L79

const sanitizeLimit = value => {
  const limit = toInteger(value) || 1
  return limit < 1 ? DEFAULT_LIMIT : limit
}

물론 javascript가 dynamic typed라서 string도 number 연산이 가능하긴 하지만, 이런 암시적인(implicit) 코드를 짜면 어느 부분에서 에러가 날지 예측하기도 어렵고, 가독성도 떨어져서 가급적 지양하는 게 좋아요.
이 부분에서는 computed로 localLimit () { return Number(this.limit) }처럼 사용하는 게 바람직해 보이네요.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정했어요!

}
},
data () {
return {
currentPage: 1
}
},
methods: {
changePage (page) {
if (page >= 1 && page <= this.numberOfPages) {
this.currentPage = page
this.$emit('input', this.currentPage)
}
}
},
computed: {
localLimit () {
return Number(this.limit)
},
numberOfPages () { // number of pages
return Math.ceil(this.totalRows / this.perPage)
},
startPage () {
return Math.trunc((this.currentPage - 1) / this.localLimit) * this.localLimit + 1
},
endPage () {
return this.startPage + this.localLimit - 1 <= this.numberOfPages ? this.startPage + this.localLimit - 1 : this.numberOfPages
},
pageList () {
return [...Array(this.endPage - this.startPage + 1).keys()].map(i => i + this.startPage)
}
}
}
</script>

<style lang="scss" scoped>
.page-itm {
width: 95%;
margin: 20px 5% 16px 0;
display: flex;
justify-content: flex-end !important;
flex-direction: row;
}

.page-btn {
width: 35px;
height: 38px;
text-align: center;
margin-left: -1px;
line-height: 35px;
color: #bdbdbd;
border: thin solid #dadada;
cursor: pointer;
}

.leftedge {
border-top-left-radius: 0.25rem !important;
border-bottom-left-radius: 0.25rem !important;
}

.rightedge {
border-top-right-radius: 0.25rem !important;
border-bottom-right-radius: 0.25rem !important;
}

.select {
background-color: #bdbdbd;
border: thin solid #bdbdbd;
color: white;
pointer-events: none;
z-index: 1;
}

.page-btn:hover {
background-color: #e9ecee;
}

</style>
Loading