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 + + + + + + + + + + +
+ + +
+ + Caio Alexandre + +
+
+
+ + +
+ + João Farias + +
+
+
+ + +
+ + Gabriel Moura + +
+
+
+ + +
+ + Luiza Maluf + +
+
+
+ + +
+ + Letícia Hladczuk + +
+
+
+ + +
+ + Gabriel Fernando + +
+
+
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 %} -
-

- - {{ task.title }} - -

-
- -
-

- 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 }}

+

+ + {{ task.title }} + +

+
+ +
+

+ 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 }}

+ +
+ +
+ {% csrf_token %} + +
+
+

Submit

+ {{ form|crispy }} +
+
+ {% 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 %} +
+
+

{{ user.username }}

+ {% if user.is_staff %} + + Staff + + {% endif %} +
+
+ {% if request.user.is_authenticated and request.user == user %} +

E-mail: {{ user.email }}

+ {% endif %} +

Score: 0

+

Contests: {{ user.contests|length }}

+
+
+{% endblock content %}