diff --git a/apps/submissions/admin.py b/apps/submissions/admin.py index 19d5b95..49cf45f 100644 --- a/apps/submissions/admin.py +++ b/apps/submissions/admin.py @@ -29,4 +29,6 @@ class SubmissionAdmin(SubmissionAdminBase): list_display = ("__str__", "author", "task") list_filter = ("author", "task", "created_at") - fieldsets = [(_("Details"), {"fields": ("author", "task", "code")})] + fieldsets = [ + (_("Details"), {"fields": ("author", "task", "code", "status")}) + ] diff --git a/apps/submissions/migrations/0003_submission_status.py b/apps/submissions/migrations/0003_submission_status.py new file mode 100644 index 0000000..323ad92 --- /dev/null +++ b/apps/submissions/migrations/0003_submission_status.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.7 on 2023-11-26 22:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("submissions", "0002_alter_submission_code"), + ] + + operations = [ + migrations.AddField( + model_name="submission", + name="status", + field=models.TextField( + choices=[ + ("WJ", "Waiting judge"), + ("JG", "Judging"), + ("AC", "Accepted"), + ("WA", "Wrong answer"), + ("RE", "Runtime error"), + ("TLE", "Time limit exceeded"), + ("MLE", "Memory limit exceeded"), + ("CE", "Compilation error"), + ("IE", "Internal error"), + ("UE", "Unknown error"), + ("SE", "Submission error"), + ("PE", "Presentation error"), + ], + default="WJ", + max_length=3, + ), + ), + ] diff --git a/apps/submissions/models.py b/apps/submissions/models.py index 6aa8517..75c4b9a 100644 --- a/apps/submissions/models.py +++ b/apps/submissions/models.py @@ -1,11 +1,29 @@ from django.core.validators import MinLengthValidator -from django.db.models import CASCADE, ForeignKey, TextField +from django.db.models import CASCADE, ForeignKey, TextChoices, TextField +from django.utils.translation import gettext_lazy as _ from apps.tasks.models import Task from apps.users.models import User from core.models import TimestampedModel +class SubmissionStatus(TextChoices): + """Represents the status of a submission.""" + + WAITING_JUDGE = ("WJ", _("Waiting judge")) + JUDGING = ("JG", _("Judging")) + ACCEPTED = ("AC", _("Accepted")) + WRONG_ANSWER = ("WA", _("Wrong answer")) + RUNTIME_ERROR = ("RE", _("Runtime error")) + TIME_LIMIT_EXCEEDED = ("TLE", _("Time limit exceeded")) + MEMORY_LIMIT_EXCEEDED = ("MLE", _("Memory limit exceeded")) + COMPILATION_ERROR = ("CE", _("Compilation error")) + INTERNAL_ERROR = ("IE", _("Internal error")) + UNKNOWN_ERROR = ("UE", _("Unknown error")) + SUBMISSION_ERROR = ("SE", _("Submission error")) + PRESENTATION_ERROR = ("PE", _("Presentation error")) + + class Submission(TimestampedModel): """ Represents a submission to a task by an user. The code field is @@ -16,6 +34,11 @@ class Submission(TimestampedModel): author = ForeignKey(User, related_name="submissions", on_delete=CASCADE) task = ForeignKey(Task, related_name="submissions", on_delete=CASCADE) code = TextField(validators=[MinLengthValidator(15)]) + status = TextField( + max_length=3, + choices=SubmissionStatus.choices, + default=SubmissionStatus.WAITING_JUDGE, + ) class Meta: db_table = "submissions" diff --git a/apps/submissions/tests.py b/apps/submissions/tests.py index 4f680f2..fe42958 100644 --- a/apps/submissions/tests.py +++ b/apps/submissions/tests.py @@ -90,5 +90,7 @@ def test_list_filter(self) -> None: self.assertEqual(self.submission_admin.list_filter, expected) def test_fieldsets(self) -> None: - expected = [(_("Details"), {"fields": ("author", "task", "code")})] + expected = [ + (_("Details"), {"fields": ("author", "task", "code", "status")}) + ] self.assertEqual(self.submission_admin.fieldsets, expected) diff --git a/apps/tasks/admin.py b/apps/tasks/admin.py index 7ceebb7..470982d 100644 --- a/apps/tasks/admin.py +++ b/apps/tasks/admin.py @@ -1,7 +1,10 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from django.contrib.admin import ModelAdmin, register +from django.core.files.uploadedfile import InMemoryUploadedFile from django.forms import CharField, IntegerField, ModelForm, Textarea +from django.forms.fields import FileField +from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from apps.tasks.models import Task @@ -19,12 +22,19 @@ class TaskModelForm(TaskModelFormBase): score = IntegerField(min_value=0, required=False) memory_limit = IntegerField( - min_value=0, required=False, help_text=_("In bytes.") + min_value=0, + required=False, + help_text=_("In bytes."), ) time_limit = IntegerField( - min_value=0, required=False, help_text=_("In seconds.") + min_value=0, + required=False, + help_text=_("In seconds."), ) + input_file = FileField() + output_file = FileField() + class Meta: model = Task fields = "__all__" @@ -41,4 +51,22 @@ class TaskAdmin(TaskAdminBase): (_("General"), {"fields": ("title", "description")}), (_("Meta"), {"fields": ("contest", "score")}), (_("Limits"), {"fields": ("memory_limit", "time_limit")}), + (_("Test case"), {"fields": ("input_file", "output_file")}), ] + + def save_model( + self, + request: HttpRequest, + obj: Task, + 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"]) + + obj.input_file = input_file.read().decode("utf-8") + obj.output_file = output_file.read().decode("utf-8") + + return super().save_model(request, obj, form, change) diff --git a/apps/tasks/migrations/0004_task_input_file_task_output_file.py b/apps/tasks/migrations/0004_task_input_file_task_output_file.py new file mode 100644 index 0000000..c02435c --- /dev/null +++ b/apps/tasks/migrations/0004_task_input_file_task_output_file.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 on 2023-11-26 21:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0003_task_memory_limit_task_score_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="task", + name="input_file", + field=models.TextField(), + ), + migrations.AddField( + model_name="task", + name="output_file", + field=models.TextField(), + ), + ] diff --git a/apps/tasks/models.py b/apps/tasks/models.py index 3443d00..c9d4c84 100644 --- a/apps/tasks/models.py +++ b/apps/tasks/models.py @@ -1,4 +1,10 @@ -from django.db.models import CASCADE, CharField, ForeignKey, IntegerField +from django.db.models import ( + CASCADE, + CharField, + ForeignKey, + IntegerField, + TextField, +) from apps.contests.enums import ContestStatus from apps.contests.models import Contest @@ -17,6 +23,9 @@ class Task(TimestampedModel): memory_limit = IntegerField(null=True) time_limit = IntegerField(null=True) + input_file = TextField() + output_file = TextField() + class Meta: db_table = "tasks" diff --git a/apps/tasks/tests.py b/apps/tasks/tests.py index b19c659..37eb078 100644 --- a/apps/tasks/tests.py +++ b/apps/tasks/tests.py @@ -1,14 +1,17 @@ from datetime import timedelta +from io import BytesIO from django.contrib.admin.sites import AdminSite +from django.core.files.uploadedfile import InMemoryUploadedFile from django.test import TestCase +from django.test.client import RequestFactory from django.urls import resolve, reverse from django.utils import timezone from apps.contests.models import Contest from apps.submissions.forms import SubmissionForm -from apps.submissions.models import Submission -from apps.tasks.admin import TaskAdmin +from apps.submissions.models import Submission, SubmissionStatus +from apps.tasks.admin import TaskAdmin, TaskModelForm from apps.tasks.models import Task from apps.tasks.views import DetailView from apps.users.models import User @@ -71,6 +74,7 @@ def test_cancelled_contest_is_not_accessible(self) -> None: class TaskAdminTestCase(TestCase): def setUp(self) -> None: now = timezone.now() + self.site = AdminSite() self.admin = TaskAdmin(Task, self.site) @@ -81,7 +85,6 @@ def setUp(self) -> None: end_time=now + timedelta(hours=1), cancelled=False, ) - self.contest.save() def test_list_display(self) -> None: list_display = self.admin.list_display @@ -97,14 +100,70 @@ def test_list_filter(self) -> None: def test_fieldsets(self) -> None: fieldsets = self.admin.fieldsets + expected = [ (("General"), {"fields": ("title", "description")}), (("Meta"), {"fields": ("contest", "score")}), (("Limits"), {"fields": ("memory_limit", "time_limit")}), + ("Test case", {"fields": ("input_file", "output_file")}), ] self.assertEqual(fieldsets, expected) + def test_save_model(self) -> None: + title = "Example task" + description = "Some example task" + 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, + memory_limit=memory_limit, + time_limit=time_limit, + contest=self.contest, + ) + + 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.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) + class TaskURLTestCase(TestCase): def test_detail_url_to_view_name(self) -> None: @@ -122,13 +181,13 @@ def test_detail_url_reverse(self) -> None: self.assertEqual(url, expected) -class DetailViewTestCase(TestCase): +class TasksViewTestCase(TestCase): def setUp(self) -> None: now = timezone.now() start_time = now - timedelta(hours=1) end_time = now + timedelta(hours=1) - self.code = "print('Hello World')" + self.code = "print('Hello, World!')" self.contest = Contest._default_manager.create( title="Test Contest 1", @@ -147,19 +206,28 @@ def setUp(self) -> None: password="password", ) + self.submission = Submission._default_manager.create( + author=self.user, + task=self.task, + code="print('Hello, World!')", + status=SubmissionStatus.ACCEPTED, + ) + self.url = reverse("tasks:detail", args=[self.task.id]) + self.view = DetailView() + self.view.object = self.task def test_send_submission_successfully(self) -> None: self.client.force_login(self.user) self.client.post(self.url, data={"code": self.code}) - self.assertEqual(Submission._default_manager.count(), 1) + self.assertEqual(Submission._default_manager.count(), 2) def test_send_submission_with_short_code(self) -> None: self.client.force_login(self.user) self.client.post(self.url, data={"code": "c"}) - self.assertEqual(Submission._default_manager.count(), 0) + self.assertEqual(Submission._default_manager.count(), 1) def test_detail_view_model_is_task(self) -> None: self.assertEqual(DetailView.model, Task) @@ -171,8 +239,6 @@ def test_detail_view_form_class_is_submission_form(self) -> None: self.assertEqual(DetailView.form_class, SubmissionForm) def test_send_submission_is_redirecting(self) -> None: - self.client.force_login(self.user) - response = self.client.post(self.url, data={"code": self.code}) self.assertEqual(response.status_code, 302) @@ -190,3 +256,41 @@ def test_access_task_that_is_not_accessible(self) -> None: response = self.client.get(self.url) self.assertEqual(response.status_code, 302) + + def test_handle_submission_with_exception(self) -> None: + self.client.force_login(self.user) + + code = "raise Exception('Test exception')" + expected = "Exception: Test exception" + + response = self.client.post(self.url, data={"code": code}) + + self.assertEqual(response.status_code, 200) + self.assertInHTML(expected, response.content.decode()) + + def test_handle_submission_with_correct_output(self) -> None: + self.client.force_login(self.user) + + self.task.output_file = "Hello, World!\n" + self.task.save() + + response = self.client.post(self.url, data={"code": self.code}) + expected = "Correct!" + + self.assertEqual(response.status_code, 200) + self.assertHTMLEqual(response.content.decode(), expected) + + def test_handle_submission_with_wrong_output(self) -> None: + self.client.force_login(self.user) + + self.task.output_file = "Hello, World!" + self.task.save() + + response = self.client.post(self.url, data={"code": self.code}) + expected = "Incorrect!" + + self.assertEqual(response.status_code, 200) + self.assertHTMLEqual(response.content.decode(), expected) + + def test_form_success_url(self) -> None: + self.assertEqual(self.view.get_success_url(), self.url) diff --git a/apps/tasks/views.py b/apps/tasks/views.py index 9278226..dff5fbe 100644 --- a/apps/tasks/views.py +++ b/apps/tasks/views.py @@ -1,3 +1,6 @@ +import sys +from io import StringIO +from traceback import format_exc from typing import TYPE_CHECKING, Any, Dict from django.http import HttpRequest, HttpResponse @@ -18,6 +21,44 @@ FormMixinBase = FormMixin +def handle_submission(request: HttpRequest, task: Task) -> HttpResponse: + submission = Submission._default_manager.create( + code=request.POST["code"], + task=task, + author=request.user, + ) + + input_data = StringIO(task.input_file) + + old_stdin = sys.stdin + sys.stdin = input_data + + old_stdout = sys.stdout + sys.stdout = stdout = StringIO() + + try: + eval(compile(request.POST["code"], "", "exec")) + except Exception as exc: + submission.status = "RE" + submission.save() + + return HttpResponse(f"Error: {exc} {format_exc()}") + finally: + sys.stdout = old_stdout + sys.stdin = old_stdin + + output = stdout.getvalue() + + if output == task.output_file: + submission.status = "AC" + submission.save() + return HttpResponse("Correct!") + else: + submission.status = "WA" + submission.save() + return HttpResponse("Incorrect!") + + class DetailView(FormMixinBase, DetailViewBase): model = Task template_name = "tasks/detail.html" @@ -53,13 +94,7 @@ def post(self, request: HttpRequest, *, pk: int) -> HttpResponse: self.object = self.get_object() form = self.get_form() - if form.is_valid(): - Submission._default_manager.create( - code=request.POST, task=self.object, author=request.user - ) + if not form.is_valid(): + return self.form_invalid(form) - return ( - self.form_valid(form) - if form.is_valid() - else self.form_invalid(form) - ) + return handle_submission(request, self.object) diff --git a/docker-compose.yml b/docker-compose.yml index 9986807..d321115 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: - main env_file: - config/.env + depends_on: + - postgres command: python -Wd manage.py runserver 0.0.0.0:8000 postgres: