diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3491f8c..111cd53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,11 @@ on: env: CLOUDAMQP_URL: "amqp://virtualjudge:virtualjudge@localhost:5672//" + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: virtualjudge + POSTGRES_PASSWORD: virtualjudge + POSTGRES_DB: virtualjudge jobs: upload: @@ -20,13 +25,21 @@ jobs: RABBITMQ_DEFAULT_PASS: virtualjudge ports: - 5672:5672 + postgresql: + image: postgres:16.0-alpine + ports: + - 5432:5432 + env: + POSTGRES_USER: virtualjudge + POSTGRES_PASSWORD: virtualjudge + POSTGRES_DB: virtualjudge strategy: max-parallel: 4 matrix: python-version: ["3.11"] poetry-version: ["1.6.1"] env: - DATABASE_URL: "sqlite:///db.sqlite3" + DATABASE_URL: "postgres://virtualjudge:virtualjudge@localhost:5432/virtualjudge" CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} steps: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 35003b4..99d3e40 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,11 @@ on: env: CLOUDAMQP_URL: "amqp://virtualjudge:virtualjudge@localhost:5672//" + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: virtualjudge + POSTGRES_PASSWORD: virtualjudge + POSTGRES_DB: virtualjudge jobs: build: @@ -20,13 +25,21 @@ jobs: RABBITMQ_DEFAULT_PASS: virtualjudge ports: - 5672:5672 + postgresql: + image: postgres:16.0-alpine + ports: + - 5432:5432 + env: + POSTGRES_USER: virtualjudge + POSTGRES_PASSWORD: virtualjudge + POSTGRES_DB: virtualjudge strategy: max-parallel: 4 matrix: python-version: ["3.11"] poetry-version: ["1.6.1"] env: - DATABASE_URL: "sqlite:///db.sqlite3" + DATABASE_URL: "postgres://virtualjudge:virtualjudge@localhost:5432/virtualjudge" steps: - name: Checkout repository uses: actions/checkout@v3 diff --git a/apps/submissions/tests.py b/apps/submissions/tests.py index fe42958..3bad1bb 100644 --- a/apps/submissions/tests.py +++ b/apps/submissions/tests.py @@ -3,6 +3,7 @@ from django.contrib.admin.sites import AdminSite from django.core.exceptions import ValidationError from django.test import TestCase +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ @@ -94,3 +95,42 @@ def test_fieldsets(self) -> None: (_("Details"), {"fields": ("author", "task", "code", "status")}) ] self.assertEqual(self.submission_admin.fieldsets, expected) + + +class SubmissionListViewtest(TestCase): + def setUp(self) -> None: + self.user = User.objects.create_user( + username="testuser", + email="testuser@example", + password="testpassword", + ) + + self.contest = Contest._default_manager.create( + title="Test Contest", + description="This is a test contest", + start_time=timezone.now(), + end_time=timezone.now() + timedelta(hours=1), + cancelled=False, + ) + + self.task = Task._default_manager.create( + title="Test Task", + description="This is a test task", + contest=self.contest, + ) + + self.submission = Submission._default_manager.create( + author=self.user, + task=self.task, + code="test code", + ) + + def test_submission_list_view(self) -> None: + self.client.login(email="testuser@example", password="testpassword") + + url = reverse("submissions:list") + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertIn("submissions", response.context) + self.assertIn(self.submission, response.context["submissions"]) diff --git a/apps/submissions/urls.py b/apps/submissions/urls.py new file mode 100644 index 0000000..6ee5e84 --- /dev/null +++ b/apps/submissions/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from apps.submissions.views import SubmissionListView + +app_name = "submissions" + +urlpatterns = [path("", SubmissionListView.as_view(), name="list")] diff --git a/apps/submissions/views.py b/apps/submissions/views.py new file mode 100644 index 0000000..9be96da --- /dev/null +++ b/apps/submissions/views.py @@ -0,0 +1,21 @@ +from typing import TYPE_CHECKING + +from django.db.models import QuerySet +from django.views.generic import ListView + +from apps.submissions.models import Submission + +if TYPE_CHECKING: + SubmissionViewBase = ListView[Submission] +else: + SubmissionViewBase = ListView + + +class SubmissionListView(SubmissionViewBase): + model = Submission + template_name = "submissions/list.html" + context_object_name = "submissions" + paginate_by = 10 + + def get_queryset(self) -> QuerySet[Submission]: + return Submission._default_manager.all().order_by("-created_at") diff --git a/apps/tasks/admin.py b/apps/tasks/admin.py index 470982d..7ddcb1b 100644 --- a/apps/tasks/admin.py +++ b/apps/tasks/admin.py @@ -1,9 +1,15 @@ from typing import TYPE_CHECKING, cast from django.contrib.admin import ModelAdmin, register +from django.contrib.postgres.forms import SimpleArrayField from django.core.files.uploadedfile import InMemoryUploadedFile -from django.forms import CharField, IntegerField, ModelForm, Textarea -from django.forms.fields import FileField +from django.forms import ( + CharField, + FileField, + IntegerField, + ModelForm, + Textarea, +) from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ @@ -19,6 +25,7 @@ class TaskModelForm(TaskModelFormBase): description = CharField(widget=Textarea(attrs={"rows": 14, "cols": 80})) + constraints = SimpleArrayField(CharField(max_length=256), required=False) score = IntegerField(min_value=0, required=False) memory_limit = IntegerField( @@ -48,7 +55,7 @@ class TaskAdmin(TaskAdminBase): list_filter = ("contest", "score") fieldsets = [ - (_("General"), {"fields": ("title", "description")}), + (_("General"), {"fields": ("title", "description", "constraints")}), (_("Meta"), {"fields": ("contest", "score")}), (_("Limits"), {"fields": ("memory_limit", "time_limit")}), (_("Test case"), {"fields": ("input_file", "output_file")}), @@ -61,10 +68,20 @@ def save_model( form: TaskModelForm, change: bool, ) -> None: - # request.FILES does not cast to the correct type so we need to - # cast it manually, otherwise Mypy will complain. - input_file = cast(InMemoryUploadedFile, request.FILES["input_file"]) - output_file = cast(InMemoryUploadedFile, request.FILES["output_file"]) + if change and len(request.FILES) == 0: + return super().save_model(request, obj, form, change) + + try: + # request.FILES does not cast to the correct type so we need + # to cast it manually, otherwise Mypy will complain. + input_file = cast( + InMemoryUploadedFile, request.FILES["input_file"] + ) + output_file = cast( + InMemoryUploadedFile, request.FILES["output_file"] + ) + except KeyError: + return super().save_model(request, obj, form, change) obj.input_file = input_file.read().decode("utf-8") obj.output_file = output_file.read().decode("utf-8") diff --git a/apps/tasks/migrations/0005_task_constraints.py b/apps/tasks/migrations/0005_task_constraints.py new file mode 100644 index 0000000..6ab1ba5 --- /dev/null +++ b/apps/tasks/migrations/0005_task_constraints.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 on 2023-12-01 18:09 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0004_task_input_file_task_output_file"), + ] + + operations = [ + migrations.AddField( + model_name="task", + name="constraints", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=256), + default=list, + size=None, + ), + ), + ] diff --git a/apps/tasks/models.py b/apps/tasks/models.py index 4b1068b..8f1c334 100644 --- a/apps/tasks/models.py +++ b/apps/tasks/models.py @@ -1,3 +1,4 @@ +from django.contrib.postgres.fields import ArrayField from django.db.models import ( CASCADE, CharField, @@ -16,6 +17,7 @@ class Task(TimestampedModel): title = CharField(max_length=256) description = CharField(max_length=4096) + constraints = ArrayField(CharField(max_length=256), default=list) contest = ForeignKey(Contest, related_name="tasks", on_delete=CASCADE) score = IntegerField(null=True) diff --git a/apps/tasks/tests.py b/apps/tasks/tests.py index 3f00b2b..820d732 100644 --- a/apps/tasks/tests.py +++ b/apps/tasks/tests.py @@ -102,7 +102,7 @@ def test_fieldsets(self) -> None: fieldsets = self.admin.fieldsets expected = [ - (("General"), {"fields": ("title", "description")}), + (("General"), {"fields": ("title", "description", "constraints")}), (("Meta"), {"fields": ("contest", "score")}), (("Limits"), {"fields": ("memory_limit", "time_limit")}), ("Test case", {"fields": ("input_file", "output_file")}), @@ -113,6 +113,7 @@ def test_fieldsets(self) -> None: def test_save_model(self) -> None: title = "Example task" description = "Some example task" + constraints = (["A sad task constraint"],) memory_limit = 256 time_limit = 1 @@ -148,6 +149,7 @@ def test_save_model(self) -> None: task = Task( title=title, description=description, + constraints=constraints, memory_limit=memory_limit, time_limit=time_limit, contest=self.contest, @@ -159,11 +161,137 @@ def test_save_model(self) -> None: self.assertEqual(task.title, title) self.assertEqual(task.description, description) + self.assertEqual(task.constraints, constraints) self.assertEqual(task.memory_limit, memory_limit) self.assertEqual(task.time_limit, time_limit) self.assertEqual(task.input_file, input_text) self.assertEqual(task.output_file, output_text) + def test_save_model_without_files(self) -> None: + title = "Example task" + description = "Some example task" + constraints = (["A sad task constraint"],) + memory_limit = 256 + time_limit = 1 + + task = Task( + title=title, + description=description, + constraints=constraints, + memory_limit=memory_limit, + time_limit=time_limit, + contest=self.contest, + ) + + # We're gonna use RequestFactory to mock a request. This is + # necessary because the save_model method requires a request + # object with the FILES attribute. + mock = RequestFactory() + request = mock.post("/admin/tasks/task/add/") + + self.admin.save_model( + request=request, obj=task, form=TaskModelForm(), change=False + ) + + self.assertEqual(task.title, title) + self.assertEqual(task.description, description) + self.assertEqual(task.constraints, constraints) + self.assertEqual(task.memory_limit, memory_limit) + self.assertEqual(task.time_limit, time_limit) + self.assertEqual(task.input_file, "") + self.assertEqual(task.output_file, "") + + def test_save_model_with_change(self) -> None: + title = "Example task" + description = "Some example task" + constraints = (["A sad task constraint"],) + memory_limit = 256 + time_limit = 1 + + input_text = "Hello, World!" + output_text = "Hello, World!" + + input_file = InMemoryUploadedFile( + file=BytesIO(input_text.encode("utf-8")), + field_name="input_file", + name="input.txt", + content_type="text/plain", + size=13, + charset="utf-8", + ) + output_file = InMemoryUploadedFile( + file=BytesIO(output_text.encode("utf-8")), + field_name="output_file", + name="output.txt", + content_type="text/plain", + size=13, + charset="utf-8", + ) + + # We're gonna use RequestFactory to mock a request. This is + # necessary because the save_model method requires a request + # object with the FILES attribute. + mock = RequestFactory() + request = mock.post("/admin/tasks/task/add/") + + request.FILES["input_file"] = input_file + request.FILES["output_file"] = output_file + + task = Task( + title=title, + description=description, + constraints=constraints, + memory_limit=memory_limit, + time_limit=time_limit, + contest=self.contest, + ) + + self.admin.save_model( + request=request, obj=task, form=TaskModelForm(), change=True + ) + + self.assertEqual(task.title, title) + self.assertEqual(task.description, description) + self.assertEqual(task.constraints, constraints) + self.assertEqual(task.memory_limit, memory_limit) + self.assertEqual(task.time_limit, time_limit) + self.assertEqual(task.input_file, input_text) + self.assertEqual(task.output_file, output_text) + + def test_save_model_with_change_without_files(self) -> None: + title = "Example task" + description = "Some example task" + constraints = (["A sad task constraint"],) + memory_limit = 256 + time_limit = 1 + + task = Task( + title=title, + description=description, + constraints=constraints, + memory_limit=memory_limit, + time_limit=time_limit, + contest=self.contest, + ) + + # We're gonna use RequestFactory to mock a request. This is + # necessary because the save_model method requires a request + # object with the FILES attribute. + mock = RequestFactory() + request = mock.post("/admin/tasks/task/add/") + + self.admin.save_model( + request=request, obj=task, form=TaskModelForm(), change=True + ) + + self.assertEqual(task.title, title) + self.assertEqual(task.description, description) + self.assertEqual(task.constraints, constraints) + self.assertEqual(task.memory_limit, memory_limit) + self.assertEqual(task.time_limit, time_limit) + self.assertEqual(task.input_file, "") + self.assertEqual(task.output_file, "") + class TaskURLTestCase(TestCase): def test_detail_url_to_view_name(self) -> None: @@ -199,6 +327,7 @@ def setUp(self) -> None: title="Example task", description="Some example task", score=200, + constraints=["A sad task constraint"], contest=self.contest, output_file="Hello, World!\n", ) @@ -279,11 +408,12 @@ def test_handle_submission_with_exception(self) -> None: code = "raise Exception('Test exception')" response = self.client.post(self.url, data={"code": code}) + url = reverse("submissions:list") self.assertEqual(response.status_code, 302) self.assertRedirects( response, - self.url, + url, status_code=302, target_status_code=200, fetch_redirect_response=True, @@ -296,11 +426,12 @@ def test_handle_submission_with_correct_output(self) -> None: self.task.save() response = self.client.post(self.url, data={"code": self.code}) + url = reverse("submissions:list") self.assertEqual(response.status_code, 302) self.assertRedirects( response, - self.url, + url, status_code=302, target_status_code=200, fetch_redirect_response=True, @@ -312,12 +443,13 @@ def test_handle_submission_with_wrong_output(self) -> None: self.task.output_file = "Hello, World!" self.task.save() + url = reverse("submissions:list") response = self.client.post(self.url, data={"code": self.code}) self.assertEqual(response.status_code, 302) self.assertRedirects( response, - self.url, + url, status_code=302, target_status_code=200, fetch_redirect_response=True, @@ -366,7 +498,8 @@ def test_repeated_accepted_submission_cant_sum_to_user_score(self) -> None: self.assertEqual(self.user.score, self.task.score) def test_form_success_url(self) -> None: - self.assertEqual(self.view.get_success_url(), self.url) + url = reverse("submissions:list") + self.assertEqual(self.view.get_success_url(), url) class BackgroundJobTaskTest(TestCase): diff --git a/apps/tasks/views.py b/apps/tasks/views.py index 2632fc9..0238fd4 100644 --- a/apps/tasks/views.py +++ b/apps/tasks/views.py @@ -78,7 +78,7 @@ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: return context def get_success_url(self) -> str: - return reverse("tasks:detail", args=[self.object.id]) + return reverse("submissions:list") def get( self, diff --git a/server/settings/base.py b/server/settings/base.py index 179eda8..ad228fa 100644 --- a/server/settings/base.py +++ b/server/settings/base.py @@ -70,6 +70,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.humanize", + "django.contrib.postgres", ] THIRD_PARTY_APPS = [ diff --git a/server/urls.py b/server/urls.py index c9f993e..2570b89 100644 --- a/server/urls.py +++ b/server/urls.py @@ -12,4 +12,5 @@ path("contests/", include("apps.contests.urls"), name="contests"), path("tasks/", include("apps.tasks.urls")), path("", include("apps.users.urls")), + path("submissions/", include("apps.submissions.urls")), ] diff --git a/templates/base.html b/templates/base.html index ebbd635..3623a73 100644 --- a/templates/base.html +++ b/templates/base.html @@ -25,6 +25,8 @@ } + {% block head %}{% endblock head %} + + {% if request.user.is_authenticated %}