diff --git a/.github/workflows/ci2develop.yml b/.github/workflows/ci2develop.yml index dec35d9c6..cc65672fd 100644 --- a/.github/workflows/ci2develop.yml +++ b/.github/workflows/ci2develop.yml @@ -6,6 +6,7 @@ on: - "deployment/**" - "scripts/**" - ".github/**" + - "kubernetes/**" branches: [develop] workflow_dispatch: @@ -53,9 +54,7 @@ jobs: context: ./backend file: ./backend/Dockerfile push: true - tags: | - ${{ secrets.HARBOR_REGISTRY }}/code-place-dev/backend:${{ github.sha }}-dev - ${{ secrets.HARBOR_REGISTRY }}/code-place-dev/backend:latest + tags: ${{ secrets.HARBOR_REGISTRY }}/code-place-dev/backend:${{ github.sha }}-dev ci-frontend-dev: needs: [detect-changes-by-component] @@ -81,9 +80,84 @@ jobs: context: ./frontend file: ./frontend/Dockerfile push: true - tags: | - ${{ secrets.HARBOR_REGISTRY }}/code-place-dev/frontend:${{ github.sha }}-dev - ${{ secrets.HARBOR_REGISTRY }}/code-place-dev/frontend:latest + tags: ${{ secrets.HARBOR_REGISTRY }}/code-place-dev/frontend:${{ github.sha }}-dev build-args: | SERVER_NAME=${{ secrets.DEV_SERVER_NAME }} APP_VERSION=${{ github.sha }}-dev + + ci-hub-auth-dev: + needs: [detect-changes-by-component] + if: ${{ needs.detect-changes-by-component.outputs.hub-auth == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Harbor + uses: docker/login-action@v1 + with: + registry: ${{ secrets.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: ./hub/auth_server + file: ./hub/auth_server/Dockerfile + push: true + tags: ${{ secrets.HARBOR_REGISTRY }}/code-place-dev/hub-auth:${{ github.sha }}-dev + + update-dev-manifest: + needs: [ci-backend-dev, ci-frontend-dev, ci-hub-auth-dev] + if: | + always() && + (needs.ci-backend-dev.result == 'success' || needs.ci-frontend-dev.result == 'success' || needs.ci-hub-auth-dev.result == 'success') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + token: ${{ secrets.ACTION_TOKEN }} + fetch-depth: 0 + + # NOTE: 기존에 kustomize를 사용하였지만 포멧 문제로 yq로 변경 (junwoo) + # images.[] .name 에 맞는 항목의 newTag 값을 github.sha-dev로 변경 + - name: Update Backend Image Tag + if: needs.ci-backend-dev.result == 'success' + uses: mikefarah/yq@master + with: + cmd: yq -i '(.images[] | select(.name == "backend").newTag) = "${{ github.sha }}-dev"' kubernetes/overlays/dev/kustomization.yaml + + - name: Update Frontend Image Tag + if: needs.ci-frontend-dev.result == 'success' + uses: mikefarah/yq@master + with: + cmd: yq -i '(.images[] | select(.name == "frontend").newTag) = "${{ github.sha }}-dev"' kubernetes/overlays/dev/kustomization.yaml + + - name: Update Hub Auth Image Tag + if: needs.ci-hub-auth-dev.result == 'success' + uses: mikefarah/yq@master + with: + cmd: yq -i '(.images[] | select(.name == "hub-auth").newTag) = "${{ github.sha }}-dev"' kubernetes/overlays/dev/kustomization.yaml + + - name: Commit and push changes + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add kubernetes/overlays/dev/kustomization.yaml + + # 변경사항이 없으면 종료 + if git diff-index --quiet HEAD; then + echo "No changes to commit" + exit 0 + fi + + # 충돌 방지를 위해 최신 develop 브랜치로 rebase 후 push + # [skip ci] 태그를 커밋 메시지에 추가하여 추가 CI 실행을 방지 + git commit -m "ci: Update dev image tags to ${{ github.sha }} [skip ci]" + git pull --rebase origin ${{ github.ref_name }} + git push origin ${{ github.ref_name }} diff --git a/.github/workflows/ci2production-and-release.yml b/.github/workflows/ci2production-and-release.yml index 6937c5b93..a06b52340 100644 --- a/.github/workflows/ci2production-and-release.yml +++ b/.github/workflows/ci2production-and-release.yml @@ -5,6 +5,7 @@ on: paths-ignore: - "deployment/**" - "scripts/**" + - "kubernetes/**" branches: [main] workflow_dispatch: @@ -81,9 +82,7 @@ jobs: context: ./backend file: ./backend/Dockerfile push: true - tags: | - ${{ secrets.HARBOR_REGISTRY }}/code-place-prod/backend:${{ github.sha }}-prod - ${{ secrets.HARBOR_REGISTRY }}/code-place-prod/backend:latest + tags: ${{ secrets.HARBOR_REGISTRY }}/code-place-prod/backend:${{ github.sha }}-prod ci-frontend: needs: [detect-changes-by-component] @@ -109,9 +108,7 @@ jobs: context: ./frontend file: ./frontend/Dockerfile push: true - tags: | - ${{ secrets.HARBOR_REGISTRY }}/code-place-prod/frontend:${{ github.sha }}-prod - ${{ secrets.HARBOR_REGISTRY }}/code-place-prod/frontend:latest + tags: ${{ secrets.HARBOR_REGISTRY }}/code-place-prod/frontend:${{ github.sha }}-prod build-args: | SERVER_NAME=${{ secrets.PROD_SERVER_NAME }} APP_VERSION=${{ github.sha }} @@ -140,17 +137,65 @@ jobs: context: ./hub/auth_server file: ./hub/auth_server/Dockerfile push: true - tags: | - ${{ secrets.HARBOR_REGISTRY }}/code-place-hub/auth-server:${{ github.sha }} - ${{ secrets.HARBOR_REGISTRY }}/code-place-hub/auth-server:latest + tags: ${{ secrets.HARBOR_REGISTRY }}/code-place-prod/hub-auth:${{ github.sha }}-prod + + update-prod-manifest: + needs: [check-release-branch, ci-backend, ci-frontend, ci-hub-auth] + if: | + always() && + needs.check-release-branch.outputs.is_release == 'true' && + (needs.ci-frontend.result == 'success' || needs.ci-backend.result == 'success' || needs.ci-hub-auth.result == 'success') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + token: ${{ secrets.ACTION_TOKEN }} + fetch-depth: 0 + + # NOTE: 기존에 kustomize를 사용하였지만 포멧 문제로 yq로 변경 (junwoo) + # images.[] .name 에 맞는 항목의 newTag 값을 github.sha 로 변경 + - name: Update Backend Image Tag + if: needs.ci-backend.result == 'success' + uses: mikefarah/yq@master + with: + cmd: yq -i '(.images[] | select(.name == "backend").newTag) = "${{ github.sha }}-prod"' kubernetes/overlays/prod/kustomization.yaml + + - name: Update Frontend Image Tag + if: needs.ci-frontend.result == 'success' + uses: mikefarah/yq@master + with: + cmd: yq -i '(.images[] | select(.name == "frontend").newTag) = "${{ github.sha }}-prod"' kubernetes/overlays/prod/kustomization.yaml + + - name: Update Hub Auth Image Tag + if: needs.ci-hub-auth.result == 'success' + uses: mikefarah/yq@master + with: + cmd: yq -i '(.images[] | select(.name == "hub-auth").newTag) = "${{ github.sha }}-prod"' kubernetes/overlays/prod/kustomization.yaml + + - name: Commit and push changes + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add kubernetes/overlays/prod/kustomization.yaml + + # 변경사항이 없으면 종료 + if git diff-index --quiet HEAD; then + echo "No changes to commit" + exit 0 + fi + + # [skip ci] 태그를 커밋 메시지에 추가하여 추가 CI 실행을 방지 + git commit -m "ci: Update prod image tags to ${{ github.sha }} [skip ci]" + git pull --rebase origin main + git push origin main create-release: - needs: [check-release-branch, ci-frontend, ci-backend] + needs: [check-release-branch, update-prod-manifest] if: | always() && needs.check-release-branch.outputs.is_release == 'true' && - (needs.ci-frontend.result == 'success' || needs.ci-frontend.result == 'skipped') && - (needs.ci-backend.result == 'success' || needs.ci-backend.result == 'skipped') + (needs.update-prod-manifest.result == 'success' || needs.update-prod-manifest.result == 'skipped') runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/.style.yapf b/.style.yapf index b3d849f2d..4ef8ba7ff 100644 --- a/.style.yapf +++ b/.style.yapf @@ -1,3 +1,6 @@ [style] -based_on_style = google +based_on_style = yapf column_limit = 120 +spaces_before_comment = 4 +split_before_logical_operator = true +indent_width = 4 \ No newline at end of file diff --git a/backend/account/migrations/0001_initial.py b/backend/account/migrations/0001_initial.py index a608a0a5f..f93d375b0 100644 --- a/backend/account/migrations/0001_initial.py +++ b/backend/account/migrations/0001_initial.py @@ -48,10 +48,9 @@ class Migration(migrations.Migration): name='UserScore', fields=[ ('user', - models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, - primary_key=True, - serialize=False, - to='account.user')), + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, + to='account.user')), ('yesterday_score', models.IntegerField(default=0)), ('total_score', models.IntegerField(default=0)), ('fluctuation', models.IntegerField(default=0)), @@ -77,10 +76,9 @@ class Migration(migrations.Migration): name='UserSolved', fields=[ ('user', - models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, - primary_key=True, - serialize=False, - to='account.user')), + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, + to='account.user')), ('math_solved', models.BigIntegerField(default=0)), ('implementation_solved', models.BigIntegerField(default=0)), ('datastructure_solved', models.BigIntegerField(default=0)), @@ -105,13 +103,13 @@ class Migration(migrations.Migration): ('oi_problems_status', models.JSONField(default=dict)), ('real_name', models.TextField(null=True)), ('student_id', - models.CharField(max_length=9, - null=True, - validators=[ - django.core.validators.RegexValidator(code='invalid_student_id', - message='학번은 6자리 이상 9자리 이하의 숫자만 입력 가능합니다.', - regex='^\\d{6,9}$') - ])), + models.CharField( + max_length=9, + null=True, + validators=[ + django.core.validators.RegexValidator( + code='invalid_student_id', message='학번은 6자리 이상 9자리 이하의 숫자만 입력 가능합니다.', regex='^\\d{6,9}$') + ])), ('avatar', models.TextField(default='/public/avatar/default.png')), ('blog', models.URLField(null=True)), ('mood', models.TextField(null=True)), diff --git a/backend/account/models.py b/backend/account/models.py index c0842f0d8..7cf728d0d 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -73,11 +73,11 @@ class Meta: def get_default_field_score(): return { - "0": 0, # "Math" - "1": 0, # "Implementation" - "2": 0, # "Datastructure" - "3": 0, # "Search" - "4": 0, # "Sorting" + "0": 0, # "Math" + "1": 0, # "Implementation" + "2": 0, # "Datastructure" + "3": 0, # "Search" + "4": 0, # "Sorting" } @@ -121,13 +121,12 @@ class UserProfile(models.Model): oi_problems_status = JSONField(default=dict) real_name = models.TextField(null=True) - student_id = models.CharField(max_length=9, - null=True, - validators=[ - RegexValidator(regex=r'^\d{6,9}$', - message='학번은 6자리 이상 9자리 이하의 숫자만 입력 가능합니다.', - code='invalid_student_id'), - ]) + student_id = models.CharField( + max_length=9, + null=True, + validators=[ + RegexValidator(regex=r'^\d{6,9}$', message='학번은 6자리 이상 9자리 이하의 숫자만 입력 가능합니다.', code='invalid_student_id'), + ]) avatar = models.TextField(default=f"{settings.AVATAR_URI_PREFIX}/default.png") blog = models.URLField(null=True) mood = models.TextField(null=True) diff --git a/backend/account/serializers.py b/backend/account/serializers.py index 8557e04b0..4f5e06909 100644 --- a/backend/account/serializers.py +++ b/backend/account/serializers.py @@ -23,22 +23,22 @@ class UserRegisterSerializer(serializers.Serializer): username = serializers.CharField(min_length=3, max_length=8) real_name = serializers.CharField(max_length=13) email = serializers.EmailField(max_length=64) - password = serializers.RegexField(regex=r'^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$', - min_length=8, - error_messages={'invalid': '비밀번호는 8글자 이상이어야 하며, 영문, 숫자, 특수문자를 모두 포함해야 합니다.'}) - student_id = serializers.RegexField(regex=r'^\d{6,9}$', - min_length=6, - max_length=9, - error_messages={'invalid': '학번은 6자리 이상 9자리 이하의 숫자만 입력 가능합니다.'}) + password = serializers.RegexField( + regex=r'^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$', + min_length=8, + error_messages={'invalid': '비밀번호는 8글자 이상이어야 하며, 영문, 숫자, 특수문자를 모두 포함해야 합니다.'}) + student_id = serializers.RegexField( + regex=r'^\d{6,9}$', min_length=6, max_length=9, error_messages={'invalid': '학번은 6자리 이상 9자리 이하의 숫자만 입력 가능합니다.'}) collegeId = serializers.IntegerField() departmentId = serializers.IntegerField() class UserChangePasswordSerializer(serializers.Serializer): old_password = serializers.CharField() - new_password = serializers.RegexField(regex=r'^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$', - min_length=8, - error_messages={'invalid': '비밀번호는 8글자 이상이어야 하며, 영문, 숫자, 특수문자를 모두 포함해야 합니다.'}) + new_password = serializers.RegexField( + regex=r'^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$', + min_length=8, + error_messages={'invalid': '비밀번호는 8글자 이상이어야 하며, 영문, 숫자, 특수문자를 모두 포함해야 합니다.'}) class GenerateUserSerializer(serializers.Serializer): @@ -130,25 +130,25 @@ class EditUserSerializer(serializers.Serializer): id = serializers.IntegerField() username = serializers.CharField(min_length=3, max_length=8) real_name = serializers.CharField(max_length=13) - password = serializers.RegexField(allow_null=True, - allow_blank=True, - regex=r'^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$', - min_length=8, - error_messages={'invalid': '비밀번호는 8글자 이상이어야 하며, 영문, 숫자, 특수문자를 모두 포함해야 합니다.'}) + password = serializers.RegexField( + allow_null=True, + allow_blank=True, + regex=r'^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$', + min_length=8, + error_messages={'invalid': '비밀번호는 8글자 이상이어야 하며, 영문, 숫자, 특수문자를 모두 포함해야 합니다.'}) # email = serializers.EmailField(max_length=64) admin_type = serializers.ChoiceField(choices=(AdminType.REGULAR_USER, AdminType.ADMIN, AdminType.SUPER_ADMIN)) - problem_permission = serializers.ChoiceField(choices=(ProblemPermission.NONE, ProblemPermission.OWN, - ProblemPermission.ALL)) + problem_permission = serializers.ChoiceField( + choices=(ProblemPermission.NONE, ProblemPermission.OWN, ProblemPermission.ALL)) college = serializers.IntegerField(allow_null=True) department = serializers.IntegerField(allow_null=True) - student_id = serializers.CharField(max_length=9, - allow_null=True, - allow_blank=True, - validators=[ - RegexValidator(regex=r'^\d{6,9}$', - message='학번은 6자리 이상 9자리 이하의 숫자만 입력 가능합니다.', - code='invalid_student_id'), - ]) + student_id = serializers.CharField( + max_length=9, + allow_null=True, + allow_blank=True, + validators=[ + RegexValidator(regex=r'^\d{6,9}$', message='학번은 6자리 이상 9자리 이하의 숫자만 입력 가능합니다.', code='invalid_student_id'), + ]) open_api = serializers.BooleanField() two_factor_auth = serializers.BooleanField() is_disabled = serializers.BooleanField() @@ -161,8 +161,8 @@ class ApplyResetPasswordSerializer(serializers.Serializer): class ResetPasswordSerializer(serializers.Serializer): token = serializers.CharField() - password = serializers.RegexField(regex=r'^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$', - min_length=8) + password = serializers.RegexField( + regex=r'^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$', min_length=8) captcha = serializers.CharField() diff --git a/backend/account/tasks.py b/backend/account/tasks.py index fdf898b66..fdcf41dab 100644 --- a/backend/account/tasks.py +++ b/backend/account/tasks.py @@ -15,12 +15,13 @@ def send_email_async(from_name, to_email, to_name, subject, content): if not SysOptions.smtp_config: return try: - send_email(smtp_config=SysOptions.smtp_config, - from_name=from_name, - to_email=to_email, - to_name=to_name, - subject=subject, - content=content) + send_email( + smtp_config=SysOptions.smtp_config, + from_name=from_name, + to_email=to_email, + to_name=to_name, + subject=subject, + content=content) except Exception as e: logger.exception(e) diff --git a/backend/account/tests.py b/backend/account/tests.py index 6eaaad219..7aecf0536 100644 --- a/backend/account/tests.py +++ b/backend/account/tests.py @@ -448,7 +448,7 @@ def test_successful_calculate_user_score_fluctuation(self): user_score.refresh_from_db() - self.assertEqual(user_score.fluctuation, 200) # 300 - 100 = 200 + self.assertEqual(user_score.fluctuation, 200) # 300 - 100 = 200 def test_database_error_calculate_user_score_fluctuation(self): user_score = self.user.userscore @@ -460,7 +460,7 @@ def test_database_error_calculate_user_score_fluctuation(self): with mock.patch('account.models.UserScore.objects.update', side_effect=DatabaseError("Test Database Error")): with self.assertRaises(DatabaseError): calculate_user_score_fluctuation() - self.assertEqual(user_score.fluctuation, 0) # Fluctuation should not change if error occurs + self.assertEqual(user_score.fluctuation, 0) # Fluctuation should not change if error occurs # class UserChangePasswordAPITest(APITestCase): diff --git a/backend/account/views/admin.py b/backend/account/views/admin.py index 22969ddb5..5fcf1b3dc 100644 --- a/backend/account/views/admin.py +++ b/backend/account/views/admin.py @@ -40,8 +40,9 @@ def get(self, request): } # 학과별 사용자 수 통계 - department_stats = (User.objects.values('userprofile__major').annotate(value=Count('id')).values( - 'value', name=F('userprofile__major')).order_by('-value')) + department_stats = ( + User.objects.values('userprofile__major').annotate(value=Count('id')).values( + 'value', name=F('userprofile__major')).order_by('-value')) # None 값 처리 (학과가 지정되지 않은 사용자) department_stats = list(department_stats) @@ -56,8 +57,9 @@ def get(self, request): start_date = datetime(current_year, 1, 1) end_date = datetime(current_year, 12, 31) - monthly_stats = (User.objects.filter(create_time__range=(start_date, end_date)).annotate( - month=TruncMonth('create_time')).values('month').annotate(count=Count('id')).order_by('month')) + monthly_stats = ( + User.objects.filter(create_time__range=(start_date, end_date)).annotate( + month=TruncMonth('create_time')).values('month').annotate(count=Count('id')).order_by('month')) months = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'] counts = [0] * 12 @@ -70,11 +72,13 @@ def get(self, request): # 주간 가입자 수 통계 today = datetime.now().date() - start_of_week = today - timedelta(days=today.weekday()) # 이번 주 월요일 - end_of_week = start_of_week + timedelta(days=6) # 이번 주 일요일 + start_of_week = today - timedelta(days=today.weekday()) # 이번 주 월요일 + end_of_week = start_of_week + timedelta(days=6) # 이번 주 일요일 - weekly_stats = (User.objects.filter(create_time__date__range=(start_of_week, end_of_week)).annotate( - weekday=ExtractWeekDay('create_time')).values('weekday').annotate(count=Count('id')).order_by('weekday')) + weekly_stats = ( + User.objects.filter(create_time__date__range=(start_of_week, end_of_week)).annotate( + weekday=ExtractWeekDay('create_time')).values('weekday').annotate( + count=Count('id')).order_by('weekday')) weekdays = ['월', '화', '수', '목', '금', '토', '일'] counts = [0] * 7 @@ -178,10 +182,8 @@ def put(self, request): if data["college"] and data["department"]: college = College.objects.get(id=data["college"]) department = Department.objects.get(id=data["department"]) - UserProfile.objects.filter(user=user).update(college=college, - department=department, - school=college.college_name, - major=department.department_name) + UserProfile.objects.filter(user=user).update( + college=college, department=department, school=college.college_name, major=department.department_name) if data["student_id"]: UserProfile.objects.filter(user=user).update(student_id=data["student_id"]) @@ -231,15 +233,16 @@ def get(self, request): user = user.filter(admin_type=AdminType.REGULAR_USER) if admin_type == "Admin": user = user.exclude(admin_type=AdminType.REGULAR_USER).order_by( - Case(When(admin_type=AdminType.SUPER_ADMIN, then=Value(0)), - default=Value(1), - output_field=IntegerField()), 'id') + Case( + When(admin_type=AdminType.SUPER_ADMIN, then=Value(0)), + default=Value(1), + output_field=IntegerField()), 'id') keyword = request.GET.get("keyword", None) if keyword: user = user.filter( - Q(username__icontains=keyword) | Q(userprofile__real_name__icontains=keyword) | - Q(email__icontains=keyword)) + Q(username__icontains=keyword) | Q(userprofile__real_name__icontains=keyword) + | Q(email__icontains=keyword)) college = request.GET.get("college", None) if college: diff --git a/backend/account/views/oj.py b/backend/account/views/oj.py index 933236ec4..3fdb5d215 100644 --- a/backend/account/views/oj.py +++ b/backend/account/views/oj.py @@ -246,13 +246,14 @@ def post(self, request): user = User.objects.create(username=data["username"], email=data["email"]) user.set_password(data["password"]) user.save() - user_profile = UserProfile.objects.create(user=user, - school=college.college_name, - major=department.department_name, - college=college, - department=department, - real_name=data["real_name"], - student_id=data["student_id"]) + user_profile = UserProfile.objects.create( + user=user, + school=college.college_name, + major=department.department_name, + college=college, + department=department, + real_name=data["real_name"], + student_id=data["student_id"]) user_profile.save() user_score = UserScore.objects.create(user=user) user_score.save() diff --git a/backend/announcement/models.py b/backend/announcement/models.py index ac609a1d6..d2b487850 100644 --- a/backend/announcement/models.py +++ b/backend/announcement/models.py @@ -16,4 +16,7 @@ class Announcement(models.Model): class Meta: db_table = "announcement" - ordering = ("-is_pinned", "-create_time",) \ No newline at end of file + ordering = ( + "-is_pinned", + "-create_time", + ) diff --git a/backend/announcement/tests.py b/backend/announcement/tests.py index 786222be1..b18668a97 100644 --- a/backend/announcement/tests.py +++ b/backend/announcement/tests.py @@ -15,7 +15,13 @@ def test_announcement_list(self): self.assertSuccess(response) def create_announcement(self): - return self.client.post(self.url, data={"title": "test", "content": "test", "visible": True, "is_pinned": False}) + return self.client.post( + self.url, data={ + "title": "test", + "content": "test", + "visible": True, + "is_pinned": False + }) def test_create_announcement(self): resp = self.create_announcement() @@ -45,14 +51,8 @@ def test_edit_announcement_not_exist(self): """ 존재하지 않는 공지사항 수정 test """ - data = { - "id": -9999, - "title": "error trial", - "content": "test content", - "visible": False, - "is_pinned": False - } - resp=self.client.put(self.url,data=data) + data = {"id": -9999, "title": "error trial", "content": "test content", "visible": False, "is_pinned": False} + resp = self.client.put(self.url, data=data) self.assertFailed(resp) self.assertEqual(resp.data["data"], "Announcement does not exist") @@ -77,7 +77,7 @@ def test_get_one_specific_announcement_not_exist(self): """ 존재하지 않는 id 로 공지사항 하나 조회 test """ - resp=self.client.get(self.url + "?id=-99999") + resp = self.client.get(self.url + "?id=-99999") self.assertFailed(resp) self.assertEqual(resp.data["data"], "Announcement does not exist") @@ -85,26 +85,11 @@ def test_get_visible_announcement_list(self): """ visible 설정된 공지사항 리스트 가져오기 test """ - self.client.post(self.url, data={ - "title": "보임1", - "content": "test", - "visible": True, - "is_pinned": False - }) - self.client.post(self.url, data={ - "title": "안보임1", - "content": "test", - "visible": False, - "is_pinned": False - }) - self.client.post(self.url, data={ - "title": "보임2", - "content": "test", - "visible": True, - "is_pinned": False - }) + self.client.post(self.url, data={"title": "보임1", "content": "test", "visible": True, "is_pinned": False}) + self.client.post(self.url, data={"title": "안보임1", "content": "test", "visible": False, "is_pinned": False}) + self.client.post(self.url, data={"title": "보임2", "content": "test", "visible": True, "is_pinned": False}) resp = self.client.get(self.url + "?visible=true") - self.assertEqual(len(resp.data["data"]["results"]),2) + self.assertEqual(len(resp.data["data"]["results"]), 2) titles = [announcement["title"] for announcement in resp.data["data"]["results"]] self.assertIn("보임1", titles) self.assertIn("보임2", titles) @@ -113,43 +98,24 @@ def test_create_pinned_announcement(self): """ 고정된 공지사항 생성 test """ - resp = self.client.post(self.url, data={ - "title": "고정 공지사항", - "content": "test", - "visible": True, - "is_pinned": True - }) + resp = self.client.post( + self.url, data={ + "title": "고정 공지사항", + "content": "test", + "visible": True, + "is_pinned": True + }) self.assertSuccess(resp) self.assertEqual(resp.data["data"]["is_pinned"], True) - + def test_get_notice_list_in_order(self): """ 공지사항 리스트 조회 시, 고정 공지사항 우선 로드 여부 test """ - self.client.post(self.url, data={ - "title": "고정1", - "content": "test", - "visible": True, - "is_pinned": True - }) - self.client.post(self.url, data={ - "title": "고정아님1", - "content": "test", - "visible": True, - "is_pinned": False - }) - self.client.post(self.url, data={ - "title": "고정2", - "content": "test", - "visible": True, - "is_pinned": True - }) - self.client.post(self.url, data={ - "title": "고정아님2", - "content": "test", - "visible": True, - "is_pinned": False - }) + self.client.post(self.url, data={"title": "고정1", "content": "test", "visible": True, "is_pinned": True}) + self.client.post(self.url, data={"title": "고정아님1", "content": "test", "visible": True, "is_pinned": False}) + self.client.post(self.url, data={"title": "고정2", "content": "test", "visible": True, "is_pinned": True}) + self.client.post(self.url, data={"title": "고정아님2", "content": "test", "visible": True, "is_pinned": False}) resp = self.client.get(self.url) self.assertSuccess(resp) @@ -160,23 +126,19 @@ def test_get_notice_list_in_order(self): self.assertTrue(results[1]["is_pinned"]) self.assertFalse(results[2]["is_pinned"]) self.assertFalse(results[3]["is_pinned"]) - + pinned_titles = {results[0]["title"], results[1]["title"]} self.assertIn("고정1", pinned_titles) self.assertIn("고정2", pinned_titles) - - + class AnnouncementAPITest(APITestCase): def setUp(self): self.create_school_fixtures(college_id=1, college_name="Test", department_id=1, department_name="Test") self.user = self.create_super_admin() - self.announcement = Announcement.objects.create(title="title", - content="content", - is_pinned=False, - visible=True, - created_by=self.user) + self.announcement = Announcement.objects.create( + title="title", content="content", is_pinned=False, visible=True, created_by=self.user) self.url = self.reverse("announcement_api") def test_get_announcement_list(self): diff --git a/backend/announcement/views/admin.py b/backend/announcement/views/admin.py index 5082e79e6..afd054a54 100644 --- a/backend/announcement/views/admin.py +++ b/backend/announcement/views/admin.py @@ -14,11 +14,12 @@ def post(self, request): publish announcement """ data = request.data - announcement = Announcement.objects.create(title=data["title"], - content=data["content"], - created_by=request.user, - visible=data["visible"], - is_pinned=data["is_pinned"]) + announcement = Announcement.objects.create( + title=data["title"], + content=data["content"], + created_by=request.user, + visible=data["visible"], + is_pinned=data["is_pinned"]) return self.success(AnnouncementSerializer(announcement).data) @validate_serializer(EditAnnouncementSerializer) diff --git a/backend/banner/views/admin.py b/backend/banner/views/admin.py index 788debd5f..6ae12f40d 100644 --- a/backend/banner/views/admin.py +++ b/backend/banner/views/admin.py @@ -52,10 +52,8 @@ def post(self, request): filename = ContentUtil.saveContentWithRandomFileName(banner_image, settings.BANNER_DIR) - new_banner = Banner(banner_image=f"{settings.BANNER_URI_PREFIX}/{filename}", - link_url=link_url, - visible=False, - order=None) + new_banner = Banner( + banner_image=f"{settings.BANNER_URI_PREFIX}/{filename}", link_url=link_url, visible=False, order=None) new_banner.save() diff --git a/backend/community/migrations/0001_initial.py b/backend/community/migrations/0001_initial.py index 9648f2b2e..062ddd0b5 100644 --- a/backend/community/migrations/0001_initial.py +++ b/backend/community/migrations/0001_initial.py @@ -23,14 +23,27 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=200)), ('content', utils.models.RichTextField()), - ('community_type', models.CharField(choices=[('GENERAL', '일반'), ('PROBLEM', '문제'), ('CONTEST', '대회')], default='GENERAL', max_length=20)), - ('post_type', models.CharField(choices=[('QUESTION', '질문'), ('ARTICLE', '글'), ('ANNOUNCEMENT', '공지')], default='ARTICLE', max_length=20)), - ('question_status', models.CharField(choices=[('OPEN', '진행중'), ('CLOSED', '해결됨')], default='OPEN', max_length=20)), + ('community_type', + models.CharField( + choices=[('GENERAL', '일반'), ('PROBLEM', '문제'), ('CONTEST', '대회')], + default='GENERAL', + max_length=20)), + ('post_type', + models.CharField( + choices=[('QUESTION', '질문'), ('ARTICLE', '글'), ('ANNOUNCEMENT', '공지')], + default='ARTICLE', + max_length=20)), + ('question_status', + models.CharField(choices=[('OPEN', '진행중'), ('CLOSED', '해결됨')], default='OPEN', max_length=20)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('contest', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contest.contest')), - ('problem', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='problem.problem')), + ('contest', + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contest.contest')), + ('problem', + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='problem.problem')), ], options={ 'ordering': ['-created_at'], @@ -44,8 +57,16 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='community.comment')), - ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='community.post')), + ('parent_comment', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='replies', + to='community.comment')), + ('post', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='community.post')), ], options={ 'ordering': ['created_at'], diff --git a/backend/community/models.py b/backend/community/models.py index 08d07f20a..faf5655d8 100644 --- a/backend/community/models.py +++ b/backend/community/models.py @@ -48,7 +48,6 @@ class SortType(models.TextChoices): OLDEST = "OLDEST", "오래된순" COMMENT = "COMMENT", "댓글많은순" - title = models.CharField(max_length=200) content = RichTextField() author = models.ForeignKey(User, on_delete=models.CASCADE) @@ -96,4 +95,4 @@ class Comment(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - ordering = ["created_at"] # 댓글은 오래된 순서대로 정렬 + ordering = ["created_at"] # 댓글은 오래된 순서대로 정렬 diff --git a/backend/community/tests.py b/backend/community/tests.py index 5dbab2af0..90abacf61 100644 --- a/backend/community/tests.py +++ b/backend/community/tests.py @@ -311,38 +311,50 @@ def test_get_post_list_with_keyword(self): """키워드로 게시글을 필터링할 수 있다.""" # 테스트 데이터 준비 self.client.force_login(self.user) - self.client.post(self.post_list_url, {"title":"HI Annyeong", "content" : "What Im saying is HIIIIIIII","post_type" : "ARTICLE" }) - self.client.post(self.post_list_url, {"title":"HI Annyeong", "content" : "What Im saying is HIIIIIIII","post_type" : "QUESTION" }) - self.client.post(self.post_list_url, {"title":"Bye Jalga", "content" : "What Im saying is B22222","post_type" : "ARTICLE" }) + self.client.post(self.post_list_url, { + "title": "HI Annyeong", + "content": "What Im saying is HIIIIIIII", + "post_type": "ARTICLE" + }) + self.client.post(self.post_list_url, { + "title": "HI Annyeong", + "content": "What Im saying is HIIIIIIII", + "post_type": "QUESTION" + }) + self.client.post(self.post_list_url, { + "title": "Bye Jalga", + "content": "What Im saying is B22222", + "post_type": "ARTICLE" + }) # API 호출 - response = self.client.get(self.post_list_url, {"keyword" : "HI"}) + response = self.client.get(self.post_list_url, {"keyword": "HI"}) # 상태값 검증 self.assertSuccess(response) self.assertEqual(response.data["data"]["total"], 2) # API 호출 - response = self.client.get(self.post_list_url, {"keyword" : "Bye"}) + response = self.client.get(self.post_list_url, {"keyword": "Bye"}) # 상태값 검증 self.assertSuccess(response) self.assertEqual(response.data["data"]["total"], 1) # API 호출 - response = self.client.get(self.post_list_url, {"keyword" : "is"}) + response = self.client.get(self.post_list_url, {"keyword": "is"}) # 상태값 검증 self.assertSuccess(response) - self.assertEqual(response.data["data"]["total"],3) - + self.assertEqual(response.data["data"]["total"], 3) + def test_get_post_list_order_by_newest(self): """게시글을 최신 순으로 정렬할 수 있다.""" #테스트 데이터 준비 self.client.force_login(self.user) - self.client.post(self.post_list_url, {"title" : "Post1", "content": "content1", "post_type": "ARTICLE"}) - self.client.post(self.post_list_url, {"title" : "Post2", "content": "content2", "post_type": "QUESTION"}) - self.client.post(self.post_list_url, {"title" : "Post3", "content": "content3", "post_type": "ARTICLE"}) + self.client.post(self.post_list_url, {"title": "Post1", "content": "content1", "post_type": "ARTICLE"}) + self.client.post(self.post_list_url, {"title": "Post2", "content": "content2", "post_type": "QUESTION"}) + self.client.post(self.post_list_url, {"title": "Post3", "content": "content3", "post_type": "ARTICLE"}) # API 호출 - response = self.client.get(self.post_list_url, {"sort_type" : "NEWEST"}) + response = self.client.get(self.post_list_url, {"sort_type": "NEWEST"}) # 상태값 검증 self.assertSuccess(response) results = response.data["data"]["results"] @@ -356,12 +368,12 @@ def test_get_post_list_order_by_oldest(self): self.general_post.delete() #테스트 데이터 준비 self.client.force_login(self.user) - self.client.post(self.post_list_url, {"title" : "Post1", "content": "content1", "post_type": "ARTICLE"}) - self.client.post(self.post_list_url, {"title" : "Post2", "content": "content2", "post_type": "QUESTION"}) - self.client.post(self.post_list_url, {"title" : "Post3", "content": "content3", "post_type": "ARTICLE"}) + self.client.post(self.post_list_url, {"title": "Post1", "content": "content1", "post_type": "ARTICLE"}) + self.client.post(self.post_list_url, {"title": "Post2", "content": "content2", "post_type": "QUESTION"}) + self.client.post(self.post_list_url, {"title": "Post3", "content": "content3", "post_type": "ARTICLE"}) #API 호출 - response = self.client.get(self.post_list_url, {"sort_type" : "OLDEST"}) + response = self.client.get(self.post_list_url, {"sort_type": "OLDEST"}) #상태값 검증 self.assertSuccess(response) results = response.data["data"]["results"] @@ -376,13 +388,13 @@ def test_get_post_list_order_by_comments(self): #테스트 데이터 준비 self.client.force_login(self.user) ## Post1, 댓글 3개 - self.client.post(self.post_list_url, {"title" : "Post1", "content": "content1", "post_type": "ARTICLE"}) + self.client.post(self.post_list_url, {"title": "Post1", "content": "content1", "post_type": "ARTICLE"}) post1 = Post.objects.get(title="Post1") Comment.objects.create(post=post1, author=self.user, content="comment1") Comment.objects.create(post=post1, author=self.user, content="comment2") Comment.objects.create(post=post1, author=self.user, content="comment3") ## Post2, 댓글 5개 - self.client.post(self.post_list_url, {"title" : "Post2", "content": "content2", "post_type": "QUESTION"}) + self.client.post(self.post_list_url, {"title": "Post2", "content": "content2", "post_type": "QUESTION"}) post2 = Post.objects.get(title="Post2") Comment.objects.create(post=post2, author=self.user, content="comment1") Comment.objects.create(post=post2, author=self.user, content="comment2") @@ -390,10 +402,10 @@ def test_get_post_list_order_by_comments(self): Comment.objects.create(post=post2, author=self.user, content="comment4") Comment.objects.create(post=post2, author=self.user, content="comment5") ## Post3, 댓글 0개 - self.client.post(self.post_list_url, {"title" : "Post3", "content": "content3", "post_type": "ARTICLE"}) + self.client.post(self.post_list_url, {"title": "Post3", "content": "content3", "post_type": "ARTICLE"}) #API 호출 - response = self.client.get(self.post_list_url, {"sort_type" : "COMMENT"}) + response = self.client.get(self.post_list_url, {"sort_type": "COMMENT"}) #상태값 검증 self.assertSuccess(response) results = response.data["data"]["results"] @@ -465,11 +477,11 @@ def test_update_question_status_when_change_to_question_type(self): # 작성한 일반글 url 생성 new_post_id = response.data["data"]["id"] new_post_url = self.reverse("community_post_detail", kwargs={"post_id": new_post_id}) - + # 질문글로 변경 - response2 = self.client.patch(new_post_url, {"post_type":"QUESTION"}) + response2 = self.client.patch(new_post_url, {"post_type": "QUESTION"}) self.assertSuccess(response2) - self.assertEqual(response2.data["data"]["question_status"],"OPEN") + self.assertEqual(response2.data["data"]["question_status"], "OPEN") def test_update_post_type_to_announcement_by_non_super_admin(self): """일반 사용자는 게시글을 ANNOUNCEMENT 타입으로 변경할 수 없다.""" diff --git a/backend/community/views/oj.py b/backend/community/views/oj.py index 8ef8dda72..f60901123 100644 --- a/backend/community/views/oj.py +++ b/backend/community/views/oj.py @@ -95,11 +95,9 @@ def get(self, request): if question_status: posts = posts.filter(question_status=question_status) - + if keyword: - posts = posts.filter( - Q(title__icontains=keyword) | Q(content__icontains=keyword) - ) + posts = posts.filter(Q(title__icontains=keyword) | Q(content__icontains=keyword)) if sort_type == Post.SortType.NEWEST: posts = posts.order_by("-created_at") @@ -166,7 +164,7 @@ def patch(self, request, post_id): for key, value in data.items(): setattr(post, key, value) - + # 질문 타입으로 변경 시, 상태가 없거나 이전 타입이 일반글이었다면 미해결로 초기화 if post.post_type == Post.PostType.QUESTION: if old_post_type == Post.PostType.ARTICLE or not post.question_status: @@ -200,8 +198,9 @@ def get(self, request, post_id): except Post.DoesNotExist: return self.error("Post does not exist") - comments = (Comment.objects.filter( - post_id=post_id).select_related("author").prefetch_related("replies__author").order_by("created_at")) + comments = ( + Comment.objects.filter( + post_id=post_id).select_related("author").prefetch_related("replies__author").order_by("created_at")) root_comments = comments.filter(parent_comment__isnull=True) data = self.paginate_data(request, root_comments, CommentSerializer) diff --git a/backend/conf/views.py b/backend/conf/views.py index e33000bd3..503a45eb7 100644 --- a/backend/conf/views.py +++ b/backend/conf/views.py @@ -66,12 +66,13 @@ def post(self, request): if not SysOptions.smtp_config: return self.error("Please setup SMTP config at first") try: - send_email(smtp_config=SysOptions.smtp_config, - from_name=SysOptions.website_name_shortcut, - to_name=request.user.username, - to_email=request.data["email"], - subject="You have successfully configured SMTP", - content="You have successfully configured SMTP") + send_email( + smtp_config=SysOptions.smtp_config, + from_name=SysOptions.website_name_shortcut, + to_name=request.user.username, + to_email=request.data["email"], + subject="You have successfully configured SMTP", + content="You have successfully configured SMTP") except smtplib.SMTPResponseException as e: # guess error message encoding msg = b"Failed to send email" @@ -225,9 +226,9 @@ class ReleaseNotesAPI(APIView): def get(self, request): try: - resp = requests.get("https://raw.githubusercontent.com/QingdaoU/OnlineJudge/master/docs/data.json?_=" + - str(time.time()), - timeout=3) + resp = requests.get( + "https://raw.githubusercontent.com/QingdaoU/OnlineJudge/master/docs/data.json?_=" + str(time.time()), + timeout=3) releases = resp.json() except (RequestException, ValueError): return self.success() @@ -241,8 +242,9 @@ class DashboardInfoAPI(APIView): def get(self, request): today = datetime.today() - today_submission_count = Submission.objects.filter(create_time__gte=datetime( - today.year, today.month, today.day, 0, 0, tzinfo=timezone.get_current_timezone())).count() + today_submission_count = Submission.objects.filter( + create_time__gte=datetime(today.year, today.month, today.day, 0, 0, + tzinfo=timezone.get_current_timezone())).count() recent_contest_count = Contest.objects.exclude(end_time__lt=timezone.now()).count() judge_server_count = len(list(filter(lambda x: x.status == "normal", JudgeServer.objects.all()))) return self.success({ diff --git a/backend/contest/models.py b/backend/contest/models.py index 211cdbf68..090beb481 100644 --- a/backend/contest/models.py +++ b/backend/contest/models.py @@ -1,4 +1,4 @@ -from utils.constants import ContestRuleType # noqa +from utils.constants import ContestRuleType # noqa from django.db import models from django.utils.timezone import now from utils.models import JSONField diff --git a/backend/contest/views/admin.py b/backend/contest/views/admin.py index 3bee351ea..a654aaef8 100644 --- a/backend/contest/views/admin.py +++ b/backend/contest/views/admin.py @@ -173,8 +173,8 @@ def delete(self, request): contest_announcement_id = request.GET.get("id") if contest_announcement_id: if request.user.is_admin(): - ContestAnnouncement.objects.filter(id=contest_announcement_id, - contest__created_by=request.user).delete() + ContestAnnouncement.objects.filter( + id=contest_announcement_id, contest__created_by=request.user).delete() else: ContestAnnouncement.objects.filter(id=contest_announcement_id).delete() return self.success() diff --git a/backend/contest/views/oj.py b/backend/contest/views/oj.py index 6dbc370f6..4f04275c0 100644 --- a/backend/contest/views/oj.py +++ b/backend/contest/views/oj.py @@ -157,9 +157,8 @@ def get(self, request): submissions = Submission.objects.filter(contest_id=contest_id) - user_submissions = submissions.values('user_id', - 'username').annotate(submission_count=Count('id'), - last_submission_ip=Max('ip')).order_by('user_id') + user_submissions = submissions.values('user_id', 'username').annotate( + submission_count=Count('id'), last_submission_ip=Max('ip')).order_by('user_id') # UserProfile 정보 가져오기 user_profiles = UserProfile.objects.filter(user_id__in=[sub['user_id'] for sub in user_submissions]) diff --git a/backend/judge/dispatcher.py b/backend/judge/dispatcher.py index 9c4b157db..54a61927b 100644 --- a/backend/judge/dispatcher.py +++ b/backend/judge/dispatcher.py @@ -306,8 +306,8 @@ def update_problem_status(self): user_profile.accepted_number += 1 elif oi_problems_status[problem_id]["status"] != JudgeStatus.ACCEPTED: # minus last time score, add this time score - user_profile.add_score(this_time_score=score, - last_time_score=oi_problems_status[problem_id]["score"]) + user_profile.add_score( + this_time_score=score, last_time_score=oi_problems_status[problem_id]["score"]) oi_problems_status[problem_id]["score"] = score oi_problems_status[problem_id]["status"] = self.submission.result if self.submission.result == JudgeStatus.ACCEPTED: diff --git a/backend/oj/settings.py b/backend/oj/settings.py index 9639550cd..51d705461 100644 --- a/backend/oj/settings.py +++ b/backend/oj/settings.py @@ -236,18 +236,18 @@ def make_key(key, key_prefix, version): CELERY_BEAT_SCHEDULE = { 'calculate_user_score_basis': { 'task': 'account.tasks.calculate_user_score_basis', - 'schedule': celery.schedules.crontab(minute=0, hour=0), # Every day at midnight + 'schedule': celery.schedules.crontab(minute=0, hour=0), # Every day at midnight }, 'calculate_user_score_fluctuation': { 'task': 'account.tasks.calculate_user_score_fluctuation', - 'schedule': celery.schedules.crontab(minute='*/1'), # Every minute + 'schedule': celery.schedules.crontab(minute='*/1'), # Every minute }, 'update_weekly_stats': { 'task': 'problem.tasks.update_weekly_stats', - 'schedule': celery.schedules.crontab(hour=0, minute=0, day_of_week='mon'), # Every Monday at midnight + 'schedule': celery.schedules.crontab(hour=0, minute=0, day_of_week='mon'), # Every Monday at midnight }, 'update_bonus_problem': { 'task': 'problem.tasks.update_bonus_problem', - 'schedule': celery.schedules.crontab(hour=0, minute=0, day_of_week='mon'), # Every Monday at midnight + 'schedule': celery.schedules.crontab(hour=0, minute=0, day_of_week='mon'), # Every Monday at midnight } } diff --git a/backend/popup/views/admin.py b/backend/popup/views/admin.py index 0c0fd31e9..eba80d337 100644 --- a/backend/popup/views/admin.py +++ b/backend/popup/views/admin.py @@ -55,11 +55,12 @@ def post(self, request): filename = ContentUtil.saveContentWithRandomFileName(popup_image, settings.POPUP_DIR) - new_popup = Popup(popup_image=f"{settings.POPUP_URI_PREFIX}/{filename}", - link_url=link_url, - popup_image_width=image_width, - visible=False, - order=None) + new_popup = Popup( + popup_image=f"{settings.POPUP_URI_PREFIX}/{filename}", + link_url=link_url, + popup_image_width=image_width, + visible=False, + order=None) new_popup.save() diff --git a/backend/problem/tests.py b/backend/problem/tests.py index f929b6128..b10f19409 100644 --- a/backend/problem/tests.py +++ b/backend/problem/tests.py @@ -126,8 +126,8 @@ def setUp(self): self.create_super_admin() def test_filter_file_name(self): - self.assertEqual(self.api.filter_name_list(["1.in", "1.out", "2.in", ".DS_Store"], spj=False), - ["1.in", "1.out"]) + self.assertEqual( + self.api.filter_name_list(["1.in", "1.out", "2.in", ".DS_Store"], spj=False), ["1.in", "1.out"]) self.assertEqual(self.api.filter_name_list(["2.in", "2.out"], spj=False), []) self.assertEqual(self.api.filter_name_list(["1.in", "1.out", "2.in"], spj=True), ["1.in", "2.in"]) diff --git a/backend/problem/views/admin.py b/backend/problem/views/admin.py index fa41d629a..1127c47b6 100644 --- a/backend/problem/views/admin.py +++ b/backend/problem/views/admin.py @@ -596,10 +596,9 @@ class ExportProblemAPI(APIView): def choose_answers(self, user, problem): ret = [] for item in problem.languages: - submission = Submission.objects.filter(problem=problem, - user_id=user.id, - language=item, - result=JudgeStatus.ACCEPTED).order_by("-create_time").first() + submission = Submission.objects.filter( + problem=problem, user_id=user.id, language=item, + result=JudgeStatus.ACCEPTED).order_by("-create_time").first() if submission: ret.append({"language": submission.language, "code": submission.code}) return ret @@ -608,20 +607,21 @@ def process_one_problem(self, zip_file, user, problem, index): info = ExportProblemSerializer(problem).data info["answers"] = self.choose_answers(user, problem=problem) compression = zipfile.ZIP_DEFLATED - zip_file.writestr(zinfo_or_arcname=f"{index}/problem.json", - data=json.dumps(info, indent=4), - compress_type=compression) + zip_file.writestr( + zinfo_or_arcname=f"{index}/problem.json", data=json.dumps(info, indent=4), compress_type=compression) problem_test_case_dir = os.path.join(settings.TEST_CASE_DIR, problem.test_case_id) with open(os.path.join(problem_test_case_dir, "info")) as f: info = json.load(f) for k, v in info["test_cases"].items(): - zip_file.write(filename=os.path.join(problem_test_case_dir, v["input_name"]), - arcname=f"{index}/testcase/{v['input_name']}", - compress_type=compression) + zip_file.write( + filename=os.path.join(problem_test_case_dir, v["input_name"]), + arcname=f"{index}/testcase/{v['input_name']}", + compress_type=compression) if not info["spj"]: - zip_file.write(filename=os.path.join(problem_test_case_dir, v["output_name"]), - arcname=f"{index}/testcase/{v['output_name']}", - compress_type=compression) + zip_file.write( + filename=os.path.join(problem_test_case_dir, v["output_name"]), + arcname=f"{index}/testcase/{v['output_name']}", + compress_type=compression) @validate_serializer(ExportProblemRequestSerialzier) def get(self, request): @@ -750,28 +750,29 @@ def _create_problem(self, problem_data, creator): our_lang = "Python3" template[our_lang] = TEMPLATE_BASE.format(prepend.get(lang, ""), t["code"], append.get(lang, "")) spj = problem_data["spj"] is not None - Problem.objects.create(_id=f"fps-{rand_str(4)}", - title=problem_data["title"], - description=problem_data["description"], - input_description=problem_data["input"], - output_description=problem_data["output"], - hint=problem_data["hint"], - test_case_score=problem_data["test_case_score"], - time_limit=time_limit, - memory_limit=problem_data["memory_limit"]["value"], - samples=problem_data["samples"], - template=template, - rule_type=ProblemRuleType.ACM, - source=problem_data.get("source", ""), - spj=spj, - spj_code=problem_data["spj"]["code"] if spj else None, - spj_language=problem_data["spj"]["language"] if spj else None, - spj_version=rand_str(8) if spj else "", - visible=False, - languages=SysOptions.language_names, - created_by=creator, - difficulty=Difficulty.MID, - test_case_id=problem_data["test_case_id"]) + Problem.objects.create( + _id=f"fps-{rand_str(4)}", + title=problem_data["title"], + description=problem_data["description"], + input_description=problem_data["input"], + output_description=problem_data["output"], + hint=problem_data["hint"], + test_case_score=problem_data["test_case_score"], + time_limit=time_limit, + memory_limit=problem_data["memory_limit"]["value"], + samples=problem_data["samples"], + template=template, + rule_type=ProblemRuleType.ACM, + source=problem_data.get("source", ""), + spj=spj, + spj_code=problem_data["spj"]["code"] if spj else None, + spj_language=problem_data["spj"]["language"] if spj else None, + spj_version=rand_str(8) if spj else "", + visible=False, + languages=SysOptions.language_names, + created_by=creator, + difficulty=Difficulty.MID, + test_case_id=problem_data["test_case_id"]) def post(self, request): form = UploadProblemForm(request.POST, request.FILES) diff --git a/backend/problem/views/oj.py b/backend/problem/views/oj.py index 25775248b..582606339 100644 --- a/backend/problem/views/oj.py +++ b/backend/problem/views/oj.py @@ -81,7 +81,7 @@ def get(self, request): .get(_id=problem_id, contest_id__isnull=True, visible=True) problem_data = ProblemSerializer(problem).data self._add_problem_status(request, problem_data) - problem_data["allow_paste"] = True # 기본 문제는 복사 붙여넣기 허용 + problem_data["allow_paste"] = True # 기본 문제는 복사 붙여넣기 허용 return self.success(problem_data) except Problem.DoesNotExist: return self.error("Problem does not exist") @@ -133,9 +133,8 @@ def get(self, request): problem_id = request.GET.get("problem_id") if problem_id: try: - problem = Problem.objects.select_related("created_by").get(_id=problem_id, - contest=self.contest, - visible=True) + problem = Problem.objects.select_related("created_by").get( + _id=problem_id, contest=self.contest, visible=True) except Problem.DoesNotExist: return self.error("Problem does not exist.") if self.contest.problem_details_permission(request.user): @@ -145,7 +144,7 @@ def get(self, request): ]) else: problem_data = ProblemSafeSerializer(problem).data - problem_data["allow_paste"] = self.contest.allow_paste # 대회의 복사 붙여넣기 허용 여부 반환 + problem_data["allow_paste"] = self.contest.allow_paste # 대회의 복사 붙여넣기 허용 여부 반환 return self.success(problem_data) contest_problems = Problem.objects.select_related("created_by").filter(contest=self.contest, visible=True) @@ -157,8 +156,8 @@ def get(self, request): submission_state_info = {} if request.user: - contest_user_submissions = Submission.objects.filter(contest=self.contest, - username__exact=request.user.username) + contest_user_submissions = Submission.objects.filter( + contest=self.contest, username__exact=request.user.username) accepted_submissions = set() partially_accepted_submissions = set() failed_submissions = set() @@ -232,8 +231,8 @@ def get(self, request): # candidate_problems = Problem.objects.filter( # difficulty__in=difficulty, visible=True, contest__isnull=True).exclude(_id__in=user_solved_problem) - candidate_problems = Problem.objects.filter(visible=True, - contest__isnull=True).exclude(_id__in=user_solved_problem) + candidate_problems = Problem.objects.filter( + visible=True, contest__isnull=True).exclude(_id__in=user_solved_problem) recommend_problems = random.sample(list(candidate_problems), min(3, len(candidate_problems))) if recommend_problems: diff --git a/backend/profile/serializers.py b/backend/profile/serializers.py index 6119f125a..61f120e98 100644 --- a/backend/profile/serializers.py +++ b/backend/profile/serializers.py @@ -29,10 +29,8 @@ class EditUserProfileSerializer(serializers.Serializer): github = serializers.URLField(max_length=256, allow_blank=True, required=False, allow_null=True) college = serializers.IntegerField(allow_null=True, required=False) department = serializers.IntegerField(allow_null=True, required=False) - language = serializers.ChoiceField(allow_null=True, - allow_blank=True, - required=False, - choices=["C", "C++", "Java", "Python3", "JavaScript"]) + language = serializers.ChoiceField( + allow_null=True, allow_blank=True, required=False, choices=["C", "C++", "Java", "Python3", "JavaScript"]) class ProfileProblemSerializer(serializers.Serializer): diff --git a/backend/profile/views/oj.py b/backend/profile/views/oj.py index d3a873ed4..1cde4d200 100644 --- a/backend/profile/views/oj.py +++ b/backend/profile/views/oj.py @@ -74,10 +74,10 @@ def get(self, request): user_id = user_profile.user_id user_score = UserScore.objects.filter(user_id=user_id).annotate( total_rank=Count('total_score', filter=Q(total_score__gt=F('total_score'))) + 1, - datastructure_rank=Count('datastructure_score', - filter=Q(datastructure_score__gt=F('datastructure_score'))) + 1, - implementation_rank=Count('implementation_score', - filter=Q(implementation_score__gt=F('implementation_score'))) + 1, + datastructure_rank=Count( + 'datastructure_score', filter=Q(datastructure_score__gt=F('datastructure_score'))) + 1, + implementation_rank=Count( + 'implementation_score', filter=Q(implementation_score__gt=F('implementation_score'))) + 1, math_rank=Count('math_score', filter=Q(math_score__gt=F('math_score'))) + 1, search_rank=Count('search_score', filter=Q(search_score__gt=F('search_score'))) + 1, sorting_rank=Count('sorting_score', filter=Q(sorting_score__gt=F('sorting_score'))) + 1, @@ -190,8 +190,8 @@ def get(self, request): return self.error("Invalid date format. Should be YYYY-MM-DD.") if start_date and end_date: - submissions = submissions.filter(create_time__gte=start_date, - create_time__lt=(end_date + datetime.timedelta(days=1))) + submissions = submissions.filter( + create_time__gte=start_date, create_time__lt=(end_date + datetime.timedelta(days=1))) elif start_date: submissions = submissions.filter(create_time__gte=start_date) elif end_date: diff --git a/backend/ranking/serializers.py b/backend/ranking/serializers.py index 362c31d8c..85fb7cdfd 100644 --- a/backend/ranking/serializers.py +++ b/backend/ranking/serializers.py @@ -20,11 +20,11 @@ def get_username(self, obj): class UserRankListSerializer(serializers.ModelSerializer): rank = serializers.SerializerMethodField() avatar = serializers.SerializerMethodField() - username = serializers.CharField() # 수정 - mood = serializers.CharField(source='userprofile.mood') # 수정 - score = serializers.IntegerField(source='userscore.total_score') # 수정 - major = serializers.CharField(source='userprofile.major') # 수정 - tier = serializers.CharField(source='userscore.tier') # 수정 + username = serializers.CharField() # 수정 + mood = serializers.CharField(source='userprofile.mood') # 수정 + score = serializers.IntegerField(source='userscore.total_score') # 수정 + major = serializers.CharField(source='userprofile.major') # 수정 + tier = serializers.CharField(source='userscore.tier') # 수정 solved = serializers.SerializerMethodField() growth = serializers.SerializerMethodField() @@ -41,7 +41,7 @@ def get_avatar(self, obj): def get_growth(self, obj): return obj.userscore.fluctuation - def get_solved(self, obj): # 추가 + def get_solved(self, obj): # 추가 return obj.usersolved.total_solved diff --git a/backend/submission/models.py b/backend/submission/models.py index 4241bdf51..381510e5a 100644 --- a/backend/submission/models.py +++ b/backend/submission/models.py @@ -44,8 +44,8 @@ class Submission(models.Model): first_failed_tc_idx = models.IntegerField(null=True, default=None) def check_user_permission(self, user, check_share=True): - if (self.user_id == user.id or user.is_super_admin() or user.can_mgmt_all_problem() or - self.problem.created_by_id == user.id): + if (self.user_id == user.id or user.is_super_admin() or user.can_mgmt_all_problem() + or self.problem.created_by_id == user.id): return True if check_share: diff --git a/backend/submission/tests.py b/backend/submission/tests.py index 119025343..e0695f7a4 100644 --- a/backend/submission/tests.py +++ b/backend/submission/tests.py @@ -312,26 +312,28 @@ def setUp(self): self.url = self.reverse("submission_rank_api") def test_get_submission_rank_success(self): - submission = self.create_submission(self.user, - self.problem, - result=JudgeStatus.ACCEPTED, - statistic_info={ - "time_cost": 100, - "memory_cost": 1024 - }) + submission = self.create_submission( + self.user, + self.problem, + result=JudgeStatus.ACCEPTED, + statistic_info={ + "time_cost": 100, + "memory_cost": 1024 + }) resp = self.client.get(f"{self.url}?submission_id={submission.id}") self.assertSuccess(resp) self.assertIn("solved_rank", resp.data["data"]) def test_statistic_info_invalid(self): - submission = self.create_submission(self.user, - self.problem, - result=JudgeStatus.ACCEPTED, - statistic_info={ - "time_cost": "invalid", - "memory_cost": "invalid" - }) + submission = self.create_submission( + self.user, + self.problem, + result=JudgeStatus.ACCEPTED, + statistic_info={ + "time_cost": "invalid", + "memory_cost": "invalid" + }) resp = self.client.get(f"{self.url}?submission_id={submission.id}") self.assertFailed(resp, "Invalid submission statistic_info") @@ -344,26 +346,28 @@ def test_statistic_info_invalid(self): self.assertFailed(resp, "Invalid submission statistic_info") def test_reject_contest_problem(self): - submission = self.create_submission(self.user, - self.contest_problem, - contest_id=self.contest["id"], - result=JudgeStatus.ACCEPTED, - statistic_info={ - "time_cost": 100, - "memory_cost": 1024 - }) + submission = self.create_submission( + self.user, + self.contest_problem, + contest_id=self.contest["id"], + result=JudgeStatus.ACCEPTED, + statistic_info={ + "time_cost": 100, + "memory_cost": 1024 + }) resp = self.client.get(f"{self.url}?submission_id={submission.id}") self.assertFailed(resp, "This API is not available for contest submissions") def test_reject_unauthorized_user(self): - submission = self.create_submission(user=self.user, - problem=self.problem, - result=JudgeStatus.ACCEPTED, - statistic_info={ - "time_cost": 100, - "memory_cost": 1024 - }) + submission = self.create_submission( + user=self.user, + problem=self.problem, + result=JudgeStatus.ACCEPTED, + statistic_info={ + "time_cost": 100, + "memory_cost": 1024 + }) # 다른 유저로 요청 other_user = self.create_user( @@ -384,34 +388,28 @@ def test_get_submission_rank_multiple_users(self): problem = self.problem # user1: Accepted, time_cost 100, memory_cost 1024, 제출 시각 가장 빠름 - sub1 = self.create_submission(user1, - problem, - result=JudgeStatus.ACCEPTED, - statistic_info={ - "time_cost": 100, - "memory_cost": 1024 - }) + sub1 = self.create_submission( + user1, problem, result=JudgeStatus.ACCEPTED, statistic_info={ + "time_cost": 100, + "memory_cost": 1024 + }) # 인위적으로 생성 시간 조절 (예: sub1가 가장 먼저 제출) Submission.objects.filter(id=sub1.id).update(create_time=timezone.now() - timedelta(minutes=10)) # user2: Accepted, time_cost 200, memory_cost 2048 - sub2 = self.create_submission(user2, - problem, - result=JudgeStatus.ACCEPTED, - statistic_info={ - "time_cost": 200, - "memory_cost": 2048 - }) + sub2 = self.create_submission( + user2, problem, result=JudgeStatus.ACCEPTED, statistic_info={ + "time_cost": 200, + "memory_cost": 2048 + }) Submission.objects.filter(id=sub2.id).update(create_time=timezone.now() - timedelta(minutes=5)) # user3: Accepted, time_cost 150, memory_cost 1024 - sub3 = self.create_submission(user3, - problem, - result=JudgeStatus.ACCEPTED, - statistic_info={ - "time_cost": 150, - "memory_cost": 1024 - }) + sub3 = self.create_submission( + user3, problem, result=JudgeStatus.ACCEPTED, statistic_info={ + "time_cost": 150, + "memory_cost": 1024 + }) Submission.objects.filter(id=sub3.id).update(create_time=timezone.now() - timedelta(minutes=1)) # user3의 제출물로 랭크 조회 @@ -444,13 +442,11 @@ def test_get_submission_rank_nonexistent(self): def test_get_submission_rank_total_zero(self): # Accepted된 submission이 없는 상태: 새로 제출 생성 (Accepted 외 상태로) - submission = self.create_submission(self.user, - self.problem, - result=JudgeStatus.PENDING, - statistic_info={ - "time_cost": 100, - "memory_cost": 1024 - }) + submission = self.create_submission( + self.user, self.problem, result=JudgeStatus.PENDING, statistic_info={ + "time_cost": 100, + "memory_cost": 1024 + }) resp = self.client.get(f"{self.url}?submission_id={submission.id}") self.assertSuccess(resp) diff --git a/backend/submission/views/oj.py b/backend/submission/views/oj.py index b4c201652..f8d22e9b2 100644 --- a/backend/submission/views/oj.py +++ b/backend/submission/views/oj.py @@ -73,13 +73,14 @@ def post(self, request): return self.error("Problem not exist") if data["language"] not in problem.languages: return self.error(f"{data['language']} is now allowed in the problem") - submission = Submission.objects.create(user_id=request.user.id, - username=request.user.username, - language=data["language"], - code=data["code"], - problem_id=problem.id, - ip=request.session["ip"], - contest_id=data.get("contest_id")) + submission = Submission.objects.create( + user_id=request.user.id, + username=request.user.username, + language=data["language"], + code=data["code"], + problem_id=problem.id, + ip=request.session["ip"], + contest_id=data.get("contest_id")) # use this for debug # JudgeDispatcher(submission.id, problem.id).judge() @@ -184,7 +185,7 @@ def get(self, request): # 사용자 필터링 if (myself and myself == "1") or not SysOptions.submission_list_show_all: submissions = submissions.filter(user_id=request.user.id) - elif username and not contest_id: # 대회에서는 username 검색 제한 + elif username and not contest_id: # 대회에서는 username 검색 제한 submissions = submissions.filter(username__icontains=username) if result: @@ -242,8 +243,8 @@ def get(self, request): if not request.GET.get("problem_id"): return self.error("Parameter error, problem_id is required") return self.success( - request.user.is_authenticated and - Submission.objects.filter(problem_id=request.GET["problem_id"], user_id=request.user.id).exists()) + request.user.is_authenticated + and Submission.objects.filter(problem_id=request.GET["problem_id"], user_id=request.user.id).exists()) class SubmissionRankAPI(APIView): diff --git a/backend/utils/api/__init__.py b/backend/utils/api/__init__.py index 9384481ce..f7269029c 100644 --- a/backend/utils/api/__init__.py +++ b/backend/utils/api/__init__.py @@ -1,2 +1,2 @@ -from ._serializers import * # NOQA -from .api import * # NOQA +from ._serializers import * # NOQA +from .api import * # NOQA diff --git a/backend/utils/api/tests.py b/backend/utils/api/tests.py index ce056ea63..abbb34cc9 100644 --- a/backend/utils/api/tests.py +++ b/backend/utils/api/tests.py @@ -22,20 +22,19 @@ def create_user(self, admin_type=AdminType.REGULAR_USER, login=True, problem_permission=ProblemPermission.NONE): - user = User.objects.create(email=email, - username=username, - admin_type=admin_type, - problem_permission=problem_permission) + user = User.objects.create( + email=email, username=username, admin_type=admin_type, problem_permission=problem_permission) user.set_password(password) school = College.objects.get(id=college_id) major = Department.objects.get(id=department_id) - UserProfile.objects.create(user=user, - real_name=real_name, - student_id=student_id, - college_id=college_id, - department_id=department_id, - school=school, - major=major) + UserProfile.objects.create( + user=user, + real_name=real_name, + student_id=student_id, + college_id=college_id, + department_id=department_id, + school=school, + major=major) UserScore.objects.create(user=user) UserSolved.objects.create(user=user) user.save() @@ -50,20 +49,22 @@ def create_school_fixtures(self, college_id, college_name, department_id, depart department.save() def create_admin(self, email="admin@admin.com", username="admin", password="admin1234!", login=True): - return self.create_user(email=email, - username=username, - password=password, - admin_type=AdminType.ADMIN, - problem_permission=ProblemPermission.OWN, - login=login) + return self.create_user( + email=email, + username=username, + password=password, + admin_type=AdminType.ADMIN, + problem_permission=ProblemPermission.OWN, + login=login) def create_super_admin(self, email="root@root.com", username="root", password="root1234!", login=True): - return self.create_user(email=email, - username=username, - password=password, - admin_type=AdminType.SUPER_ADMIN, - problem_permission=ProblemPermission.ALL, - login=login) + return self.create_user( + email=email, + username=username, + password=password, + admin_type=AdminType.SUPER_ADMIN, + problem_permission=ProblemPermission.ALL, + login=login) def reverse(self, url_name, *args, **kwargs): return reverse(url_name, *args, **kwargs) diff --git a/backend/utils/cache.py b/backend/utils/cache.py index 61b38d9ba..73b0f2fcb 100644 --- a/backend/utils/cache.py +++ b/backend/utils/cache.py @@ -1,5 +1,5 @@ -from django.core.cache import cache, caches # noqa -from django.conf import settings # noqa +from django.core.cache import cache, caches # noqa +from django.conf import settings # noqa from django_redis.cache import RedisCache from django_redis.client.default import DefaultClient diff --git a/backend/utils/constants.py b/backend/utils/constants.py index ace1abd82..a5a95847a 100644 --- a/backend/utils/constants.py +++ b/backend/utils/constants.py @@ -68,11 +68,11 @@ class ProblemField: class ProblemScore: score = { - 'VeryLow': 10, # VeryLow - 'Low': 20, # Low - 'Mid': 160, # Mid - 'High': 640, # High - 'VeryHigh': 1280, # VeryHigh + 'VeryLow': 10, # VeryLow + 'Low': 20, # Low + 'Mid': 160, # Mid + 'High': 640, # High + 'VeryHigh': 1280, # VeryHigh } diff --git a/backend/utils/contest_ranking_writer.py b/backend/utils/contest_ranking_writer.py index bff96265e..0015514e8 100644 --- a/backend/utils/contest_ranking_writer.py +++ b/backend/utils/contest_ranking_writer.py @@ -41,7 +41,7 @@ def __init__(self, contest, data): }, } self.common_participant_info_tag = ["순위", "닉네임", "실명", "이메일", "단과대학", "학과명", "학번"] - self.table_start_pos_y = "7" # 대회 결과 테이블이 작성되기 시작하는 row number + self.table_start_pos_y = "7" # 대회 결과 테이블이 작성되기 시작하는 row number @staticmethod def get_ac_state_name(is_ac): diff --git a/backend/utils/management/commands/inituser.py b/backend/utils/management/commands/inituser.py index 72329aa38..24e5d5fbb 100644 --- a/backend/utils/management/commands/inituser.py +++ b/backend/utils/management/commands/inituser.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand from account.models import AdminType, ProblemPermission, User, UserProfile, UserScore, UserSolved -from utils.shortcuts import rand_str # NOQA +from utils.shortcuts import rand_str # NOQA class Command(BaseCommand): @@ -25,10 +25,11 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS(f"User {username} exists, operation ignored")) exit() - user = User.objects.create(username=username, - email=username, - admin_type=AdminType.SUPER_ADMIN, - problem_permission=ProblemPermission.ALL) + user = User.objects.create( + username=username, + email=username, + admin_type=AdminType.SUPER_ADMIN, + problem_permission=ProblemPermission.ALL) user.set_password(password) user.save() UserProfile.objects.create(user=user) @@ -37,10 +38,8 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS("User created")) elif action == "create_admin": - user = User.objects.create(username=username, - email=username, - admin_type=AdminType.ADMIN, - problem_permission=ProblemPermission.OWN) + user = User.objects.create( + username=username, email=username, admin_type=AdminType.ADMIN, problem_permission=ProblemPermission.OWN) user.set_password(password) user.save() UserProfile.objects.create(user=user) diff --git a/backend/utils/models.py b/backend/utils/models.py index e70dd97fc..7e8e78bbd 100644 --- a/backend/utils/models.py +++ b/backend/utils/models.py @@ -1,4 +1,4 @@ -from django.db.models import JSONField # NOQA +from django.db.models import JSONField # NOQA from django.db import models from utils.xss_filter import XSSHtml diff --git a/backend/utils/shortcuts.py b/backend/utils/shortcuts.py index 2f65104b3..16b1f5027 100644 --- a/backend/utils/shortcuts.py +++ b/backend/utils/shortcuts.py @@ -80,15 +80,14 @@ def natural_sort_key(s, _nsre=re.compile(r"(\d+)")): def send_email(smtp_config, from_name, to_email, to_name, subject, content): - envelope = Envelope(from_addr=(smtp_config["email"], from_name), - to_addr=(to_email, to_name), - subject=subject, - html_body=content) - return envelope.send(smtp_config["server"], - login=smtp_config["email"], - password=smtp_config["password"], - port=smtp_config["port"], - tls=smtp_config["tls"]) + envelope = Envelope( + from_addr=(smtp_config["email"], from_name), to_addr=(to_email, to_name), subject=subject, html_body=content) + return envelope.send( + smtp_config["server"], + login=smtp_config["email"], + password=smtp_config["password"], + port=smtp_config["port"], + tls=smtp_config["tls"]) def get_env(name, default=""): diff --git a/backend/utils/testcase_cache.py b/backend/utils/testcase_cache.py index 9206f8d01..bd5606a45 100644 --- a/backend/utils/testcase_cache.py +++ b/backend/utils/testcase_cache.py @@ -12,7 +12,7 @@ class TestCaseCacheManager: """테스트 케이스의 Input/Output을 캐싱하는 클래스입니다.""" CACHE_PREFIX = "testcase" - DEFAULT_TIMEOUT = 60 * 60 # 1 시간 + DEFAULT_TIMEOUT = 60 * 60 # 1 시간 @classmethod def _get_cache_key(cls, testcase_dir: str, testcase_idx: int) -> str: diff --git a/backend/utils/tests.py b/backend/utils/tests.py index 3149843fd..7e2f85efc 100644 --- a/backend/utils/tests.py +++ b/backend/utils/tests.py @@ -82,7 +82,7 @@ def test_get_testcase_cache_miss_no_file_data(self, mock_read_file): self.assertIsNone(result) mock_read_file.assert_called_once_with(testcase_dir, testcase_idx) - @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 + @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 def test_read_testcase_invalid_directory_name(self, mock_logger): """잘못된 디렉토리 이름 검증 테스트""" result = TestCaseCacheManager._read_testcase_from_file("problem-1", 1) @@ -91,7 +91,7 @@ def test_read_testcase_invalid_directory_name(self, mock_logger): mock_logger.error.assert_called_with( "Invalid testcase directory name. Only alphanumeric characters are allowed.") - @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 + @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 def test_read_testcase_invalid_index_negative(self, mock_logger): """음수 인덱스 검증 테스트""" result = TestCaseCacheManager._read_testcase_from_file("problem1", -1) @@ -99,7 +99,7 @@ def test_read_testcase_invalid_index_negative(self, mock_logger): self.assertIsNone(result) mock_logger.error.assert_called_with("Invalid testcase index. It must be a non-negative integer.") - @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 + @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 def test_read_testcase_invalid_index_non_integer(self, mock_logger): """정수가 아닌 인덱스 검증 테스트""" result = TestCaseCacheManager._read_testcase_from_file("problem1", "invalid") @@ -109,14 +109,14 @@ def test_read_testcase_invalid_index_non_integer(self, mock_logger): @override_settings(TEST_CASE_DIR="/test/cases") @patch('os.path.abspath') - @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 + @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 def test_read_testcase_path_traversal_attack(self, mock_logger, mock_abspath): """경로 순회 공격 방지 테스트""" # abspath 모킹 - 악성 경로로 설정 mock_abspath.side_effect = [ - "/test/cases", # base_dir - "/etc/passwd", # input_file_path (악성) - "/test/cases/problem1/1.out" # output_file_path + "/test/cases", # base_dir + "/etc/passwd", # input_file_path (악성) + "/test/cases/problem1/1.out" # output_file_path ] result = TestCaseCacheManager._read_testcase_from_file("problem1", 1) @@ -130,9 +130,9 @@ def test_read_testcase_path_traversal_attack(self, mock_logger, mock_abspath): def test_read_testcase_from_file_success(self, mock_abspath, mock_file): """파일 읽기 성공 테스트""" mock_abspath.side_effect = [ - "/test/cases", # base_dir - "/test/cases/problem5/5.in", # input_file_path - "/test/cases/problem5/5.out" # output_file_path + "/test/cases", # base_dir + "/test/cases/problem5/5.in", # input_file_path + "/test/cases/problem5/5.out" # output_file_path ] mock_file.side_effect = [ @@ -151,13 +151,13 @@ def test_read_testcase_from_file_success(self, mock_abspath, mock_file): @override_settings(TEST_CASE_DIR="/test/cases") @patch("builtins.open", side_effect=FileNotFoundError("File not found")) @patch('os.path.abspath') - @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 + @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 def test_read_testcase_from_file_not_found(self, mock_logger, mock_abspath, mock_open_file): """파일이 존재하지 않는 경우 테스트""" mock_abspath.side_effect = [ - "/test/cases", # base_dir - "/test/cases/problem6/6.in", # input_file_path - "/test/cases/problem6/6.out" # output_file_path + "/test/cases", # base_dir + "/test/cases/problem6/6.in", # input_file_path + "/test/cases/problem6/6.out" # output_file_path ] result = TestCaseCacheManager._read_testcase_from_file("problem6", 6) @@ -170,13 +170,13 @@ def test_read_testcase_from_file_not_found(self, mock_logger, mock_abspath, mock @override_settings(TEST_CASE_DIR="/test/cases") @patch("builtins.open", side_effect=IOError("IO Error")) @patch('os.path.abspath') - @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 + @patch('utils.testcase_cache.logger') # 실제 모듈 경로에 맞게 수정 def test_read_testcase_from_file_io_error(self, mock_logger, mock_abspath, mock_open_file): """IO 에러 발생 시 테스트""" mock_abspath.side_effect = [ - "/test/cases", # base_dir - "/test/cases/problem7/7.in", # input_file_path - "/test/cases/problem7/7.out" # output_file_path + "/test/cases", # base_dir + "/test/cases/problem7/7.in", # input_file_path + "/test/cases/problem7/7.out" # output_file_path ] result = TestCaseCacheManager._read_testcase_from_file("problem7", 7) @@ -188,7 +188,7 @@ def test_read_testcase_from_file_io_error(self, mock_logger, mock_abspath, mock_ def test_cache_timeout_setting(self): """캐시 타임아웃 설정 테스트""" - self.assertEqual(TestCaseCacheManager.DEFAULT_TIMEOUT, 3600) # 1시간 + self.assertEqual(TestCaseCacheManager.DEFAULT_TIMEOUT, 3600) # 1시간 def test_cache_prefix_setting(self): """캐시 프리픽스 설정 테스트""" @@ -221,9 +221,9 @@ def test_os_path_join_usage(self, mock_path_join): with patch('os.path.abspath') as mock_abspath: mock_abspath.side_effect = [ - "/test/cases", # base_dir - "/test/cases/test/1.in", # input_file_path - "/test/cases/test/1.out" # output_file_path + "/test/cases", # base_dir + "/test/cases/test/1.in", # input_file_path + "/test/cases/test/1.out" # output_file_path ] with patch("builtins.open", side_effect=FileNotFoundError()): diff --git a/docs/content/developer/infra/service-architecture/_index.md b/docs/content/developer/infra/service-architecture/_index.md new file mode 100644 index 000000000..3d679dc79 --- /dev/null +++ b/docs/content/developer/infra/service-architecture/_index.md @@ -0,0 +1,102 @@ +--- +date: 2026-02-05T18:55:49+09:00 +draft: false +title: "Service Architecture" +weight: 1 +--- + +{{< callout >}} +이 문서에서는 CodePlace의 전체적인 Service Architecture와 트래픽 처리 흐름에 대해 설명합니다. +{{< /callout >}} + +### 0. CodePlace Service-Architecture +image + +--- + +### 1. Entry Point + +서비스의 모든 요청 트래픽은 가장 먼저 `Traefik`을 거치게 됩니다. CodePlace에서 `Traefik`은 크게 두 가지 핵심적인 네트워크 처리를 담당합니다. + +- 보안을 위해 HTTP(80) 요청을 강제로 HTTPS(443)로 Redirect합니다. +- 외부에서 암호화되어 들어온 패킷을 여기서 복호화(TLS Termination)하여 내부망으로 전달합니다. + +덕분에 내부 컨테이너들은 복잡한 암호화/복호화 연산 없이 효율적으로 통신할 수 있습니다. `Traefik`은 위 과정을 거친 모든 요청을 Frontend 컨테이너 (`Nginx`)로 전달합니다. + +--- + +### 2. Routing + +`Traefik`에서 전달된 요청은 Frontend 컨테이너의 `Nginx`에 도달하며, URL 경로에 따라 적절한 서비스로 분기됩니다. + +### 1) 페이지 요청 (/) + +루트 경로 등 페이지 관련 요청이 들어오면, Nginx는 사전에 빌드된 정적 파일을 브라우저에게 서빙합니다. +이 과정을 이해하기 위해서는 아래의 내용을 이해하여야 합니다. + +- **Build Process** +
+Frontend 서버의 궁극적인 목표는 **정적파일 서빙**입니다. **배포 전 단계에서** 수행되는, 코드베이스에서 여러 가지로 흩어져 있는 소스코드를 하나로 묶는 과정이 빌드입니다. Build는 Node.js 환경에서 `Webpack`을 통해서 수행됩니다.
빌드는 **코드 난독화, 압축, 번들링** 등 여러 가지를 포함하는데, 그중 JavaScript 버전 문법 호환성은 `Babel`을 통해 해결합니다.
+빌드 과정을 거친 결과물(`vendor.js`, `index.html`등)은 **nginx 폴더** 안에 저장이 됩니다. 이 결과물을 Nginx가 브라우저에 서빙하는 것입니다. + +
+ +- **SPA (Single Page Application)** +
+Code Place는 `SPA`방식을 사용하고 있습니다. `SPA`는 **단일 페이지 애플리케이션**으로, MPA와 대비됩니다. 비교를 위해 `MPA`를 설명하겠습니다. `MPA`는 Multi Page Application으로서, 페이지 이동 시마다 서버에 정적파일을 새로 요청하여 브라우저에 렌더링 하는 방식입니다.
+반면 `SPA`는 최초 접속 시, 위의 빌드과정을 통해 사전에 만들어진 애플리케이션 구동에 필요한 모든 자원(`vendor.js`, `index.html` 등)을 한 번에 로드합니다.
+이때 모든 정적파일을 모두 다 하나의 파일에 담지는 않습니다. 초기 로딩 속도 최적화를 위해 자주 쓰이지 않거나 무거운 기능은 **Chunk** 단위로 잘라두었다가, 실제 해당 기능이 필요할 때, 브라우저가 요청하여 로드하도록 설계되어 있습니다.
+이후 페이지 전환 시에는, 이미 서빙된 **index.html** 파일 위에서 사전에 로드된 혹은 필요 시 Chunk로 로드된 `vendor.js` 등의 자바스크립트가 컴포넌트만 교체하여 화면 전환을 수행합니다. 이를 통해 서버 요청을 최소화하여 MPA에 비해 빠른 사용성을 제공합니다. + +이러한 빌드, SPA 방식을 통해서 화면 로드가 일어납니다. + +### 2) API 요청 (/api) + +`/api`로 시작하는 경로로 들어온 요청일 경우, Nginx는 이를 뒷단의 **Django 서버**로 프록시합니다. 전달받은 요청에 따라 Django 서버는 매핑된 URL 엔드포인트를 통해 비즈니스 로직 (DB 조회, 연산 등)을 수행한 후, 그 결과를 브라우저에게 반환합니다. + +--- + +### 3. Celery + +`Celery`는 비동기 작업 큐 프레임워크로서, `Celery Beat`와 `Celery Worker`로 구성됩니다.
+백엔드 로직 중 실시간성이 필요하지 않거나 리소스를 많이 점유하는 작업은 Redis를 사용하여 비동기로 처리합니다.
주로 *채점*, *이메일 발송*, *세션 관리*, 그리고 *등수/점수 업데이트와 같은 스케줄링 Job*이 여기에 해당합니다. +이 구조의 이해를 돕기 위해 Celery의 두 핵심 컴포넌트를 설명하겠습니다. + +- **Celery Worker :** Redis 큐에 쌓인 작업을 실제로 가져가서 수행하는 주체입니다. Django 서버와 실행환경은 유사하지만, Redis 큐에 있는 작업만 처리합니다. +- **Celery Beat :** 정해진 시간마다 주기적으로 실행되어야 하는 작업을 Redis 큐에 자동으로 예약해주는 스케줄러입니다. + +
+ +**[동작 프로세스 예시]** + +1. **문제 제출 (`Celery Worker`)**
+사용자가 '제출하기' 버튼을 누르면, Django 서버는 직접 채점을 수행하지 않고 **채점 요청 메시지**를 `Redis Queue`에 등록만 하고 즉시 응답을 반환합니다. 대기 중이던 `Celery Worker`가 Redis Queue의 job을 감지하여 가져간 뒤 별도의 격리된 채점 서버와 통신하며 채점을 진행합니다. + +2. **통계 업데이트 (`Celery Beat`)**
+사용자가 직접 요청하지 않아도 시스템이 수행해야 하는 '랭킹 업데이트' 같은 작업은 `Celery Beat`가 담당합니다. `Celery Beat`는 설정된 시각이 되면 자동으로 Redis에 작업 요청을 넣고, `Celery Worker`가 이를 가져가서 점수를 갱신합니다. + +`Celery Worker`는 Django 서버와 본질적으로 유사한 환경(동일한 코드베이스, ORM 사용)을 가집니다.  다만 가장 큰 차이점은 수신 대상이 다르다는 것입니다. + +- **Django Server :**  `HTTP Port`로 유입되는 클라이언트의 요청을 수신하여 처리합니다. +- **Celery Worker :** `Redis`에 등록된 비동기 작업을 대기 상태로 감시하다가 새로운 작업이 들어오면 이를 가져와 처리합니다. + +
+ +**왜 이렇게 나누었을까요?** + +만약 Celery 없이 Django가 모든 작업을 직접 처리한다고 가정해봅시다. + +채점 요청이 동시에 1,000개 이상 들어오는 상황에서, CPU 연산량이 크고 실행 시간이 긴 **채점 작업**을 Django 서버 프로세스 안에서 수행하게 되면 요청 처리 흐름이 지연됩니다. 그 결과 로그인, 페이지 조회와 같은 일반적인 사용자 요청까지 영향을 받아 서비스 응답성이 급격히 저하될 수 있습니다. + +물론 Django 내부에서도 비동기 처리를 구현할 수는 있습니다. +하지만 채점과 같이 **CPU 사용량이 크고 오래 걸리는 작업**을 HTTP 요청 처리 흐름과 함께 수행할 경우, 여전히 다른 사용자 요청에 영향을 주는 구조적 한계가 존재합니다. + +따라서 CodePlace는 이러한 문제를 방지하기 위해, 무거운 작업을 Django 서버와 분리된 별도의 실행 주체인 `Celery Worker`에서 처리하도록 설계하였습니다. + +--- + +### 4. Container + +`Frontend 서버`, `Backend 서버`, `Redis`, `PostgreSQL`, `Celery Worker`, `Celery Beat`, `Judge Server` 모두 **Docker Container** 기반으로 운용됩니다. + +이를 통해 각 서비스의 실행 환경을 격리시켜 충돌을 방지하고, `docker-compose`를 이용해 각 컨테이너를 일관성 있게 배포하고 관리할 수 있는 확장 가능한 구조를 갖췄습니다. diff --git a/frontend/.babelrc b/frontend/.babelrc index 1a14ec59f..587e29ec5 100644 --- a/frontend/.babelrc +++ b/frontend/.babelrc @@ -1,27 +1,22 @@ { "presets": [ - ["env", { - "modules": false, - "targets": { - "browsers": ["> 1%", "last 2 versions", "not ie <= 9"] - }, - "useBuiltIns": true - }], + [ + "env", + { + "modules": false, + "targets": { + "browsers": ["> 1%", "last 2 versions", "not ie <= 9"] + }, + "useBuiltIns": true + } + ], "stage-2" ], - "plugins": [ - "transform-runtime", - "syntax-dynamic-import" - ], + "plugins": ["transform-runtime", "syntax-dynamic-import"], "env": { "test": { - "presets": [ - "env", - "stage-2" - ], - "plugins": [ - "istanbul" - ] + "presets": ["env", "stage-2"], + "plugins": ["istanbul"] } } } diff --git a/frontend/.eslintignore b/frontend/.eslintignore index 8a6f41a94..2b4cad210 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1,4 +1,2 @@ -build/*.js -config/*.js -src/**/*.js -src/**/*.vue +/* +!/src/ \ No newline at end of file diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index c5242c4f9..7f7c03f88 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -1,31 +1,35 @@ module.exports = { root: true, - parser: 'babel-eslint', + // 각 환경의 전역 변수 인식 + env: { + browser: true, // for browser + node: true, // for node.js + }, + parser: "vue-eslint-parser", parserOptions: { - sourceType: 'module', + parser: "babel-eslint", + sourceType: "module", }, - // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style - extends: ['standard', 'prettier'], - lintOnSave: false, - // required to lint *.vue files - plugins: ['html'], + // Apply external rule-sets + extends: ["eslint:recommended", "plugin:vue/essential", "prettier"], + // Vue 파일 지원 ESLint 확장플러그인 등록 + plugins: ["vue"], // add your custom rules here rules: { - 'prettier/prettier': 'error', // allow paren-less arrow functions - 'arrow-parens': 0, + "arrow-parens": 0, // allow async-await - 'generator-star-spacing': 0, + "generator-star-spacing": 0, // allow debugger during development - 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, - 'no-irregular-whitespace': [ - 'error', + "no-debugger": process.env.NODE_ENV === "production" ? 2 : 0, + "no-irregular-whitespace": [ + "error", { skipComments: true, skipTemplates: true, }, ], - 'no-unused-vars': ['warn'], - quotes: ['off', 'single'], + "no-unused-vars": ["warn"], + quotes: ["off", "single"], }, } diff --git a/frontend/.github/issue_template.md b/frontend/.github/issue_template.md index c5646f5cf..653b25b0c 100644 --- a/frontend/.github/issue_template.md +++ b/frontend/.github/issue_template.md @@ -1,8 +1,15 @@ ## **TODO(필수)** + - 할 일을 작성해주세요. + ## **요청사항(부분 필수)** + - 본인이 아닌 다른 Assignee를 지정할 때 요청사항을 명확하게 작성합니다. + ## **제약사항(선택)** + - 해야하는 일 중 제약사항이 있다면 작성합니다. + ## **참고사항(선택)** + - 본인이 아닌 다른 Assignee를 지정할 때, 참고사항(파일위치 등)을 작성합니다. diff --git a/frontend/.github/workflows/ci_develop.yml b/frontend/.github/workflows/ci_develop.yml index 7b5aa5d5e..782f75498 100644 --- a/frontend/.github/workflows/ci_develop.yml +++ b/frontend/.github/workflows/ci_develop.yml @@ -14,7 +14,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '16.x' + node-version: "16.x" - run: echo "The Job is installing dependencies." - name: Install dependencies run: npm install @@ -23,4 +23,3 @@ jobs: - name: Build Test run: npm run build - run: echo "The Job Finished." - diff --git a/frontend/.postcssrc.js b/frontend/.postcssrc.js index ea9a5ab87..71c1dabfa 100644 --- a/frontend/.postcssrc.js +++ b/frontend/.postcssrc.js @@ -1,8 +1,8 @@ // https://github.com/michael-ciniawsky/postcss-load-config module.exports = { - "plugins": { + plugins: { // to edit target browsers: use "browserlist" field in package.json - "autoprefixer": {} - } + autoprefixer: {}, + }, } diff --git a/frontend/.prettierrc.js b/frontend/.prettierrc.js index 59bf861bb..ffe944199 100644 --- a/frontend/.prettierrc.js +++ b/frontend/.prettierrc.js @@ -1,7 +1,8 @@ module.exports = { semi: false, tabWidth: 2, - trailingComma: 'all', + trailingComma: "all", printWidth: 80, - endOfLine: 'lf', + endOfLine: "lf", + singleAttributePerLine: false, } diff --git a/frontend/README.md b/frontend/README.md index 863ce8cf7..042f9f781 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,4 +1,5 @@ # CSEP Front End + [![vue](https://img.shields.io/badge/vue-2.5.13-blue.svg?style=flat-square)](https://github.com/vuejs/vue) [![vuex](https://img.shields.io/badge/vuex-3.0.1-blue.svg?style=flat-square)](https://vuex.vuejs.org/) [![echarts](https://img.shields.io/badge/echarts-3.8.3-blue.svg?style=flat-square)](https://github.com/ecomfe/echarts) @@ -8,8 +9,8 @@ ![banner1.png](src%2Fassets%2Fbanner1.png) - ## 1.소개 + [운영중인 사이트](http://oj.pusan.ac.kr/) [베타 사이트는 현재 부산대학교 내부에서만 접속이 가능합니다.](http://10.125.121.115:8080/) @@ -37,18 +38,23 @@ Vue.js 2.5.13 버전으로 개발된 기존 [QingdaoU의 OnlineJudge 프론트 ### 개발 기간 + 2023.12 ~ 2024.3 (현재 진행 중) ### 개발 인원 -| github | 사진 | 역할 | 이메일 주소 | 소속 | -|----------|-----------------------------------------------------------------|--------------------|------------------------|-------------------| -| [hunsy9](https://github.com/hunsy9) | ![유저 아바타](https://avatars.githubusercontent.com/u/101303791?v=4) | PM, 프론트/백엔드 기획 및 개발 | juniper0917@gmail.com | 정보컴퓨터공학부 | -| [minmunui](https://github.com/minmunui) | ![유저 아바타](https://avatars.githubusercontent.com/u/82745129?v=4) | 프론트엔드(Client) 기획 및 개발 | ehdwls1638@pusan.ac.kr | 정보컴퓨터공학부 | -| [llddang](https://github.com/llddang) | ![유저 아바타](https://avatars.githubusercontent.com/u/77055208?v=4) | 프론트엔드(Admin) 기획 및 개발 | bodyness@naver.com | 정보컴퓨터공학부 | + +| github | 사진 | 역할 | 이메일 주소 | 소속 | +| --------------------------------------- | --------------------------------------------------------------------- | ------------------------------- | ---------------------- | ---------------- | +| [hunsy9](https://github.com/hunsy9) | ![유저 아바타](https://avatars.githubusercontent.com/u/101303791?v=4) | PM, 프론트/백엔드 기획 및 개발 | juniper0917@gmail.com | 정보컴퓨터공학부 | +| [minmunui](https://github.com/minmunui) | ![유저 아바타](https://avatars.githubusercontent.com/u/82745129?v=4) | 프론트엔드(Client) 기획 및 개발 | ehdwls1638@pusan.ac.kr | 정보컴퓨터공학부 | +| [llddang](https://github.com/llddang) | ![유저 아바타](https://avatars.githubusercontent.com/u/77055208?v=4) | 프론트엔드(Admin) 기획 및 개발 | bodyness@naver.com | 정보컴퓨터공학부 | ## 2.프로젝트 설치 + 서버가 설치되어 있지 않은 경우, [OnlineJudgeBE 프로젝트]()를 이용하여 DB와 서버를 설치해야 합니다. + ### Linux + ```bash npm install # we use webpack DllReference to decrease the build time, @@ -83,6 +89,7 @@ npm run dev ``` ### 프로덕션 배포 + [Deploy.md를 참고해주십시오](https://github.com/PNU-CSEP/CSEP_FE/blob/main/deploy/Deploy.md) ## 4.디렉터리 구조 @@ -106,15 +113,18 @@ npm run dev │ ├── utils/ # 유틸리티 ├── static/ # 정적 파일 ``` + ## 5.Troubleshooting ### webpack.dll.conf 에서 fs 관련 에러 + webpack.dll.conf.js 파일에서 fs 관련 에러가 발생할 경우, 다음과 같이 수정합니다. 프로젝트를 빌드할 때 설정한 코드가 제대로 동작하지 않아 발생하는 에러입니다. + ```javascript // build/webpack.dll.conf.js :L30 -oldDlls.forEach(f => { - fs.unlink(f, ()=> {}) +oldDlls.forEach((f) => { + fs.unlink(f, () => {}) }) ``` @@ -124,9 +134,11 @@ oldDlls.forEach(f => { 그나마 규칙성을 발견하여 아래에 기록했습니다. 새로운 기능을 추가할 때에는 다음과 같은 규칙을 적용하도록 하겠습니다. ### 권장되는 커밋 메세지 + - 어느 커밋으로 checkout하더라도 프로젝트가 정상적으로 동작할 수 있도록 커밋을 작성해야 합니다. - 커밋은 최대한 작은 단위로 나누어 작성해야 합니다. - 커밋 메세지는 다음과 같은 형식을 따라야 합니다. + ```bash <타입>: <제목> @@ -134,6 +146,7 @@ oldDlls.forEach(f => { [꼬리말] ``` + - 타입은 가급적 소문자로 작성합니다. - feat: 새로운 기능 추가 - fix: 버그 수정 @@ -145,7 +158,9 @@ oldDlls.forEach(f => { - [커밋 메시지 컨벤션](https://www.conventionalcommits.org/ko/v1.0.0/) ### 작명 규칙 - 모든 작명규칙은 기존의 프로젝트에 있던 것들을 참고하여 작성되었습니다. 일관성을 유지하기 위해 기존의 작명규칙을 따르도록 하겠습니다. + +모든 작명규칙은 기존의 프로젝트에 있던 것들을 참고하여 작성되었습니다. 일관성을 유지하기 위해 기존의 작명규칙을 따르도록 하겠습니다. + - 변수명, 함수명은 camelCase를 따릅니다. - 컴포넌트명은 PascalCase를 따릅니다. - 상수는 대문자로 작성하며, 단어 사이는 언더바로 구분합니다. 다른 곳에서 공통적으로 사용될 것으로 예상되는 상수는 `src/utils/constants.js`에 작성합니다. @@ -153,7 +168,9 @@ oldDlls.forEach(f => { - 서버로부터 받아온 데이터 object의 key값은 snake_case를 따릅니다. ### 기타 유의사항 + 1. component에 특정 문자열을 넣을 때에는 `src/i18n/US.js`에 해당 문자열을 추가한 후 사용합니다. 절대로 하드코딩하지 않습니다. + ```javascript // bad
@@ -165,11 +182,13 @@ oldDlls.forEach(f => { {{ $t('Hello') }}
``` + 2. `src/utils`에는 각종 유틸리티 함수를 작성합니다. 다른 곳에서 공통적으로 사용될 것으로 예상되는 함수를 작성합니다. 3. `src/constant`에는 각종 상수를 작성합니다. 다른 곳에서 공통적으로 사용될 것으로 예상되는 상수를 작성합니다. 하드코딩하지 않습니다. 4. 공톡적으로 사용할 수 있는 컴포넌트는 최대한 범용성있게 작성한 후`src/components`에 작성합니다. 5. `src/pages/*/views`에는 범용성이 없는 컴포넌트를 작성합니다. 특정 페이지에 종속되는 경우 해당 페이지의 하위 디렉터리에 작성합니다. 6. router를 사용할 때에는 `url`을 하드코딩하지 않습니다. `src/router`에 작성된 라우트를 vue의 기능을 이용하여 `name`으로 사용합니다. + ```javascript // bad 1번 문제로 가기 @@ -177,6 +196,7 @@ oldDlls.forEach(f => { // good 1번 문제로 가기 ``` + 7. `src/i18n/US.js`에서 변수를 지정할 때에는 일반적으로 첫 글자 대문자인 Snake_Case를 사용합니다. 단, 특정 맥락이 사용되는 경우 해당 맥락의 이름을 붙여, PascalCase로 작성합니다. ```javascript @@ -192,5 +212,7 @@ oldDlls.forEach(f => { {{ $t('UserInfoSolvedProblem') }} ``` + ## 7.라이센스 + [MIT](http://opensource.org/licenses/MIT) diff --git a/frontend/build/build.js b/frontend/build/build.js index ab7ffc9e1..850115a2d 100644 --- a/frontend/build/build.js +++ b/frontend/build/build.js @@ -1,40 +1,49 @@ -'use strict' -require('./check-versions')() +"use strict" +require("./check-versions")() -process.env.NODE_ENV = 'production' +process.env.NODE_ENV = "production" -const ora = require('ora') -const rm = require('rimraf') -const path = require('path') -const chalk = require('chalk') -const webpack = require('webpack') -const config = require('../config') -const webpackConfig = require('./webpack.prod.conf') +const ora = require("ora") +const rm = require("rimraf") +const path = require("path") +const chalk = require("chalk") +const webpack = require("webpack") +const config = require("../config") +const webpackConfig = require("./webpack.prod.conf") -const spinner = ora('building for production...') +const spinner = ora("building for production...") spinner.start() -rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { - if (err) throw err - webpack(webpackConfig, function (err, stats) { - spinner.stop() +rm( + path.join(config.build.assetsRoot, config.build.assetsSubDirectory), + (err) => { if (err) throw err - process.stdout.write(stats.toString({ - colors: true, - modules: false, - children: false, - chunks: false, - chunkModules: false - }) + '\n\n') + webpack(webpackConfig, function (err, stats) { + spinner.stop() + if (err) throw err + process.stdout.write( + stats.toString({ + colors: true, + modules: false, + children: false, + chunks: false, + chunkModules: false, + }) + "\n\n", + ) - if (stats.hasErrors()) { - console.log(chalk.red(' Build failed with errors.\n')) - process.exit(1) - } + if (stats.hasErrors()) { + console.log(chalk.red(" Build failed with errors.\n")) + process.exit(1) + } - console.log(chalk.cyan(' Congratulations, the project built complete without error\n')) - console.log(chalk.yellow( - ' You can now check the onlinejudge in http://YouIP/' - )) - }) -}) + console.log( + chalk.cyan( + " Congratulations, the project built complete without error\n", + ), + ) + console.log( + chalk.yellow(" You can now check the onlinejudge in http://YouIP/"), + ) + }) + }, +) diff --git a/frontend/build/check-versions.js b/frontend/build/check-versions.js index ca407bb16..65d8fc33f 100644 --- a/frontend/build/check-versions.js +++ b/frontend/build/check-versions.js @@ -1,25 +1,25 @@ -'use strict' -const chalk = require('chalk') -const semver = require('semver') -const packageConfig = require('../package.json') -const shell = require('shelljs') -function exec (cmd) { - return require('child_process').execSync(cmd).toString().trim() +"use strict" +const chalk = require("chalk") +const semver = require("semver") +const packageConfig = require("../package.json") +const shell = require("shelljs") +function exec(cmd) { + return require("child_process").execSync(cmd).toString().trim() } const versionRequirements = [ { - name: 'node', + name: "node", currentVersion: semver.clean(process.version), - versionRequirement: packageConfig.engines.node - } + versionRequirement: packageConfig.engines.node, + }, ] -if (shell.which('npm')) { +if (shell.which("npm")) { versionRequirements.push({ - name: 'npm', - currentVersion: exec('npm --version'), - versionRequirement: packageConfig.engines.npm + name: "npm", + currentVersion: exec("npm --version"), + versionRequirement: packageConfig.engines.npm, }) } @@ -28,20 +28,27 @@ module.exports = function () { for (let i = 0; i < versionRequirements.length; i++) { const mod = versionRequirements[i] if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { - warnings.push(mod.name + ': ' + - chalk.red(mod.currentVersion) + ' should be ' + - chalk.green(mod.versionRequirement) + warnings.push( + mod.name + + ": " + + chalk.red(mod.currentVersion) + + " should be " + + chalk.green(mod.versionRequirement), ) } } if (warnings.length) { - console.log('') - console.log(chalk.yellow('To use this template, you must update following to modules:')) + console.log("") + console.log( + chalk.yellow( + "To use this template, you must update following to modules:", + ), + ) console.log() for (let i = 0; i < warnings.length; i++) { const warning = warnings[i] - console.log(' ' + warning) + console.log(" " + warning) } console.log() process.exit(1) diff --git a/frontend/build/dev-client.js b/frontend/build/dev-client.js index 2f75dd531..283b5086a 100644 --- a/frontend/build/dev-client.js +++ b/frontend/build/dev-client.js @@ -1,10 +1,10 @@ /* eslint-disable */ -'use strict' -require('eventsource-polyfill') -var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') +"use strict" +require("eventsource-polyfill") +var hotClient = require("webpack-hot-middleware/client?noInfo=true&reload=true") hotClient.subscribe(function (event) { - if (event.action === 'reload') { + if (event.action === "reload") { window.location.reload() } }) diff --git a/frontend/build/dev-server.js b/frontend/build/dev-server.js index 9234ddd3e..bee1a7497 100644 --- a/frontend/build/dev-server.js +++ b/frontend/build/dev-server.js @@ -1,17 +1,17 @@ -'use strict' -require('./check-versions')() +"use strict" +require("./check-versions")() -const config = require('../config') +const config = require("../config") if (!process.env.NODE_ENV) { process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV) } -const opn = require('opn') -const path = require('path') -const express = require('express') -const webpack = require('webpack') -const proxyMiddleware = require('http-proxy-middleware') -const webpackConfig = require('./webpack.dev.conf') +const opn = require("opn") +const path = require("path") +const express = require("express") +const webpack = require("webpack") +const proxyMiddleware = require("http-proxy-middleware") +const webpackConfig = require("./webpack.dev.conf") // default port where dev server listens for incoming traffic const port = process.env.PORT || config.dev.port @@ -24,14 +24,14 @@ const proxyTable = config.dev.proxyTable const app = express() const compiler = webpack(webpackConfig) -const devMiddleware = require('webpack-dev-middleware')(compiler, { +const devMiddleware = require("webpack-dev-middleware")(compiler, { publicPath: webpackConfig.output.publicPath, - quiet: true + quiet: true, }) -const hotMiddleware = require('webpack-hot-middleware')(compiler, { +const hotMiddleware = require("webpack-hot-middleware")(compiler, { log: false, - heartbeat: 2000 + heartbeat: 2000, }) // force page reload when html-webpack-plugin template changes // currently disabled until this is resolved: @@ -50,7 +50,7 @@ app.use(hotMiddleware) // proxy api requests Object.keys(proxyTable).forEach(function (context) { let options = proxyTable[context] - if (typeof options === 'string') { + if (typeof options === "string") { options = { target: options } } app.use(proxyMiddleware(options.filter || context, options)) @@ -58,22 +58,27 @@ Object.keys(proxyTable).forEach(function (context) { // handle fallback for HTML5 history API const rewrites = { - rewrites: [{ - from: '/admin/', // 正则或者字符串 - to: '/admin/index.html', // 字符串或者函数 - }] + rewrites: [ + { + from: "/admin/", // 正则或者字符串 + to: "/admin/index.html", // 字符串或者函数 + }, + ], } -const historyMiddleware = require('connect-history-api-fallback')(rewrites); +const historyMiddleware = require("connect-history-api-fallback")(rewrites) app.use(historyMiddleware) // serve webpack bundle output app.use(devMiddleware) // serve pure static assets -const staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) -app.use(staticPath, express.static('./static')) +const staticPath = path.posix.join( + config.dev.assetsPublicPath, + config.dev.assetsSubDirectory, +) +app.use(staticPath, express.static("./static")) -const uri = 'http://localhost:' + port +const uri = "http://localhost:" + port var _resolve var _reject @@ -83,20 +88,20 @@ var readyPromise = new Promise((resolve, reject) => { }) var server -var portfinder = require('portfinder') +var portfinder = require("portfinder") portfinder.basePort = port -console.log('> Starting dev server...') +console.log("> Starting dev server...") devMiddleware.waitUntilValid(() => { portfinder.getPort((err, port) => { if (err) { _reject(err) } process.env.PORT = port - var uri = 'http://localhost:' + port - console.log('> Listening at ' + uri + '\n') + var uri = "http://localhost:" + port + console.log("> Listening at " + uri + "\n") // when env is testing, don't need open it - if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') { + if (autoOpenBrowser && process.env.NODE_ENV !== "testing") { opn(uri) } server = app.listen(port) @@ -108,5 +113,5 @@ module.exports = { ready: readyPromise, close: () => { server.close() - } + }, } diff --git a/frontend/build/utils.js b/frontend/build/utils.js index 3e6009bd7..e2154ab49 100644 --- a/frontend/build/utils.js +++ b/frontend/build/utils.js @@ -1,12 +1,13 @@ -'use strict' -const path = require('path') -const config = require('../config') -const ExtractTextPlugin = require('extract-text-webpack-plugin') +"use strict" +const path = require("path") +const config = require("../config") +const ExtractTextPlugin = require("extract-text-webpack-plugin") exports.assetsPath = function (_path) { - const assetsSubDirectory = process.env.NODE_ENV === 'production' - ? config.build.assetsSubDirectory - : config.dev.assetsSubDirectory + const assetsSubDirectory = + process.env.NODE_ENV === "production" + ? config.build.assetsSubDirectory + : config.dev.assetsSubDirectory return path.posix.join(assetsSubDirectory, _path) } @@ -14,22 +15,22 @@ exports.cssLoaders = function (options) { options = options || {} const cssLoader = { - loader: 'css-loader', + loader: "css-loader", options: { - minimize: process.env.NODE_ENV === 'production', - sourceMap: options.sourceMap - } + minimize: process.env.NODE_ENV === "production", + sourceMap: options.sourceMap, + }, } // generate loader string to be used with extract text plugin - function generateLoaders (loader, loaderOptions) { + function generateLoaders(loader, loaderOptions) { const loaders = [cssLoader] if (loader) { loaders.push({ - loader: loader + '-loader', + loader: loader + "-loader", options: Object.assign({}, loaderOptions, { - sourceMap: options.sourceMap - }) + sourceMap: options.sourceMap, + }), }) } @@ -38,10 +39,10 @@ exports.cssLoaders = function (options) { if (options.extract) { return ExtractTextPlugin.extract({ use: loaders, - fallback: 'vue-style-loader' + fallback: "vue-style-loader", }) } else { - return ['vue-style-loader'].concat(loaders) + return ["vue-style-loader"].concat(loaders) } } @@ -49,11 +50,11 @@ exports.cssLoaders = function (options) { return { css: generateLoaders(), postcss: generateLoaders(), - less: generateLoaders('less'), - sass: generateLoaders('sass', {indentedSyntax: true}), - scss: generateLoaders('sass'), - stylus: generateLoaders('stylus'), - styl: generateLoaders('stylus') + less: generateLoaders("less"), + sass: generateLoaders("sass", { indentedSyntax: true }), + scss: generateLoaders("sass"), + stylus: generateLoaders("stylus"), + styl: generateLoaders("stylus"), } } @@ -64,8 +65,8 @@ exports.styleLoaders = function (options) { for (const extension in loaders) { const loader = loaders[extension] output.push({ - test: new RegExp('\\.' + extension + '$'), - use: loader + test: new RegExp("\\." + extension + "$"), + use: loader, }) } return output @@ -73,5 +74,5 @@ exports.styleLoaders = function (options) { exports.getNodeEnv = function () { const NODE_ENV = process.env.NODE_ENV - return NODE_ENV ? NODE_ENV: 'production' + return NODE_ENV ? NODE_ENV : "production" } diff --git a/frontend/build/vue-loader.conf.js b/frontend/build/vue-loader.conf.js index eece58fe1..ac3716cc4 100644 --- a/frontend/build/vue-loader.conf.js +++ b/frontend/build/vue-loader.conf.js @@ -1,19 +1,19 @@ -'use strict' -const utils = require('./utils') -const config = require('../config') -const isProduction = process.env.NODE_ENV === 'production' +"use strict" +const utils = require("./utils") +const config = require("../config") +const isProduction = process.env.NODE_ENV === "production" module.exports = { loaders: utils.cssLoaders({ sourceMap: isProduction ? config.build.productionSourceMap : config.dev.cssSourceMap, - extract: isProduction + extract: isProduction, }), transformToRequire: { - video: 'src', - source: 'src', - img: 'src', - image: 'xlink:href' - } + video: "src", + source: "src", + img: "src", + image: "xlink:href", + }, } diff --git a/frontend/build/webpack.base.conf.js b/frontend/build/webpack.base.conf.js index bbedddd5d..afe82e0f8 100644 --- a/frontend/build/webpack.base.conf.js +++ b/frontend/build/webpack.base.conf.js @@ -1,24 +1,24 @@ -'use strict' -const path = require('path') -const glob = require('glob') -const webpack = require('webpack') -const utils = require('./utils') -const config = require('../config') -const vueLoaderConfig = require('./vue-loader.conf') -const HtmlWebpackIncludeAssetsPlugin = require('html-webpack-include-assets-plugin') +"use strict" +const path = require("path") +const glob = require("glob") +const webpack = require("webpack") +const utils = require("./utils") +const config = require("../config") +const vueLoaderConfig = require("./vue-loader.conf") +const HtmlWebpackIncludeAssetsPlugin = require("html-webpack-include-assets-plugin") -function resolve (dir) { - return path.join(__dirname, '..', dir) +function resolve(dir) { + return path.join(__dirname, "..", dir) } -function getEntries () { +function getEntries() { const base = { - 'oj': ['./src/pages/oj/index.js'], - 'admin': ['./src/pages/admin/index.js'] + oj: ["./src/pages/oj/index.js"], + admin: ["./src/pages/admin/index.js"], } - if (process.env.USE_SENTRY === '1') { - Object.keys(base).forEach(entry => { - base[entry].push('./src/utils/sentry.js') + if (process.env.USE_SENTRY === "1") { + Object.keys(base).forEach((entry) => { + base[entry].push("./src/utils/sentry.js") }) } return base @@ -27,99 +27,97 @@ function getEntries () { // get all entries const entries = getEntries() console.log("All entries: ") -Object.keys(entries).forEach(entry => { +Object.keys(entries).forEach((entry) => { console.log(entry) - entries[entry].forEach(ele => { + entries[entry].forEach((ele) => { console.log("- %s", ele) }) console.log() }) // prepare vendor asserts -const globOptions = {cwd: resolve('static/js')}; -let vendorAssets = glob.sync('vendor.dll.*.js', globOptions); -vendorAssets = vendorAssets.map(file => 'static/js/' + file) - +const globOptions = { cwd: resolve("static/js") } +let vendorAssets = glob.sync("vendor.dll.*.js", globOptions) +vendorAssets = vendorAssets.map((file) => "static/js/" + file) module.exports = { entry: entries, output: { path: config.build.assetsRoot, - filename: '[name].js', - publicPath: process.env.NODE_ENV === 'production' - ? config.build.assetsPublicPath - : config.dev.assetsPublicPath + filename: "[name].js", + publicPath: + process.env.NODE_ENV === "production" + ? config.build.assetsPublicPath + : config.dev.assetsPublicPath, }, resolve: { - modules: ['node_modules'], - extensions: ['.js', '.vue', '.json'], + modules: ["node_modules"], + extensions: [".js", ".vue", ".json"], alias: { - 'vue$': 'vue/dist/vue.esm.js', - '@': resolve('src'), - '@oj': resolve('src/pages/oj'), - '@admin': resolve('src/pages/admin'), - '~': resolve('src/components') - } + vue$: "vue/dist/vue.esm.js", + "@": resolve("src"), + "@oj": resolve("src/pages/oj"), + "@admin": resolve("src/pages/admin"), + "~": resolve("src/components"), + }, }, module: { rules: [ - { - test: /\.(js|vue)$/, - loader: 'eslint-loader', - enforce: 'pre', - include: [resolve('src')], - options: { - formatter: require('eslint-friendly-formatter') - } - }, { test: /\.vue$/, - loader: 'vue-loader', - options: vueLoaderConfig + loader: "vue-loader", + options: vueLoaderConfig, }, { test: /\.js$/, - loader: 'babel-loader?cacheDirectory=true', + loader: "babel-loader?cacheDirectory=true", // include -> inclue 안에 있는 모듈 '만' babel이 로드 합니다. // exclude -> exclude 안에 있는 모듈 '만' babel 로드 시 제외 합니다. // 따라서 현재 include 안에 node_modules 가 없으므로, node_modules 안의 파일은 babel이 로드하지 않습니다. - include: [resolve('src'), resolve('test'), resolve('node_modules/tiptap'), resolve('node_modules/tiptap-extensions'), resolve('node_modules/prosemirror-tables'), resolve('node_modules/prosemirror-utils')] + include: [ + resolve("src"), + resolve("test"), + resolve("node_modules/tiptap"), + resolve("node_modules/tiptap-extensions"), + resolve("node_modules/prosemirror-tables"), + resolve("node_modules/prosemirror-utils"), + ], }, { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, - loader: 'url-loader', + loader: "url-loader", options: { limit: 10000, - name: utils.assetsPath('img/[name].[hash:7].[ext]') - } + name: utils.assetsPath("img/[name].[hash:7].[ext]"), + }, }, { test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, - loader: 'url-loader', + loader: "url-loader", options: { limit: 10000, - name: utils.assetsPath('media/[name].[hash:7].[ext]') - } + name: utils.assetsPath("media/[name].[hash:7].[ext]"), + }, }, { test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, - loader: 'url-loader', + loader: "url-loader", options: { limit: 10000, - name: utils.assetsPath('fonts/[name].[hash:7].[ext]') - } - } - ] + name: utils.assetsPath("fonts/[name].[hash:7].[ext]"), + }, + }, + ], }, plugins: [ new webpack.DllReferencePlugin({ context: __dirname, - manifest: require('./vendor-manifest.json') + manifest: require("./vendor-manifest.json"), }), new HtmlWebpackIncludeAssetsPlugin({ assets: [vendorAssets[0]], - files: ['index.html', 'admin/index.html'], - append: false + files: ["index.html", "admin/index.html"], + append: false, }), - ] + ], } diff --git a/frontend/build/webpack.dev.conf.js b/frontend/build/webpack.dev.conf.js index 726051513..df8786196 100644 --- a/frontend/build/webpack.dev.conf.js +++ b/frontend/build/webpack.dev.conf.js @@ -1,26 +1,28 @@ -'use strict' -const utils = require('./utils') -const webpack = require('webpack') -const config = require('../config') -const merge = require('webpack-merge') -const baseWebpackConfig = require('./webpack.base.conf') -const HtmlWebpackPlugin = require('html-webpack-plugin') -const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') +"use strict" +const utils = require("./utils") +const webpack = require("webpack") +const config = require("../config") +const merge = require("webpack-merge") +const baseWebpackConfig = require("./webpack.base.conf") +const HtmlWebpackPlugin = require("html-webpack-plugin") +const FriendlyErrorsPlugin = require("friendly-errors-webpack-plugin") // add hot-reload related code to entry chunks Object.keys(baseWebpackConfig.entry).forEach(function (name) { - baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) + baseWebpackConfig.entry[name] = ["./build/dev-client"].concat( + baseWebpackConfig.entry[name], + ) }) module.exports = merge(baseWebpackConfig, { module: { - rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) + rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }), }, // cheap-module-eval-source-map is faster for development - devtool: '#cheap-module-eval-source-map', + devtool: "#cheap-module-eval-source-map", plugins: [ new webpack.DefinePlugin({ - 'process.env': config.dev.env + "process.env": config.dev.env, }), // https://github.com/glenjamin/webpack-hot-middleware#installation--usage new webpack.HotModuleReplacementPlugin(), @@ -30,15 +32,15 @@ module.exports = merge(baseWebpackConfig, { new HtmlWebpackPlugin({ filename: config.build.ojIndex, template: config.build.ojTemplate, - chunks: ['oj'], - inject: true + chunks: ["oj"], + inject: true, }), new HtmlWebpackPlugin({ filename: config.build.adminIndex, template: config.build.adminTemplate, - chunks: ['admin'], - inject: true + chunks: ["admin"], + inject: true, }), - new FriendlyErrorsPlugin() - ] + new FriendlyErrorsPlugin(), + ], }) diff --git a/frontend/build/webpack.dll.conf.js b/frontend/build/webpack.dll.conf.js index cad8d99b0..eab019a6e 100644 --- a/frontend/build/webpack.dll.conf.js +++ b/frontend/build/webpack.dll.conf.js @@ -1,60 +1,61 @@ -const webpack = require('webpack'); -const path = require('path'); -const UglifyJSPlugin = require('uglifyjs-webpack-plugin') -const config = require('../config') -const utils = require('./utils') -const glob = require('glob') -const fs = require('fs') +const webpack = require("webpack") +const path = require("path") +const UglifyJSPlugin = require("uglifyjs-webpack-plugin") +const config = require("../config") +const utils = require("./utils") +const glob = require("glob") +const fs = require("fs") -function resolve (dir) { - return path.join(__dirname, '..', dir) +function resolve(dir) { + return path.join(__dirname, "..", dir) } const NODE_ENV = utils.getNodeEnv() const vendors = [ - 'vue/dist/vue.esm.js', - 'vue-router', - 'vuex', - 'axios', - 'moment', - 'raven-js', - 'browser-detect' -]; + "vue/dist/vue.esm.js", + "vue-router", + "vuex", + "axios", + "moment", + "raven-js", + "browser-detect", +] // clear old dll -const globOptions = {cwd: resolve('static/js'), absolute: true}; -let oldDlls = glob.sync('vendor.dll.*.js', globOptions); +const globOptions = { cwd: resolve("static/js"), absolute: true } +let oldDlls = glob.sync("vendor.dll.*.js", globOptions) console.log("cleaning old dll..") -oldDlls.forEach(f => { - fs.unlink(f, ()=> {}) +oldDlls.forEach((f) => { + fs.unlink(f, () => {}) }) console.log("building ..") module.exports = { entry: { - "vendor": vendors, + vendor: vendors, }, output: { - path: path.join(__dirname, '../static/js'), - filename: '[name].dll.[hash:7].js', - library: '[name]_[hash]_dll', + path: path.join(__dirname, "../static/js"), + filename: "[name].dll.[hash:7].js", + library: "[name]_[hash]_dll", }, plugins: [ new webpack.DefinePlugin({ - 'process.env': NODE_ENV === 'production' ? config.build.env : config.dev.env + "process.env": + NODE_ENV === "production" ? config.build.env : config.dev.env, }), new webpack.optimize.ModuleConcatenationPlugin(), new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /zh-cn/), new UglifyJSPlugin({ exclude: /\.min\.js$/, cache: true, - parallel: true + parallel: true, }), new webpack.DllPlugin({ context: __dirname, - path: path.join(__dirname, '[name]-manifest.json'), - name: '[name]_[hash]_dll', - }) - ] -}; + path: path.join(__dirname, "[name]-manifest.json"), + name: "[name]_[hash]_dll", + }), + ], +} diff --git a/frontend/build/webpack.prod.conf.js b/frontend/build/webpack.prod.conf.js index c2e3d4fc4..40117c56a 100644 --- a/frontend/build/webpack.prod.conf.js +++ b/frontend/build/webpack.prod.conf.js @@ -1,63 +1,63 @@ -'use strict' -const os = require('os'); -const path = require('path') -const utils = require('./utils') -const webpack = require('webpack') -const config = require('../config') -const merge = require('webpack-merge') -const baseWebpackConfig = require('./webpack.base.conf') -const CopyWebpackPlugin = require('copy-webpack-plugin') -const HtmlWebpackPlugin = require('html-webpack-plugin') -const ExtractTextPlugin = require('extract-text-webpack-plugin') -const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') -const UglifyJSPlugin = require('uglifyjs-webpack-plugin') +"use strict" +const os = require("os") +const path = require("path") +const utils = require("./utils") +const webpack = require("webpack") +const config = require("../config") +const merge = require("webpack-merge") +const baseWebpackConfig = require("./webpack.base.conf") +const CopyWebpackPlugin = require("copy-webpack-plugin") +const HtmlWebpackPlugin = require("html-webpack-plugin") +const ExtractTextPlugin = require("extract-text-webpack-plugin") +const OptimizeCSSPlugin = require("optimize-css-assets-webpack-plugin") +const UglifyJSPlugin = require("uglifyjs-webpack-plugin") const webpackConfig = merge(baseWebpackConfig, { module: { rules: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, - extract: true - }) + extract: true, + }), }, - devtool: config.build.productionSourceMap ? '#hidden-source-map' : false, + devtool: config.build.productionSourceMap ? "#hidden-source-map" : false, output: { path: config.build.assetsRoot, - filename: utils.assetsPath('js/[name].[chunkhash].js'), - chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') + filename: utils.assetsPath("js/[name].[chunkhash].js"), + chunkFilename: utils.assetsPath("js/[id].[chunkhash].js"), }, plugins: [ // http://vuejs.github.io/vue-loader/en/workflow/production.html new webpack.DefinePlugin({ - 'process.env': config.build.env + "process.env": config.build.env, }), new webpack.optimize.ModuleConcatenationPlugin(), // extract css into its own file new ExtractTextPlugin({ - filename: utils.assetsPath('css/[name].[contenthash].css'), - allChunks: true + filename: utils.assetsPath("css/[name].[contenthash].css"), + allChunks: true, }), // Compress extracted CSS. We are using this plugin so that possible // duplicated CSS from different components can be deduped. new OptimizeCSSPlugin({ cssProcessorOptions: { - safe: true - } + safe: true, + }, }), new UglifyJSPlugin({ exclude: /\.min\.js$/, cache: true, parallel: true, - sourceMap: true + sourceMap: true, }), // keep module.id stable when vender modules does not change new webpack.HashedModuleIdsPlugin(), // split vendor js into its own file new webpack.optimize.CommonsChunkPlugin({ - name: 'vendor', - chunks: ['oj', 'admin'], - minChunks: 2 + name: "vendor", + chunks: ["oj", "admin"], + minChunks: 2, // minChunks: function (module) { // any required modules inside node_modules are extracted to vendor // return ( @@ -72,16 +72,16 @@ const webpackConfig = merge(baseWebpackConfig, { // extract webpack runtime and module manifest to its own file in order to // prevent vendor hash from being updated whenever app bundle is updated new webpack.optimize.CommonsChunkPlugin({ - name: 'manifest', - chunks: ['vendor'] + name: "manifest", + chunks: ["vendor"], }), // copy custom static assets new CopyWebpackPlugin([ { - from: path.resolve(__dirname, '../static'), + from: path.resolve(__dirname, "../static"), to: config.build.assetsSubDirectory, - ignore: ['.*'] - } + ignore: [".*"], + }, ]), // generate dist index.html with correct asset hash for caching. // you can customize output by editing /index.html @@ -90,53 +90,52 @@ const webpackConfig = merge(baseWebpackConfig, { new HtmlWebpackPlugin({ filename: config.build.ojIndex, template: config.build.ojTemplate, - chunks: ['manifest', 'vendor', 'oj'], + chunks: ["manifest", "vendor", "oj"], inject: true, minify: { removeComments: true, collapseWhitespace: true, - removeAttributeQuotes: true + removeAttributeQuotes: true, // more options: // https://github.com/kangax/html-minifier#options-quick-reference - } + }, }), // admin new HtmlWebpackPlugin({ filename: config.build.adminIndex, template: config.build.adminTemplate, - chunks: ['manifest', 'vendor', 'admin'], + chunks: ["manifest", "vendor", "admin"], inject: true, minify: { removeComments: true, collapseWhitespace: true, - removeAttributeQuotes: true + removeAttributeQuotes: true, // more options: // https://github.com/kangax/html-minifier#options-quick-reference - } - }) - ] + }, + }), + ], }) if (config.build.productionGzip) { - const CompressionWebpackPlugin = require('compression-webpack-plugin') + const CompressionWebpackPlugin = require("compression-webpack-plugin") webpackConfig.plugins.push( new CompressionWebpackPlugin({ - asset: '[path].gz[query]', - algorithm: 'gzip', + asset: "[path].gz[query]", + algorithm: "gzip", test: new RegExp( - '\\.(' + - config.build.productionGzipExtensions.join('|') + - ')$' + "\\.(" + config.build.productionGzipExtensions.join("|") + ")$", ), threshold: 10240, - minRatio: 0.8 - }) + minRatio: 0.8, + }), ) } if (config.build.bundleAnalyzerReport) { - const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin + const BundleAnalyzerPlugin = + require("webpack-bundle-analyzer").BundleAnalyzerPlugin webpackConfig.plugins.push(new BundleAnalyzerPlugin()) } diff --git a/frontend/config/dev.env.js b/frontend/config/dev.env.js index 8e636a91c..9649cddb9 100644 --- a/frontend/config/dev.env.js +++ b/frontend/config/dev.env.js @@ -1,13 +1,12 @@ // let date = require('moment')().format('YYYYMMDD') // let commit = require('child_process').execSync('git rev-parse HEAD').toString().slice(0, 5) // let version = `"${date}-${commit}"` -let version = JSON.stringify(process.env.VUE_APP_VERSION || 'dev') - +let version = JSON.stringify(process.env.VUE_APP_VERSION || "dev") console.log(`current version is ${version}`) module.exports = { NODE_ENV: '"development"', VERSION: version, - USE_SENTRY: '0' + USE_SENTRY: "0", } diff --git a/frontend/config/index.js b/frontend/config/index.js index 9df2df5f6..0e6d6b3a0 100644 --- a/frontend/config/index.js +++ b/frontend/config/index.js @@ -1,54 +1,54 @@ -'use strict' +"use strict" // Template version: 1.1.1 // see http://vuejs-templates.github.io/webpack for documentation. -const path = require('path') +const path = require("path") const commonProxy = { onProxyReq: (proxyReq, req, res) => { - proxyReq.setHeader('Referer', process.env.TARGET) + proxyReq.setHeader("Referer", process.env.TARGET) }, target: process.env.TARGET, - changeOrigin: true + changeOrigin: true, } module.exports = { build: { - env: require('./prod.env'), - ojIndex: path.resolve(__dirname, '../dist/index.html'), - ojTemplate: path.resolve(__dirname, '../src/pages/oj/index.html'), - adminIndex: path.resolve(__dirname, '../dist/admin/index.html'), - adminTemplate: path.resolve(__dirname, '../src/pages/admin/index.html'), - assetsRoot: path.resolve(__dirname, '../dist'), - assetsSubDirectory: 'static', - assetsPublicPath: '/__STATIC_CDN_HOST__/', - productionSourceMap: process.env.USE_SENTRY === '1', + env: require("./prod.env"), + ojIndex: path.resolve(__dirname, "../dist/index.html"), + ojTemplate: path.resolve(__dirname, "../src/pages/oj/index.html"), + adminIndex: path.resolve(__dirname, "../dist/admin/index.html"), + adminTemplate: path.resolve(__dirname, "../src/pages/admin/index.html"), + assetsRoot: path.resolve(__dirname, "../dist"), + assetsSubDirectory: "static", + assetsPublicPath: "/__STATIC_CDN_HOST__/", + productionSourceMap: process.env.USE_SENTRY === "1", // Gzip off by default as many popular static hosts such as // Surge or Netlify already gzip all static assets for you. // Before setting to `true`, make sure to: // npm install --save-dev compression-webpack-plugin productionGzip: false, - productionGzipExtensions: ['js', 'css'], + productionGzipExtensions: ["js", "css"], // Run the build command with an extra argument to // View the bundle analyzer report after build finishes: // `npm run build --report` // Set to `true` or `false` to always turn it on or off - bundleAnalyzerReport: process.env.npm_config_report + bundleAnalyzerReport: process.env.npm_config_report, }, dev: { - env: require('./dev.env'), + env: require("./dev.env"), port: process.env.PORT || 8080, autoOpenBrowser: true, - assetsSubDirectory: 'static', - assetsPublicPath: '/', + assetsSubDirectory: "static", + assetsPublicPath: "/", proxyTable: { "/api": commonProxy, - "/public": commonProxy + "/public": commonProxy, }, // CSS Sourcemaps off by default because relative paths are "buggy" // with this option, according to the CSS-Loader README // (https://github.com/webpack/css-loader#sourcemaps) // In our experience, they generally work as expected, // just be aware of this issue when enabling this option. - cssSourceMap: false - } + cssSourceMap: false, + }, } diff --git a/frontend/config/prod.env.js b/frontend/config/prod.env.js index addfa950d..b1be71970 100644 --- a/frontend/config/prod.env.js +++ b/frontend/config/prod.env.js @@ -1,5 +1,5 @@ -const merge = require('webpack-merge') -const devEnv = require('./dev.env') +const merge = require("webpack-merge") +const devEnv = require("./dev.env") module.exports = merge(devEnv, { NODE_ENV: '"production"', diff --git a/frontend/package.json b/frontend/package.json index b770cf36c..d75bed2b8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -61,14 +61,16 @@ "connect-history-api-fallback": "^1.3.0", "copy-webpack-plugin": "^4.5.1", "css-loader": "^0.28.11", + "eslint": "^8.57.1", + "eslint-config-prettier": "^8.10.2", "eslint-config-standard": "^10.2.1", "eslint-friendly-formatter": "^3.0.0", - "eslint-loader": "^1.7.1", "eslint-plugin-html": "^3.0.0", "eslint-plugin-import": "^2.11.0", "eslint-plugin-node": "^5.2.0", "eslint-plugin-promise": "^3.7.0", "eslint-plugin-standard": "^3.1.0", + "eslint-plugin-vue": "^9.33.0", "eventsource-polyfill": "^0.9.6", "express": "^4.16.3", "extract-text-webpack-plugin": "^3.0.0", @@ -84,12 +86,13 @@ "optimize-css-assets-webpack-plugin": "^3.2.0", "ora": "^1.2.0", "portfinder": "^1.0.13", - "prettier": "^3.6.2", + "prettier": "^3.8.1", "rimraf": "^2.6.0", "semver": "^5.5.0", "shelljs": "^0.8.2", "uglifyjs-webpack-plugin": "^1.2.5", "url-loader": "^0.5.8", + "vue-eslint-parser": "^9.4.3", "vue-loader": "^13.3.0", "vue-style-loader": "^3.0.1", "vue-template-compiler": "^2.5.16", diff --git a/frontend/src/i18n/admin/en-US.js b/frontend/src/i18n/admin/en-US.js index c991aaafe..1a7d2e2cf 100644 --- a/frontend/src/i18n/admin/en-US.js +++ b/frontend/src/i18n/admin/en-US.js @@ -175,8 +175,11 @@ export const m = { PruneTestCase_Delete_All: "전체 삭제", // Problem.vue + Question_Creation: "문제 생성", Display_ID: "문제 번호", + Check_ID_Duplication: "중복 확인", Title: "제목", + Question_Title: "문제 제목", Description: "문제 설명", Input_Description: "입력 설명", Output_Description: "출력 설명", @@ -186,8 +189,8 @@ export const m = { Visible: "공개 여부", ShareSubmission: "제출 공유 여부", Languages: "언어", - Input_Samples: "입력 예시", - Output_Samples: "출력 예시", + Input_Samples: "예제 입력", + Output_Samples: "예제 출력", Add_Sample: "예시 추가", Code_Template: "코드 템플릿", Special_Judge: "스페셜 저지", diff --git a/frontend/src/i18n/oj/en-US.js b/frontend/src/i18n/oj/en-US.js index b71fd18b1..8c0c00968 100644 --- a/frontend/src/i18n/oj/en-US.js +++ b/frontend/src/i18n/oj/en-US.js @@ -105,7 +105,7 @@ export const m = { Contest_Announcements: "대회 공지사항", By: "By", CSEP: "코드플레이스", - SWCenter: "소프트웨어융합교육원", + SWCenter: "AI융합교육원", CSEPDescription: "코드플레이스", // ApplyResetPassword.vue The_email_doesnt_exist: "존재하지 않는 이메일입니다.", @@ -253,6 +253,7 @@ export const m = { // announcements.vue Refresh: "새로고침", Back: "뒤로가기", + Notice_List: "목록", No_Announcements: "공지사항이 없습니다", Next_Announcement: "다음 공지", Before_Announcement: "이전 공지", @@ -503,7 +504,7 @@ export const m = { // footer.vue PNUCSEP: "부산대학교 코드플레이스", AddressPNUCSEP: - "부산광역시 금정구 부산대학로63번길 2 (장전동) 부산대학교 소프트웨어융합교육원", + "부산광역시 금정구 부산대학로63번길 2 (장전동) 부산대학교 AI융합교육원", UserGuide: "사용자 가이드", ReportBugAndInconvenience: "버그 및 불편사항 제보", Developer: "개발진", @@ -548,6 +549,7 @@ export const m = { Community_Content_Placeholder: "내용을 입력해주세요.", Community_Post_Edit: "수정", Community_Post_Delete: "삭제", + Community_List: "목록", Community_Post_Save: "저장", Community_Post_Cancel: "취소", Community_Post_Edit_Success: "게시글이 수정되었습니다.", diff --git a/frontend/src/pages/admin/components/CodeMirror.vue b/frontend/src/pages/admin/components/CodeMirror.vue index ece178449..1786ee67c 100644 --- a/frontend/src/pages/admin/components/CodeMirror.vue +++ b/frontend/src/pages/admin/components/CodeMirror.vue @@ -1,9 +1,11 @@ diff --git a/frontend/src/pages/admin/views/general/Announcement.vue b/frontend/src/pages/admin/views/general/Announcement.vue index 1a79aa76d..e7f194c40 100644 --- a/frontend/src/pages/admin/views/general/Announcement.vue +++ b/frontend/src/pages/admin/views/general/Announcement.vue @@ -27,14 +27,26 @@ - + - +
@@ -66,12 +78,20 @@
{{ $t("m.Announcement_visible") }} - +
{{ $t("m.Announcement_Pin") }} - +
@@ -259,6 +279,16 @@ export default { is_pinned: row.is_pinned, }) }, + handlePinSwitch(row) { + this.mode = "edit" + this.submitAnnouncement({ + id: row.id, + title: row.title, + content: row.content, + visible: row.visible, + is_pinned: row.is_pinned, + }) + }, handlePinSwitch(row) { this.mode = "edit" this.submitAnnouncement({ diff --git a/frontend/src/pages/admin/views/general/User.vue b/frontend/src/pages/admin/views/general/User.vue index dc290c1fa..4d9365a62 100644 --- a/frontend/src/pages/admin/views/general/User.vue +++ b/frontend/src/pages/admin/views/general/User.vue @@ -2,31 +2,92 @@
- - 인원 개요 + + 인원 개요
    -
  • 총 사용자 수 {{this.stat.all_users}}명
  • -
  • 최고 관리자 {{this.stat.super_admins}}명
  • -
  • 관리자 {{this.stat.admins}}명
  • -
  • 일반 사용자 {{this.stat.regular_users}}명
  • +
  • + 총 사용자 수 {{ this.stat.all_users }}명 +
  • +
  • + 최고 관리자 {{ this.stat.super_admins }}명 +
  • +
  • + 관리자 {{ this.stat.admins }}명 +
  • +
  • + 일반 사용자 {{ this.stat.regular_users }}명 +
- - 학과별 가입자 수 현황 -
- + + 학과별 가입자 수 현황 +
+
- - 월별 가입자 수 현황 + + 월별 가입자 수 현황
- +
- - 주간 가입자 수 현황 + + 주간 가입자 수 현황
- +
@@ -36,27 +97,46 @@
- {{$t('m.Icon_Delete')}} + {{ $t("m.Icon_Delete") }} - - - + + + - + - - + +
@@ -66,26 +146,46 @@ @selection-change="handleSelectionChange" ref="table" :data="userList" - style="width: 100%;"> + style="width: 100%" + > - + - + - + - + - + - + @@ -99,16 +199,30 @@ - -