diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c443555..e216ee5 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -33,7 +33,7 @@ repos:
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.5.1
+ rev: v1.6.1
hooks:
- id: mypy
additional_dependencies:
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..70859a6
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,2 @@
+release: python manage.py migrate
+web: gunicorn server.wsgi
diff --git a/README.md b/README.md
index 6f617c5..4c6b2a9 100644
--- a/README.md
+++ b/README.md
@@ -4,25 +4,22 @@ Repositório contendo o código do projeto da disciplina de Métodos de
Desenvolvimento de Software. O projeto consiste em um juíz online para
programação competitiva.
-## Membros
-
-| Nome | Matrícula | GitHub |
-|------------------|-----------|--------|
-| Caio Alexandre | 221007644 | [@bitterteriyaki](https://github.com/bitterteriyaki) |
-| João Farias | 221008187 | [@jpcfarias](https://github.com/jpcfarias) |
-| Gabriel Moura | 221008060 | [@thegm445](https://github.com/thegm445) |
-| Luiza Maluf | 221008294 | [@LuizaMaluf](https://github.com/LuizaMaluf) |
-| Letícia Hladczuk | 221039209 | [@HladczukLe](https://github.com/HladczukLe) |
-| Gabriel Fernando | 222022162 | [@MMcLovin](https://github.com/MMcLovin) |
-
## Resumo
-Um juiz online representa uma plataforma essencial em competições de programação, desempenhando o papel crucial de avaliar e classificar as soluções submetidas pelos participantes. Além de ser uma ferramenta valiosa para competições, também serve como um ambiente propício para a exploração e aprendizado de novas linguagens de programação. Nessa plataforma, os participantes enfrentam desafios por meio de questões específicas, e ao submeterem seus códigos, essas submissões são sujeitas a limites rigorosos de tempo e memória, adicionando uma dimensão adicional de desafio e eficiência à avaliação.
-
+Um juiz online representa uma plataforma essencial em competições de
+programação, desempenhando o papel crucial de avaliar e classificar as soluções
+submetidas pelos participantes. Além de ser uma ferramenta valiosa para
+competições, também serve como um ambiente propício para a exploração e
+aprendizado de novas linguagens de programação. Nessa plataforma, os
+participantes enfrentam desafios por meio de questões específicas, e ao
+submeterem seus códigos, essas submissões são sujeitas a limites rigorosos de
+tempo e memória, adicionando uma dimensão adicional de desafio e eficiência à
+avaliação.
## Motivação
-Nossa motivação é desenvolver um juiz online que adira estritamente aos princípios do Software Livre.
+Nossa motivação é desenvolver um juiz online que adira estritamente aos
+princípios do software livre.
## Instalação
@@ -60,14 +57,16 @@ $ poetry run ./bin/create-env
Por fim, rode o projeto com o Docker:
```bash
-$ docker compose up # para rodar em segundo plano, adicione ' -d '
+$ docker compose up
+# Ou para rodar em segundo plano:
+$ docker compose up -d
```
Para rodar as migrações do banco de dados, você precisará criar um container
temporário que executará as migrações. Faça isso com o seguinte comando:
```bash
-$ docker compose run --rm web python manage.py migrate
+$ docker compose run --rm django python manage.py migrate
```
Para fechar o servidor do Django, use o seguinte comando:
@@ -76,7 +75,8 @@ Para fechar o servidor do Django, use o seguinte comando:
$ docker compose down
# Caso você queira remover os volumes do Docker, use:
$ docker compose down -v
-# Isto removerá os volumes do Docker, o que significa que os dados do banco de dados serão perdidos.
+# Isto removerá os volumes do Docker, o que significa que os dados do banco de
+# dados serão perdidos.
```
Em caso de problemas com a instalação, verifique a
@@ -85,3 +85,70 @@ Em caso de problemas com a instalação, verifique a
## Links
- [Documentação](https://mds.kyomi.dev/pt/latest/)
+
+## Membros
+
+
diff --git a/app.json b/app.json
new file mode 100644
index 0000000..7e89f83
--- /dev/null
+++ b/app.json
@@ -0,0 +1,16 @@
+{
+ "repository": "https://github.com/unb-mds/2023-2-Squad06",
+ "addons": ["heroku-postgresql:mini", "papertrail:choklad"],
+ "buildpacks": [
+ {
+ "url": "https://github.com/moneymeets/python-poetry-buildpack.git"
+ },
+ {
+ "url": "heroku/python"
+ }
+ ],
+ "env": {
+ "PYTHON_RUNTIME_VERSION": "3.11.5",
+ "POETRY_VERSION": "1.6.1"
+ }
+}
diff --git a/apps/submissions/admin.py b/apps/submissions/admin.py
new file mode 100644
index 0000000..49cf45f
--- /dev/null
+++ b/apps/submissions/admin.py
@@ -0,0 +1,34 @@
+from typing import TYPE_CHECKING
+
+from django.contrib.admin import ModelAdmin, register
+from django.forms import CharField, ModelForm, Textarea
+from django.utils.translation import gettext_lazy as _
+
+from apps.submissions.models import Submission
+
+if TYPE_CHECKING:
+ SubmissionAdminBase = ModelAdmin[Submission]
+ SubmissionModelFormBase = ModelForm[Submission]
+else:
+ SubmissionAdminBase = ModelAdmin
+ SubmissionModelFormBase = ModelForm
+
+
+class SubmissionModelForm(SubmissionModelFormBase):
+ code = CharField(widget=Textarea(attrs={"rows": 20, "cols": 80}))
+
+ class Meta:
+ model = Submission
+ fields = "__all__"
+
+
+@register(Submission)
+class SubmissionAdmin(SubmissionAdminBase):
+ form = SubmissionModelForm
+
+ list_display = ("__str__", "author", "task")
+ list_filter = ("author", "task", "created_at")
+
+ fieldsets = [
+ (_("Details"), {"fields": ("author", "task", "code", "status")})
+ ]
diff --git a/apps/submissions/forms.py b/apps/submissions/forms.py
new file mode 100644
index 0000000..b0f91d1
--- /dev/null
+++ b/apps/submissions/forms.py
@@ -0,0 +1,10 @@
+from django.forms import CharField, Form, Textarea
+
+
+class SubmissionForm(Form):
+ code = CharField(
+ label="Source Code",
+ required=True,
+ min_length=15,
+ widget=Textarea(attrs={"rows": 12, "style": "width: 100%;"}),
+ )
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 649466d..fe42958 100644
--- a/apps/submissions/tests.py
+++ b/apps/submissions/tests.py
@@ -1,10 +1,13 @@
from datetime import timedelta
+from django.contrib.admin.sites import AdminSite
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.utils import timezone
+from django.utils.translation import gettext as _
from apps.contests.models import Contest
+from apps.submissions.admin import SubmissionAdmin
from apps.submissions.models import Submission
from apps.tasks.models import Task
from apps.users.models import User
@@ -14,9 +17,10 @@ class SubmissionTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user(
username="user",
- email="user@email.com",
+ email="user@example",
password="password",
)
+
self.contest = Contest._default_manager.create(
title="Test Contest",
description="This is a test contest",
@@ -24,30 +28,43 @@ def setUp(self) -> None:
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(
+
+ self.code = "print('hello world')"
+
+ def test_create_submission(self) -> None:
+ submission = Submission._default_manager.create(
author=self.user,
task=self.task,
- code="print('hello world')",
+ code=self.code,
)
- def test_submission_representation(self) -> None:
- expected = f"#{self.submission.id}"
- self.assertEqual(str(self.submission), expected)
+ self.assertEqual(submission.author, self.user)
+ self.assertEqual(submission.task, self.task)
+ self.assertEqual(submission.code, self.code)
- def test_submission_has_author_relationship(self) -> None:
- self.assertEqual(self.submission.author, self.user)
+ def test_submission_representation(self) -> None:
+ submission = Submission._default_manager.create(
+ author=self.user,
+ task=self.task,
+ code=self.code,
+ )
- def test_submission_has_task_relationship(self) -> None:
- self.assertEqual(self.submission.task, self.task)
+ expected = f"#{submission.id}"
+ self.assertEqual(str(submission), expected)
def test_submission_code_min_length_validator(self) -> None:
code = "a" * 14
- submission = Submission(author=self.user, task=self.task, code=code)
+ submission = Submission(
+ author=self.user,
+ task=self.task,
+ code=code,
+ )
expected = [
"Ensure this value has at least 15 characters (it has 14)."
@@ -57,3 +74,23 @@ def test_submission_code_min_length_validator(self) -> None:
submission.full_clean()
self.assertEqual(context.exception.messages, expected)
+
+
+class SubmissionAdminTest(TestCase):
+ def setUp(self) -> None:
+ self.site = AdminSite()
+ self.submission_admin = SubmissionAdmin(Submission, self.site)
+
+ def test_list_display(self) -> None:
+ expected = ("__str__", "author", "task")
+ self.assertEqual(self.submission_admin.list_display, expected)
+
+ def test_list_filter(self) -> None:
+ expected = ("author", "task", "created_at")
+ self.assertEqual(self.submission_admin.list_filter, expected)
+
+ def test_fieldsets(self) -> None:
+ 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/forms.py b/apps/tasks/forms.py
deleted file mode 100644
index fc0b395..0000000
--- a/apps/tasks/forms.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from django.forms import CharField, Form
-
-
-class TaskForm(Form):
- code = CharField(label="Source Code")
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 1e794c9..37eb078 100644
--- a/apps/tasks/tests.py
+++ b/apps/tasks/tests.py
@@ -1,14 +1,20 @@
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.tasks.admin import TaskAdmin
+from apps.submissions.forms import SubmissionForm
+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
class TaskTestCase(TestCase):
@@ -68,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)
@@ -78,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
@@ -94,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:
@@ -119,9 +181,116 @@ 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.contest = Contest._default_manager.create(
+ title="Test Contest 1",
+ description="This is a test contest",
+ start_time=start_time,
+ end_time=end_time,
+ )
+ self.task = Task._default_manager.create(
+ title="Example task",
+ description="Some example task",
+ contest=self.contest,
+ )
+ self.user = User._default_manager.create(
+ email="user@email.com",
+ username="user",
+ 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(), 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(), 1)
+
def test_detail_view_model_is_task(self) -> None:
self.assertEqual(DetailView.model, Task)
def test_detail_view_template_name_is_correct(self) -> None:
self.assertEqual(DetailView.template_name, "tasks/detail.html")
+
+ 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:
+ response = self.client.post(self.url, data={"code": self.code})
+ self.assertEqual(response.status_code, 302)
+
+ def test_send_submission_without_authentication(self) -> None:
+ response = self.client.post(self.url, data={"code": self.code})
+ self.assertEqual(response.status_code, 302)
+
+ def test_access_task_that_is_accessible(self) -> None:
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_access_task_that_is_not_accessible(self) -> None:
+ self.task.contest.cancelled = True
+ self.task.contest.save()
+
+ 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 1266790..dff5fbe 100644
--- a/apps/tasks/views.py
+++ b/apps/tasks/views.py
@@ -1,40 +1,100 @@
+import sys
+from io import StringIO
+from traceback import format_exc
from typing import TYPE_CHECKING, Any, Dict
from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect
+from django.urls import reverse
from django.views import generic
from django.views.generic.edit import FormMixin
-from apps.tasks.forms import TaskForm
+from apps.submissions.forms import SubmissionForm
+from apps.submissions.models import Submission
from apps.tasks.models import Task
if TYPE_CHECKING:
DetailViewBase = generic.DetailView[Task]
- FormMixinBase = FormMixin[TaskForm]
+ FormMixinBase = FormMixin[SubmissionForm]
else:
DetailViewBase = generic.DetailView
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"
- form_class = TaskForm
+ form_class = SubmissionForm
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
- context["form"] = TaskForm()
+ context["form"] = SubmissionForm()
return context
- def post(self, request: HttpRequest) -> HttpResponse:
+ def get_success_url(self) -> str:
+ return reverse("tasks:detail", args=[self.object.id])
+
+ def get(
+ self, request: HttpRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponse:
+ self.object = self.get_object()
+
+ if not self.object.is_accessible:
+ return redirect("home")
+
+ context = self.get_context_data(object=self.object)
+ return self.render_to_response(context)
+
+ def post(self, request: HttpRequest, *, pk: int) -> HttpResponse:
+ # Unauthenticated users should not be able to submit
+ # a submission to a task, so we redirect them to the
+ # login page.
+ if not request.user.is_authenticated:
+ return redirect("login")
+
self.object = self.get_object()
form = self.get_form()
- return (
- self.form_valid(form)
- if form.is_valid()
- else self.form_invalid(form)
- )
+ if not form.is_valid():
+ return self.form_invalid(form)
- def form_valid(self, form: TaskForm) -> HttpResponse:
- return super().form_valid(form)
+ return handle_submission(request, self.object)
diff --git a/apps/users/tests.py b/apps/users/tests.py
index b509386..1d5930d 100644
--- a/apps/users/tests.py
+++ b/apps/users/tests.py
@@ -1,7 +1,9 @@
from django.test import TestCase
+from django.urls import reverse
from django.utils.translation import gettext as _
from apps.users.admin import UserAdmin
+from apps.users.forms import CreateUserForm
from apps.users.models import User
@@ -86,3 +88,54 @@ def test_add_fieldsets(self) -> None:
)
self.assertEqual(UserAdmin.add_fieldsets, expected_add_fieldsets)
+
+
+class RegisterViewTest(TestCase):
+ def setUp(self) -> None:
+ self.url = reverse("users:register")
+ self.valid_data = {
+ "username": "testuser",
+ "email": "test@example.com",
+ "password1": "TestPassword123",
+ "password2": "TestPassword123",
+ }
+
+ def test_register_view_get(self) -> None:
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "registration/register.html")
+ self.assertIsInstance(response.context["form"], CreateUserForm)
+
+ def test_register_view_post_invalid_data(self) -> None:
+ response = self.client.post(self.url, data={}, follow=True)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "registration/register.html")
+ self.assertContains(response, "This field is required.")
+
+ def test_post_valid_data(self) -> None:
+ response = self.client.post(
+ self.url, data=self.valid_data, follow=True
+ )
+
+ user = User.objects.get(username=self.valid_data["username"])
+
+ self.assertRedirects(response, reverse("home"))
+ self.assertIsNotNone(user)
+ self.client.force_login(user)
+ self.assertTrue(self.client.session["_auth_user_id"])
+
+ def test_email(self) -> None:
+ user = User.objects.create_user(
+ username="testuser",
+ email="test@example.com",
+ password="testpassword",
+ )
+ self.client.force_login(user)
+
+ url = reverse("users:profile", args=[user.username])
+
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, user.email)
diff --git a/apps/users/urls.py b/apps/users/urls.py
index a3d45bc..1fc5368 100644
--- a/apps/users/urls.py
+++ b/apps/users/urls.py
@@ -1,9 +1,10 @@
from django.urls import path
-from apps.users.views import RegisterView
+from apps.users.views import ProfileView, RegisterView
app_name = "users"
urlpatterns = [
- path("", RegisterView.as_view(), name="register"),
+ path("register/", RegisterView.as_view(), name="register"),
+ path("profile//", ProfileView.as_view(), name="profile"),
]
diff --git a/apps/users/views.py b/apps/users/views.py
index 012556e..81410be 100644
--- a/apps/users/views.py
+++ b/apps/users/views.py
@@ -1,8 +1,10 @@
+from django.contrib.auth import login
from django.http import HttpRequest, HttpResponse
-from django.shortcuts import redirect, render
+from django.shortcuts import get_object_or_404, redirect, render
from django.views import View
from apps.users.forms import CreateUserForm
+from apps.users.models import User
class RegisterView(View):
@@ -15,7 +17,20 @@ def get(self, request: HttpRequest) -> HttpResponse:
def post(self, request: HttpRequest) -> HttpResponse:
form = CreateUserForm(self.request.POST)
if form.is_valid():
- form.save()
- return redirect("login")
+ user = form.save()
+
+ # Since we have multiple authentication backends, we need to
+ # specify which one we want to use. In this case, we want to
+ # use the :class:`ModelBackend`, which is the default one.
+ login(request, user, "django.contrib.auth.backends.ModelBackend")
+ return redirect("home")
return render(request, self.template_name, {"form": form})
+
+
+class ProfileView(View):
+ template_name = "users/profile.html"
+
+ def get(self, request: HttpRequest, *, username: str) -> HttpResponse:
+ user = get_object_or_404(User, username=username)
+ return render(request, self.template_name, {"user": user})
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:
diff --git a/docs/source/reunioes/atas/reuniao-04.rst b/docs/source/reunioes/atas/reuniao-04.rst
index d790cff..d2d06f4 100644
--- a/docs/source/reunioes/atas/reuniao-04.rst
+++ b/docs/source/reunioes/atas/reuniao-04.rst
@@ -1,7 +1,7 @@
Reunião 04
==========
-:bdg-info:`18/10/2023` :bdg-warning:`Sprint 2`
+:bdg-info:`18/10/2023` :bdg-warning:`Sprint 2`
Resumo
------
diff --git a/docs/source/reunioes/atas/reuniao-05.rst b/docs/source/reunioes/atas/reuniao-05.rst
index 04cbb3d..c4252d3 100644
--- a/docs/source/reunioes/atas/reuniao-05.rst
+++ b/docs/source/reunioes/atas/reuniao-05.rst
@@ -1,7 +1,7 @@
Reunião 04
==========
-:bdg-info:`25/10/2023` :bdg-warning:`Sprint 3`
+:bdg-info:`25/10/2023` :bdg-warning:`Sprint 3`
Resumo
------
diff --git a/docs/source/reunioes/atas/reuniao-06.rst b/docs/source/reunioes/atas/reuniao-06.rst
index 23737d4..134b718 100644
--- a/docs/source/reunioes/atas/reuniao-06.rst
+++ b/docs/source/reunioes/atas/reuniao-06.rst
@@ -1,9 +1,10 @@
Reunião 06
==========
-:bdg-info:`13/11/2023` :bdg-warning:`Sprint 4`
+:bdg-info:`13/11/2023` :bdg-warning:`Sprint 4`
Resumo
------
-Esta reunião teve como objetivos discutir o progresso, anunciar a saida de um integrante.
+Esta reunião teve como objetivos discutir o progresso, anunciar a saida de um
+integrante.
diff --git a/docs/source/reunioes/atas/reuniao-07.rst b/docs/source/reunioes/atas/reuniao-07.rst
index b6a9241..b1c5615 100644
--- a/docs/source/reunioes/atas/reuniao-07.rst
+++ b/docs/source/reunioes/atas/reuniao-07.rst
@@ -1,9 +1,10 @@
Reunião 07
==========
-:bdg-info:`16/11/2023` :bdg-warning:`Sprint 4`
+:bdg-info:`16/11/2023` :bdg-warning:`Sprint 4`
Resumo
------
-Esta reunião teve como objetivos redistribuir as partes de cada integrante e a reduçao do prazo para entrega das issues.
+Esta reunião teve como objetivos redistribuir as partes de cada integrante e a
+redução do prazo para entrega das issues.
diff --git a/poetry.lock b/poetry.lock
index f4f727a..5331828 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -550,6 +550,26 @@ mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.11.0,<2.12.0"
pyflakes = ">=3.1.0,<3.2.0"
+[[package]]
+name = "gunicorn"
+version = "21.2.0"
+description = "WSGI HTTP Server for UNIX"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"},
+ {file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"},
+]
+
+[package.dependencies]
+packaging = "*"
+
+[package.extras]
+eventlet = ["eventlet (>=0.24.1)"]
+gevent = ["gevent (>=1.4.0)"]
+setproctitle = ["setproctitle"]
+tornado = ["tornado (>=0.2)"]
+
[[package]]
name = "identify"
version = "2.5.32"
@@ -1337,6 +1357,20 @@ files = [
{file = "wcwidth-0.2.10.tar.gz", hash = "sha256:390c7454101092a6a5e43baad8f83de615463af459201709556b6e4b1c861f97"},
]
+[[package]]
+name = "whitenoise"
+version = "6.6.0"
+description = "Radically simplified static file serving for WSGI applications"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "whitenoise-6.6.0-py3-none-any.whl", hash = "sha256:b1f9db9bf67dc183484d760b99f4080185633136a273a03f6436034a41064146"},
+ {file = "whitenoise-6.6.0.tar.gz", hash = "sha256:8998f7370973447fac1e8ef6e8ded2c5209a7b1f67c1012866dbcd09681c3251"},
+]
+
+[package.extras]
+brotli = ["Brotli"]
+
[[package]]
name = "zipp"
version = "3.17.0"
@@ -1355,4 +1389,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
-content-hash = "c59191f6dccb24ff1dac0ad0d8c6cfd50a6271edd71da7ecb1f00f875e8b8a22"
+content-hash = "ee0f61a49b3dc68037a41f78dc055adedc1f97f4b8e1afd1ace1924a17f209a1"
diff --git a/pyproject.toml b/pyproject.toml
index cd9663d..21942bb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,8 @@ django-guardian = "^2.4.0"
django-bootstrap-v5 = "^1.0.11"
django-crispy-forms = "^2.1"
crispy-bootstrap5 = "^2023.10"
+gunicorn = "^21.2.0"
+whitenoise = "^6.6.0"
[tool.poetry.group.dev.dependencies]
pre-commit = "^3.5.0"
diff --git a/server/settings/base.py b/server/settings/base.py
index 8f47102..179eda8 100644
--- a/server/settings/base.py
+++ b/server/settings/base.py
@@ -30,6 +30,7 @@
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
+ "whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -130,6 +131,14 @@
STATIC_URL = "/static/"
+STATIC_ROOT = BASE_DIR / "static"
+
+STORAGES = {
+ "staticfiles": {
+ "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
+ },
+}
+
####################
# Authentication #
####################
diff --git a/server/settings/development.py b/server/settings/development.py
index d0cc532..91061cf 100644
--- a/server/settings/development.py
+++ b/server/settings/development.py
@@ -4,6 +4,8 @@
"localhost",
"0.0.0.0",
"127.0.0.1",
+ ".herokuapp.com",
+ "develop.squad06.com",
]
# In development, we don't need a secure password hasher. We can use
diff --git a/server/urls.py b/server/urls.py
index 6a0d6ff..c9f993e 100644
--- a/server/urls.py
+++ b/server/urls.py
@@ -9,7 +9,7 @@
path("", include("django.contrib.auth.urls")),
# Local views
path("", IndexView.as_view(), name="home"),
- path("contests/", include("apps.contests.urls")),
+ path("contests/", include("apps.contests.urls"), name="contests"),
path("tasks/", include("apps.tasks.urls")),
- path("register/", include("apps.users.urls")),
+ path("", include("apps.users.urls")),
]
diff --git a/server/wsgi.py b/server/wsgi.py
index aa9ff36..e670c6d 100644
--- a/server/wsgi.py
+++ b/server/wsgi.py
@@ -2,6 +2,6 @@
from django.core.wsgi import get_wsgi_application
-environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
+environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings.development")
application = get_wsgi_application()
diff --git a/static/.keep b/static/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/templates/base.html b/templates/base.html
index acd146e..ebbd635 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -1,3 +1,5 @@
+
+
{% load static i18n %}
{% load bootstrap5 %}
@@ -11,7 +13,6 @@
{% get_current_language as LANGUAGE_CODE %}
-
@@ -24,6 +25,25 @@
}
+
+
+
@@ -51,7 +71,7 @@
- {% if user.is_authenticated %}
+ {% if request.user.is_authenticated %}
-
{% block content %}{% endblock content %}
-
diff --git a/templates/tasks/detail.html b/templates/tasks/detail.html
index 9ab07fc..e8a9956 100644
--- a/templates/tasks/detail.html
+++ b/templates/tasks/detail.html
@@ -1,39 +1,59 @@
{% extends "base.html" %}
+{% load crispy_forms_tags %}
+
{% block title %}{{ task.title }}{% endblock title %}
{% block content %}
-
-
-
-
-
-
- Score: {{ task.score|default:"???" }}
-
-
- Memory limit:
- {% if task.memory_limit %}
- {{ task.memory_limit|filesizeformat }}
- {% else %}
- Unlimited
- {% endif %}
-
-
- Time limit:
- {% if task.time_limit %}
- {{ task.time_limit }}
- second{{ task.time_limit|pluralize:"s" }}
- {% else %}
- Unlimited
- {% endif %}
-
-
-
-
{{ task.description }}
+
+
+
+
+
+ Score: {{ task.score|default:"???" }}
+
+
+ Memory limit:
+ {% if task.memory_limit %}
+ {{ task.memory_limit|filesizeformat }}
+ {% else %}
+ Unlimited
+ {% endif %}
+
+
+ Time limit:
+ {% if task.time_limit %}
+ {{ task.time_limit }}
+ second{{ task.time_limit|pluralize:"s" }}
+ {% else %}
+ Unlimited
+ {% endif %}
+
+
+
+
Description
+
{{ task.description }}
+
+
+
+
+
{% endblock content %}
diff --git a/templates/users/profile.html b/templates/users/profile.html
new file mode 100644
index 0000000..461dec9
--- /dev/null
+++ b/templates/users/profile.html
@@ -0,0 +1,26 @@
+{% extends "base.html" %}
+
+{% block title %}{{ user.username }}{% endblock title %}
+
+{% block content %}
+
+
+
+ {% if request.user.is_authenticated and request.user == user %}
+
E-mail: {{ user.email }}
+ {% endif %}
+
Score: 0
+
Contests: {{ user.contests|length }}
+
+
+{% endblock content %}