diff --git a/config/settings/base.py b/config/settings/base.py index 79f21cd..ca7e21b 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -229,4 +229,5 @@ # -OPEN_URLS = ["/admin/login/"] +OPEN_URLS = ["/accounts/login/"] +STUDENT_VIEWS = ["workshop_student_waiting", "problem_student", "workshop_auth"] diff --git a/config/settings/test.py b/config/settings/test.py index 459a45c..3ce79d3 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -59,4 +59,7 @@ 'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'testdb.db', } -} \ No newline at end of file +} +ALLOWED_HOSTS = ["localhost"] + +JUDGE0_ENDPOINT = "https://api.judge0.com" \ No newline at end of file diff --git a/manage.py b/manage.py index 352db50..1871941 100755 --- a/manage.py +++ b/manage.py @@ -27,4 +27,4 @@ current_path = os.path.dirname(os.path.abspath(__file__)) sys.path.append(os.path.join(current_path, "programdom")) - execute_from_command_line(sys.argv) + execute_from_command_line(sys.argv) \ No newline at end of file diff --git a/programdom/admin.py b/programdom/admin.py index 243e25d..c0867f1 100644 --- a/programdom/admin.py +++ b/programdom/admin.py @@ -3,6 +3,7 @@ from .models import * admin.site.register(Problem) +admin.site.register(ProblemTest) admin.site.register(Workshop) admin.site.register(Submission) admin.site.register(SubmissionTestResult) diff --git a/programdom/api/routes.py b/programdom/api/routes.py index 24f3b14..2aa81ed 100644 --- a/programdom/api/routes.py +++ b/programdom/api/routes.py @@ -6,6 +6,7 @@ router = routers.DefaultRouter() router.register(r'submissions', views.SubmissionView) +router.register(r'problems', views.ProblemView) urlpatterns = [ diff --git a/programdom/api/serializers.py b/programdom/api/serializers.py index e0df720..5633331 100644 --- a/programdom/api/serializers.py +++ b/programdom/api/serializers.py @@ -1,10 +1,14 @@ from rest_framework import serializers - -from programdom.models import Submission +from programdom.models import Submission, Problem class SubmissionSerializer(serializers.ModelSerializer): class Meta: model = Submission - fields = ('id', 'problem', 'user', 'code', 'options') + fields = ('id', 'problem', 'user', 'code', 'workshop', 'options') + +class ProblemSerializer(serializers.ModelSerializer): + class Meta: + model = Problem + fields = ('id', 'title', 'skeleton', 'language') \ No newline at end of file diff --git a/programdom/api/views.py b/programdom/api/views.py index c151da8..ad61fbc 100644 --- a/programdom/api/views.py +++ b/programdom/api/views.py @@ -1,11 +1,13 @@ -from rest_framework import viewsets, mixins -from rest_framework.generics import CreateAPIView -from rest_framework.views import APIView - -from programdom.api.serializers import SubmissionSerializer -from programdom.models import Submission +from rest_framework import viewsets +from .serializers import SubmissionSerializer, ProblemSerializer +from programdom.models import Submission, Problem class SubmissionView(viewsets.ModelViewSet): queryset = Submission.objects.all() serializer_class = SubmissionSerializer + + +class ProblemView(viewsets.ModelViewSet): + queryset = Problem.objects.all() + serializer_class = ProblemSerializer diff --git a/programdom/bridge/client.py b/programdom/bridge/client.py index d7e08db..931f9e1 100644 --- a/programdom/bridge/client.py +++ b/programdom/bridge/client.py @@ -14,7 +14,11 @@ client = api.Client(settings.JUDGE0_ENDPOINT) +# Do not wait for the program to +client.wait = False + # This was needed with mooshak, but is no longer, as Judge0 sessions do not expire. + # """ # Use asyncio rather than threads, in order to reduce complexity # """ diff --git a/programdom/bridge/consumers.py b/programdom/bridge/consumers.py index 4d94f83..6c11dbb 100644 --- a/programdom/bridge/consumers.py +++ b/programdom/bridge/consumers.py @@ -1,27 +1,27 @@ import asyncio -import os -from pprint import pprint +import logging -import aiofiles as aiofiles from asgiref.sync import sync_to_async from channels.consumer import AsyncConsumer +from channels.db import database_sync_to_async from django.core.cache import cache from channels.layers import get_channel_layer -from django.conf import settings import judge0api as api from programdom.bridge.client import client -from programdom.models import Problem, SubmissionTestResult +from programdom.models import SubmissionTestResult, Submission channel_layer = get_channel_layer() -class ProgramdomBridgeConsumer(AsyncConsumer): +logger = logging.getLogger(__name__) + +class ProgramdomBridgeConsumer(AsyncConsumer): + submission = None problem = None workshop_id = None session_id = None channel_name = None - file = None async def evaluate(self, message): """ @@ -29,55 +29,57 @@ async def evaluate(self, message): :param message: a dict containing the following: message = { "type": "solution.evaluate", # To match to this method - "problem_id": The Mooshak ID of the problem this solution is for - "code_url": The URL of the code for this submission + "submission_id": the PK of the submission object "session_id": The clients session_id - used to send messages back to the user "workshop_id": the PK of the workshop associated with this submission - used for stats tracking } """ - - self.problem = Problem.objects.get(id=message["problem_id"]) - self.workshop_id = message["workshop_id"] + self.submission = await database_sync_to_async(Submission.objects.get)(pk=message["submission_id"]) + self.problem = self.submission.problem + self.workshop_id = self.submission.workshop.id self.session_id = message["session_id"] - self.channel_name = cache.get(f"session_{self.session_id}_channel-name") - - url = message["code_url"] - - - # Our old system downloaded files and then sent them off. We will probably go back to this once in prod and have - # an actual object storage system working. However, ATM we can just use the local files. - - # async with aiohttp.ClientSession() as session: - # # TODO: Check file is accessable (aka http 200) - # async with session.get(f"{url}") as response: - # data = await response.read() - - # TODO: This is horrible, and should be changed - async with aiofiles.open(str(settings.APPS_DIR(url[1:])), mode='rb') as f: - self.file = await f.read() - - for test in self.problem.problemtest_set.all(): - submission = sync_to_async(api.submission.submit)(client, self.file, self.problem.language.judge_zero_id, stdin=test.std_in, expected_output=test.std_out) - - client_result = dict(vars(submission)) - - await self.handle_state(client_result) - - test_result = SubmissionTestResult() - - client_result.update({"type": "submission.status"}) - client_result.update({"test": test}) - - - async def handle_state(self, message): - # Sends a message to the end user saying what is happening - await channel_layer.send(self.channel_name, message) - # Update the lecturers graph - await channel_layer.group_send(f"workshop_{self.workshop_id}_control", {"type": "graph.update"}) - - def submit_allowed(self): - """ - Checks if the current session is able to submit, by sesing if their answer has been approved - """ - return True - + self.channel_name = await sync_to_async(cache.get)(f"session_{self.session_id}_channel-name") + + logger.debug(f"Evaluating {self.submission}") + + with await sync_to_async(open)(self.submission.code.path, 'rb') as f: + source_code = await sync_to_async(f.read)() + + loop = asyncio.get_event_loop() + for test in await database_sync_to_async(self.problem.problemtest_set.all)(): + loop.create_task(self.test_submit(source_code, test)) + + async def test_submit(self, source_code, test): + submission = await sync_to_async(api.submission.submit)( + client, + source_code, + self.problem.language.judge_zero_id, + stdin=test.std_in.encode(), + expected_output=test.std_out.encode() + ) + await sync_to_async(submission.load)(client) + logger.debug(f"Running test {test} for {self.submission}") + + # TODO: Cleanup + test_result = await database_sync_to_async(SubmissionTestResult)(submission=self.submission, test=test, result_data=dict(submission)) + await database_sync_to_async(test_result.save)() + await sync_to_async(test_result.send_user_status)(self.channel_name) + + # If we don't have an actual result yet, then + if test_result.result_data["status"]["id"] in [1, 2]: + await self.test_schedule(test_result) + + async def test_reload(self, test): + data = await sync_to_async(api.submission.get)(client, test.result_data["token"]) + if data.status["id"] != test.result_data["status"]["id"]: + test.result_data.update(**dict(data)) + await database_sync_to_async(test.save)() + await sync_to_async(test.send_user_status)(self.channel_name) + if data.status["id"] in [2]: + await self.test_schedule(test) + else: + await self.test_schedule(test) + + async def test_schedule(self, test): + await asyncio.sleep(0.5) + await self.test_reload(test) diff --git a/programdom/conftest.py b/programdom/conftest.py deleted file mode 100644 index 59fbe8c..0000000 --- a/programdom/conftest.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest -from django.conf import settings -from django.test import RequestFactory - -from programdom.users.tests.factories import UserFactory - - -@pytest.fixture(autouse=True) -def media_storage(settings, tmpdir): - settings.MEDIA_ROOT = tmpdir.strpath - - -@pytest.fixture -def user() -> settings.AUTH_USER_MODEL: - return UserFactory() - - -@pytest.fixture -def request_factory() -> RequestFactory: - return RequestFactory() diff --git a/programdom/fixtures/problem_tests.json b/programdom/fixtures/problem_tests.json new file mode 100644 index 0000000..d0890fa --- /dev/null +++ b/programdom/fixtures/problem_tests.json @@ -0,0 +1,11 @@ +[ + { + "model": "programdom.ProblemTest", + "fields": { + "name": "New Test", + "std_in": "Bob", + "std_out": "Hello Bob", + "problem": 1 + } + } +] \ No newline at end of file diff --git a/programdom/fixtures/problems.json b/programdom/fixtures/problems.json new file mode 100644 index 0000000..243ba55 --- /dev/null +++ b/programdom/fixtures/problems.json @@ -0,0 +1,10 @@ +[ + { + "model": "programdom.Problem", + "fields": { + "title": "Test Problem", + "skeleton": "print('this is a test')", + "language": 3 + } + } +] \ No newline at end of file diff --git a/programdom/fixtures/workshops.json b/programdom/fixtures/workshops.json new file mode 100644 index 0000000..a1c5d70 --- /dev/null +++ b/programdom/fixtures/workshops.json @@ -0,0 +1,8 @@ +[ + { + "model": "programdom.Workshop", + "fields": { + "title": "Test Workshop" + } + } +] \ No newline at end of file diff --git a/programdom/middleware/login_required.py b/programdom/middleware/login_required.py index 44a748c..55808f1 100644 --- a/programdom/middleware/login_required.py +++ b/programdom/middleware/login_required.py @@ -1,16 +1,24 @@ +from django.http import HttpResponseForbidden from django.shortcuts import redirect from django.conf import settings +from django.urls import resolve class LoginRequiredMiddleware: def __init__(self, get_response): self.get_response = get_response self.login_url = settings.LOGIN_URL + self.student_views = settings.STUDENT_VIEWS self.open_urls = [self.login_url] + \ getattr(settings, 'OPEN_URLS', []) def __call__(self, request): if not (request.user.is_authenticated or request.session.get("current_workshop_id")) and not request.path_info in self.open_urls: return redirect(self.login_url+'?next='+request.path) + else: + if request.session.get("current_workshop_id") and not self._is_student_url(request.path_info): + return HttpResponseForbidden() + return self.get_response(request) - return self.get_response(request) \ No newline at end of file + def _is_student_url(self, path): + return resolve(path).view_name in self.student_views \ No newline at end of file diff --git a/programdom/migrations/0001_initial.py b/programdom/migrations/0001_initial.py index 8008c1c..7a5b2b1 100644 --- a/programdom/migrations/0001_initial.py +++ b/programdom/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.1.7 on 2019-04-02 13:37 +# Generated by Django 2.1.7 on 2019-04-02 18:53 from django.conf import settings import django.contrib.postgres.fields.jsonb @@ -50,7 +50,7 @@ class Migration(migrations.Migration): ('code', models.FileField(upload_to='')), ('options', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict)), ('problem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programdom.Problem')), - ('user', models.ForeignKey(blank=-2, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), migrations.CreateModel( @@ -71,6 +71,11 @@ class Migration(migrations.Migration): ('problems', models.ManyToManyField(blank=True, to='programdom.Problem')), ], ), + migrations.AddField( + model_name='submission', + name='workshop', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programdom.Workshop'), + ), migrations.AddField( model_name='problem', name='language', diff --git a/programdom/migrations/0002_problemtest_name.py b/programdom/migrations/0002_problemtest_name.py new file mode 100644 index 0000000..a6f1893 --- /dev/null +++ b/programdom/migrations/0002_problemtest_name.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-04-07 15:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programdom', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='problemtest', + name='name', + field=models.CharField(default='New Test', max_length=100), + ), + ] diff --git a/programdom/migrations/0003_submission_date.py b/programdom/migrations/0003_submission_date.py new file mode 100644 index 0000000..8de408e --- /dev/null +++ b/programdom/migrations/0003_submission_date.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.7 on 2019-04-08 15:05 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('programdom', '0002_problemtest_name'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='date', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/programdom/models.py b/programdom/models.py index b4206fe..ad84914 100644 --- a/programdom/models.py +++ b/programdom/models.py @@ -1,15 +1,19 @@ +import asyncio import random import string -from asgiref.sync import async_to_sync +from asgiref.sync import async_to_sync, sync_to_async from channels.layers import get_channel_layer from django.contrib.auth import get_user_model +from django.core.cache import cache from django.contrib.auth.models import Group from django.contrib.postgres.fields import JSONField from django.db import models from django.urls import reverse User = get_user_model() +channel_layer = get_channel_layer() + class ProblemLanguage(models.Model): """ @@ -33,7 +37,6 @@ class Problem(models.Model): skeleton = models.TextField(blank=True, default="") language = models.ForeignKey(ProblemLanguage, on_delete=models.CASCADE, help_text="The language that the code for this problem should be completed in") - def __str__(self): return self.title @@ -47,10 +50,17 @@ class ProblemTest(models.Model): The STD In will get supplied to the problem, and if the STD Out of the program matches stdout, then the test passes """ + name = models.CharField(max_length=100, default="New Test") std_in = models.TextField(blank=True, default="") std_out = models.TextField(blank=True, default="") problem = models.ForeignKey(Problem, on_delete=models.CASCADE) + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('problem_test_update', kwargs={'pk': self.problem.id, "tc_pk": self.id}) + class Workshop(models.Model): """ @@ -90,9 +100,11 @@ class Submission(models.Model): A users submission of code for a problem """ problem = models.ForeignKey(Problem, on_delete=models.CASCADE) - user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=~True) + user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) + workshop = models.ForeignKey(Workshop, on_delete=models.CASCADE) code = models.FileField() options = JSONField(blank=True, default=dict) + date = models.DateTimeField(auto_now_add=True) class SubmissionTestResult(models.Model): @@ -104,7 +116,10 @@ class SubmissionTestResult(models.Model): test = models.ForeignKey(ProblemTest, on_delete=models.CASCADE) result_data = JSONField(blank=True, default=dict) - def add_submission_result_data(self, submission): - self.result_data = dict(vars(submission)) + def send_user_status(self, channel_name): + send_data = {**self.result_data, "test_id": self.test.id, "type": "submission.status"} + async_to_sync(channel_layer.send)(channel_name, send_data) + + diff --git a/programdom/problems/forms.py b/programdom/problems/forms.py index ca09284..55183f5 100644 --- a/programdom/problems/forms.py +++ b/programdom/problems/forms.py @@ -1,11 +1,10 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Submit -from django.forms import ModelForm +from django import forms +from programdom.models import Problem, ProblemTest -from programdom.models import Problem - -class EditProblemForm(ModelForm): +class EditProblemForm(forms.ModelForm): def __init__(self, *args, **kwargs): super(EditProblemForm, self).__init__(*args, **kwargs) @@ -15,3 +14,18 @@ def __init__(self, *args, **kwargs): class Meta: model = Problem fields = ["title", "language"] + + +class ProblemTestForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super(ProblemTestForm, self).__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.add_input(Submit('submit', 'Save', css_class='btn-primary')) + + class Meta: + model = ProblemTest + fields = ['name', 'std_in', 'std_out'] + widgets = { + "problem": forms.HiddenInput + } diff --git a/programdom/problems/urls.py b/programdom/problems/urls.py index 64aa39d..e5aa8f7 100644 --- a/programdom/problems/urls.py +++ b/programdom/problems/urls.py @@ -1,13 +1,18 @@ from django.urls import path from programdom.problems.views import ProblemStudentView, ProblemListView, ProblemDetailView, ProblemDeleteView, \ - ProblemCreateView + ProblemCreateView, ProblemTestcaseCreateView, ProblemTestCaseUpdateView, ProblemTestCaseDeleteView urlpatterns = [ path("", ProblemListView.as_view(), name="problem_list"), + path("new/", ProblemCreateView.as_view(), name="problem_create"), path("/", ProblemDetailView.as_view(), name="problem_detail"), path("/delete/", ProblemDeleteView.as_view(), name="problem_delete"), - path("new/", ProblemCreateView.as_view(), name="problem_create"), path("/student/", ProblemStudentView.as_view(), name="problem_student"), + path("/tests/new/", ProblemTestcaseCreateView.as_view(), name="problem_test_new"), + path("/tests//", ProblemTestCaseUpdateView.as_view(), name="problem_test_update"), + path("/tests//delete/", ProblemTestCaseDeleteView.as_view(), name="problem_test_delete"), + + ] diff --git a/programdom/problems/views.py b/programdom/problems/views.py index 1adcd1d..34e1517 100644 --- a/programdom/problems/views.py +++ b/programdom/problems/views.py @@ -1,9 +1,11 @@ +from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin +from django.http import HttpResponseRedirect from django.urls import reverse_lazy from django.views.generic import DetailView, ListView, DeleteView, UpdateView, CreateView -from programdom.models import Problem -from programdom.problems.forms import EditProblemForm +from programdom.models import Problem, ProblemTest +from programdom.problems.forms import EditProblemForm, ProblemTestForm class ProblemListView(ListView): @@ -12,7 +14,6 @@ class ProblemListView(ListView): class ProblemStudentView(DetailView): - model = Problem template_name = "programdom/problem/student.html" @@ -34,3 +35,50 @@ class ProblemCreateView(CreateView): model = Problem template_name = "programdom/problem/problem_create.html" form_class = EditProblemForm + + +class ProblemTestcaseCreateView(SuccessMessageMixin, CreateView): + model = ProblemTest + template_name = "programdom/problem/test/test_create.html" + form_class = ProblemTestForm + success_message = "Testcase Created Successfully" + + def get_success_url(self): + return self.object.problem.get_absolute_url() + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.problem_id = self.kwargs.get("pk") + self.object.save() + + success_message = self.get_success_message(form.cleaned_data) + if success_message: + messages.success(self.request, success_message) + return HttpResponseRedirect(self.get_success_url()) + + def get_context_data(self, **kwargs): + context = super(ProblemTestcaseCreateView, self).get_context_data(**kwargs) + context.update({"parent": Problem.objects.get(id=self.kwargs.get("pk"))}) + return context + + +class ProblemTestCaseUpdateView(SuccessMessageMixin, UpdateView): + model = ProblemTest + template_name = "programdom/problem/test/test_create.html" + form_class = ProblemTestForm + pk_url_kwarg = "tc_pk" + success_message = "Testcase Updated Sucessfully" + + def get_success_url(self): + return self.object.problem.get_absolute_url() + + +class ProblemTestCaseDeleteView(SuccessMessageMixin, DeleteView): + model = ProblemTest + template_name = "programdom/problem/test/test_delete.html" + form_class = ProblemTestForm + pk_url_kwarg = "tc_pk" + success_message = "Testcase Deleted Successfully" + + def get_success_url(self): + return self.object.problem.get_absolute_url() diff --git a/programdom/signals.py b/programdom/signals.py index b69cba3..ecac693 100644 --- a/programdom/signals.py +++ b/programdom/signals.py @@ -19,21 +19,16 @@ def save_submission(sender, instance, **kwargs): :param message: a JSON string, containing: "type": "submission.create" - to match to this method "submission_id": the PK of the submission object - "problem_id": the PK of the problem associated with this submission "workshop_id": the PK of the workshop associated with this submission - "code_url": the url of the code to upload to mooshak "session_id": the Session ID of the user who made the submission """ async_to_sync(channel_layer.send)( "mooshakbridge", { "type": "evaluate", - "problem_id": instance.problem.id, - "code_url": instance.code.url, + "submission_id": instance.id, "session_id": instance.options["session_id"], - "workshop_id": instance.options["workshop_id"], } ) - print("test1") diff --git a/programdom/templates/base.html b/programdom/templates/base.html index c4d0a2f..9f7c5c1 100644 --- a/programdom/templates/base.html +++ b/programdom/templates/base.html @@ -46,6 +46,11 @@ + {% if user.is_authenticated %} + + {% endif %} diff --git a/programdom/templates/programdom/problem/problem_detail_view.html b/programdom/templates/programdom/problem/problem_detail_view.html index e105c5b..2c4f313 100644 --- a/programdom/templates/programdom/problem/problem_detail_view.html +++ b/programdom/templates/programdom/problem/problem_detail_view.html @@ -21,22 +21,57 @@

{% block title %}{{ object }}{% endblock title %}

-
+

Problem Details

{% crispy form %}
+
+
+
+
+

Problem Tests

+
+
+ New Test +
+
+ + {% for test in object.problemtest_set.all %} +
+ + + + + + + + +
{{ test }}
+
+ {% empty %} +

No tests exist for this Problem

+ {% endfor %} + +
+
+
{{ object.skeleton }}
- +
{% endblock content %} + + + + + {% block javascript %} {{ block.super }} + + {% csrf_token %} + + {% endblock javascript %} {% block css %} {{ block.super }} diff --git a/programdom/templates/programdom/problem/student.html b/programdom/templates/programdom/problem/student.html index 4b9985d..142b98b 100644 --- a/programdom/templates/programdom/problem/student.html +++ b/programdom/templates/programdom/problem/student.html @@ -3,7 +3,7 @@ {{ block.super }} @@ -23,21 +23,76 @@

{% block title %}{{ problem }}{% endblock %}

- -

Compiler Output

- -

Input

- -

Output

- -

Errors

- + + +
+ {% for test in object.problemtest_set.all %} +
+
+
+ + +
+
+ +
+
+
+
Status
+
Not submitted
+
Program Input
+
{{ test.std_in | linebreaks }}
+
Expected Output
+
{{ test.std_out | linebreaks }}
+
Actual Output
+
No Output yet
+
Error Output
+
No Output yet
+
Compiler Output
+
No Output yet
+
+ +
+
+
+ {% empty %} + This Problem has no Tests + {% endfor %} +
{% endblock %} + +{% block modal %} + {% for test in object.problemtest_set.all %} + + + {% endfor %} +{% endblock %} + {% block javascript %} {{ block.super }}