diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..7c9270922 Binary files /dev/null and b/.DS_Store differ diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 23da7f558..8cff69de7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,13 +1,20 @@ -FROM mcr.microsoft.com/vscode/devcontainers/base:ubuntu-20.04 +# [Choice] Python version (https://github.com/microsoft/vscode-dev-containers/tree/main/containers/python-3) +ARG VARIANT=3.8-bullseye +FROM mcr.microsoft.com/vscode/devcontainers/python:${VARIANT} -# Install Node 16 -ENV NODE_VERSION=16 -RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash \ - && bash -c ". $HOME/.nvm/nvm.sh \ - && nvm install $NODE_VERSION \ - && nvm alias default $NODE_VERSION \ - && npm install -g typescript yarn" +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="16" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi -# Install packages from apt -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install --no-install-recommends vim python3-pip python3-setuptools +RUN apt-get update && \ + export DEBIAN_FRONTEND=noninteractive && \ + apt-get -y install --no-install-recommends \ + libgtk2.0-0 \ + libgtk-3-0 \ + libgbm-dev \ + libnotify-dev \ + libgconf-2-4 \ + libnss3 \ + libxss1 \ + libasound2 \ + libxtst6 xauth xvfb diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9ceb57c22..f06a65613 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,6 +3,7 @@ "dockerComposeFile": "docker-compose.yml", "service": "app", "workspaceFolder": "/workspace", + "forwardPorts": [10000], "postCreateCommand": "./.devcontainer/postCreateCommand.sh", @@ -14,10 +15,14 @@ "editorconfig.editorconfig", "gruntfuggly.todo-tree", "johnsoncodehk.volar", + "mhutchie.git-graph", "ms-python.python", "naumovs.color-highlight", "oderwat.indent-rainbow", "pkief.material-icon-theme", "rangav.vscode-thunder-client" - ] + ], + + // Connect as non-root user (https://code.visualstudio.com/remote/advancedcontainers/add-nonroot-user) + "remoteUser": "vscode" } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml old mode 100644 new mode 100755 index 12ca37d0d..ab13f6618 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -14,7 +14,13 @@ services: - DISABLE_HEARTBEAT=1 - JUDGE_SERVER_URL=http://judge-server-dev:8080 - JUDGE_SERVER_TOKEN=CHANGE_THIS - + - DISPLAY=:14 + - LIBGL_ALWAYS_INDIRECT=0 + - CYPRESS_BASE_URL=http://localhost:8080 + volumes_from: + - x11-bridge:rw + depends_on: + - x11-bridge judge-server-dev: image: skkunpc/judge-server container_name: judge-server-dev @@ -31,8 +37,7 @@ services: - /tmp volumes: - ../backend/data/test_case:/test_case:ro - - ./judge-server/log:/log - - ./judge-server/run:/judger + - judger-data:/judger environment: - DISABLE_HEARTBEAT=1 - TOKEN=CHANGE_THIS @@ -53,6 +58,21 @@ services: volumes: - redis-data:/data + x11-bridge: + image: jare/x11-bridge + volumes: + - "/tmp/.X11-unix:/tmp/.X11-unix:rw" + ports: + - "10000:10000" + restart: always + environment: + - MODE=tcp + - XPRA_HTML=yes + - DISPLAY=:14 + - XPRA_TCP_PORT=10000 + - XPRA_PASSWORD=CHANGE_THIS + volumes: postgres-data: null redis-data: null + judger-data: null diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index 82c5b392b..51cbd0cd4 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -35,3 +35,4 @@ SysOptions.judge_server_token='$JUDGE_SERVER_TOKEN' # Install Node packages yarn --cwd /workspace/frontend install +yarn --cwd /workspace/frontend cypress install diff --git a/.editorconfig b/.editorconfig index 7b429ec36..326f552ea 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,6 @@ indent_size = 2 [*.{py,sh,conf}] indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/build-image.yml b/.github/workflows/build.yml similarity index 73% rename from .github/workflows/build-image.yml rename to .github/workflows/build.yml index bca4e134e..e8db5d4fb 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build.yml @@ -6,8 +6,8 @@ on: - master jobs: - build-image: - runs-on: ubuntu-18.04 + build: + runs-on: ubuntu-latest steps: - name: Checkout to repository @@ -20,11 +20,11 @@ jobs: uses: docker/login-action@v1 with: registry: ghcr.io - username: dotoleeoak - password: ${{ secrets.CR_PAT }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push container image uses: docker/build-push-action@v2 with: push: true - tags: ghcr.io/skku-npc/coding-platform:latest + tags: ghcr.io/skkuding/coding-platform:latest diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 7b9d8fb97..849dcb900 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -5,7 +5,8 @@ on: # Triggers the workflow on push or pull request events but only for the master branch pull_request: paths: - - 'backend/**' + - "backend/**" + - ".github/workflows/test-backend.yml" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -22,8 +23,8 @@ jobs: - uses: actions/setup-python@v2 with: - python-version: '3.7.10' # using SemVer's version range syntax - architecture: 'x64' + python-version: "3.8.10" # using SemVer's version range syntax + architecture: "x64" - uses: actions/cache@v2 with: diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 7ab686f89..1dbd345c7 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -6,6 +6,7 @@ on: pull_request: paths: - "frontend/**" + - ".github/workflows/test-frontend.yml" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -13,7 +14,7 @@ on: # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-18.04 env: working-directory: ./frontend @@ -22,7 +23,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: '14' + node-version: "14" - name: Cache Node modules id: cache-yarn @@ -43,7 +44,7 @@ jobs: - name: Check styles working-directory: ${{ env.working-directory }} run: yarn lint --no-fix - + - name: Build production files working-directory: ${{ env.working-directory }} run: yarn build diff --git a/.gitignore b/.gitignore index abffbd4d1..f3c4fa0f4 100644 --- a/.gitignore +++ b/.gitignore @@ -196,3 +196,6 @@ typings/ # devcontainer .devcontainer/data + +# MacOS file +.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json index 0b62d2a3d..ad32a0b5b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,15 +1,38 @@ { - "version": "0.2.0", - "configurations": [ - { - "name": "Python: Django", - "type": "python", - "request": "launch", - "program": "${workspaceFolder}/backend/manage.py", - "args": [ - "runserver" - ], - "django": true - } - ] + "version": "0.2.0", + "configurations": [ + { + "name": "Vue.js: Chrome", + "type": "chrome", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/frontend/src", + "breakOnLoad": true, + "sourceMapPathOverrides": { + "webpack:///src/*": "${webRoot}/*" + } + }, + { + "name": "Vue.js: Edge", + "type": "msedge", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/frontend/src", + "breakOnLoad": true, + "sourceMapPathOverrides": { + "webpack:///src/*": "${webRoot}/*" + } + }, + { + "name": "Python: Django", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/backend/manage.py", + "args": [ + "runserver", + "--noreload" + ], + "django": true + } + ] } diff --git a/Dockerfile b/Dockerfile index 724e8ed5a..1d9b62424 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN yarn install && \ yarn build # Deploy Stage -FROM python:3.7-alpine3.13 +FROM python:3.8.12-alpine3.15 ENV OJ_ENV production ENV NODE_ENV production diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..687071f2c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-present skkuding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4b3cfb6ee..e16473826 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # SKKU Coding Platform -![issues](https://img.shields.io/github/issues/skku-npc/skku-coding-platform) -![docker](https://img.shields.io/docker/cloud/automated/skkunpc/coding-platform) -![checks](https://img.shields.io/github/checks-status/skku-npc/skku-coding-platform/master) -![python](https://img.shields.io/badge/Python-3.7.10-blue) -![django](https://img.shields.io/badge/Django-3.2.4-darkgreen) +![issues](https://img.shields.io/github/issues/skkuding/skku-coding-platform) +[![build status](https://github.com/skkuding/skku-coding-platform/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/skkuding/skku-coding-platform/actions/workflows/build.yml) +![python](https://img.shields.io/badge/Python-3.8.10-blue) +![django](https://img.shields.io/badge/Django-3.2.12-darkgreen) ![vue](https://img.shields.io/badge/Vue-2.6.11-green) [QingdaoU OJ](https://github.com/QingdaoU/OnlineJudge)를 기반으로 제작한 성균관대학교 Online Judge 시스템입니다. @@ -13,7 +12,7 @@ Docker를 설치하고, docker compose를 실행합니다. ```shell -> git clone https://github.com/skku-npc/skku-coding-platform.git +> git clone https://github.com/skkuding/skku-coding-platform.git > cd skku-coding-platform > docker-compose up -d ``` @@ -23,7 +22,7 @@ Docker를 설치하고, docker compose를 실행합니다. ## Documentation 📚 Wiki를 참고해주세요. -https://github.com/skku-npc/skku-coding-platform/wiki +https://github.com/skkuding/skku-coding-platform/wiki ### 운영진 박민서 [@minseo999](https://github.com/minseo999) diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 000000000..9d2e2a117 Binary files /dev/null and b/backend/.DS_Store differ diff --git a/backend/account/models.py b/backend/account/models.py index 63e4676e3..6e0989da3 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -58,6 +58,9 @@ def can_mgmt_all_problem(self): def is_contest_admin(self, contest): return self.is_authenticated and (contest.created_by == self or self.admin_type == AdminType.SUPER_ADMIN) + def is_assignment_admin(self, assignment): + return self.is_authenticated and (assignment.created_by == self or self.admin_type == AdminType.SUPER_ADMIN) + class Meta: db_table = "user" diff --git a/backend/announcement/migrations/0004_alter_announcement_id.py b/backend/announcement/migrations/0004_alter_announcement_id.py index 9386c655e..f5e45b086 100644 --- a/backend/announcement/migrations/0004_alter_announcement_id.py +++ b/backend/announcement/migrations/0004_alter_announcement_id.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.4 on 2021-10-30 06:35 +# Generated by Django 3.2.5 on 2021-12-26 05:58 from django.db import migrations, models diff --git a/backend/assignment/__init__.py b/backend/assignment/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/assignment/migrations/0001_initial.py b/backend/assignment/migrations/0001_initial.py new file mode 100644 index 000000000..74c87b8dd --- /dev/null +++ b/backend/assignment/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.24 on 2021-07-30 13:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import utils.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Assignment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField()), + ('content', utils.models.RichTextField()), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('last_update_time', models.DateTimeField(auto_now=True)), + ('visible', models.BooleanField(default=True)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.Course')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'assignment', + 'ordering': ('-start_time',), + }, + ), + ] diff --git a/backend/assignment/migrations/0002_alter_assignment_id.py b/backend/assignment/migrations/0002_alter_assignment_id.py new file mode 100644 index 000000000..f85089fd1 --- /dev/null +++ b/backend/assignment/migrations/0002_alter_assignment_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-12-26 05:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assignment', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='assignment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/assignment/migrations/__init__.py b/backend/assignment/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/assignment/models.py b/backend/assignment/models.py new file mode 100644 index 000000000..1c7f758e2 --- /dev/null +++ b/backend/assignment/models.py @@ -0,0 +1,38 @@ +from django.db import models +from django.utils.timezone import now + +from course.models import Course +from account.models import User +from utils.models import RichTextField +from utils.constants import AssignmentStatus + + +class Assignment(models.Model): + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + course = models.ForeignKey(Course, on_delete=models.CASCADE) + title = models.TextField() + content = RichTextField() + start_time = models.DateTimeField() + end_time = models.DateTimeField() + create_time = models.DateTimeField(auto_now_add=True) + last_update_time = models.DateTimeField(auto_now=True) + visible = models.BooleanField(default=True) + + @property + def status(self): + if self.start_time > now(): + # NOT_START return 1 + return AssignmentStatus.ASSIGNMENT_NOT_START + elif self.end_time < now(): + # ENDED return -1 + return AssignmentStatus.ASSIGNMENT_ENDED + else: + # UNDERWAY return 0 + return AssignmentStatus.ASSIGNMENT_UNDERWAY + + def problem_details_permission(self, user): + return user.is_authenticated and user.is_assignment_admin(self) + + class Meta: + db_table = "assignment" + ordering = ("-start_time",) diff --git a/backend/assignment/serializers.py b/backend/assignment/serializers.py new file mode 100644 index 000000000..07cd388a7 --- /dev/null +++ b/backend/assignment/serializers.py @@ -0,0 +1,64 @@ +from utils.api import UsernameSerializer, serializers + +from .models import Assignment +from submission.models import Submission +from course.serializers import CourseSerializer + + +class AssignmentProfessorSerializer(serializers.ModelSerializer): + created_by = UsernameSerializer() + course = CourseSerializer() + status = serializers.CharField() + + class Meta: + model = Assignment + fields = "__all__" + + +class AssignmentSerializer(serializers.ModelSerializer): + status = serializers.CharField() + total_problem = serializers.SerializerMethodField(read_only=True) + submitted_problem = serializers.SerializerMethodField(read_only=True) + accepted_problem = serializers.SerializerMethodField(read_only=True) + + def get_total_problem(self, obj): + return obj.problems.count() + + def get_submitted_problem(self, obj): + user = self.context["request"].user + return Submission.objects.filter(assignment=obj, user_id=user.id).values("problem").distinct().count() + + def get_accepted_problem(self, obj): + user = self.context["request"].user + return Submission.objects.filter(assignment=obj, user_id=user.id, result=0).values("problem").distinct().count() + + class Meta: + model = Assignment + exclude = ("created_by", "visible", "create_time", "last_update_time", "course") + + +class AssignmentCourseSerializer(serializers.ModelSerializer): + course = CourseSerializer() + + class Meta: + model = Assignment + exclude = ("created_by", "content", "start_time", "end_time", "create_time", "last_update_time", "visible") + + +class CreateAssignmentSerializer(serializers.Serializer): + title = serializers.CharField(max_length=128) + course_id = serializers.IntegerField() + content = serializers.CharField(max_length=1024 * 1024 * 8) + start_time = serializers.DateTimeField() + end_time = serializers.DateTimeField() + visible = serializers.BooleanField() + + +class EditAssignmentSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField(max_length=128) + course_id = serializers.IntegerField() + content = serializers.CharField(max_length=1024 * 1024 * 8) + start_time = serializers.DateTimeField() + end_time = serializers.DateTimeField() + visible = serializers.BooleanField() diff --git a/backend/assignment/tests.py b/backend/assignment/tests.py new file mode 100644 index 000000000..36b6e72c0 --- /dev/null +++ b/backend/assignment/tests.py @@ -0,0 +1,83 @@ +import copy +from datetime import timedelta +from django.utils import timezone + +from utils.api.tests import APITestCase + +from course.models import Registration, Course +from .models import Assignment + +DEFAULT_COURSE_DATA = {"title": "test course", + "course_code": "ABCD123", + "class_number": 12, + "registered_year": 2021, + "semester": 1} + +DEFAULT_ASSIGNMENT_DATA = {"title": "test assignment", + "content": "test content", + "start_time": timezone.localtime(timezone.now()), + "end_time": timezone.localtime(timezone.now()) + timedelta(days=1), + "visible": True} + + +class AssignmentProfessorAPITest(APITestCase): + def setUp(self): + professor = self.create_admin() + self.url = self.reverse("assignment_professor_api") + self.course_id = Course.objects.create(created_by=professor, **DEFAULT_COURSE_DATA).id + self.data = copy.deepcopy(DEFAULT_ASSIGNMENT_DATA) + self.data["course_id"] = self.course_id + + def test_create_assignment(self): + resp = self.client.post(self.url, data=self.data) + self.assertSuccess(resp) + return resp + + def test_delete_assignment(self): + id = self.test_create_assignment().data["data"]["id"] + resp = self.client.delete(f"{self.url}?course_id={self.course_id}&assignment_id={id}") + self.assertSuccess(resp) + self.assertFalse(Assignment.objects.filter(id=id).exists()) + + def test_edit_assignment(self): + id = self.test_create_assignment().data["data"]["id"] + update_data = {"id": id, + "title": "update title", + "content": "update content"} + data = copy.deepcopy(self.data) + data.update(update_data) + resp = self.client.put(self.url, data=data) + self.assertSuccess(resp) + resp_data = resp.data["data"] + self.assertEqual(resp_data["title"], "update title") + self.assertEqual(resp_data["content"], "update content") + + def test_get_assignment_list(self): + self.test_create_assignment() + resp = self.client.get(f"{self.url}?course_id={self.course_id}") + self.assertSuccess(resp) + + def test_get_one_assignment(self): + id = self.test_create_assignment().data["data"]["id"] + resp = self.client.get(f"{self.url}?course_id={self.course_id}&assignment_id={id}") + self.assertSuccess(resp) + + +class AssignmentStudentAPITest(APITestCase): + def setUp(self): + professor = self.create_admin() + self.course_id = Course.objects.create(created_by=professor, **DEFAULT_COURSE_DATA).id + assignment_data = copy.deepcopy(DEFAULT_ASSIGNMENT_DATA) + assignment_data["course_id"] = self.course_id + self.assignment_id = Assignment.objects.create(created_by=professor, **assignment_data).id + student_id = self.create_user("2020123123", "123").id + Registration.objects.create(user_id=student_id, course_id=self.course_id) + self.url = self.reverse("assignment_api") + + def test_get_assignment_list(self): + resp = self.client.get(f"{self.url}?course_id={self.course_id}") + self.assertSuccess(resp) + + def test_get_one_assignment(self): + resp = self.client.get(f"{self.url}?course_id={self.course_id}&assignment_id={self.assignment_id}") + self.assertSuccess(resp) diff --git a/backend/assignment/urls/professor.py b/backend/assignment/urls/professor.py new file mode 100644 index 000000000..700903534 --- /dev/null +++ b/backend/assignment/urls/professor.py @@ -0,0 +1,8 @@ +from django.urls import path + +from ..views.professor import AssignmentAPI, DownloadAssignmentSubmissions + +urlpatterns = [ + path("course/assignment/", AssignmentAPI.as_view(), name="assignment_professor_api"), + path("download_submissions/", DownloadAssignmentSubmissions.as_view(), name="download_assignment_submissions"), +] diff --git a/backend/assignment/urls/student.py b/backend/assignment/urls/student.py new file mode 100644 index 000000000..610825fca --- /dev/null +++ b/backend/assignment/urls/student.py @@ -0,0 +1,7 @@ +from django.urls import path + +from ..views.student import AssignmentAPI + +urlpatterns = [ + path("course/assignment/", AssignmentAPI.as_view(), name="assignment_api"), +] diff --git a/backend/assignment/views/professor.py b/backend/assignment/views/professor.py new file mode 100644 index 000000000..47cd7d194 --- /dev/null +++ b/backend/assignment/views/professor.py @@ -0,0 +1,260 @@ +import os +import zipfile + +import dateutil.parser +from utils.api import APIView, validate_serializer +from utils.shortcuts import rand_str +from utils.tasks import delete_files +from utils.decorators import ensure_created_by, admin_role_required +from django.http import FileResponse +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from ..models import Assignment +from account.models import User +from course.models import Course +from problem.models import Problem +from submission.models import Submission +from ..serializers import AssignmentProfessorSerializer, CreateAssignmentSerializer, EditAssignmentSerializer + + +class AssignmentAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="course_id", + in_=openapi.IN_QUERY, + description="Unique ID of a course", + required=True, + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="assignment_id", + in_=openapi.IN_QUERY, + description="Unique ID of a assignment", + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="limit", + in_=openapi.IN_QUERY, + description="Number of contests to show", + type=openapi.TYPE_STRING, + default=10, + ), + openapi.Parameter( + name="offset", + in_=openapi.IN_QUERY, + description="ID of the first contest of list", + type=openapi.TYPE_STRING, + default=0, + ), + ], + operation_description="Get assignment list of the course generated by requesting admin", + responses={200: AssignmentProfessorSerializer}, + ) + @admin_role_required + def get(self, request): + assignment_id = request.GET.get("assignment_id") + course_id = request.GET.get("course_id") + + if not course_id: + assignments = Assignment.objects.filter(created_by=request.user) + return self.success(self.paginate_data(request, assignments, AssignmentProfessorSerializer)) + + try: + course = Course.objects.get(id=course_id) + ensure_created_by(course, request.user) + except Course.DoesNotExist: + return self.error("Course does not exist") + + if assignment_id: + try: + assignment = Assignment.objects.get(id=assignment_id, course_id=course_id) + return self.success(AssignmentProfessorSerializer(assignment).data) + except Assignment.DoesNotExist: + return self.error("Assignment does not exists") + + assignments = Assignment.objects.filter(course_id=course_id) + return self.success(self.paginate_data(request, assignments, AssignmentProfessorSerializer)) + + @swagger_auto_schema( + request_body=CreateAssignmentSerializer, + operation_description="Create one assignment", + responses={200: AssignmentProfessorSerializer}, + ) + @validate_serializer(CreateAssignmentSerializer) + @admin_role_required + def post(self, request): + data = request.data + course_id = request.data["course_id"] + + try: + course = Course.objects.get(id=course_id) + ensure_created_by(course, request.user) + except Course.DoesNotExist: + return self.error("Course does not exists") + data["start_time"] = dateutil.parser.parse(data["start_time"]) + data["end_time"] = dateutil.parser.parse(data["end_time"]) + data["created_by"] = request.user + + if data["end_time"] <= data["start_time"]: + return self.error("Start time must occur earlier than end time") + + assignment = Assignment.objects.create(**data) + return self.success(AssignmentProfessorSerializer(assignment).data) + + @swagger_auto_schema( + request_body=EditAssignmentSerializer, + operation_description="Update assignment", + responses={200: AssignmentProfessorSerializer}, + ) + @validate_serializer(EditAssignmentSerializer) + @admin_role_required + def put(self, request): + data = request.data + try: + assignment = Assignment.objects.get(id=data.pop("id")) + ensure_created_by(assignment, request.user) + except Assignment.DoesNotExist: + return self.error("Assignment does not exist") + + data["start_time"] = dateutil.parser.parse(data["start_time"]) + data["end_time"] = dateutil.parser.parse(data["end_time"]) + + if data["end_time"] <= data["start_time"]: + return self.error("Start time must occur earlier than end time") + + for k, v in data.items(): + setattr(assignment, k, v) + assignment.save() + return self.success(AssignmentProfessorSerializer(assignment).data) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="course_id", + in_=openapi.IN_QUERY, + description="Id of course", + required=True, + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="assignment_id", + in_=openapi.IN_QUERY, + description="Id of assignment to delete", + required=True, + type=openapi.TYPE_INTEGER, + ) + ], + operation_description="Delete one contest announcement", + ) + @admin_role_required + def delete(self, request): + course_id = request.GET.get("course_id") + assignment_id = request.GET.get("assignment_id") + + if not course_id: + return self.error("Invalid parameter, course_id is required") + if not assignment_id: + return self.error("Invalid parameter, assignment_id is required") + + try: + course = Course.objects.get(id=course_id) + ensure_created_by(course, request.user) + except Course.DoesNotExist: + return self.error("Course does not exist") + + try: + assignment = Assignment.objects.get(id=assignment_id) + ensure_created_by(assignment, request.user) + except Assignment.DoesNotExist: + return self.error("Assignment does not exist") + + if Submission.objects.filter(assignment_id=assignment_id).exists(): + return self.error("Can't delete the assignment as it has submissions") + + assignment.delete() + return self.success() + + +class DownloadAssignmentSubmissions(APIView): + def _dump_submissions(self, assignment, problem, exclude_admin=True, last_submission=True): + submissions = Submission.objects.filter(assignment=assignment, problem=problem).order_by("-create_time") + if last_submission: + submissions = submissions.order_by("username", "-create_time").distinct("username") + user_ids = submissions.values_list("user_id", flat=True) + users = User.objects.filter(id__in=user_ids) + path = f"/tmp/{rand_str()}.zip" + with zipfile.ZipFile(path, "w") as zip_file: + for user in users: + if user.is_admin_role() and exclude_admin: + continue + user_submissions = submissions.filter(user_id=user.id) + index = 1 + for submission in user_submissions: + file_name = f"{user.username}_{problem._id}_{index}.txt" + compression = zipfile.ZIP_DEFLATED + zip_file.writestr(zinfo_or_arcname=f"{file_name}", + data=submission.code, + compress_type=compression) + index += 1 + return path + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="assignment_id", + in_=openapi.IN_QUERY, + description="ID of contest", + required=True, + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="problem_id", + in_=openapi.IN_QUERY, + description="Unique ID of problem", + required=True, + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="exclude_admin", + in_=openapi.IN_QUERY, + description="Set value '1' to exclude admin", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + name="last_submission", + in_=openapi.IN_QUERY, + description="Set value '1' to get only last submissions", + type=openapi.TYPE_STRING, + ), + ], + operation_description="Download submissions of single contest", + ) + def get(self, request): + assignment_id = request.GET.get("assignment_id") + problem_id = request.GET.get("problem_id") + if not assignment_id: + return self.error("Invalid parameter, assignment_id is required") + if not problem_id: + return self.error("Invalid parameter, problem_id is required") + + try: + assignment = Assignment.objects.get(id=assignment_id) + ensure_created_by(assignment, request.user) + except Assignment.DoesNotExist: + return self.error("Assignment does not exist") + + try: + problem = Problem.objects.get(id=problem_id) + except Problem.DoesNotExist: + return self.error("Problem does not exist") + + exclude_admin = request.GET.get("exclude_admin") == "1" + last_submission = request.GET.get("last_submission") == "1" + zip_path = self._dump_submissions(assignment, problem, exclude_admin, last_submission) + delete_files.send_with_options(args=(zip_path,), delay=300_000) + res = FileResponse(open(zip_path, "rb")) + res["Content-Type"] = "application/zip" + res["Content-Disposition"] = f"attatchment;filename={os.path.basename(zip_path)}" + return res diff --git a/backend/assignment/views/student.py b/backend/assignment/views/student.py new file mode 100644 index 000000000..15bcb236e --- /dev/null +++ b/backend/assignment/views/student.py @@ -0,0 +1,72 @@ +from utils.api import APIView +from utils.decorators import login_required + +from course.models import Course, Registration + +from ..models import Assignment +from ..serializers import AssignmentSerializer +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + + +class AssignmentAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="course_id", + in_=openapi.IN_QUERY, + description="Unique ID of a course", + required=True, + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="assignment_id", + in_=openapi.IN_QUERY, + description="Unique ID of a assignment", + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="limit", + in_=openapi.IN_QUERY, + description="Number of assignments to show", + type=openapi.TYPE_STRING, + default=10, + ), + openapi.Parameter( + name="offset", + in_=openapi.IN_QUERY, + description="ID of the first assignment of list", + type=openapi.TYPE_STRING, + default=0, + ), + ], + operation_description="Get assignment list of the course", + responses={200: AssignmentSerializer}, + ) + @login_required + def get(self, request): + assignment_id = request.GET.get("assignment_id") + course_id = request.GET.get("course_id") + + if not course_id: + return self.error("Invalid parameter, course_id is required") + + try: + Course.objects.get(id=course_id) + Registration.objects.get(user_id=request.user.id, course_id=course_id) + except Course.DoesNotExist: + return self.error("Course does not exist") + except Registration.DoesNotExist: + return self.error("Invalid access, not registered user") + + context = {"request": request} + + if assignment_id: + try: + assignment = Assignment.objects.get(id=assignment_id, course_id=course_id, visible=True) + return self.success(AssignmentSerializer(assignment, context=context).data) + except Assignment.DoesNotExist: + return self.error("Assignment does not exists") + + assignments = Assignment.objects.filter(course_id=course_id, visible=True) + return self.success(self.paginate_data(request, assignments, AssignmentSerializer, context)) diff --git a/backend/banner/__init__.py b/backend/banner/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/banner/migrations/0001_initial.py b/backend/banner/migrations/0001_initial.py new file mode 100644 index 000000000..761fa2898 --- /dev/null +++ b/backend/banner/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.9 on 2022-01-01 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Banner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField()), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('path', models.TextField()), + ('visible', models.BooleanField(default=False)), + ], + options={ + 'db_table': 'banner', + 'ordering': ('-create_time',), + }, + ), + ] diff --git a/backend/banner/migrations/__init__.py b/backend/banner/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/banner/models.py b/backend/banner/models.py new file mode 100644 index 000000000..6307049a6 --- /dev/null +++ b/backend/banner/models.py @@ -0,0 +1,12 @@ +from django.db import models + + +class Banner(models.Model): + title = models.TextField() + create_time = models.DateTimeField(auto_now_add=True) + path = models.TextField() + visible = models.BooleanField(default=False) + + class Meta: + db_table = "banner" + ordering = ("-create_time",) diff --git a/backend/banner/serializers.py b/backend/banner/serializers.py new file mode 100644 index 000000000..291e41c43 --- /dev/null +++ b/backend/banner/serializers.py @@ -0,0 +1,24 @@ +from utils.api import serializers + +from .models import Banner + + +class BannerSerializer(serializers.Serializer): + path = serializers.CharField(max_length=1024) + + +class BannerAdminSerializer(serializers.ModelSerializer): + class Meta: + model = Banner + fields = "__all__" + + +class CreateBannerSerializer(serializers.Serializer): + title = serializers.CharField(max_length=64) + path = serializers.CharField(max_length=1024) + + +class EditBannerSerializer(serializers.Serializer): + id = serializers.IntegerField() + path = serializers.CharField() + visible = serializers.BooleanField() diff --git a/backend/banner/tests.py b/backend/banner/tests.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/banner/urls/__init__.py b/backend/banner/urls/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/banner/urls/admin.py b/backend/banner/urls/admin.py new file mode 100644 index 000000000..47419b851 --- /dev/null +++ b/backend/banner/urls/admin.py @@ -0,0 +1,7 @@ +from django.urls import path + +from ..views.admin import BannerAdminAPI + +urlpatterns = [ + path("banner/", BannerAdminAPI.as_view(), name="banner_admin_api") +] diff --git a/backend/banner/urls/oj.py b/backend/banner/urls/oj.py new file mode 100644 index 000000000..985764cf3 --- /dev/null +++ b/backend/banner/urls/oj.py @@ -0,0 +1,7 @@ +from django.urls import path + +from ..views.oj import BannerAPI + +urlpatterns = [ + path("banner/", BannerAPI.as_view(), name="banner_api") +] diff --git a/backend/banner/views/__init__.py b/backend/banner/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/banner/views/admin.py b/backend/banner/views/admin.py new file mode 100644 index 000000000..25cc50acf --- /dev/null +++ b/backend/banner/views/admin.py @@ -0,0 +1,85 @@ +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from banner.models import Banner +from banner.serializers import (BannerAdminSerializer, CreateBannerSerializer, EditBannerSerializer) +from utils.api import APIView, validate_serializer +from utils.decorators import super_admin_required + + +class BannerAdminAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="id", in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + description="unique banner id", + ) + ], + operation_description="Get Banner image" + ) + @super_admin_required + def get(self, request): + banner_id = request.GET.get("id") + # get single banner image + if banner_id: + try: + banner = Banner.objects.get(id=banner_id) + return self.success(BannerAdminSerializer(banner).data) + except Banner.DoesNotExist: + return self.error("Banner does not exist") + # get all banner images + else: + banners = Banner.objects.all() + return self.success(BannerAdminSerializer(banners, many=True).data) + + @swagger_auto_schema( + request_body=CreateBannerSerializer, + operation_description="Create new Banner image", + responses={200: BannerAdminSerializer}, + ) + @validate_serializer(CreateBannerSerializer) + @super_admin_required + def post(self, request): + data = request.data + banner = Banner.objects.create(title=data["title"], + path=data["path"]) + return self.success(BannerAdminSerializer(banner).data) + + @swagger_auto_schema( + request_body=EditBannerSerializer, + operation_description="Edit Banner image" + ) + @validate_serializer(EditBannerSerializer) + @super_admin_required + def put(self, request): + data = request.data + try: + banner = Banner.objects.get(id=data.pop("id")) + except Banner.DoesNotExist: + return self.error("Banner does not exist") + + setattr(banner, "visible", data["visible"]) + banner.save() + return self.success(BannerAdminSerializer(banner).data) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="id", in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + description="unique banner id", + ), + ], + operation_description="Delete Banner Image" + ) + @super_admin_required + def delete(self, request): + banner_id = request.GET.get("id") + if not banner_id: + return self.error("Invalid parameter, id is required") + try: + Banner.objects.filter(id=request.GET["id"]).delete() + return self.success() + except Banner.DoesNotExist: + return self.error("Banner does not exist") diff --git a/backend/banner/views/oj.py b/backend/banner/views/oj.py new file mode 100644 index 000000000..e01c3f135 --- /dev/null +++ b/backend/banner/views/oj.py @@ -0,0 +1,17 @@ +from drf_yasg.utils import swagger_auto_schema + +from banner.models import Banner +from banner.serializers import BannerSerializer +from utils.api import APIView + + +class BannerAPI(APIView): + @swagger_auto_schema( + manual_parameters=[], + operation_description="Get Banner Image List" + ) + def get(self, request): + banners = Banner.objects.filter(visible=True) + data = {} + data["path"] = BannerSerializer(banners, many=True).data + return self.success(data) diff --git a/backend/conf/migrations/0005_alter_judgeserver_id.py b/backend/conf/migrations/0005_alter_judgeserver_id.py index b76a58fd4..b5d27f55c 100644 --- a/backend/conf/migrations/0005_alter_judgeserver_id.py +++ b/backend/conf/migrations/0005_alter_judgeserver_id.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.4 on 2021-10-30 06:35 +# Generated by Django 3.2.5 on 2021-12-26 05:58 from django.db import migrations, models diff --git a/backend/conf/tests.py b/backend/conf/tests.py index 6e7bd8038..6dcefdf9b 100644 --- a/backend/conf/tests.py +++ b/backend/conf/tests.py @@ -1,3 +1,4 @@ +import copy import hashlib from unittest import mock @@ -8,6 +9,10 @@ from utils.api.tests import APITestCase from .models import JudgeServer +from course.models import Course, Registration +from assignment.models import Assignment +from assignment.tests import DEFAULT_COURSE_DATA, DEFAULT_ASSIGNMENT_DATA + class SMTPConfigTest(APITestCase): def setUp(self): @@ -193,3 +198,21 @@ def setUp(self): def test_get_ip(self): resp = self.client.get(self.url) self.assertSuccess(resp) + + +class ProfessorDashboardInfoAPI(APITestCase): + def setUp(self): + self.url = self.reverse("professor_dashboard_info_api") + student_id = self.create_user("2020123123", "123").id + professor = self.create_admin() + course_id = Course.objects.create(created_by=professor, **DEFAULT_COURSE_DATA).id + assignment_data = copy.deepcopy(DEFAULT_ASSIGNMENT_DATA) + assignment_data["course_id"] = course_id + Assignment.objects.create(created_by=professor, **assignment_data).id + Registration.objects.create(user_id=student_id, course_id=course_id) + + def test_get_info(self): + resp = self.client.get(self.url) + self.assertSuccess(resp) + self.assertEqual(resp.data["data"]["student_count"], 1) + self.assertTrue(resp.data["data"]["underway_assignments"]["total"], 1) diff --git a/backend/conf/urls/professor.py b/backend/conf/urls/professor.py new file mode 100644 index 000000000..982c31c6e --- /dev/null +++ b/backend/conf/urls/professor.py @@ -0,0 +1,7 @@ +from django.urls import path + +from ..views import ProfessorDashboardInfoAPI + +urlpatterns = [ + path("professor_dashboard_info/", ProfessorDashboardInfoAPI.as_view(), name="professor_dashboard_info_api"), +] diff --git a/backend/conf/views.py b/backend/conf/views.py index 52262add2..2a6de0525 100644 --- a/backend/conf/views.py +++ b/backend/conf/views.py @@ -17,13 +17,16 @@ from rest_framework.parsers import MultiPartParser, JSONParser from account.models import User +from assignment.models import Assignment +from assignment.serializers import AssignmentCourseSerializer from contest.models import Contest +from course.models import Registration from judge.dispatcher import process_pending_task from options.options import SysOptions from problem.models import Problem from submission.models import Submission from utils.api import APIView, CSRFExemptAPIView, validate_serializer -from utils.decorators import super_admin_required +from utils.decorators import super_admin_required, admin_role_required from utils.shortcuts import send_email from utils.xss_filter import XSSHtml from .models import JudgeServer @@ -264,7 +267,7 @@ class ReleaseNotesAPI(APIView): @swagger_auto_schema(operation_description="Get ReleaseNotes") def get(self, request): try: - resp = requests.get("https://raw.githubusercontent.com/skku-npc/skku-coding-platform/master/backend/docs/data.json?_=" + str(time.time()), + resp = requests.get("https://raw.githubusercontent.com/skkuding/skku-coding-platform/master/backend/docs/data.json?_=" + str(time.time()), timeout=3) releases = resp.json() except (RequestException, ValueError): @@ -302,3 +305,38 @@ def get(self, request): return self.success({ "ip": ip }) + + +class ProfessorDashboardInfoAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="limit", + in_=openapi.IN_QUERY, + description="Number of assignments to show", + type=openapi.TYPE_STRING, + default=10, + ), + openapi.Parameter( + name="offset", + in_=openapi.IN_QUERY, + description="ID of the first assignment of list", + type=openapi.TYPE_STRING, + default=0, + ), + ], + operation_description="Get Dashboard information of professor page" + ) + @admin_role_required + def get(self, request): + today = datetime.today() + student_count = Registration.objects.filter(course__created_by=request.user).count() + today_submission_count = Submission.objects.filter( + assignment__created_by=request.user, + create_time__gte=datetime(today.year, today.month, today.day, 0, 0, tzinfo=pytz.UTC)).count() + underway_assignments = Assignment.objects.filter(created_by=request.user, end_time__gte=timezone.now(), start_time__lte=timezone.now()) + return self.success({ + "student_count": student_count, + "today_submission_count": today_submission_count, + "underway_assignments": self.paginate_data(request, underway_assignments, AssignmentCourseSerializer) + }) diff --git a/backend/contest/migrations/0013_contestannouncement_problem.py b/backend/contest/migrations/0013_contestannouncement_problem.py new file mode 100644 index 000000000..46c70a06d --- /dev/null +++ b/backend/contest/migrations/0013_contestannouncement_problem.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.10 on 2022-01-07 14:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('problem', '__first__'), + ('contest', '0012_rename_total_score_acmcontestrank_total_penalty'), + ] + + operations = [ + migrations.AddField( + model_name='contestannouncement', + name='problem', + field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='problem.problem'), + ), + ] diff --git a/backend/contest/migrations/0014_auto_20220302_1508.py b/backend/contest/migrations/0014_auto_20220302_1508.py new file mode 100644 index 000000000..e77580b74 --- /dev/null +++ b/backend/contest/migrations/0014_auto_20220302_1508.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.12 on 2022-03-02 06:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contest', '0013_contestannouncement_problem'), + ] + + operations = [ + migrations.AddField( + model_name='contest', + name='constraints', + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name='contest', + name='requirements', + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name='contest', + name='scoring', + field=models.TextField(default='ACM-ICPC style'), + ), + migrations.CreateModel( + name='ContestPrize', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('color', models.TextField()), + ('name', models.TextField()), + ('reward', models.TextField()), + ('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contest.contest')), + ], + ), + migrations.AddField( + model_name='acmcontestrank', + name='prize', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contest.contestprize'), + ), + migrations.AddField( + model_name='oicontestrank', + name='prize', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contest.contestprize'), + ), + ] diff --git a/backend/contest/models.py b/backend/contest/models.py index 7ca53bd6f..2f8333bec 100644 --- a/backend/contest/models.py +++ b/backend/contest/models.py @@ -7,10 +7,16 @@ from account.models import User from utils.models import RichTextField +from account.models import AdminType + class Contest(models.Model): title = models.TextField() description = RichTextField() + requirements = JSONField(default=list) + constraints = JSONField(default=list) + # allowed_groups = models.ManyToManyField(Group, blank=True) + scoring = models.TextField(default="ACM-ICPC style") # show real time rank or cached rank real_time_rank = models.BooleanField() password = models.TextField(null=True) @@ -55,10 +61,18 @@ class Meta: ordering = ("-start_time",) +class ContestPrize(models.Model): + contest = models.ForeignKey(Contest, on_delete=models.CASCADE) + color = models.TextField() + name = models.TextField() + reward = models.TextField() + + class AbstractContestRank(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) contest = models.ForeignKey(Contest, on_delete=models.CASCADE) submission_number = models.IntegerField(default=0) + prize = models.ForeignKey(ContestPrize, on_delete=models.SET_NULL, blank=True, null=True) class Meta: abstract = True @@ -73,6 +87,17 @@ class ACMContestRank(AbstractContestRank): # key is problem id submission_info = JSONField(default=dict) + @property + def rank(self): + qs_contest = Contest.objects.get(id=self.contest.id) + if qs_contest.status == ContestStatus.CONTEST_ENDED: + qs_participants = ACMContestRank.objects.filter(contest=self.contest.id, user__admin_type=AdminType.REGULAR_USER, user__is_disabled=False).\ + select_related("user").order_by("-accepted_number", "total_penalty", "total_time") + for i in range(qs_participants.count()): + if qs_participants[i].user.id == self.user.id: + return i+1 + return -1 + class Meta: db_table = "acm_contest_rank" unique_together = (("user", "contest"),) @@ -91,6 +116,7 @@ class Meta: class ContestAnnouncement(models.Model): contest = models.ForeignKey(Contest, on_delete=models.CASCADE) + problem = models.ForeignKey("problem.Problem", on_delete=models.CASCADE, default=None) title = models.TextField() content = RichTextField() created_by = models.ForeignKey(User, on_delete=models.CASCADE) diff --git a/backend/contest/serializers.py b/backend/contest/serializers.py index 356cddaee..eecdd0714 100644 --- a/backend/contest/serializers.py +++ b/backend/contest/serializers.py @@ -54,6 +54,7 @@ class Meta: class CreateContestAnnouncementSerializer(serializers.Serializer): contest_id = serializers.IntegerField() + problem_id = serializers.IntegerField() title = serializers.CharField(max_length=128) content = serializers.CharField() visible = serializers.BooleanField() @@ -61,6 +62,7 @@ class CreateContestAnnouncementSerializer(serializers.Serializer): class EditContestAnnouncementSerializer(serializers.Serializer): id = serializers.IntegerField() + problem_id = serializers.IntegerField(required=False) title = serializers.CharField(max_length=128, required=False) content = serializers.CharField(required=False, allow_blank=True) visible = serializers.BooleanField(required=False) @@ -72,18 +74,22 @@ class ContestPasswordVerifySerializer(serializers.Serializer): class ACMContestRankSerializer(serializers.ModelSerializer): - user = serializers.SerializerMethodField() + username = serializers.SerializerMethodField() class Meta: model = ACMContestRank - fields = "__all__" + fields = ["accepted_number", "total_time", "total_penalty", "submission_info", "username"] def __init__(self, *args, **kwargs): self.is_contest_admin = kwargs.pop("is_contest_admin", False) super().__init__(*args, **kwargs) - def get_user(self, obj): - return UsernameSerializer(obj.user, need_real_name=self.is_contest_admin).data + def get_username(self, obj): + data = UsernameSerializer(obj.user, need_real_name=self.is_contest_admin).data + username = data["username"] + if len(username) >= 10: + username = username[:4]+"****"+username[8:] + return username class OIContestRankSerializer(serializers.ModelSerializer): @@ -106,3 +112,11 @@ class ACMContesHelperSerializer(serializers.Serializer): problem_id = serializers.CharField() rank_id = serializers.IntegerField() checked = serializers.BooleanField() + + +class ProfileContestSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField(max_length=128) + start_time = serializers.DateTimeField() + rank = serializers.IntegerField() + percentage = serializers.FloatField() diff --git a/backend/contest/tests.py b/backend/contest/tests.py index 26a9bd28b..099adfdbd 100644 --- a/backend/contest/tests.py +++ b/backend/contest/tests.py @@ -5,7 +5,21 @@ from utils.api.tests import APITestCase -from .models import ContestAnnouncement, ContestRuleType, Contest +from .models import ContestAnnouncement, ContestRuleType, Contest, ACMContestRank +from submission.models import Submission +from problem.models import Problem, ProblemIOMode, ProblemTag + +DEFAULT_PROBLEM_DATA = {"_id": "A-110", "title": "test", "description": "

test

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

test

", "source": "test"} DEFAULT_CONTEST_DATA = {"title": "test title", "description": "test description", "start_time": timezone.localtime(timezone.now()), @@ -15,6 +29,22 @@ "allowed_ip_ranges": [], "visible": True, "real_time_rank": True} +DEFAULT_SUBMISSION_DATA = { + "problem_id": "1", + "user_id": 1, + "username": "test", + "code": "xxxxxxxxxxxxxx", + "result": -2, + "info": {}, + "language": "C", + "statistic_info": {} +} + + +DEFAULT_ACMCONTESTRANK_DATA = {"submission_number": 1, "accepted_number": 1, "total_time": 123, "total_penalty": 123, + "submission_info": {"1": {"is_ac": True, "ac_time": 123, "penalty": 123, "problem_submission": 1}}, + "contest": 1} + class ContestAdminAPITest(APITestCase): def setUp(self): @@ -103,7 +133,12 @@ def setUp(self): self.create_super_admin() self.url = self.reverse("contest_announcement_admin_api") contest_id = self.create_contest().data["data"]["id"] - self.data = {"title": "test title", "content": "test content", "contest_id": contest_id, "visible": True} + url = self.reverse("contest_problem_admin_api") + data = copy.deepcopy(DEFAULT_PROBLEM_DATA) + data["contest_id"] = contest_id + self.problem = self.client.post(url, data=data).data["data"] + problem_id = self.problem["id"] + self.data = {"title": "test title", "content": "test content", "contest_id": contest_id, "visible": True, "problem_id": problem_id} def create_contest(self): url = self.reverse("contest_admin_api") @@ -140,11 +175,53 @@ def setUp(self): def create_contest_announcements(self): contest_id = self.client.post(self.reverse("contest_admin_api"), data=DEFAULT_CONTEST_DATA).data["data"]["id"] url = self.reverse("contest_announcement_admin_api") - self.client.post(url, data={"title": "test title1", "content": "test content1", "contest_id": contest_id}) - self.client.post(url, data={"title": "test title2", "content": "test content2", "contest_id": contest_id}) + self.client.post(url, data={"title": "test title1", "content": "test content1", "contest_id": contest_id, "problem_id": "1"}) + self.client.post(url, data={"title": "test title2", "content": "test content2", "contest_id": contest_id, "problem_id": "1"}) return contest_id def test_get_contest_announcement_list(self): contest_id = self.create_contest_announcements() response = self.client.get(self.url, data={"contest_id": contest_id}) self.assertSuccess(response) + + +class UserContestAPITest(APITestCase): + def setUp(self): + # create contest + admin = self.create_admin() + self.contest = Contest.objects.create(created_by=admin, **DEFAULT_CONTEST_DATA) + + # create problem in contest + data = copy.deepcopy(DEFAULT_PROBLEM_DATA) + data["contest_id"] = self.contest.id + tags = data.pop("tags") + problem = Problem.objects.create(created_by=admin, **data) + + for item in tags: + try: + tag = ProblemTag.objects.get(name=item) + except ProblemTag.DoesNotExist: + tag = ProblemTag.objects.create(name=item) + problem.tags.add(tag) + + self.problem = problem + # user submit problem + user = self.create_user("test", "test123") + data = copy.deepcopy(DEFAULT_SUBMISSION_DATA) + data["contest_id"] = self.contest.id + data["problem_id"] = self.problem.id + data["user_id"] = user.id + self.submission = Submission.objects.create(**data) + + # create ACMContestRank + data = copy.deepcopy(DEFAULT_ACMCONTESTRANK_DATA) + data["user"] = user + data["contest"] = self.contest + self.rank = ACMContestRank.objects.create(**data) + + self.url = self.reverse("contest_user_api") + + # test UserContestAPI : can user get contest info which he participated and rank? + def test_get_participated_contest_list(self): + response = self.client.get(self.url) + self.assertSuccess(response) diff --git a/backend/contest/urls/oj.py b/backend/contest/urls/oj.py index 9e82b81bd..6a3f768d3 100644 --- a/backend/contest/urls/oj.py +++ b/backend/contest/urls/oj.py @@ -3,6 +3,7 @@ from ..views.oj import ContestAnnouncementListAPI from ..views.oj import ContestPasswordVerifyAPI, ContestAccessAPI from ..views.oj import ContestListAPI, ContestAPI, ContestRankAPI +from ..views.oj import UserContestAPI urlpatterns = [ path("contests/", ContestListAPI.as_view(), name="contest_list_api"), @@ -11,4 +12,5 @@ path("contest/announcement/", ContestAnnouncementListAPI.as_view(), name="contest_announcement_api"), path("contest/access/", ContestAccessAPI.as_view(), name="contest_access_api"), path("contest/rank/", ContestRankAPI.as_view(), name="contest_rank_api"), + path("contest/user/", UserContestAPI.as_view(), name="contest_user_api"), ] diff --git a/backend/contest/views/oj.py b/backend/contest/views/oj.py index 9e82647e8..efef1f806 100644 --- a/backend/contest/views/oj.py +++ b/backend/contest/views/oj.py @@ -14,6 +14,7 @@ from ..serializers import ContestAnnouncementSerializer from ..serializers import ContestSerializer, ContestPasswordVerifySerializer from ..serializers import ACMContestRankSerializer +from ..serializers import ProfileContestSerializer class ContestAnnouncementListAPI(APIView): @@ -229,3 +230,29 @@ def get(self, request): page_qs["results"] = serializer(page_qs["results"], many=True, is_contest_admin=is_contest_admin).data page_qs["results"].append(self.contest.id) return self.success(page_qs) + + +class UserContestAPI(APIView): + def get(self, request): + user = request.user + # queryset for all problems information which user submitted + qs_problems = ACMContestRank.objects.filter(user=user.id, user__admin_type=AdminType.REGULAR_USER, user__is_disabled=False) + contests = [] + for problem in qs_problems: + contest_id = problem.contest.id + try: + contest = Contest.objects.get(id=contest_id, visible=True) + contest = ContestSerializer(contest).data + total_participants = ACMContestRank.objects.filter(contest=contest_id, user__admin_type=AdminType.REGULAR_USER, user__is_disabled=False).count() + contest["rank"] = problem.rank + contest["percentage"] = round(contest["rank"]/total_participants*100, 2) + contests.append(contest) + except Contest.DoesNotExist: + return self.error("Contest does not exist") + + # priority + priority = request.GET.get("priority") + if priority: + contests = sorted(contests, key=lambda c: c[priority]) + + return self.success(ProfileContestSerializer(contests, many=True).data) diff --git a/backend/course/__init__.py b/backend/course/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/course/migrations/0001_initial.py b/backend/course/migrations/0001_initial.py new file mode 100644 index 000000000..afe8fadb8 --- /dev/null +++ b/backend/course/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 2.2.24 on 2021-07-30 13:44 + +import datetime +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField()), + ('course_code', models.TextField()), + ('class_number', models.IntegerField()), + ('registered_year', models.IntegerField()), + ('semester', models.IntegerField()), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'course', + 'ordering': ('-registered_year',), + }, + ), + migrations.CreateModel( + name='Takes', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.Course')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'takes', + }, + ), + ] diff --git a/backend/course/migrations/0002_auto_20210808_1709.py b/backend/course/migrations/0002_auto_20210808_1709.py new file mode 100644 index 000000000..40391666a --- /dev/null +++ b/backend/course/migrations/0002_auto_20210808_1709.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.24 on 2021-08-08 08:09 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course', '0001_initial'), + ] + + operations = [ + migrations.RenameModel( + old_name='Takes', + new_name='Registration', + ), + migrations.AlterModelTable( + name='registration', + table='registration', + ), + ] diff --git a/backend/course/migrations/0003_auto_20211226_1458.py b/backend/course/migrations/0003_auto_20211226_1458.py new file mode 100644 index 000000000..bc8f2bbc7 --- /dev/null +++ b/backend/course/migrations/0003_auto_20211226_1458.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.5 on 2021-12-26 05:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0002_auto_20210808_1709'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='registration', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/course/migrations/0004_registration_bookmark.py b/backend/course/migrations/0004_registration_bookmark.py new file mode 100644 index 000000000..512ba8ea4 --- /dev/null +++ b/backend/course/migrations/0004_registration_bookmark.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-12-26 07:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0003_auto_20211226_1458'), + ] + + operations = [ + migrations.AddField( + model_name='registration', + name='bookmark', + field=models.BooleanField(default=True), + ), + ] diff --git a/backend/course/migrations/__init__.py b/backend/course/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/course/models.py b/backend/course/models.py new file mode 100644 index 000000000..941fcec75 --- /dev/null +++ b/backend/course/models.py @@ -0,0 +1,25 @@ +from django.db import models + +from account.models import User + + +class Course(models.Model): + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + title = models.TextField() + course_code = models.TextField() + class_number = models.IntegerField() + registered_year = models.IntegerField() + semester = models.IntegerField() + + class Meta: + db_table = "course" + ordering = ("-registered_year",) + + +class Registration(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + bookmark = models.BooleanField(default=True) + + class Meta: + db_table = "registration" diff --git a/backend/course/serializers.py b/backend/course/serializers.py new file mode 100644 index 000000000..d20dd88be --- /dev/null +++ b/backend/course/serializers.py @@ -0,0 +1,79 @@ +from utils.api import UsernameSerializer, serializers +from account.serializers import UserAdminSerializer +from .models import Course, Registration + + +class CreateCourseSerializer(serializers.Serializer): + title = serializers.CharField(max_length=64) + course_code = serializers.CharField(max_length=64) + class_number = serializers.IntegerField() + registered_year = serializers.IntegerField() + semester = serializers.IntegerField() + + +class EditCourseSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField(max_length=64) + course_code = serializers.CharField(max_length=64) + class_number = serializers.IntegerField() + registered_year = serializers.IntegerField() + semester = serializers.IntegerField() + + +class RegisterSerializer(serializers.Serializer): + username = serializers.ListField(child=serializers.CharField(), allow_empty=False, min_length=1, max_length=None) + course_id = serializers.IntegerField() + + +class EditRegisterSerializer(serializers.Serializer): + registration_id = serializers.IntegerField() + course_id = serializers.IntegerField() + + +class RegisterErrorSerializer(serializers.Serializer): + user_not_exist = serializers.ListField(child=serializers.CharField(), allow_empty=True) + already_registered_user = serializers.ListField(child=serializers.CharField(), allow_empty=True) + + +class CourseSerializer(serializers.ModelSerializer): + + class Meta: + model = Course + fields = "__all__" + + +class CourseUsernameSerializer(serializers.ModelSerializer): + created_by = UsernameSerializer(need_real_name=True) + + class Meta: + model = Course + fields = "__all__" + + +class CourseProfessorSerializer(serializers.ModelSerializer): + created_by = UsernameSerializer() + + class Meta: + model = Course + fields = "__all__" + + +class CourseRegistrationSerializer(serializers.ModelSerializer): + course = CourseUsernameSerializer() + + class Meta: + model = Registration + fields = ("course", "bookmark",) + + +class BookmarkCourseSerializer(serializers.Serializer): + course_id = serializers.IntegerField() + bookmark = serializers.BooleanField() + + +class UserListSerializer(serializers.ModelSerializer): + user = UserAdminSerializer() + + class Meta: + model = Registration + fields = "__all__" diff --git a/backend/course/tests.py b/backend/course/tests.py new file mode 100644 index 000000000..0b0fbbca9 --- /dev/null +++ b/backend/course/tests.py @@ -0,0 +1,120 @@ +import copy +from utils.api.tests import APITestCase + +from .models import Course, Registration + +DEFAULT_COURSE_DATA = {"title": "Test", + "course_code": "TESTCODE12", + "class_number": 43, + "registered_year": 2021, + "semester": 1} + + +class CourseProfessorAPITest(APITestCase): + def setUp(self): + self.create_admin() + self.url = self.reverse("course_professor_api") + self.data = copy.deepcopy(DEFAULT_COURSE_DATA) + + def test_get_course_list(self): + self.test_create_course() + res = self.client.get(self.url) + self.assertSuccess(res) + + def test_get_course(self): + id = self.test_create_course().data["data"]["id"] + res = self.client.get(f"{self.url}?id={id}") + self.assertSuccess(res) + + def test_create_course(self): + res = self.client.post(self.url, data=self.data) + self.assertSuccess(res) + return res + + def test_edit_course(self): + id = self.test_create_course().data["data"]["id"] + update_data = {"id": id, + "title": "update title", + "course_code": "UPDATE12", + "class_number": 12, + "registered_year": 2022, + "semester": 0} + res = self.client.put(self.url, data=update_data) + self.assertSuccess(res) + res_data = res.data["data"] + for k in update_data.keys(): + self.assertEqual(res_data[k], update_data[k]) + + def test_delete_course(self): + id = self.test_create_course().data["data"]["id"] + res = self.client.delete(f"{self.url}?id={id}") + self.assertSuccess(res) + self.assertFalse(Course.objects.filter(id=id).exists()) + + +class CourseStudentAPITest(APITestCase): + def setUp(self): + professor = self.create_admin() + self.course = Course.objects.create(created_by=professor, title="title", course_code="code", class_number=12, registered_year=2021, semester=0) + self.url = self.reverse("course_api") + self.user = self.create_user("user", "password") + Registration.objects.create(user_id=self.user.id, course_id=self.course.id) + + def test_get_course_list(self): + res = self.client.get(self.url) + self.assertSuccess(res) + + def test_get_course(self): + res = self.client.get(f"{self.url}?id={self.course.id}") + self.assertSuccess(res) + + +class StudentManagementAPITest(APITestCase): + def setUp(self): + self.student = self.create_user("2016313683", "password") + self.create_user("2011111111", "password") + self.create_user("2011111112", "password") + self.professor = self.create_admin() + self.course = Course.objects.create(created_by=self.professor, title="title", course_code="code", class_number=12, registered_year=2021, semester=0) + self.registration = Registration.objects.create(user_id=self.student.id, course_id=self.course.id) + self.url = self.reverse("student_management_api") + + def test_get_registraion_list(self): + res = self.client.get(f"{self.url}?course_id={self.course.id}") + self.assertSuccess(res) + + def test_get_regitstration_count(self): + res = self.client.get(f"{self.url}?course_id={self.course.id}&count=1") + self.assertTrue("total_students" in res.data["data"]) + res = self.client.get(f"{self.url}?course_id={self.course.id}&count=0") + self.assertFalse("total_students" in res.data["data"]) + + def test_create_registration(self): + res = self.client.post(self.url, data={"username": ["2011111111"], "course_id": self.course.id}) + self.assertSuccess(res) + + def test_create_multiple_registration(self): + res = self.client.post(self.url, data={"username": ["2011111111", "2011111112"], "course_id": self.course.id}) + self.assertSuccess(res) + + def test_create_registration_user_not_exist(self): + res = self.client.post(self.url, data={"username": ["123123"], "course_id": self.course.id}) + self.assertTrue(res.data["data"]["error"] is not None) + self.assertSuccess(res) + + def test_create_registration_already_registered_user(self): + res = self.client.post(self.url, data={"username": ["2016313683"], "course_id": self.course.id}) + self.assertTrue(res.data["data"]["error"] is not None) + self.assertSuccess(res) + + def test_edit_registration(self): + id = self.registration.id + course = Course.objects.create(created_by=self.professor, title="title2", course_code="code2", class_number=12, registered_year=2021, semester=1) + res = self.client.put(self.url, data={"registration_id": id, "course_id": course.id}) + self.assertSuccess(res) + + def test_delete_registration(self): + id = self.registration.id + res = self.client.delete(f"{self.url}?registration_id={id}") + self.assertSuccess(res) + self.assertFalse(Registration.objects.filter(id=id).exists()) diff --git a/backend/course/urls/__init__.py b/backend/course/urls/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/course/urls/professor.py b/backend/course/urls/professor.py new file mode 100644 index 000000000..a4593c5ac --- /dev/null +++ b/backend/course/urls/professor.py @@ -0,0 +1,9 @@ +from django.urls import path + +from ..views.professor import BookmarkCourseAPI, CourseAPI, StudentManagementAPI + +urlpatterns = [ + path("course/", CourseAPI.as_view(), name="course_professor_api"), + path("course/students/", StudentManagementAPI.as_view(), name="student_management_api"), + path("bookmark_course/", BookmarkCourseAPI.as_view(), name="bookmark_professor_api") +] diff --git a/backend/course/urls/student.py b/backend/course/urls/student.py new file mode 100644 index 000000000..30eed5702 --- /dev/null +++ b/backend/course/urls/student.py @@ -0,0 +1,8 @@ +from django.urls import path + +from ..views.student import BookmarkCourseAPI, CourseAPI + +urlpatterns = [ + path("course/", CourseAPI.as_view(), name="course_api"), + path("bookmark_course/", BookmarkCourseAPI.as_view(), name="bookmark_course_api"), +] diff --git a/backend/course/views/__init__.py b/backend/course/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/course/views/professor.py b/backend/course/views/professor.py new file mode 100644 index 000000000..1f6fd3e16 --- /dev/null +++ b/backend/course/views/professor.py @@ -0,0 +1,305 @@ +from utils.api import APIView, validate_serializer +from utils.decorators import ensure_created_by, admin_role_required + +from account.models import User +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from ..models import Course, Registration +from ..serializers import (BookmarkCourseSerializer, CourseProfessorSerializer, CourseRegistrationSerializer, CreateCourseSerializer, EditCourseSerializer, + RegisterSerializer, EditRegisterSerializer, RegisterErrorSerializer, UserListSerializer) + + +class CourseAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="id", + in_=openapi.IN_QUERY, + description="Unique ID of a course", + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="limit", + in_=openapi.IN_QUERY, + description="Number of courses to show", + type=openapi.TYPE_STRING, + default=10, + ), + openapi.Parameter( + name="offset", + in_=openapi.IN_QUERY, + description="ID of the first course of list", + type=openapi.TYPE_STRING, + default=0, + ), + ], + operation_description="Get course list or specific course generated by requesting admin", + responses={200: CourseRegistrationSerializer}, + ) + @admin_role_required + def get(self, request): + course_id = request.GET.get("id") + + if course_id: + try: + course = Course.objects.get(id=course_id) + ensure_created_by(course, request.user) + return self.success(CourseProfessorSerializer(course).data) + except Course.DoesNotExist: + return self.error("Course does not exist") + + if request.GET.get("bookmark") == "true": + courses = Registration.objects.filter(course__created_by=request.user, bookmark=True) + return self.success(self.paginate_data(request, courses, CourseRegistrationSerializer)) + + courses = Registration.objects.filter(course__created_by=request.user) + return self.success(self.paginate_data(request, courses, CourseRegistrationSerializer)) + + @swagger_auto_schema( + request_body=CreateCourseSerializer, + operation_description="Create new course", + responses={200: CourseProfessorSerializer}, + ) + @validate_serializer(CreateCourseSerializer) + @admin_role_required + def post(self, request): + data = request.data + course = Course.objects.create(title=data["title"], + course_code=data["course_code"], + class_number=data["class_number"], + created_by=request.user, + registered_year=data["registered_year"], + semester=data["semester"]) + + Registration.objects.create(course_id=course.id, user_id=request.user.id) + return self.success(CourseProfessorSerializer(course).data) + + @swagger_auto_schema( + request_body=EditCourseSerializer, + operation_description="Edit course", + responses={200: CourseProfessorSerializer}, + ) + @validate_serializer(EditCourseSerializer) + @admin_role_required + def put(self, request): + data = request.data + try: + course = Course.objects.get(id=data.pop("id")) + ensure_created_by(course, request.user) + except Course.DoesNotExist: + return self.error("Course does not exist") + + for k, v in data.items(): + setattr(course, k, v) + course.save() + return self.success(CourseProfessorSerializer(course).data) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="id", + in_=openapi.IN_QUERY, + description="Unique ID of a course", + required=True, + type=openapi.TYPE_INTEGER, + ), + ] + ) + @admin_role_required + def delete(self, request): + id = request.GET.get("id") + if not id: + return self.error("Invalid parameter, id is required") + try: + course = Course.objects.get(id=id) + ensure_created_by(course, request.user) + except Course.DoesNotExist: + return self.error("Course does not exists") + + course.delete() + return self.success() + + +class BookmarkCourseAPI(APIView): + @swagger_auto_schema( + request_body=BookmarkCourseSerializer, + operation_description="Bookmark a course" + ) + @validate_serializer(BookmarkCourseSerializer) + @admin_role_required + def put(self, request): + data = request.data + course_id = data["course_id"] + + try: + course = Course.objects.get(id=course_id) + ensure_created_by(course, request.user) + registration = Registration.objects.get(user_id=request.user.id, course_id=course_id) + except Course.DoesNotExist: + return self.error("Course does not exist") + except Registration.DoesNotExist: + return self.error("Invalid access, not registered course") + + registration.bookmark = data["bookmark"] + + registration.save() + return self.success(BookmarkCourseSerializer(registration).data) + + +class StudentManagementAPI(APIView): + @swagger_auto_schema( + request_body=RegisterSerializer, + operation_description="Register user to a course" + ) + @validate_serializer(RegisterSerializer) + @admin_role_required + def post(self, request): + data = request.data + course_id = data["course_id"] + username = data["username"] + + try: + course = Course.objects.get(id=course_id) + ensure_created_by(course, request.user) + except Course.DoesNotExist: + return self.error("Course does not exist") + + user_not_exist = [] + already_registered_user = [] + + for user in username: + try: + user_id = User.objects.get(username=user).id + except User.DoesNotExist: + user_not_exist.append(user) + continue + + try: + Registration.objects.get(user_id=user_id, course_id=course_id) + already_registered_user.append(user) + except Registration.DoesNotExist: + continue + + if user_not_exist or already_registered_user: + data = {"user_not_exist": user_not_exist, "already_registered_user": already_registered_user} + serialized_data = RegisterErrorSerializer(data).data + serialized_data["error"] = "User does not exist or has been already registered" + return self.success(serialized_data) + + for user in username: + user_id = User.objects.get(username=user).id + Registration.objects.create(user_id=user_id, course_id=course_id) + return self.success() + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="course_id", + in_=openapi.IN_QUERY, + description="Unique ID of a course", + required=True, + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="count", + in_=openapi.IN_QUERY, + description="Return number of total registered user if 1( { 'total_students': registration count } )", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + name="limit", + in_=openapi.IN_QUERY, + description="Number of registration to show", + type=openapi.TYPE_STRING, + default=10, + ), + openapi.Parameter( + name="offset", + in_=openapi.IN_QUERY, + description="ID of the first registration of list", + type=openapi.TYPE_STRING, + default=0, + ), + ], + operation_description="Get registered student list, if parameter count is given as 1, total number of students of the course is returned", + responses={200: UserListSerializer}, + ) + @admin_role_required + def get(self, request): + course_id = request.GET.get("course_id") + get_students_count = request.GET.get("count") + + if not course_id: + return self.error("Invalid parameter, course_id is required") + + try: + course = Course.objects.get(id=course_id) + ensure_created_by(course, request.user) + except Course.DoesNotExist: + return self.error("Course does not exist") + + registration = Registration.objects.filter(course_id=course_id) + + # Return number of total registered students + if get_students_count == "1": + return self.success({"total_students": registration.count()}) + return self.success(self.paginate_data(request, registration, UserListSerializer)) + + @swagger_auto_schema( + request_body=EditRegisterSerializer, + operation_description="Change registered course of a user", + responses={200: UserListSerializer}, + ) + @validate_serializer(EditRegisterSerializer) + @admin_role_required + def put(self, request): + data = request.data + course_id = data["course_id"] + + try: + registration = Registration.objects.get(id=data.pop("registration_id")) + except Registration.DoesNotExist: + return self.error("Register information does not exist") + + try: + course = Course.objects.get(id=course_id) + ensure_created_by(course, request.user) + except Course.DoesNotExist: + return self.error("Course does not exist") + + try: + Registration.objects.get(user_id=registration.user_id, course_id=course_id) + return self.error("User has been already registered to the course") + except Registration.DoesNotExist: + for k, v in data.items(): + setattr(registration, k, v) + registration.save() + return self.success(UserListSerializer(registration).data) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="registration_id", + in_=openapi.IN_QUERY, + description="Id of registration to delete", + required=True, + type=openapi.TYPE_INTEGER, + ) + ], + operation_description="Delete a registered user", + ) + @admin_role_required + def delete(self, request): + id = request.GET.get("registration_id") + if not id: + return self.error("Invalid parameter, registration_id is required") + + try: + registration = Registration.objects.get(id=id) + course = Course.objects.get(id=registration.course_id) + ensure_created_by(course, request.user) + except Registration.DoesNotExist: + return self.error("Register information does not exists") + + registration.delete() + return self.success() diff --git a/backend/course/views/student.py b/backend/course/views/student.py new file mode 100644 index 000000000..483d02661 --- /dev/null +++ b/backend/course/views/student.py @@ -0,0 +1,88 @@ +from utils.api import APIView, validate_serializer +from utils.decorators import login_required +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from ..models import Course, Registration +from ..serializers import BookmarkCourseSerializer, CourseRegistrationSerializer + + +class CourseAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="id", + in_=openapi.IN_QUERY, + description="Unique ID of a course", + requied=True, + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + name="bookmark", + in_=openapi.IN_QUERY, + description="True for get bookmark list", + type=openapi.TYPE_BOOLEAN + ), + openapi.Parameter( + name="limit", + in_=openapi.IN_QUERY, + description="Number of courses to show", + type=openapi.TYPE_STRING, + default=10, + ), + openapi.Parameter( + name="offset", + in_=openapi.IN_QUERY, + description="ID of the first course of list", + type=openapi.TYPE_STRING, + default=0, + ), + ], + operation_description="Get registered course list of requesting user", + responses={200: CourseRegistrationSerializer}, + ) + @login_required + def get(self, request): + course_id = request.GET.get("id") + user_id = request.user.id + + if course_id: + try: + Course.objects.get(id=course_id) + course = Registration.objects.get(user_id=user_id, course_id=course_id) + return self.success(CourseRegistrationSerializer(course).data) + except Course.DoesNotExist: + return self.error("Course does not exist") + except Registration.DoesNotExist: + return self.error("Invalid access, not registered user") + + courses = Registration.objects.filter(user_id=user_id) + + if request.GET.get("bookmark") == "true": + courses = courses.filter(bookmark=True) + + return self.success(self.paginate_data(request, courses, CourseRegistrationSerializer)) + + +class BookmarkCourseAPI(APIView): + @swagger_auto_schema( + request_body=BookmarkCourseSerializer, + operation_description="Bookmark a course" + ) + @validate_serializer(BookmarkCourseSerializer) + @login_required + def put(self, request): + data = request.data + course_id = data["course_id"] + + try: + Course.objects.get(id=course_id) + registration = Registration.objects.get(user_id=request.user.id, course_id=course_id) + except Course.DoesNotExist: + return self.error("Course does not exist") + except Registration.DoesNotExist: + return self.error("Invalid access, not registered course") + + registration.bookmark = data["bookmark"] + + registration.save() + return self.success(BookmarkCourseSerializer(registration).data) diff --git a/backend/deploy/requirements.txt b/backend/deploy/requirements.txt index 9adf39f70..5daa81f10 100644 --- a/backend/deploy/requirements.txt +++ b/backend/deploy/requirements.txt @@ -6,7 +6,7 @@ click==8.0.1 coreapi==2.3.3 coreschema==0.0.4 coverage==5.4 -Django==3.2.10 +Django==3.2.12 django-dbconn-retry==0.1.5 django-dramatiq==0.10.0 django-redis==4.12.1 @@ -29,7 +29,7 @@ jsonschema==3.2.0 MarkupSafe==1.1.1 mccabe==0.6.1 packaging==20.9 -Pillow==8.3.2 +Pillow==9.0.1 prometheus-client==0.9.0 psycopg2-binary==2.8.6 pycodestyle==2.8.0 diff --git a/backend/group/__init__.py b/backend/group/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/group/migrations/0001_initial.py b/backend/group/migrations/0001_initial.py new file mode 100644 index 000000000..d21d9b325 --- /dev/null +++ b/backend/group/migrations/0001_initial.py @@ -0,0 +1,75 @@ +# Generated by Django 3.2.11 on 2022-02-02 17:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import utils.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Group', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField()), + ('short_description', models.TextField()), + ('description', utils.models.RichTextField()), + ('is_official', models.BooleanField()), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('last_update_time', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'group', + }, + ), + migrations.CreateModel( + name='GroupRegistrationRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField()), + ('short_description', models.TextField()), + ('description', utils.models.RichTextField()), + ('is_official', models.BooleanField()), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'group_registration_request', + }, + ), + migrations.CreateModel( + name='GroupMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_admin', models.BooleanField()), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='group.group')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='GroupApplication', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', utils.models.RichTextField()), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='group.group')), + ], + options={ + 'db_table': 'group_application', + }, + ), + migrations.AddField( + model_name='group', + name='members', + field=models.ManyToManyField(related_name='groups', through='group.GroupMember', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/group/migrations/0002_alter_groupmember_is_admin.py b/backend/group/migrations/0002_alter_groupmember_is_admin.py new file mode 100644 index 000000000..39689ed19 --- /dev/null +++ b/backend/group/migrations/0002_alter_groupmember_is_admin.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-02-02 17:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('group', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='groupmember', + name='is_admin', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/group/migrations/0003_auto_20220203_2234.py b/backend/group/migrations/0003_auto_20220203_2234.py new file mode 100644 index 000000000..a5d843d20 --- /dev/null +++ b/backend/group/migrations/0003_auto_20220203_2234.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.11 on 2022-02-03 13:34 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('group', '0002_alter_groupmember_is_admin'), + ] + + operations = [ + migrations.RenameModel( + old_name='GroupApplication', + new_name='GroupMemberJoin', + ), + migrations.AlterModelTable( + name='groupmemberjoin', + table='group_member_join', + ), + ] diff --git a/backend/group/migrations/0004_rename_is_admin_groupmember_is_group_admin.py b/backend/group/migrations/0004_rename_is_admin_groupmember_is_group_admin.py new file mode 100644 index 000000000..d33279d66 --- /dev/null +++ b/backend/group/migrations/0004_rename_is_admin_groupmember_is_group_admin.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-02-05 01:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('group', '0003_auto_20220203_2234'), + ] + + operations = [ + migrations.RenameField( + model_name='groupmember', + old_name='is_admin', + new_name='is_group_admin', + ), + ] diff --git a/backend/group/migrations/__init__.py b/backend/group/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/group/models.py b/backend/group/models.py new file mode 100644 index 000000000..b6360efab --- /dev/null +++ b/backend/group/models.py @@ -0,0 +1,52 @@ +# from django.forms import ImageField +from django.db import models + +from account.models import User +from utils.models import RichTextField + + +class GroupRegistrationRequest(models.Model): + name = models.TextField() + short_description = models.TextField() + description = RichTextField() + is_official = models.BooleanField() + + create_time = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + + class Meta: + db_table = "group_registration_request" + + +class Group(models.Model): + name = models.TextField() + short_description = models.TextField() + description = RichTextField() + is_official = models.BooleanField() + # logo = ImageField() + + created_by = models.ForeignKey(User, on_delete=models.PROTECT) + + create_time = models.DateTimeField(auto_now_add=True) + last_update_time = models.DateTimeField(auto_now=True) + + members = models.ManyToManyField(User, related_name="groups", through="GroupMember", through_fields=("group", "user")) + + class Meta: + db_table = "group" + + +class GroupMember(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + group = models.ForeignKey(Group, on_delete=models.CASCADE) + + is_group_admin = models.BooleanField(default=False) + + +class GroupMemberJoin(models.Model): + group = models.ForeignKey(Group, on_delete=models.CASCADE) + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + description = RichTextField() + + class Meta: + db_table = "group_member_join" diff --git a/backend/group/serializers.py b/backend/group/serializers.py new file mode 100644 index 000000000..fe0723bd1 --- /dev/null +++ b/backend/group/serializers.py @@ -0,0 +1,59 @@ +from .models import GroupMemberJoin, GroupMember, GroupRegistrationRequest, Group +from utils.api import serializers, UsernameSerializer + + +class CreateGroupRegistrationRequestSerializer(serializers.Serializer): + name = serializers.CharField() + short_description = serializers.CharField() + description = serializers.CharField() + is_official = serializers.BooleanField() + # logo = serializers.ImageField() + + +class GroupRegistrationRequestSerializer(serializers.ModelSerializer): + class Meta: + model = GroupRegistrationRequest + fields = "__all__" + + +class GroupSummarySerializer(serializers.ModelSerializer): + # logo = serializers.ImageField() + + class Meta: + model = Group + fields = ["name", "short_description", "id"] + + +class GroupDetailSerializer(serializers.ModelSerializer): + created_by = UsernameSerializer() + + class Meta: + model = Group + fields = "__all__" + + +class CreateGroupMemberJoinSerializer(serializers.Serializer): + group_id = serializers.IntegerField() + description = serializers.CharField() + + +class GroupMemberJoinSerializer(serializers.ModelSerializer): + created_by = UsernameSerializer() + + class Meta: + model = GroupMemberJoin + fields = "__all__" + + +class EditGroupMemberPermissionSerializer(serializers.Serializer): + user_id = serializers.IntegerField() + group_id = serializers.IntegerField() + is_group_admin = serializers.BooleanField() + + +class GroupMemberSerializer(serializers.ModelSerializer): + user = UsernameSerializer() + + class Meta: + model = GroupMember + fields = "__all__" diff --git a/backend/group/tests.py b/backend/group/tests.py new file mode 100644 index 000000000..f75584641 --- /dev/null +++ b/backend/group/tests.py @@ -0,0 +1,225 @@ +from utils.api.tests import APITestCase +from .models import GroupRegistrationRequest, Group + + +class GroupRegistrationRequestAPITest(APITestCase): + def setUp(self): + self.create_super_admin() + self.url = self.reverse("group_registration_request_api") + self.data = { + "name": "SKKUding", + "short_description": "post group registration request", + "description": "post group registration request", + "is_official": "true" + } + + def test_create_group_registration_request(self): + res = self.client.post(self.url, data=self.data) + self.assertSuccess(res) + + def test_create_registration_request_duplicate_group_name(self): + self.client.post(self.url, data=self.data) + res = self.client.post(self.url, data=self.data) + self.assertFailed(res, msg="Duplicate group name") + + +class AdminGroupRegistrationRequestAPITest(APITestCase): + def setUp(self): + super_admin = self.create_super_admin() + self.url = self.reverse("group_registration_request_admin_api") + self.group_registration_request = GroupRegistrationRequest.objects.create( + created_by=super_admin, + name="SKKUding", + short_description="post group registration request", + description="post group registration request", + is_official=True + ) + + def test_get_admin_group_registration_request(self): + res = self.client.get(self.url) + self.assertSuccess(res) + + def test_delete_group_registration_request_accept(self): + res = self.client.delete(self.url + "?accept=True&request_id=" + str(self.group_registration_request.id)) + self.assertSuccess(res) + + def test_delete_group_registration_request_reject(self): + res = self.client.delete(self.url + "?accept=False&request_id=" + str(self.group_registration_request.id)) + self.assertSuccess(res) + + def test_delete_group_registration_double_reject_fail(self): + self.client.delete(self.url + "?accept=False&request_id=" + str(self.group_registration_request.id)) + res = self.client.delete(self.url + "?accept=False&request_id=" + str(self.group_registration_request.id)) + self.assertFailed(res, msg="Invalid group registration request id") + + def test_delete_group_registration_double_accept_fail(self): + self.client.delete(self.url + "?accept=True&request_id=" + str(self.group_registration_request.id)) + res = self.client.delete(self.url + "?accept=True&request_id=" + str(self.group_registration_request.id)) + self.assertFailed(res, msg="Invalid group registration request id") + + +class GroupAPITest(APITestCase): + def setUp(self): + admin = self.create_admin() + user = self.create_user("user", "useruser") + super_admin = self.create_super_admin() + + self.url = self.reverse("group_api") + group_admin = Group.objects.create( + created_by=super_admin, + name="SKKUding", + short_description="post group registration request", + description="post group registration request", + is_official=True + ) + group_admin.members.add(super_admin, through_defaults={"is_group_admin": True}) + + group = Group.objects.create( + created_by=admin, + name="AdminClub", + short_description="post group registration request", + description="post group registration request", + is_official=True + ) + group.members.add(admin, through_defaults={"is_group_admin": False}) + group.members.add(super_admin) + + other_group = Group.objects.create( + created_by=user, + name="UserClub", + short_description="post group registration request", + description="post group registration request", + is_official=False + ) + other_group.members.add(user) + + def test_get_group_list(self): + res = self.client.get(self.url) + self.assertSuccess(res) + + +class GroupDetailAPITest(APITestCase): + def setUp(self): + super_admin = self.create_super_admin() + group = Group.objects.create( + created_by=super_admin, + name="SKKUding", + short_description="post group registration request", + description="post group registration request", + is_official=True + ) + group.members.add(super_admin, through_defaults={"is_group_admin": True}) + self.url = self.reverse("group_api") + "?id=" + str(group.id) + + def test_get_group_detail(self): + res = self.client.get(self.url) + self.assertSuccess(res) + + +class GroupMemberAPITest(APITestCase): + def setUp(self): + admin = self.create_admin() + super_admin = self.create_super_admin() + + group = Group.objects.create( + created_by=super_admin, + name="SKKUding", + short_description="post group registration request", + description="post group registration request", + is_official=True + ) + group.members.add(super_admin, through_defaults={"is_group_admin": True}) + group.members.add(admin) + self.url = self.reverse("group_member_api") + + self.group_id = group.id + self.admin_id = admin.id + self.super_admin_id = super_admin.id + + def test_change_group_permission_into_admin(self): + res = self.client.put(self.url, data={ + "group_id": self.group_id, + "user_id": self.admin_id, + "is_group_admin": True + }) + self.assertSuccess(res) + + def test_change_group_permission_into_common(self): + res = self.client.put(self.url, data={ + "group_id": self.group_id, + "user_id": self.admin_id, + "is_group_admin": False + }) + self.assertSuccess(res) + + def test_change_group_permission_of_creator_into_common_fail(self): + self.client.login(username="admin", password="admin") + res = self.client.put(self.url, data={ + "group_id": self.group_id, + "user_id": self.super_admin_id, + "is_group_admin": False + }) + self.assertFailed(res) + + def test_delete_group_member_success(self): + res = self.client.delete(self.url + "?group_id={}&user_id={}".format(self.group_id, self.admin_id)) + self.assertSuccess(res) + + def test_delete_group_member_no_permission_fail(self): + res = self.client.delete(self.url + "?group_id={}&user_id={}".format(self.group_id, self.super_admin_id)) + self.assertFailed(res) + + def test_delete_group_member_cannot_delete_admin_fail(self): + self.client.put(self.url, data={ + "group_id": self.group_id, + "user_id": self.admin_id, + "is_group_admin": True + }) + res = self.client.delete(self.url + "?group_id={}&user_id={}".format(self.group_id, self.admin_id)) + self.assertFailed(res) + + +class GroupMemberJoinAPITest(APITestCase): + def setUp(self): + super_admin = self.create_super_admin() + self.create_admin() + + group = Group.objects.create( + created_by=super_admin, + name="SKKUding", + short_description="post group registration request", + description="post group registration request", + is_official=True + ) + group.members.add(super_admin, through_defaults={"is_group_admin": True}) + + self.group_id = group.id + self.url = self.reverse("group_member_join_api") + + def test_post_group_member_join(self): + self.client.login(username="admin", password="admin") + res = self.client.post(self.url, data={ + "group_id": self.group_id, + "description": "I have to be in there!" + }) + self.assertSuccess(res) + + def test_delete_group_member_join_accept(self): + self.client.login(username="admin", password="admin") + member_join = self.client.post(self.url, data={ + "group_id": self.group_id, + "description": "I have to be in there!" + }) + self.client.login(username="root", password="root") + res = self.client.delete("{}?group_id={}&member_join_id={}&accept={}".format(self.url, self.group_id, member_join.data["data"]["id"], True)) + self.assertSuccess(res) + + def test_delete_group_member_join_reject(self): + self.client.login(username="admin", password="admin") + member_join = self.client.post(self.url, data={ + "group_id": self.group_id, + "description": "I have to be in there!" + }) + self.client.login(username="root", password="root") + res = self.client.delete("{}?group_id={}&member_join_id={}&accept={}".format(self.url, self.group_id, member_join.data["data"]["id"], False)) + self.assertSuccess(res) diff --git a/backend/group/urls/__init__.py b/backend/group/urls/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/group/urls/admin.py b/backend/group/urls/admin.py new file mode 100644 index 000000000..5753d3ef2 --- /dev/null +++ b/backend/group/urls/admin.py @@ -0,0 +1,7 @@ +from django.urls import path +from ..views.admin import AdminGroupRegistrationRequestAPI + + +urlpatterns = [ + path("group/registration_request/", AdminGroupRegistrationRequestAPI.as_view(), name="group_registration_request_admin_api") +] diff --git a/backend/group/urls/oj.py b/backend/group/urls/oj.py new file mode 100644 index 000000000..6b7da6410 --- /dev/null +++ b/backend/group/urls/oj.py @@ -0,0 +1,10 @@ +from django.urls import path +from ..views.oj import GroupMemberJoinAPI, GroupMemberAPI, GroupRegistrationRequestAPI, GroupAPI + + +urlpatterns = [ + path("group/registration_request/", GroupRegistrationRequestAPI.as_view(), name="group_registration_request_api"), + path("group/", GroupAPI.as_view(), name="group_api"), + path("group/member_join/", GroupMemberJoinAPI.as_view(), name="group_member_join_api"), + path("group/member/", GroupMemberAPI.as_view(), name="group_member_api") +] diff --git a/backend/group/views/__init__.py b/backend/group/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/group/views/admin.py b/backend/group/views/admin.py new file mode 100644 index 000000000..6f640d97e --- /dev/null +++ b/backend/group/views/admin.py @@ -0,0 +1,74 @@ +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from utils.decorators import super_admin_required + +# from group.models import Group, GroupMemberJoin +from group.serializers import GroupRegistrationRequestSerializer, GroupDetailSerializer +from utils.api import APIView + +from ..models import GroupRegistrationRequest, Group + + +class AdminGroupRegistrationRequestAPI(APIView): + @swagger_auto_schema( + manual_parameters=[], + operation_description="Request to group registration", + responses={200: GroupRegistrationRequestSerializer(many=True)} + ) + @super_admin_required + def get(self, request): + group_registration_requests = GroupRegistrationRequest.objects.all() + return self.success(GroupRegistrationRequestSerializer(group_registration_requests, many=True).data) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="request_id", + in_=openapi.IN_QUERY, + description="Unique ID of a registration request.", + type=openapi.TYPE_INTEGER, + required=True + ), + openapi.Parameter( + name="accept", + in_=openapi.IN_QUERY, + description="if accept, accept registration request and create group. else, just delete registration request", + type=openapi.TYPE_BOOLEAN, + required=True + ) + ], + operation_description="if accept is true, accept registration request and create group.\ + else, just delete registration request. \ + Be careful if accept is not given, also considered as accepted=False", + responses={200: GroupDetailSerializer} + ) + @super_admin_required + def delete(self, request): + request_id = request.GET.get("request_id") + accept = request.GET.get("accept") + + if not accept: + try: + GroupRegistrationRequest.objects.get(id=request_id).delete() + return self.success("Successfully deleted group registration request") + except GroupRegistrationRequest.DoesNotExist: + return self.error("Invalid group registration request id") + + try: + group_registration_request = GroupRegistrationRequest.objects.get(id=request_id) + except GroupRegistrationRequest.DoesNotExist: + return self.error("Invalid group registration request id") + creator = group_registration_request.created_by + group = Group.objects.create( + name=group_registration_request.name, + short_description=group_registration_request.short_description, + description=group_registration_request.description, + is_official=group_registration_request.is_official, + + created_by=creator + ) + group.members.add(creator, through_defaults={"is_group_admin": True}) + GroupRegistrationRequest.objects.get(id=request_id).delete() + + return self.success(GroupDetailSerializer(group).data) diff --git a/backend/group/views/oj.py b/backend/group/views/oj.py new file mode 100644 index 000000000..0f27e3bf9 --- /dev/null +++ b/backend/group/views/oj.py @@ -0,0 +1,245 @@ +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from group.serializers import CreateGroupMemberJoinSerializer, EditGroupMemberPermissionSerializer, GroupMemberJoinSerializer, GroupDetailSerializer, GroupMemberSerializer +from group.serializers import GroupRegistrationRequestSerializer, GroupSummarySerializer, CreateGroupRegistrationRequestSerializer +from utils.api import APIView, validate_serializer +from utils.decorators import check_group_admin + +from django.db.models import Q +from ..models import GroupMemberJoin, GroupMember, GroupRegistrationRequest, Group + + +class GroupRegistrationRequestAPI(APIView): + @swagger_auto_schema( + request_body=CreateGroupRegistrationRequestSerializer, + operation_description="Request to register a group", + responses={200: GroupRegistrationRequestSerializer} + ) + @validate_serializer(CreateGroupRegistrationRequestSerializer) + def post(self, request): + user = request.user + if not user.is_authenticated: + return self.error("Login First") + + data = request.data + name = data["name"] + + if GroupRegistrationRequest.objects.filter(name=name).exists() or Group.objects.filter(name=name).exists(): + return self.error("Duplicate group name") + + registration_request = GroupRegistrationRequest.objects.create( + name=name, + short_description=data["short_description"], + description=data["description"], + is_official=data["is_official"], + created_by=request.user + ) + return self.success(GroupRegistrationRequestSerializer(registration_request).data) + + +class GroupAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="id", + in_=openapi.IN_QUERY, + description="Unique ID of a group. if id is not given, return group list, else return detail of a group", + type=openapi.TYPE_INTEGER, + required=False + ), + ], + operation_description="Get group list or detail of a group" + ) + def get(self, request): + user = request.user + if not user.is_authenticated: + return self.error("Login First") + + group_id = request.GET.get("id") + + # Group List + if not group_id: + groups_not_admin = Group.objects.filter(groupmember__is_group_admin=False, groupmember__user=user) + groups_admin = Group.objects.filter(groupmember__is_group_admin=True, groupmember__user=user) + other_groups = Group.objects.exclude(Q(members=user)) + + data = {} + data["admin_groups"] = GroupSummarySerializer(groups_not_admin, many=True).data + data["groups"] = GroupSummarySerializer(groups_admin, many=True).data + data["other_groups"] = GroupSummarySerializer(other_groups, many=True).data + + return self.success(data) + + # Group Detail + try: + group = Group.objects.get(id=group_id) + except Group.DoesNotExist: + return self.error("Group does not exist") + data = GroupDetailSerializer(group).data + + data["members"] = GroupMemberSerializer(GroupMember.objects.filter(group=group_id), many=True).data + + if GroupMember.objects.filter(is_group_admin=True, group=group, user=user).exists(): + group_member_join = GroupMemberJoin.objects.filter(group=group) + data["group_member_join"] = GroupMemberJoinSerializer(group_member_join, many=True).data + + return self.success(data) + + +class GroupMemberAPI(APIView): + # Change User Group Permission + @swagger_auto_schema( + request_body=EditGroupMemberPermissionSerializer, + operation_description="Change group member permission. only can change is_group_admin field.", + responses={200: GroupMemberSerializer} + ) + @validate_serializer(EditGroupMemberPermissionSerializer) + @check_group_admin() + def put(self, request): + data = request.data + user = request.user + + if data["is_group_admin"]: + try: + member = GroupMember.objects.get(user=data["user_id"], group=data["group_id"]) + except GroupMember.DoesNotExist: + return self.error("Group Member does not exists") + member.is_group_admin = data["is_group_admin"] # True + member.save() + return self.success(GroupMemberSerializer(member).data) + + # Only group creator can downgrade group admin's permission. + try: + group = Group.objects.get(id=data["group_id"]) + except Group.DoesNotExist: + return self.error("Group does not exists") + if not (group.created_by.id == user.id): + return self.error("Only group creator can change group admin's permission") + + try: + member = GroupMember.objects.get(user=data["user_id"], group=data["group_id"]) + except GroupMember.DoesNotExist: + return self.error("Group member does not exist") + member.is_group_admin = data["is_group_admin"] # False + member.save() + return self.success(GroupMemberSerializer(member).data) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="user_id", + in_=openapi.IN_QUERY, + description="Unique ID of a user. not member_join(intermediary model) id.", + type=openapi.TYPE_INTEGER, + required=False + ), + openapi.Parameter( + name="group_id", + in_=openapi.IN_QUERY, + description="Unique ID of a group", + type=openapi.TYPE_INTEGER, + required=False + ), + ], + operation_description="Get group list", + responses={200: "Member successfully removed from this group."} + ) + @check_group_admin() + def delete(self, request): + user_id = request.GET.get("user_id") + group_id = request.GET.get("group_id") + + try: + member = GroupMember.objects.get(user=user_id, group=group_id) + except GroupMember.DoesNotExist: + return self.error("group member does not exist") + + if member.is_group_admin: + return self.error("Cannot remove admin member.") + + member.delete() + return self.success("Member successfully removed from this group.") + + +class GroupMemberJoinAPI(APIView): + @swagger_auto_schema( + request_body=CreateGroupMemberJoinSerializer, + operation_description="Post a group member join", + responses={200: GroupMemberJoinSerializer} + ) + @validate_serializer(CreateGroupMemberJoinSerializer) + def post(self, request): + user = request.user + if not user.is_authenticated: + return self.error("Login First") + + group_id = request.data["group_id"] + description = request.data["description"] + + if Group.objects.filter(id=group_id, members=user).exists(): + return self.error("You are already a member of this group.") + + if GroupMemberJoin.objects.filter(group=group_id, created_by=user).exists(): + return self.error("You have already submitted your member join to this group.") + + group_member_join = GroupMemberJoin.objects.create( + group_id=group_id, + description=description, + created_by=user + ) + + return self.success(GroupMemberJoinSerializer(group_member_join).data) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="group_id", in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + description="Unique id of group.", + required=True + ), + openapi.Parameter( + name="member_join_id", in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + description="Unique id of member_join", + required=True + ), + openapi.Parameter( + name="accept", in_=openapi.IN_QUERY, + type=openapi.TYPE_BOOLEAN, + description="true if accept else reject the member_join", + required=True + ), + ], + operation_description="Resolve group member join. accept=True -> accept the member to join our group. accept=False or not given -> reject the member", + responses={200: GroupDetailSerializer} + ) + @check_group_admin() + def delete(self, request): + group_id = request.GET.get("group_id") + member_join_id = request.GET.get("member_join_id") + accept = request.GET.get("accept") + + try: + group_member_join = GroupMemberJoin.objects.get(id=member_join_id) + except GroupMemberJoin.DoesNotExist: + self.error("Group member join does not exist") + + if not accept: + group_member_join.delete() + return self.success("Successfully rejected a group member join") + + group_member_join_created_by = group_member_join.created_by + try: + group = Group.objects.get(id=group_id) + except Group.DoesNotExist: + self.error("Group does not exist") + + if group.members.filter(id=group_member_join_created_by.id).exists(): + self.error("This user is already a member. This member_join may be already resolved.") + + group.members.add(group_member_join_created_by) + group_member_join.delete() + + return self.success(GroupDetailSerializer(group).data) diff --git a/backend/judge/dispatcher.py b/backend/judge/dispatcher.py index 5c191e1c0..63cbb0f56 100644 --- a/backend/judge/dispatcher.py +++ b/backend/judge/dispatcher.py @@ -94,6 +94,7 @@ def __init__(self, submission_id, problem_id): super().__init__() self.submission = Submission.objects.get(id=submission_id) self.contest_id = self.submission.contest_id + self.assignment_id = self.submission.assignment_id self.last_result = self.submission.result if self.submission.info else None if self.contest_id: @@ -107,8 +108,8 @@ def _compute_statistic_info(self, resp_data): self.submission.statistic_info["time_cost"] = max([x["cpu_time"] for x in resp_data]) self.submission.statistic_info["memory_cost"] = max([x["memory"] for x in resp_data]) - # sum up the score in OI mode - if self.problem.rule_type == ProblemRuleType.OI: + # sum up the score in OI or ASSIGNMENT mode + if self.problem.rule_type in (ProblemRuleType.OI, ProblemRuleType.ASSIGNMENT): score = 0 try: for i in range(len(resp_data)): @@ -157,7 +158,8 @@ def judge(self): if language in self.problem.template: template = parse_problem_template(self.problem.template[language]) - code = f"{template['prepend']}\n{self.submission.code}\n{template['append']}" + parse_code = parse_problem_template(self.submission.code) + code = f"{template['prepend']}\n{parse_code['template']}\n{template['append']}" else: code = self.submission.code @@ -218,7 +220,7 @@ def judge(self): with transaction.atomic(): self.update_contest_problem_status() self.update_contest_rank() - else: + elif not self.assignment_id: if self.last_result: self.update_problem_status_rejudge() else: diff --git a/backend/oj/settings.py b/backend/oj/settings.py index 8f0c39829..11154a0a2 100644 --- a/backend/oj/settings.py +++ b/backend/oj/settings.py @@ -39,6 +39,7 @@ LOCAL_APPS = [ 'account', 'announcement', + 'banner', 'conf', 'problem', 'contest', @@ -47,6 +48,9 @@ 'temperature', 'options', 'judge', + 'assignment', + 'course', + 'group' ] INSTALLED_APPS = VENDOR_APPS + LOCAL_APPS diff --git a/backend/oj/urls.py b/backend/oj/urls.py index 229436953..618af2c57 100644 --- a/backend/oj/urls.py +++ b/backend/oj/urls.py @@ -8,11 +8,22 @@ path("api/admin/", include("announcement.urls.admin")), path("api/", include("conf.urls.oj")), path("api/admin/", include("conf.urls.admin")), + path("api/professor/", include("conf.urls.professor")), path("api/", include("problem.urls.oj")), path("api/admin/", include("problem.urls.admin")), + path("api/lecture/", include("problem.urls.student")), + path("api/lecture/professor/", include("problem.urls.professor")), path("api/", include("contest.urls.oj")), path("api/admin/", include("contest.urls.admin")), path("api/", include("submission.urls")), path("api/admin/", include("utils.urls")), path("api/", include("temperature.urls")), + path("api/", include("banner.urls.oj")), + path("api/admin/", include("banner.urls.admin")), + path("api/", include("group.urls.oj")), + path("api/admin/", include("group.urls.admin")), + path("api/lecture/", include("course.urls.student")), + path("api/lecture/professor/", include("course.urls.professor")), + path("api/lecture/", include("assignment.urls.student")), + path("api/lecture/professor/", include("assignment.urls.professor")), ] diff --git a/backend/options/options.py b/backend/options/options.py index f2f7cd05f..5c727e900 100644 --- a/backend/options/options.py +++ b/backend/options/options.py @@ -111,7 +111,7 @@ class OptionDefaultValue: website_base_url = "http://127.0.0.1" website_name = "SKKU Coding Platform" website_name_shortcut = "Coding Platform" - website_footer = "SKKU Coding Platform by SKKU NPC Club" + website_footer = "SKKU Coding Platform by SKKUDING" allow_register = True submission_list_show_all = True smtp_config = {} diff --git a/backend/problem/migrations/0015_auto_20210730_2244.py b/backend/problem/migrations/0015_auto_20210730_2244.py new file mode 100644 index 000000000..5a4cdda32 --- /dev/null +++ b/backend/problem/migrations/0015_auto_20210730_2244.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.24 on 2021-07-30 13:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('assignment', '0001_initial'), + ('problem', '0014_problem_share_submission'), + ] + + operations = [ + migrations.AddField( + model_name='problem', + name='assignment', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='assignment.Assignment'), + ), + migrations.AddField( + model_name='problem', + name='type', + field=models.TextField(default='Problem'), + ), + ] diff --git a/backend/problem/migrations/0016_auto_20210801_2045.py b/backend/problem/migrations/0016_auto_20210801_2045.py new file mode 100644 index 000000000..8983aa9c1 --- /dev/null +++ b/backend/problem/migrations/0016_auto_20210801_2045.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.24 on 2021-08-01 11:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('problem', '0015_auto_20210730_2244'), + ] + + operations = [ + migrations.AlterField( + model_name='problem', + name='assignment', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='problems', to='assignment.Assignment'), + ), + ] diff --git a/backend/problem/migrations/0017_auto_20210814_1415.py b/backend/problem/migrations/0017_auto_20210814_1415.py new file mode 100644 index 000000000..dd569a9b0 --- /dev/null +++ b/backend/problem/migrations/0017_auto_20210814_1415.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-08-14 05:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('problem', '0016_auto_20210801_2045'), + ] + + operations = [ + migrations.AlterField( + model_name='problem', + name='type', + field=models.TextField(default='Problem', null=True), + ), + ] diff --git a/backend/problem/migrations/0015_auto_20211030_1535.py b/backend/problem/migrations/0018_auto_20211226_1458.py similarity index 93% rename from backend/problem/migrations/0015_auto_20211030_1535.py rename to backend/problem/migrations/0018_auto_20211226_1458.py index dd34b37cf..3a6a1b00d 100644 --- a/backend/problem/migrations/0015_auto_20211030_1535.py +++ b/backend/problem/migrations/0018_auto_20211226_1458.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.4 on 2021-10-30 06:35 +# Generated by Django 3.2.5 on 2021-12-26 05:58 from django.db import migrations, models import problem.models @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('problem', '0014_problem_share_submission'), + ('problem', '0017_auto_20210814_1415'), ] operations = [ diff --git a/backend/problem/models.py b/backend/problem/models.py index 42130fea3..9f2d2a40a 100644 --- a/backend/problem/models.py +++ b/backend/problem/models.py @@ -6,6 +6,8 @@ from utils.models import RichTextField from utils.constants import Choices +from assignment.models import Assignment + class ProblemTag(models.Model): name = models.TextField() @@ -17,6 +19,7 @@ class Meta: class ProblemRuleType(Choices): ACM = "ACM" OI = "OI" + ASSIGNMENT = "ASSIGNMENT" class ProblemDifficulty(object): @@ -41,6 +44,8 @@ def _default_io_mode(): class Problem(models.Model): # display ID _id = models.TextField(db_index=True) + # assignment ID + assignment = models.ForeignKey(Assignment, null=True, on_delete=models.CASCADE, related_name="problems") contest = models.ForeignKey(Contest, null=True, on_delete=models.CASCADE) # for contest problem is_public = models.BooleanField(default=False) @@ -85,6 +90,8 @@ class Problem(models.Model): # {JudgeStatus.ACCEPTED: 3, JudgeStaus.WRONG_ANSWER: 11}, the number means count statistic_info = JSONField(default=dict) share_submission = models.BooleanField(default=False) + # Submission type + type = models.TextField(default="Problem", null=True) class Meta: db_table = "problem" diff --git a/backend/problem/serializers.py b/backend/problem/serializers.py index d637ac9c0..b5d0d77bc 100644 --- a/backend/problem/serializers.py +++ b/backend/problem/serializers.py @@ -6,6 +6,7 @@ from utils.constants import Difficulty from utils.serializers import LanguageNameMultiChoiceField, SPJLanguageNameChoiceField, LanguageNameChoiceField +from submission.models import Submission from .models import Problem, ProblemRuleType, ProblemTag, ProblemIOMode from .utils import parse_problem_template @@ -57,7 +58,7 @@ class CreateOrEditProblemSerializer(serializers.Serializer): memory_limit = serializers.IntegerField(min_value=1, max_value=1024) languages = LanguageNameMultiChoiceField() template = serializers.DictField(child=serializers.CharField(min_length=1)) - rule_type = serializers.ChoiceField(choices=[ProblemRuleType.ACM, ProblemRuleType.OI]) + rule_type = serializers.ChoiceField(choices=[ProblemRuleType.ACM, ProblemRuleType.OI, ProblemRuleType.ASSIGNMENT]) io_mode = ProblemIOModeSerializer() spj = serializers.BooleanField() spj_language = SPJLanguageNameChoiceField(allow_blank=True, allow_null=True) @@ -93,6 +94,14 @@ class EditContestProblemSerializer(CreateOrEditProblemSerializer): contest_id = serializers.IntegerField() +class CreateAssignmentProblemSerializer(CreateOrEditProblemSerializer): + assignment_id = serializers.IntegerField() + + +class EditAssignmentProblemSerializer(CreateOrEditProblemSerializer): + id = serializers.IntegerField() + + class TagSerializer(serializers.ModelSerializer): class Meta: model = ProblemTag @@ -126,6 +135,17 @@ class Meta: fields = "__all__" +class ProblemProfessorSerializer(BaseProblemSerializer): + total_submission_assignment = serializers.SerializerMethodField() + + def get_total_submission_assignment(self, obj): + return Submission.objects.filter(problem=obj, assignment=obj.assignment).values("user_id").distinct().count() + + class Meta: + model = Problem + fields = "__all__" + + class ProblemSerializer(BaseProblemSerializer): class Meta: @@ -155,6 +175,12 @@ class AddContestProblemSerializer(serializers.Serializer): display_id = serializers.CharField() +class AddAssignmentProblemSerializer(serializers.Serializer): + assignment_id = serializers.IntegerField() + problem_id = serializers.IntegerField() + display_id = serializers.CharField() + + class ExportProblemRequestSerialzier(serializers.Serializer): problem_id = serializers.ListField(child=serializers.IntegerField(), allow_empty=False) diff --git a/backend/problem/tests.py b/backend/problem/tests.py index 5ba6501e9..381ef8204 100644 --- a/backend/problem/tests.py +++ b/backend/problem/tests.py @@ -13,6 +13,10 @@ from .models import Problem, ProblemRuleType from contest.models import Contest from contest.tests import DEFAULT_CONTEST_DATA +from course.models import Course, Registration +from course.tests import DEFAULT_COURSE_DATA +from assignment.models import Assignment +from assignment.tests import DEFAULT_ASSIGNMENT_DATA from .views.admin import TestCaseAPI from .utils import parse_problem_template @@ -260,6 +264,71 @@ def test_reguar_user_get_started_contest_problem(self): self.assertSuccess(resp) +class AssignmentProblemProfessorTest(ProblemCreateTestBase): + def setUp(self): + student = self.create_user("student", "1234") + admin = self.create_admin() + course = Course.objects.create(created_by=admin, **DEFAULT_COURSE_DATA) + Registration.objects.create(user_id=student.id, course_id=course.id) + self.assignment_id = Assignment.objects.create(created_by=admin, course=course, **DEFAULT_ASSIGNMENT_DATA).id + self.url = self.reverse("assignment_problem_professor_api") + + def test_get_assignment_problem_list(self): + self.test_create_assignment_problem() + res = self.client.get(f"{self.url}?assignment_id={self.assignment_id}") + self.assertSuccess(res) + + def test_get_assignment_problem(self): + problem_id = self.test_create_assignment_problem()["id"] + res = self.client.get(f"{self.url}?assignment_id={self.assignment_id}&problem_id={problem_id}") + self.assertSuccess(res) + + def test_create_assignment_problem(self): + data = copy.deepcopy(DEFAULT_PROBLEM_DATA) + data["assignment_id"] = self.assignment_id + res = self.client.post(self.url, data=data) + self.assertSuccess(res) + return res.data["data"] + + def test_edit_assignment_problem(self): + problem_id = self.test_create_assignment_problem()["id"] + data = copy.deepcopy(DEFAULT_PROBLEM_DATA) + data["id"] = problem_id + data["title"] = "edit test" + data["assignment"] = self.assignment_id + res = self.client.put(self.url, data=data) + self.assertSuccess(res) + self.assertEqual(res.data["data"]["title"], "edit test") + + def test_delete_assignment_problem(self): + problem_id = self.test_create_assignment_problem()["id"] + res = self.client.delete(f"{self.url}?id={problem_id}") + self.assertSuccess(res) + self.assertFalse(Problem.objects.filter(id=problem_id).exists()) + + +class AssignmentProblemStudentTest(ProblemCreateTestBase): + def setUp(self): + admin = self.create_admin() + student = self.create_user("student", "1234") + course = Course.objects.create(created_by=admin, **DEFAULT_COURSE_DATA) + Registration.objects.create(user_id=student.id, course_id=course.id) + self.assignment_id = Assignment.objects.create(created_by=admin, course=course, **DEFAULT_ASSIGNMENT_DATA).id + self.problem = self.add_problem(DEFAULT_PROBLEM_DATA, admin) + self.problem.assignment_id = self.assignment_id + self.problem.save() + self.url = self.reverse("assignment_problem_student_api") + + def test_get_assignment_problem_list(self): + res = self.client.get(f"{self.url}?assignment_id={self.assignment_id}") + self.assertSuccess(res) + + def test_getassignment_problem(self): + problem_id = self.problem._id + res = self.client.get(f"{self.url}?assignment_id={self.assignment_id}&problem_id={problem_id}") + self.assertSuccess(res) + + class AddProblemFromPublicProblemAPITest(ProblemCreateTestBase): def setUp(self): admin = self.create_admin() @@ -283,6 +352,26 @@ def test_add_contest_problem(self): self.assertTrue(Problem.objects.filter(contest_id=self.contest["id"]).exists()) +class AddAssignmentProblemFromPublicProblemAPITest(ProblemCreateTestBase): + def setUp(self): + admin = self.create_admin() + course = Course.objects.create(created_by=admin, **DEFAULT_COURSE_DATA) + self.assignment_id = Assignment.objects.create(created_by=admin, course=course, **DEFAULT_ASSIGNMENT_DATA).id + self.problem = self.add_problem(DEFAULT_PROBLEM_DATA, admin) + self.url = self.reverse("add_assignment_problem_from_public_api") + self.data = { + "display_id": "1000", + "assignment_id": self.assignment_id, + "problem_id": self.problem.id + } + + def test_add_assignment_problem(self): + resp = self.client.post(self.url, data=self.data) + self.assertSuccess(resp) + self.assertTrue(Problem.objects.all().exists()) + self.assertTrue(Problem.objects.filter(assignment_id=self.assignment_id).exists()) + + class ParseProblemTemplateTest(APITestCase): def test_parse(self): template_str = """ diff --git a/backend/problem/urls/professor.py b/backend/problem/urls/professor.py new file mode 100644 index 000000000..ca8d6a361 --- /dev/null +++ b/backend/problem/urls/professor.py @@ -0,0 +1,8 @@ +from django.urls import path + +from ..views.professor import AssignmentProblemAPI, AddAssignmentProblemAPI + +urlpatterns = [ + path("course/assignment/problem/", AssignmentProblemAPI.as_view(), name="assignment_problem_professor_api"), + path("course/assignment/add_problem_from_public/", AddAssignmentProblemAPI.as_view(), name="add_assignment_problem_from_public_api"), +] diff --git a/backend/problem/urls/student.py b/backend/problem/urls/student.py new file mode 100644 index 000000000..b6c62f312 --- /dev/null +++ b/backend/problem/urls/student.py @@ -0,0 +1,7 @@ +from django.urls import path + +from ..views.student import AssignmentProblemAPI + +urlpatterns = [ + path("course/assignment/problem/", AssignmentProblemAPI.as_view(), name="assignment_problem_student_api"), +] diff --git a/backend/problem/views/admin.py b/backend/problem/views/admin.py index 5e1dfdae9..ef48887fa 100644 --- a/backend/problem/views/admin.py +++ b/backend/problem/views/admin.py @@ -243,6 +243,8 @@ def get(self, request): if problem.contest: ensure_created_by(problem.contest, request.user) + elif problem.assignment: + ensure_created_by(problem.assignment, request.user) else: ensure_created_by(problem, request.user) @@ -300,7 +302,7 @@ def common_checks(self, request): else: data["spj_language"] = None data["spj_code"] = None - if data["rule_type"] == ProblemRuleType.OI: + if data["rule_type"] in (ProblemRuleType.OI, ProblemRuleType.ASSIGNMENT): total_score = 0 for item in data["test_case_score"]: if item["score"] <= 0: @@ -321,7 +323,7 @@ class ProblemAPI(ProblemBase): def post(self, request): data = request.data _id = data["_id"] - if Problem.objects.filter(_id=_id, contest_id__isnull=True).exists(): + if Problem.objects.filter(_id=_id, contest_id__isnull=True, assignment_id__isnull=True).exists(): return self.error("Display ID already exists") error_info = self.common_checks(request) @@ -378,7 +380,7 @@ def get(self, request): except Problem.DoesNotExist: return self.error("Problem does not exist") - problems = Problem.objects.filter(contest_id__isnull=True).order_by("-create_time") + problems = Problem.objects.filter(contest_id__isnull=True, assignment_id__isnull=True).order_by("-create_time") if rule_type: if rule_type not in ProblemRuleType.choices(): return self.error("Invalid rule_type") @@ -411,7 +413,7 @@ def put(self, request): _id = data["_id"] if not _id: return self.error("Display ID is required") - if Problem.objects.exclude(id=problem_id).filter(_id=_id, contest_id__isnull=True).exists(): + if Problem.objects.exclude(id=problem_id).filter(_id=_id, contest_id__isnull=True, assignment_id__isnull=True).exists(): return self.error("Display ID already exists") error_info = self.common_checks(request) @@ -452,7 +454,7 @@ def delete(self, request): if not id: return self.error("Invalid parameter, id is required") try: - problem = Problem.objects.get(id=id, contest_id__isnull=True) + problem = Problem.objects.get(id=id, contest_id__isnull=True, assignment_id__isnull=True) except Problem.DoesNotExist: return self.error("Problem does not exists") ensure_created_by(problem, request.user) @@ -645,7 +647,7 @@ class MakeContestProblemPublicAPIView(APIView): def post(self, request): data = request.data display_id = data.get("display_id") - if Problem.objects.filter(_id=display_id, contest_id__isnull=True).exists(): + if Problem.objects.filter(_id=display_id, contest_id__isnull=True, assignment_id__isnull=True).exists(): return self.error("Duplicate display ID") try: diff --git a/backend/problem/views/oj.py b/backend/problem/views/oj.py index 860544769..e87ebcc49 100644 --- a/backend/problem/views/oj.py +++ b/backend/problem/views/oj.py @@ -11,7 +11,7 @@ class ProblemTagAPI(APIView): @swagger_auto_schema( operation_description="Pick a set of problems that contain certain tag.", - responses=TagSerializer + responses={200: TagSerializer} ) def get(self, request): tags = ProblemTag.objects.annotate(problem_count=Count("problem")).filter(problem_count__gt=0) @@ -78,7 +78,7 @@ def get(self, request): if problem_id: try: problem = Problem.objects.select_related("created_by") \ - .get(_id=problem_id, contest_id__isnull=True, visible=True) + .get(_id=problem_id, contest_id__isnull=True, assignment_id__isnull=True, visible=True) problem_data = ProblemSerializer(problem).data self._add_problem_status(request, problem_data) return self.success(problem_data) @@ -89,7 +89,7 @@ def get(self, request): if not limit: return self.error("Limit is needed") - problems = Problem.objects.select_related("created_by").filter(contest_id__isnull=True, visible=True) + problems = Problem.objects.select_related("created_by").filter(contest_id__isnull=True, assignment_id__isnull=True, visible=True) # filter by label tag_text = request.GET.get("tag") if tag_text: diff --git a/backend/problem/views/professor.py b/backend/problem/views/professor.py new file mode 100644 index 000000000..1f24f5971 --- /dev/null +++ b/backend/problem/views/professor.py @@ -0,0 +1,216 @@ +from utils.api import APIView +from utils.api import validate_serializer +from utils.constants import AssignmentStatus +from utils.decorators import ensure_created_by, admin_role_required + +from submission.models import Submission +from assignment.models import Assignment +from .admin import ProblemBase + +from ..models import Problem, ProblemRuleType, ProblemTag +from ..serializers import (CreateAssignmentProblemSerializer, ProblemAdminSerializer, AddAssignmentProblemSerializer, + ProblemProfessorSerializer, EditAssignmentProblemSerializer) +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + + +class AssignmentProblemAPI(ProblemBase): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="problem_id", + in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + description="Unique id of problem.", + ), + openapi.Parameter( + name="assignment_id", + in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + required=True, + description="Unique id of assignment.", + ), + ], + operation_description="Get problems of certain assignment. If problem_id is set, certain problem would be returned.", + responses={200: ProblemProfessorSerializer}, + ) + @admin_role_required + def get(self, request): + problem_id = request.GET.get("problem_id") + assignment_id = request.GET.get("assignment_id") + user = request.user + if problem_id: + try: + problem = Problem.objects.get(id=problem_id) + ensure_created_by(problem.assignment, user) + except Problem.DoesNotExist: + return self.error("Problem does not exist") + return self.success(ProblemProfessorSerializer(problem).data) + + if not assignment_id: + return self.error("Invalid parameter, assignment id is required") + try: + assignment = Assignment.objects.get(id=assignment_id) + ensure_created_by(assignment, user) + except Assignment.DoesNotExist: + return self.error("Assignment does not exist") + + problems = Problem.objects.filter(assignment=assignment).order_by("create_time") + return self.success(self.paginate_data(request, problems, ProblemProfessorSerializer)) + + @swagger_auto_schema( + request_body=(CreateAssignmentProblemSerializer), + operation_description="Create a problem of assignment.", + responses={200: ProblemAdminSerializer}, + ) + @validate_serializer(CreateAssignmentProblemSerializer) + @admin_role_required + def post(self, request): + data = request.data + try: + assignment = Assignment.objects.get(id=data.pop("assignment_id")) + ensure_created_by(assignment, request.user) + except Assignment.DoesNotExist: + return self.error("Assignment does not exist") + if assignment.status == AssignmentStatus.ASSIGNMENT_ENDED: + return self.error("Assignment deadline has expired") + + _id = data["_id"] + + if Problem.objects.filter(_id=_id, assignment=assignment).exists(): + return self.error("Duplicate Display id") + + error_info = self.common_checks(request) + if error_info: + return self.error(error_info) + + data["assignment"] = assignment + tags = data.pop("tags") + data["created_by"] = request.user + problem = Problem.objects.create(**data) + + if not _id: + problem._id = problem.id + problem.save() + + for item in tags: + try: + tag = ProblemTag.objects.get(name=item) + except ProblemTag.DoesNotExist: + tag = ProblemTag.objects.create(name=item) + problem.tags.add(tag) + return self.success(ProblemAdminSerializer(problem).data) + + @validate_serializer(EditAssignmentProblemSerializer) + @admin_role_required + def put(self, request): + data = request.data + user = request.user + + problem_id = data.pop("id") + assignment_id = Problem.objects.get(id=problem_id).assignment_id + try: + assignment = Assignment.objects.get(id=assignment_id) + ensure_created_by(assignment, user) + except Assignment.DoesNotExist: + return self.error("Assignment does not exist") + + try: + problem = Problem.objects.get(id=problem_id, assignment=assignment) + except Problem.DoesNotExist: + return self.error("Problem does not exist") + + _id = data["_id"] + if not _id: + return self.error("Display ID is required") + if Problem.objects.exclude(id=problem_id).filter(_id=_id, assignment=assignment).exists(): + return self.error("Display ID already exists") + + error_info = self.common_checks(request) + if error_info: + return self.error(error_info) + + tags = data.pop("tags") + data["languages"] = list(data["languages"]) + + for k, v in data.items(): + setattr(problem, k, v) + problem.save() + + problem.tags.remove(*problem.tags.all()) + for tag in tags: + try: + tag = ProblemTag.objects.get(name=tag) + except ProblemTag.DoesNotExist: + tag = ProblemTag.objects.create(name=tag) + problem.tags.add(tag) + return self.success(ProblemAdminSerializer(problem).data) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="id", + in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + description="Unique id of problem.", + required=True + ), + ], + operation_description="Delete certain problem of assignment." + ) + @admin_role_required + def delete(self, request): + id = request.GET.get("id") + if not id: + return self.error("Invalid parameter, id is required") + try: + problem = Problem.objects.get(id=id, assignment_id__isnull=False) + ensure_created_by(problem.assignment, request.user) + except Problem.DoesNotExist: + return self.error("Problem does not exists") + + if Submission.objects.filter(problem=problem).exists(): + return self.error("Can't delete the problem as it has submissions") + + problem.delete() + return self.success() + + +class AddAssignmentProblemAPI(APIView): + @validate_serializer(AddAssignmentProblemSerializer) + @swagger_auto_schema( + request_body=AddAssignmentProblemSerializer, + operation_description="Add problems from public problems into the assignment." + ) + def post(self, request): + data = request.data + try: + assignment = Assignment.objects.get(id=data["assignment_id"]) + ensure_created_by(assignment, request.user) + problem = Problem.objects.get(id=data["problem_id"]) + except Assignment.DoesNotExist: + return self.error("Assignment does not exist") + except Problem.DoesNotExist: + return self.error("Problem does not exist") + + if assignment.status == AssignmentStatus.ASSIGNMENT_ENDED: + return self.error("Assignment deadline has expired") + if Problem.objects.filter(assignment=assignment, _id=data["display_id"]).exists(): + return self.error("Duplicate display id in this assignment") + + total_score = 0 + for item in problem.test_case_score: + total_score += item["score"] + problem.total_score = total_score + tags = problem.tags.all() + problem.pk = None + problem.assignment = assignment + problem.rule_type = ProblemRuleType.ASSIGNMENT + problem.is_public = True + problem.visible = True + problem._id = request.data["display_id"] + problem.submission_number = problem.accepted_number = 0 + problem.statistic_info = {} + problem.save() + problem.tags.set(tags) + return self.success() diff --git a/backend/problem/views/student.py b/backend/problem/views/student.py new file mode 100644 index 000000000..6d88c70a5 --- /dev/null +++ b/backend/problem/views/student.py @@ -0,0 +1,40 @@ +from utils.api import APIView +from utils.decorators import check_assignment_permission +from ..models import Problem +from ..serializers import ProblemSafeSerializer +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + + +class AssignmentProblemAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="assignment_id", in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + description="Unique id of assignment", + required=True + ), + openapi.Parameter( + name="problem_id", in_=openapi.IN_QUERY, + type=openapi.TYPE_INTEGER, + description="Unique id of problem", + ) + ], + operation_description="Get problem of specific assignment. If \'problem_id\' is not set, whole problems of the assignment would be returned.", + responses={200: ProblemSafeSerializer}, + ) + @check_assignment_permission() + 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, + assignment=self.assignment, + visible=True) + except Problem.DoesNotExist: + return self.error("Problem does not exist.") + return self.success(ProblemSafeSerializer(problem).data) + + assignment_problems = Problem.objects.select_related("created_by").filter(assignment=self.assignment, visible=True) + return self.success(self.paginate_data(request, assignment_problems, ProblemSafeSerializer)) diff --git a/backend/run_test.py b/backend/run_test.py index bcc8a3e8b..8e25f7136 100755 --- a/backend/run_test.py +++ b/backend/run_test.py @@ -25,4 +25,7 @@ ret = os.system(f'coverage run --include="$PWD/*" manage.py test {test_module} --settings={setting}') if not ret and is_coverage: - os.system("coverage html && open htmlcov/index.html") + os.system('echo "\n----------------------------------------------------------"') + os.system('echo "🌎 Open http://localhost:9000 to check coverage result."') + os.system('echo "✋ Press Ctrl + C to stop serving.\n"') + os.system("coverage html && npx --yes http-server htmlcov -s -p 9000") diff --git a/backend/submission/migrations/0013_auto_20210730_2244.py b/backend/submission/migrations/0013_auto_20210730_2244.py new file mode 100644 index 000000000..e2996b437 --- /dev/null +++ b/backend/submission/migrations/0013_auto_20210730_2244.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.24 on 2021-07-30 13:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('assignment', '0001_initial'), + ('submission', '0012_auto_20180501_0436'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='assignment', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='assignment.Assignment'), + ), + migrations.AddField( + model_name='submission', + name='score', + field=models.IntegerField(null=True), + ), + ] diff --git a/backend/submission/migrations/0014_auto_20210802_2115.py b/backend/submission/migrations/0014_auto_20210802_2115.py new file mode 100644 index 000000000..866726573 --- /dev/null +++ b/backend/submission/migrations/0014_auto_20210802_2115.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.24 on 2021-08-02 12:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('submission', '0013_auto_20210730_2244'), + ] + + operations = [ + migrations.AlterField( + model_name='submission', + name='assignment', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='assignment.Assignment'), + ), + ] diff --git a/backend/submission/migrations/0015_remove_submission_score.py b/backend/submission/migrations/0015_remove_submission_score.py new file mode 100644 index 000000000..3d970e93d --- /dev/null +++ b/backend/submission/migrations/0015_remove_submission_score.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.24 on 2021-08-08 08:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('submission', '0014_auto_20210802_2115'), + ] + + operations = [ + migrations.RemoveField( + model_name='submission', + name='score', + ), + ] diff --git a/backend/submission/migrations/0013_auto_20211030_1535.py b/backend/submission/migrations/0016_auto_20211226_1458.py similarity index 81% rename from backend/submission/migrations/0013_auto_20211030_1535.py rename to backend/submission/migrations/0016_auto_20211226_1458.py index b1a0e1b62..6a2e6ee82 100644 --- a/backend/submission/migrations/0013_auto_20211030_1535.py +++ b/backend/submission/migrations/0016_auto_20211226_1458.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.4 on 2021-10-30 06:35 +# Generated by Django 3.2.5 on 2021-12-26 05:58 from django.db import migrations, models @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('submission', '0012_auto_20180501_0436'), + ('submission', '0015_remove_submission_score'), ] operations = [ diff --git a/backend/submission/migrations/0017_auto_20220302_1508.py b/backend/submission/migrations/0017_auto_20220302_1508.py new file mode 100644 index 000000000..a4a912204 --- /dev/null +++ b/backend/submission/migrations/0017_auto_20220302_1508.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.12 on 2022-03-02 06:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submission', '0016_auto_20211226_1458'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='code_length', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='submission', + name='title', + field=models.TextField(null=True), + ), + ] diff --git a/backend/submission/models.py b/backend/submission/models.py index d1128ded8..0f27489d9 100644 --- a/backend/submission/models.py +++ b/backend/submission/models.py @@ -7,6 +7,8 @@ from utils.shortcuts import rand_str +from assignment.models import Assignment + class JudgeStatus: COMPILE_ERROR = -2 @@ -26,6 +28,7 @@ class Submission(models.Model): id = models.TextField(default=rand_str, primary_key=True, db_index=True) contest = models.ForeignKey(Contest, null=True, on_delete=models.CASCADE) problem = models.ForeignKey(Problem, on_delete=models.CASCADE) + assignment = models.ForeignKey(Assignment, null=True, on_delete=models.CASCADE, related_name="submissions") title = models.TextField(null=True) create_time = models.DateTimeField(auto_now_add=True) user_id = models.IntegerField(db_index=True) diff --git a/backend/submission/serializers.py b/backend/submission/serializers.py index 17d196dd9..5ae336673 100644 --- a/backend/submission/serializers.py +++ b/backend/submission/serializers.py @@ -1,6 +1,8 @@ from .models import Submission +from account.models import User from utils.api import serializers from utils.serializers import LanguageNameChoiceField +from utils.api._serializers import UsernameSerializer class CreateSubmissionSerializer(serializers.Serializer): @@ -8,6 +10,7 @@ class CreateSubmissionSerializer(serializers.Serializer): language = LanguageNameChoiceField() code = serializers.CharField(max_length=1024 * 1024) contest_id = serializers.IntegerField(required=False) + assignment_id = serializers.IntegerField(required=False) captcha = serializers.CharField(required=False) @@ -16,6 +19,11 @@ class ShareSubmissionSerializer(serializers.Serializer): shared = serializers.BooleanField() +class EditSubmissionScoreSerializer(serializers.Serializer): + id = serializers.CharField() + score = serializers.IntegerField(min_value=0, max_value=100) + + class SubmissionModelSerializer(serializers.ModelSerializer): problem_name = serializers.CharField(source="problem.title") @@ -30,7 +38,7 @@ class SubmissionSafeModelSerializer(serializers.ModelSerializer): class Meta: model = Submission - exclude = ("info", "contest", "ip") + exclude = ("info", "contest", "assignment", "ip") class SubmissionListSerializer(serializers.ModelSerializer): @@ -43,10 +51,25 @@ def __init__(self, *args, **kwargs): class Meta: model = Submission - exclude = ("info", "contest", "code", "ip") + exclude = ("info", "contest", "assignment", "code", "ip") def get_show_link(self, obj): # No user or anonymous user if self.user is None or not self.user.is_authenticated: return False return obj.check_user_permission(self.user) + + +class SubmissionListProfessorSerializer(serializers.ModelSerializer): + created_by = serializers.SerializerMethodField() + + def get_created_by(self, obj): + try: + user = User.objects.get(id=obj.user_id) + except User.DoesNotExist: + return None + return UsernameSerializer(user).data + + class Meta: + model = Submission + exclude = ("contest", "assignment", "shared", "ip") diff --git a/backend/submission/tests.py b/backend/submission/tests.py index 45a41b981..099ff2593 100644 --- a/backend/submission/tests.py +++ b/backend/submission/tests.py @@ -2,18 +2,14 @@ from unittest import mock from problem.models import Problem, ProblemTag +from problem.tests import DEFAULT_PROBLEM_DATA +from course.models import Course, Registration +from course.tests import DEFAULT_COURSE_DATA +from assignment.models import Assignment +from assignment.tests import DEFAULT_ASSIGNMENT_DATA from utils.api.tests import APITestCase from .models import Submission -DEFAULT_PROBLEM_DATA = {"_id": "A-110", "title": "test", "description": "

test

", "input_description": "test", - "output_description": "test", "time_limit": 1000, "memory_limit": 256, "difficulty": "Level1", - "visible": True, "tags": ["test"], "languages": ["C", "C++", "Java", "Python2"], "template": {}, - "samples": [{"input": "test", "output": "test"}], "spj": False, "spj_language": "C", - "spj_code": "", "test_case_id": "499b26290cc7994e0b497212e842ea85", - "test_case_score": [{"output_name": "1.out", "input_name": "1.in", "output_size": 0, - "stripped_output_md5": "d41d8cd98f00b204e9800998ecf8427e", - "input_size": 0, "score": 0}], - "rule_type": "ACM", "hint": "

test

", "source": "test"} DEFAULT_SUBMISSION_DATA = { "problem_id": "1", @@ -23,7 +19,7 @@ "result": -2, "info": {}, "language": "C", - "statistic_info": {} + "statistic_info": {"score": 0, "err_info": "test"} } @@ -45,6 +41,20 @@ def _create_problem_and_submission(self): self.submission_data["problem_id"] = self.problem.id self.submission = Submission.objects.create(**self.submission_data) + def _create_assignment_submission(self): + professor = self.create_admin() + self.course_id = Course.objects.create(created_by=professor, **DEFAULT_COURSE_DATA).id + assignment_data = deepcopy(DEFAULT_ASSIGNMENT_DATA) + assignment_data["course_id"] = self.course_id + self.assignment_id = Assignment.objects.create(created_by=professor, **assignment_data).id + self.problem_data = deepcopy(DEFAULT_PROBLEM_DATA) + self.problem_data["assignment_id"] = self.assignment_id + self.problem = self.client.post(self.reverse("assignment_problem_professor_api"), data=self.problem_data).data["data"] + self.submission_data = deepcopy(DEFAULT_SUBMISSION_DATA) + self.submission_data["problem_id"] = self.problem["id"] + self.submission_data["assignment_id"] = self.assignment_id + self.submission = Submission.objects.create(**self.submission_data) + class SubmissionListTest(SubmissionPrepare): def setUp(self): @@ -60,19 +70,76 @@ def test_get_submission_list(self): @mock.patch("judge.tasks.judge_task.send") class SubmissionAPITest(SubmissionPrepare): def setUp(self): - self._create_problem_and_submission() - self.user = self.create_user("123", "test123") self.url = self.reverse("submission_api") def test_create_submission(self, judge_task): + self._create_problem_and_submission() + self.create_user("123", "test123") + resp = self.client.post(self.url, self.submission_data) + self.assertSuccess(resp) + judge_task.assert_called() + + def test_create_assignment_submission(self, judge_task): + self._create_assignment_submission() + student = self.create_user("123", "test123") + Registration.objects.create(user_id=student.id, course_id=self.course_id) resp = self.client.post(self.url, self.submission_data) self.assertSuccess(resp) judge_task.assert_called() def test_create_submission_with_wrong_language(self, judge_task): + self._create_problem_and_submission() + self.create_user("123", "test123") self.submission_data.update({"language": "Python3"}) resp = self.client.post(self.url, self.submission_data) self.assertFailed(resp) self.assertDictEqual(resp.data, {"error": "error", "data": "Python3 is now allowed in the problem"}) judge_task.assert_not_called() + + +class AssignmentSubmissionListTest(SubmissionPrepare): + def setUp(self): + self._create_assignment_submission() + self.url = self.reverse("assignment_submission_list_api") + + def test_get_assignment_submission_list(self): + problem_id = self.problem["_id"] + resp = self.client.get(f"{self.url}?assignment_id={self.assignment_id}&problem_id={problem_id}") + self.assertSuccess(resp) + + def test_get_student_assignment_submission_list(self): + student = self.create_user("2020123123", "123") + Registration.objects.create(user_id=student.id, course_id=self.course_id) + self.submission_data["user_id"] = student.id + self.submission_data["username"] = student.username + Submission.objects.create(**self.submission_data) + problem_id = self.problem["_id"] + resp = self.client.get(f"{self.url}?assignment_id={self.assignment_id}&problem_id={problem_id}") + self.assertSuccess(resp) + + +class AssignmentSubmissionListProfessorTest(SubmissionPrepare): + def setUp(self): + self._create_assignment_submission() + self.url = self.reverse("assignment_submission_list_professor_api") + + def test_get_assignment_submission_list_professor(self): + assignment_id = self.assignment_id + problem_id = self.problem["id"] + resp = self.client.get(f"{self.url}?assignment_id={assignment_id}&problem_id={problem_id}") + self.assertSuccess(resp) + + +class EditSubmissionScoreTest(SubmissionPrepare): + def setUp(self): + self._create_assignment_submission() + self.url = self.reverse("edit_submission_score_api") + + def test_edit_submission_score(self): + submission_id = self.submission.id + data = {"id": submission_id, "score": 100} + resp = self.client.put(self.url, data=data) + self.assertSuccess(resp) + resp_data = resp.data["data"] + self.assertEqual(resp_data["statistic_info"]["score"], 100) diff --git a/backend/submission/urls.py b/backend/submission/urls.py index beed1ff4a..dd88cac4a 100644 --- a/backend/submission/urls.py +++ b/backend/submission/urls.py @@ -1,11 +1,16 @@ from django.urls import path -from .views import ProfileSubmissionListAPI, SubmissionAPI, SubmissionListAPI, ContestSubmissionListAPI, SubmissionExistsAPI +from .views import (SubmissionAPI, SubmissionListAPI, ContestSubmissionListAPI, AssignmentSubmissionListAPI, + AssignmentSubmissionListProfessorAPI, SubmissionExistsAPI, EditSubmissionScoreAPI) +from .views import ProfileSubmissionListAPI urlpatterns = [ path("submission/", SubmissionAPI.as_view(), name="submission_api"), path("submissions/", SubmissionListAPI.as_view(), name="submission_list_api"), path("submission_exists/", SubmissionExistsAPI.as_view(), name="submission_exists"), path("contest_submissions/", ContestSubmissionListAPI.as_view(), name="contest_submission_list_api"), + path("assignment_submissions/", AssignmentSubmissionListAPI.as_view(), name="assignment_submission_list_api"), + path("assignment_submissions_professor/", AssignmentSubmissionListProfessorAPI.as_view(), name="assignment_submission_list_professor_api"), + path("edit_submission_score/", EditSubmissionScoreAPI.as_view(), name="edit_submission_score_api"), path("profile_submissions/", ProfileSubmissionListAPI.as_view(), name="profile_submission_list_api"), ] diff --git a/backend/submission/views.py b/backend/submission/views.py index f07fb407d..827f25e4f 100644 --- a/backend/submission/views.py +++ b/backend/submission/views.py @@ -3,6 +3,7 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi +from assignment.models import Assignment from contest.models import ContestStatus, ContestRuleType from judge.tasks import judge_task from options.options import SysOptions @@ -11,14 +12,15 @@ from account.models import User, AdminType from django.db.models import Q from utils.api import APIView, validate_serializer +from utils.constants import AssignmentStatus from utils.cache import cache from utils.captcha import Captcha -from utils.decorators import login_required, check_contest_permission +from utils.decorators import login_required, check_contest_permission, admin_role_required, check_assignment_permission from utils.throttling import TokenBucket from .models import Submission from .serializers import (CreateSubmissionSerializer, SubmissionModelSerializer, - ShareSubmissionSerializer) -from .serializers import SubmissionSafeModelSerializer, SubmissionListSerializer + ShareSubmissionSerializer, SubmissionSafeModelSerializer, SubmissionListSerializer, + SubmissionListProfessorSerializer, EditSubmissionScoreSerializer) class SubmissionAPI(APIView): @@ -40,6 +42,12 @@ def check_contest_permission(self, request): if not any(user_ip in ipaddress.ip_network(cidr, strict=False) for cidr in contest.allowed_ip_ranges): return self.error("Your IP is not allowed in this contest") + @check_assignment_permission() + def check_assignment_permission(self, request): + assignment = self.assignment + if assignment.status == AssignmentStatus.ASSIGNMENT_ENDED: + return self.error("The Assignment deadline has expired") + @swagger_auto_schema(request_body=CreateSubmissionSerializer) @validate_serializer(CreateSubmissionSerializer) @login_required @@ -54,6 +62,14 @@ def post(self, request): if not contest.problem_details_permission(request.user): hide_id = True + if data.get("assignment_id"): + error = self.check_assignment_permission(request) + if error: + return error + # assignment = self.assignment + # if not assignment.problem_details_permission(request.user): + # hide_id = True + if data.get("captcha"): if not Captcha(request).check(data["captcha"]): return self.error("Invalid captcha") @@ -62,7 +78,7 @@ def post(self, request): return self.error(error) try: - problem = Problem.objects.get(id=data["problem_id"], contest_id=data.get("contest_id"), visible=True) + problem = Problem.objects.get(id=data["problem_id"], contest_id=data.get("contest_id"), assignment_id=data.get("assignment_id"), visible=True) except Problem.DoesNotExist: return self.error("Problem not exist") if data["language"] not in problem.languages: @@ -75,7 +91,8 @@ def post(self, request): problem_id=problem.id, title=problem.title, ip=request.session["ip"], - contest_id=data.get("contest_id")) + contest_id=data.get("contest_id"), + assignment_id=data.get("assignment_id")) # use this for debug # JudgeDispatcher(submission.id, problem.id).judge() judge_task.send(submission.id, problem.id) @@ -185,14 +202,14 @@ def get(self, request): if request.GET.get("contest_id"): return self.error("Parameter error") - submissions = Submission.objects.filter(contest_id__isnull=True).select_related("problem__created_by") + submissions = Submission.objects.filter(contest_id__isnull=True, assignment_id__isnull=True).select_related("problem__created_by") problem_id = request.GET.get("problem_id") myself = request.GET.get("myself") result = request.GET.get("result") username = request.GET.get("username") if problem_id: try: - problem = Problem.objects.get(_id=problem_id, contest_id__isnull=True, visible=True) + problem = Problem.objects.get(_id=problem_id, contest_id__isnull=True, assignment_id__isnull=True, visible=True) except Problem.DoesNotExist: return self.error("Problem doesn't exist") submissions = submissions.filter(problem=problem) @@ -301,6 +318,150 @@ def get(self, request): return self.success(data) +class AssignmentSubmissionListAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="limit", + in_=openapi.IN_QUERY, + required=False, + type=openapi.TYPE_INTEGER + ), + openapi.Parameter( + name="offset", + in_=openapi.IN_QUERY, + required=False, + type=openapi.TYPE_INTEGER + ), + openapi.Parameter( + name="assignment_id", + in_=openapi.IN_QUERY, + required=False, + type=openapi.TYPE_INTEGER + ), + openapi.Parameter( + name="problem_id", + in_=openapi.IN_QUERY, + required=False, + type=openapi.TYPE_INTEGER + ), + openapi.Parameter( + name="result", in_=openapi.IN_QUERY, required=False, type=openapi.TYPE_INTEGER + ), + openapi.Parameter( + name="username", in_=openapi.IN_QUERY, required=False, type=openapi.TYPE_STRING + ), + ], + operation_description="Submission list for assignment problem page", + responses={200: SubmissionListSerializer} + ) + @check_assignment_permission() + def get(self, request): + assignment = self.assignment + submissions = Submission.objects.filter(assignment_id=assignment.id).select_related("problem__created_by") + problem_id = request.GET.get("problem_id") + result = request.GET.get("result") + username = request.GET.get("username") + if problem_id: + try: + problem = Problem.objects.get(_id=problem_id, assignment_id=assignment.id, visible=True) + except Problem.DoesNotExist: + return self.error("Problem doesn't exist") + submissions = submissions.filter(problem=problem) + + if username: + submissions = submissions.filter(username__icontains=username) + if result: + submissions = submissions.filter(result=result) + + # students can only see their own submissions + if not request.user.is_assignment_admin(assignment): + submissions = submissions.filter(user_id=request.user.id) + + # filter the test submissions submitted before contest start + if assignment.status != AssignmentStatus.ASSIGNMENT_NOT_START: + submissions = submissions.filter(create_time__gte=assignment.start_time) + + data = self.paginate_data(request, submissions) + data["results"] = SubmissionListSerializer(data["results"], many=True, user=request.user).data + return self.success(data) + + +class AssignmentSubmissionListProfessorAPI(APIView): + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="limit", + in_=openapi.IN_QUERY, + required=False, + type=openapi.TYPE_INTEGER + ), + openapi.Parameter( + name="offset", + in_=openapi.IN_QUERY, + required=False, + type=openapi.TYPE_INTEGER + ), + openapi.Parameter( + name="assignment_id", + in_=openapi.IN_QUERY, + required=True, + type=openapi.TYPE_INTEGER + ), + openapi.Parameter( + name="problem_id", + in_=openapi.IN_QUERY, + required=True, + type=openapi.TYPE_INTEGER + ), + ], + operation_description="Submission list for professor page", + responses={200: SubmissionListProfessorSerializer} + ) + @admin_role_required + def get(self, request): + assignment_id = request.GET.get("assignment_id") + problem_id = request.GET.get("problem_id") + if not assignment_id: + return self.error("Invalid parameter, assignment_id is required") + if not problem_id: + return self.error("Invalid parameter, problem_id is required") + + try: + Assignment.objects.get(id=assignment_id) + Problem.objects.get(id=problem_id) + except Assignment.DoesNotExist: + return self.error("Assignment does not exist") + except Problem.DoesNotExist: + return self.error("Problem does not exist") + + submissions = Submission.objects.filter(assignment_id=assignment_id).order_by("username", "-create_time").distinct("username") + return self.success(self.paginate_data(request, submissions, SubmissionListProfessorSerializer)) + + +class EditSubmissionScoreAPI(APIView): + @swagger_auto_schema( + request_body=EditSubmissionScoreSerializer, + operation_description="Edit submission score", + responses={200: SubmissionListSerializer}, + ) + @validate_serializer(EditSubmissionScoreSerializer) + @admin_role_required + def put(self, request): + data = request.data + submission_id = data["id"] + score = data["score"] + + try: + submission = Submission.objects.get(id=submission_id) + except Submission.DoesNotExist: + return self.error("Submission does not exist") + + submission.statistic_info["score"] = score + submission.save() + return self.success(SubmissionListSerializer(submission).data) + + class SubmissionExistsAPI(APIView): @swagger_auto_schema( manual_parameters=[ diff --git a/backend/utils/api/api.py b/backend/utils/api/api.py index bced2dacb..eb1ce13f8 100644 --- a/backend/utils/api/api.py +++ b/backend/utils/api/api.py @@ -104,7 +104,7 @@ def invalid_serializer(self, serializer): def server_error(self): return self.error(err="server-error", msg="server error") - def paginate_data(self, request, query_set, object_serializer=None): + def paginate_data(self, request, query_set, object_serializer=None, context=None): """ :param request: django's request :param query_set: django model query set or other list like objects @@ -126,7 +126,10 @@ def paginate_data(self, request, query_set, object_serializer=None): results = query_set[offset:offset + limit] if object_serializer: count = query_set.count() - results = object_serializer(results, many=True).data + if not context: + results = object_serializer(results, many=True).data + else: + results = object_serializer(results, context=context, many=True).data else: count = query_set.count() data = {"results": results, diff --git a/backend/utils/constants.py b/backend/utils/constants.py index f22939058..a5e651011 100644 --- a/backend/utils/constants.py +++ b/backend/utils/constants.py @@ -21,6 +21,12 @@ class ContestRuleType(Choices): OI = "OI" +class AssignmentStatus: + ASSIGNMENT_NOT_START = "1" + ASSIGNMENT_ENDED = "-1" + ASSIGNMENT_UNDERWAY = "0" + + class CacheKey: waiting_queue = "waiting_queue" contest_rank_cache = "contest_rank_cache" diff --git a/backend/utils/decorators.py b/backend/utils/decorators.py index 0351d1b8e..18e8752ef 100644 --- a/backend/utils/decorators.py +++ b/backend/utils/decorators.py @@ -2,10 +2,13 @@ import hashlib import time +from assignment.models import Assignment +from group.models import Group, GroupMember from problem.models import Problem +from course.models import Registration from contest.models import Contest, ContestType, ContestStatus, ContestRuleType from .api import JSONResponse, APIError -from .constants import CONTEST_PASSWORD_SESSION_KEY +from .constants import CONTEST_PASSWORD_SESSION_KEY, AssignmentStatus from account.models import ProblemPermission @@ -139,6 +142,68 @@ def _check_permission(*args, **kwargs): return decorator +def check_assignment_permission(): + def decorator(func): + def _check_permission(*args, **kwargs): + self = args[0] + request = args[1] + user = request.user + if request.data.get("assignment_id"): + assignment_id = request.data["assignment_id"] + else: + assignment_id = request.GET.get("assignment_id") + if not assignment_id: + return self.error("Parameter error, assignment_id is required") + + try: + self.assignment = Assignment.objects.select_related("created_by").get(id=assignment_id, visible=True) + except Assignment.DoesNotExist: + return self.error("Assignment %s doesn't exist" % assignment_id) + + if not user.is_authenticated: + return self.error("Please login first.") + + if user.is_assignment_admin(self.assignment): + return func(*args, **kwargs) + + if self.assignment.status == AssignmentStatus.ASSIGNMENT_NOT_START: + return self.error("Assignment has not started yet.") + + # check if user is registered to the course + try: + Registration.objects.get(user_id=user.id, course_id=self.assignment.course_id) + except Registration.DoesNotExist: + return self.error("Invalid access, not registered user") + + return func(*args, **kwargs) + return _check_permission + return decorator + + +def check_group_admin(): + def decorator(func): + def _check_permission(*args, **kwargs): + self = args[0] + request = args[1] + user = request.user + + if not user.is_authenticated: + return self.error("Please login first.") + + group_id = request.data["group_id"] if request.data.get("group_id") else request.GET.get("group_id") + if not group_id: + return self.error("Parameter error, group_id is required") + + if not Group.objects.filter(id=group_id).exists(): + return self.error("Group does not exist") + if not GroupMember.objects.filter(group=group_id, user=user, is_group_admin=True).exists(): + return self.error("permission-denied: Group admin is required") + + return func(*args, **kwargs) + return _check_permission + return decorator + + def ensure_created_by(obj, user): e = APIError(msg=f"{obj.__class__.__name__} does not exist") if not user.is_admin_role(): diff --git a/backend/utils/views.py b/backend/utils/views.py index e6eee42e5..371aeaac1 100644 --- a/backend/utils/views.py +++ b/backend/utils/views.py @@ -40,7 +40,8 @@ def post(self, request): except IOError as e: logger.error(e) return self.error("Upload Error") - return self.success({"file_path": f"{settings.UPLOAD_PREFIX}/{img_name}"}) + return self.success({"file_path": f"{settings.UPLOAD_PREFIX}/{img_name}", + "img_name": img_name}) class SimditorFileUploadAPIView(CSRFExemptAPIView): @@ -72,4 +73,4 @@ def post(self, request): return self.success({ "file_path": f"{settings.UPLOAD_PREFIX}/{file_name}", "file_name": file.name - }) + }) diff --git a/docker-compose.yml b/docker-compose.yml index 3021770ac..e41bd303a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: # - judger_debug=1 coding-platform: - image: ghcr.io/skku-npc/coding-platform + image: ghcr.io/skkuding/coding-platform container_name: coding-platform restart: always depends_on: diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 9041e4514..dfc7e3ece 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -5,7 +5,8 @@ module.exports = { }, extends: [ 'plugin:vue/essential', - '@vue/standard' + '@vue/standard', + 'plugin:cypress/recommended' ], parserOptions: { parser: '@babel/eslint-parser' diff --git a/frontend/cypress.json b/frontend/cypress.json new file mode 100644 index 000000000..a2a3cc7ad --- /dev/null +++ b/frontend/cypress.json @@ -0,0 +1,3 @@ +{ + "baseUrl": "https://localhost" +} diff --git a/frontend/cypress/fixtures/example.json b/frontend/cypress/fixtures/example.json new file mode 100644 index 000000000..02e425437 --- /dev/null +++ b/frontend/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/frontend/cypress/fixtures/problem-example.json b/frontend/cypress/fixtures/problem-example.json new file mode 100644 index 000000000..379270ae6 --- /dev/null +++ b/frontend/cypress/fixtures/problem-example.json @@ -0,0 +1,71 @@ +{ + "problem": { + "_id": "A", + "title": "Boss, gambling is just for fun", + "description": "

There is a legendary British scumbag named Steven Gerrard. Yoon Seong met this sloppy man while traveling to England.

The rules of Yabawi are as follows. There are three cups and one ball. Put the ball into a random cup and keep changing the cups randomly. It just stops at some point while changing, and at that time, the audience just has to choose a cup with a ball in it.

If you hit where the ball is, you pay 10x, not 2x. Yoon-seong, who was very tempted by the reward, could not avoid placing a bet.

However, it did not become a legend of Yabawi for nothing. Steven Gerrard never places the ball where the spectator picks it up for a single game. The trick is so great that no one in the audience will notice.

Yoon-seong continues to bet, but gets tired and angry and leaves. This is the situation when -1 is entered in the problem.

Let's write a program that prints out how much money Yoon-seong threw away before he left.

", + "input_description": "

The money, in the form of a positive integer, that Yoon Seong placed on each hand is entered across the line. When the last -1 is input, the program is terminated.

", + "output_description": "

Prints out the total sum of money Yoon-seong discarded on the gambling board.

", + "time_limit": 1000, + "memory_limit": 256, + "difficulty": "Level2", + "visible": true, + "share_submission": false, + "tags": [ + "Cypress", + "Example" + ], + "languages": [ + "C", + "C++", + "Golang", + "Java", + "Python2", + "Python3" + ], + "template": {}, + "samples": [ + { + "input": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n-1", + "output": "55" + } + ], + "testcases": [ + { + "input": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n-1\n\n", + "output": "55\n" + }, + { + "input": "2147483648\n-1\n", + "output": "2147483648\n" + }, + { + "input": "4441\n6274\n1804\n2027\n6585\n9339\n2600\n8885\n9036\n5071\n7785\n189\n3098\n4302\n3743\n4805\n9237\n9867\n4153\n3364\n3694\n8992\n2925\n3668\n8829\n4313\n7348\n9411\n4413\n8596\n2298\n8434\n5805\n-1\n", + "output": "185331\n" + }, + { + "input": "5989\n1020\n3986\n1281\n5362\n1323\n3484\n5744\n3218\n8215\n3337\n279\n9531\n4625\n1660\n7936\n4091\n7886\n8394\n1305\n4175\n6502\n7308\n7525\n9037\n8534\n6400\n9531\n4283\n2014\n2569\n8021\n6533\n164\n4939\n3907\n7435\n416\n1840\n3398\n3744\n8654\n8293\n9333\n9020\n9775\n9213\n6043\n6619\n5383\n1197\n5878\n765\n6080\n6750\n9450\n2468\n9286\n1647\n828\n-1\n", + "output": "313623\n" + }, + { + "input": "7937\n1574\n7656\n6342\n4139\n3306\n4768\n5708\n7400\n5950\n377\n369\n9069\n9140\n3769\n9850\n8545\n5904\n8442\n5053\n1953\n2523\n8987\n4086\n7757\n51\n1261\n3843\n9961\n3944\n5543\n3416\n2669\n4977\n5347\n1340\n214\n357\n9246\n6394\n9155\n7139\n2748\n7521\n4296\n6586\n753\n2469\n7790\n7009\n249\n5868\n5393\n8976\n3985\n1041\n3281\n6629\n2155\n340\n1074\n4513\n2110\n9943\n5899\n2568\n9652\n9731\n9819\n2661\n-1\n", + "output": "350520\n" + } + ], + "spj": false, + "spj_language": "C", + "spj_code": "", + "spj_compile_ok": false, + "rule_type": "ACM", + "hint": "

The sum of all entered numbers does not exceed 2,147,483,647. The entered values do not exceed 2,000.

", + "source": "", + "io_mode": { + "io_mode": "Standard IO", + "input": "input.txt", + "output": "output.txt" + } + }, + "solution": { + "language": "C++", + "code": "#include \nusing namespace std;\n\n#define FastIO cin.tie(0)->sync_with_stdio(0)\n#define endl \"\\n\"\n\nusing ll = long long;\n\nll num;\nll sum;\nvoid input() {\n\tcin >> num;\n}\nvoid solve() {\n\tsum += num;\n}\nint main(void) {\n\tFastIO;\n\twhile(num>=0) {\n\t\tinput();\n\t\tsolve();\n\t}\n\tcout << sum + 1 << endl;\n\treturn 0;\n}\n" + } +} diff --git a/frontend/cypress/integration/admin/problem/admin_problem.spec.js b/frontend/cypress/integration/admin/problem/admin_problem.spec.js new file mode 100644 index 000000000..0139362d4 --- /dev/null +++ b/frontend/cypress/integration/admin/problem/admin_problem.spec.js @@ -0,0 +1,76 @@ +/// + +describe('General Problem', () => { + context('When Admin tries to go Problem Tab', () => { + before(() => { + cy.loginSuperAdmin() + cy.visit('admin') + }) + it('should go to problem list page', () => { + cy.get('.list-group-item').contains('Problem').click() + cy.get('.list-group-item').contains('Problem List').click() + cy.url().should('include', '/admin/problems') + }) + }) + context('When Admin tries to create problem', () => { + beforeEach(() => { + cy.loginSuperAdmin() + let id = -1 + cy.intercept('GET', '/api/admin/problem/*', (req) => { + req.continue((res) => { + for (const result of res.body.data.results) { + if (result._id === 'C1') { + id = result.id + res.send() + } + } + }) + }).as('getAdminProblem') + cy.visit('/admin/problems') + // TODO: Make initial state using db conmmand, not using conditional API or UI control -- This code little bit anti-pattern + cy.wait('@getAdminProblem').then(() => { + if (id !== -1) { + cy.request('DELETE', 'api/admin/problem/?id=' + id).as('initiate') + } + }) + cy.contains('+ Create').click() + }) + // TODO: Validate problem form + it('should create a new problem - with minimum fields', () => { + cy.intercept('GET', '/api/admin/problem/?*', (req) => { + req.reply() + }).as('getAdminProblem') + + cy.fixture('problem-example').then((problemExample) => { + cy.get('#input-display-id').type('C1') + cy.get('#input-title').type(problemExample.problem.title) + cy.get('[data-cy="input-description"] > .ProseMirror').invoke('text', problemExample.problem.description) + cy.get('[data-cy="input-input-description"] > .ProseMirror').invoke('text', problemExample.problem.input_description) + cy.get('[data-cy="input-output-description"] > .ProseMirror').invoke('text', problemExample.problem.output_description) + + cy.get('.tag-dropdown').contains('tag').click() + for (const tag of problemExample.problem.tags) { + cy.get('[data-cy="input-add-tag"]').type(tag) + cy.get('[data-cy="button-add-tag"]').click() + } + + cy.get('[data-cy="input-input-samples1"]').type(problemExample.problem.samples[0].input) + cy.get('[data-cy="input-output-samples1"]').type(problemExample.problem.samples[0].output) + + cy.get('[data-cy="input-input-testcase1"]').type(problemExample.problem.testcases[0].input) + cy.get('[data-cy="input-output-testcase1"]').type(problemExample.problem.testcases[0].output) + + cy.get('[data-cy="button-save"]').click() + + cy.url().should('include', '/admin/problems') + + cy.visit('admin/problems') + cy.wait('@getAdminProblem') + cy.contains('C1').parent() + .next().should('have.text', problemExample.problem.title) + .next().should('have.text', 'root') + }) + }) + // TODO: Make test that posts a problem with maximum fields + }) +}) diff --git a/frontend/cypress/integration/oj/problem/problem.spec.js b/frontend/cypress/integration/oj/problem/problem.spec.js new file mode 100644 index 000000000..e2cfeba7c --- /dev/null +++ b/frontend/cypress/integration/oj/problem/problem.spec.js @@ -0,0 +1,19 @@ +/// + +// describe -> context -> it +context('When user tries to submit a problem (not in contest)', () => { + beforeEach(() => { + cy.loginSuperAdmin() + cy.visit('problem') + }) + // TODO: make it independent from admin_problem_spec (anti-pattern) + it('Submit Code', () => { + cy.fixture('problem-example').then((problemExample) => { + cy.get('tbody > tr > .problem-title-field').contains(problemExample.problem.title).click() + cy.get('[data-cy="toggle-language"] > .dropdown-toggle-split').click() + cy.get('[data-cy="select-langauge"]').contains(problemExample.solution.language).click() + cy.get('.CodeMirror-code').click().type(problemExample.solution.code, { delay: 0 }) + cy.get('[data-cy="button-submit"]').click() + }) + }) +}) diff --git a/frontend/cypress/plugins/index.js b/frontend/cypress/plugins/index.js new file mode 100644 index 000000000..59b2bab6e --- /dev/null +++ b/frontend/cypress/plugins/index.js @@ -0,0 +1,22 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/frontend/cypress/support/commands.js b/frontend/cypress/support/commands.js new file mode 100644 index 000000000..c7b5b6119 --- /dev/null +++ b/frontend/cypress/support/commands.js @@ -0,0 +1,24 @@ +Cypress.Commands.add('loginSuperAdmin', () => { + cy.visit('') + cy.getCookie('csrftoken') + .should('exist') + .then((csrftoken) => { + cy.request({ + method: 'post', + url: 'api/login/', + body: { + username: 'root', + password: 'rootroot' + }, + headers: { + 'X-CSRFToken': csrftoken.value + } + }) + }) + const getStore = () => cy.window().its('app.$store') + getStore().then(store => { + store.dispatch('getProfile') + }) +}) + +// TODO: Register, Login by general user diff --git a/frontend/cypress/support/index.js b/frontend/cypress/support/index.js new file mode 100644 index 000000000..e90732546 --- /dev/null +++ b/frontend/cypress/support/index.js @@ -0,0 +1,43 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +import './commands' + +Cypress.Cookies.defaults({ + preserve: ['csrftoken', 'session_id'] +}) + +Cypress.Commands.overwrite('request', (originalFn, ...args) => { + let options = {} + if (typeof args[0] === 'object' && args[0] !== null) { + options = args[0] + } else if (args.length === 1) { + [options.url] = args + } else if (args.length === 2) { + [options.method, options.url] = args + } else if (args.length === 3) { + [options.method, options.url, options.body] = args + } + cy.getCookie('csrftoken') + .should('exist') + .then((csrftoken) => { + const defaults = { + headers: { + 'X-CSRFToken': csrftoken.value + } + } + return originalFn({ ...defaults, ...options, ...{ headers: { ...defaults.headers, ...options.headers } } }) + }) +}) diff --git a/frontend/package.json b/frontend/package.json index ac7b53b15..ca0457a92 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,29 +6,38 @@ "serve": "vue-cli-service serve", "build": "vue-cli-service build", "lint": "vue-cli-service lint", - "dll": "vue-cli-service dll" + "dll": "vue-cli-service dll", + "cy": "cypress" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^1.2.36", + "@fortawesome/free-regular-svg-icons": "^5.15.4", + "@fortawesome/free-solid-svg-icons": "^5.15.4", + "@fortawesome/vue-fontawesome": "^2.0.6", "@tiptap/extension-image": "^2.0.0-beta.14", "@tiptap/extension-link": "^2.0.0-beta.18", "@tiptap/starter-kit": "^2.0.0-beta.80", "@tiptap/vue-2": "^2.0.0-beta.38", + "autoprefixer": "^9.8.8", "axios": "^0.24.0", "bootstrap-vue": "^2.21.2", "browser-detect": "^0.2.28", + "chart.js": "^3.7.0", "core-js": "^3.19.3", "highlight.js": "10.7.3", "iview": "2", "katex": "^0.15.1", "moment": "^2.29.1", - "papaparse": "^5.3.0", - "turndown": "^7.0.0", + "papaparse": "^5.3.1", + "postcss": "^7.0.39", + "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.17", + "turndown": "^7.1.1", "vue": "^2.6.14", - "vue-clipboard2": "^0.3.1", + "vue-clipboard2": "^0.3.3", "vue-codemirror-lite": "^1.0.4", - "vue-dompurify-html": "^2.3.0", + "vue-dompurify-html": "^2.4.0", "vue-katex": "^0.5.0", - "vue-router": "^3.2.0", + "vue-router": "^3.5.3", "vue-simple-alert": "^1.1.1", "vuex": "^3.4.0", "vuex-router-sync": "^5.0.0" @@ -41,7 +50,9 @@ "@vue/cli-plugin-vuex": "~4.5.0", "@vue/cli-service": "~4.5.0", "@vue/eslint-config-standard": "^5.1.2", + "cypress": "^9.2.1", "eslint": "^7.5.0", + "eslint-plugin-cypress": "^2.12.1", "eslint-plugin-import": "^2.20.2", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.2.1", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..85f717cc0 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/frontend/src/assets/logos/codingPlatformLogo.png b/frontend/src/assets/logos/codingPlatformLogo.png new file mode 100644 index 000000000..685ec2645 Binary files /dev/null and b/frontend/src/assets/logos/codingPlatformLogo.png differ diff --git a/frontend/src/pages/admin/api.js b/frontend/src/pages/admin/api.js index fe3d7059e..3dcacd8cd 100644 --- a/frontend/src/pages/admin/api.js +++ b/frontend/src/pages/admin/api.js @@ -97,6 +97,26 @@ export default { data }) }, + getBannerImage (id) { + return ajax('admin/banner/', 'get', { + params: { + id + } + }) + }, + createBannerImage (data) { + return ajax('admin/banner/', 'post', { data }) + }, + editBannerImage (data) { + return ajax('admin/banner/', 'put', { data }) + }, + deleteBannerImage (id) { + return ajax('admin/banner/', 'delete', { + params: { + id + } + }) + }, getLanguages () { return ajax('languages/', 'get') }, diff --git a/frontend/src/pages/admin/components/SideMenu.vue b/frontend/src/pages/admin/components/SideMenu.vue index 28322f272..44f1d5810 100644 --- a/frontend/src/pages/admin/components/SideMenu.vue +++ b/frontend/src/pages/admin/components/SideMenu.vue @@ -37,6 +37,12 @@ > Announcement + + Banner + - + @@ -113,7 +113,7 @@ - + Insert image @@ -124,7 +124,7 @@ - + Insert File @@ -147,6 +147,10 @@ export default { value: { type: String, default: '' + }, + name: { + type: String, + default: '' } }, data () { @@ -157,7 +161,9 @@ export default { fileModal: false, fileURL: null, fileLink: null, - selectedFile: null + selectedFile: null, + disableInsertImage: true, + disableInsertFile: true } }, watch: { @@ -211,6 +217,7 @@ export default { this.selectedFile = null this.fileURL = null this.imageModal = false + this.disableInsertImage = true }, async uploadImage () { const formData = new FormData() @@ -220,9 +227,11 @@ export default { } const res = await api.uploadImage(formData) this.fileURL = res.data.data.file_path + this.disableInsertImage = false }, insertFile () { this.editor.chain().focus().insertContent(this.fileLink).run() + this.disableInsertFile = true }, async uploadFile () { const formData = new FormData() @@ -233,6 +242,7 @@ export default { const res = await api.uploadFile(formData) const link = '' + res.data.data.file_name + '' this.fileLink = link + this.disableInsertFile = false } } } diff --git a/frontend/src/pages/admin/router.js b/frontend/src/pages/admin/router.js index d83784102..626e96d93 100644 --- a/frontend/src/pages/admin/router.js +++ b/frontend/src/pages/admin/router.js @@ -3,7 +3,7 @@ import VueRouter from 'vue-router' // 引入 view 组件 import { Announcement, Conf, Contest, ContestList, Home, JudgeServer, Login, - Problem, ProblemList, User, PruneTestCase, Dashboard + Problem, ProblemList, User, PruneTestCase, Dashboard, Banner } from './views' Vue.use(VueRouter) @@ -31,6 +31,11 @@ export default new VueRouter({ name: 'announcement', component: Announcement }, + { + path: '/banner', + name: 'banner', + component: Banner + }, { path: '/user', name: 'user', diff --git a/frontend/src/pages/admin/views/contest/ContestList.vue b/frontend/src/pages/admin/views/contest/ContestList.vue index ab6c5eb8d..5b8414782 100644 --- a/frontend/src/pages/admin/views/contest/ContestList.vue +++ b/frontend/src/pages/admin/views/contest/ContestList.vue @@ -18,7 +18,9 @@ @@ -248,4 +250,7 @@ export default { max-width: 120px; word-break: break-all; } + .table::v-deep { + cursor: default; + } diff --git a/frontend/src/pages/admin/views/general/Announcement.vue b/frontend/src/pages/admin/views/general/Announcement.vue index 5ef48ee26..b78bdcaec 100644 --- a/frontend/src/pages/admin/views/general/Announcement.vue +++ b/frontend/src/pages/admin/views/general/Announcement.vue @@ -132,6 +132,16 @@ placeholder="Title" > +

+ * Related Problem ID +

+ +

* Content

@@ -195,7 +205,8 @@ export default { content: '' }, announcementDialogTitle: 'Edit Announcement', - currentPage: 0 + currentPage: 0, + problemOption: [] } }, watch: { @@ -270,6 +281,7 @@ export default { } if (this.contestID) { data.contest_id = this.contestID + data.problem_id = this.announcement.problem_id funcName = this.mode === 'edit' ? 'updateContestAnnouncement' : 'createContestAnnouncement' } else { funcName = this.mode === 'edit' ? 'updateAnnouncement' : 'createAnnouncement' @@ -294,7 +306,20 @@ export default { this.loading = true } }, - openAnnouncementDialog (id) { + async getProblemOption () { + const res = await api.getContestProblemList({ + contest_id: this.contestID, + limit: 100, + offset: 0 + }) + this.problemOption = res.data.data.results.map(item => { + return { + value: item.id, + text: item._id + ' ' + item.title + } + }) + }, + async openAnnouncementDialog (id) { this.showEditAnnouncementDialog = true if (id !== null) { if (this.contestID) { @@ -306,11 +331,13 @@ export default { this.announcement.visible = item.visible this.announcement.top_fixed = item.top_fixed this.announcement.content = item.content + this.announcement.problem_id = item.problem this.mode = 'edit' return true } return false }) + await this.getProblemOption() } else { this.currentAnnouncementId = id this.announcementDialogTitle = 'Edit Announcement' @@ -332,7 +359,11 @@ export default { this.announcement.visible = true this.announcement.top_fixed = false this.announcement.content = '' + this.announcement.problem_id = '' this.mode = 'create' + if (this.contestID) { + await this.getProblemOption() + } } }, handleSwitch (row) { @@ -367,6 +398,9 @@ export default { margin-right: 10px; } } + .table td { + cursor: default; + } diff --git a/frontend/src/pages/admin/views/general/JudgeServer.vue b/frontend/src/pages/admin/views/general/JudgeServer.vue index 8158922d9..b65e2394c 100644 --- a/frontend/src/pages/admin/views/general/JudgeServer.vue +++ b/frontend/src/pages/admin/views/general/JudgeServer.vue @@ -143,3 +143,8 @@ export default { } } + diff --git a/frontend/src/pages/admin/views/general/User.vue b/frontend/src/pages/admin/views/general/User.vue index 69038324b..8bd5eb5b0 100644 --- a/frontend/src/pages/admin/views/general/User.vue +++ b/frontend/src/pages/admin/views/general/User.vue @@ -370,7 +370,7 @@ export default { { key: 'create_time', label: 'Create Time' }, { key: 'last_login', label: 'Last Login' }, { key: 'real_name', label: 'Real Name' }, - { key: 'email', label: 'Eamil' }, + { key: 'email', label: 'Email' }, { key: 'admin_type', label: 'User Type' }, { key: 'major', label: 'User Major' }, { key: 'Option', label: 'Option', thClass: 'userTable' } @@ -566,6 +566,9 @@ export default { text-align: left; } } + .table{ + cursor: default; + } diff --git a/frontend/src/pages/oj/api.js b/frontend/src/pages/oj/api.js index f2e32e4dd..96cfa6545 100644 --- a/frontend/src/pages/oj/api.js +++ b/frontend/src/pages/oj/api.js @@ -100,11 +100,6 @@ export default { data }) }, - sendEmailAuth (data) { - return ajax('send_email_auth/', 'post', { - data - }) - }, changePassword (data) { return ajax('change_password/', 'post', { data @@ -213,6 +208,13 @@ export default { } }) }, + getUserContestInfo (offset, limit, params) { + params.limit = limit + params.offset = offset + return ajax('contest/user/', 'get', { + params + }) + }, submitCode (data) { return ajax('submission/', 'post', { data @@ -257,6 +259,75 @@ export default { return ajax('submission/', 'put', { data }) + }, + getBannerImage () { + return ajax('banner/', 'get') + }, + getCourseList () { + return ajax('lecture/course/', 'get') + }, + getCourse (courseID) { + return ajax('lecture/course/', 'get', { + params: { + course_id: courseID + } + }) + }, + getBookmarkCourseList () { + return ajax('lecture/course/', 'get', { + params: { + bookmark: true + } + }) + }, + setBookmark (courseID, bookmark) { + return ajax('lecture/bookmark_course/', 'put', { + data: { + course_id: courseID, + bookmark: bookmark + } + }) + }, + getCourseAssignmentList (courseID, offset, limit) { + return ajax('lecture/course/assignment/', 'get', { + params: { + course_id: courseID, + paging: true, + offset, + limit + } + }) + }, + getCourseAssignment (courseID, assignmentID) { + return ajax('lecture/course/assignment/', 'get', { + params: { + course_id: courseID, + assignment_id: assignmentID + } + }) + }, + getCourseAssignmentProblemList (courseID, assignmentID) { + return ajax('lecture/course/assignment/problem/', 'get', { + params: { + course_id: courseID, + assignment_id: assignmentID + } + }) + }, + getCourseAssignmentProblem (assignmentID, problemID) { + return ajax('lecture/course/assignment/problem/', 'get', { + params: { + assignment_id: assignmentID, + problem_id: problemID + } + }) + }, + getAssignmentSubmissionList (offset, limit, params) { + params.limit = limit + params.offset = offset + return ajax('assignment_submissions/', 'get', { + params + }) } } diff --git a/frontend/src/pages/oj/components/Banner.vue b/frontend/src/pages/oj/components/Banner.vue index c414ad931..e57df347a 100644 --- a/frontend/src/pages/oj/components/Banner.vue +++ b/frontend/src/pages/oj/components/Banner.vue @@ -6,23 +6,30 @@ @sliding-start="onSlideStart" @sliding-end="onSlideEnd" > - - - - - + diff --git a/frontend/src/pages/oj/components/Footer.vue b/frontend/src/pages/oj/components/Footer.vue index 795a62003..e8203e24f 100644 --- a/frontend/src/pages/oj/components/Footer.vue +++ b/frontend/src/pages/oj/components/Footer.vue @@ -1,14 +1,14 @@