diff --git a/codewof/programming/filters.py b/codewof/programming/filters.py index 7d55b9314..3d215523a 100644 --- a/codewof/programming/filters.py +++ b/codewof/programming/filters.py @@ -6,6 +6,7 @@ DifficultyLevel, ProgrammingConcepts, QuestionContexts, + Draft, ) from programming.widgets import IndentCheckbox, DifficultyCheckbox, TypeCheckbox @@ -42,3 +43,34 @@ class Meta: model = Question fields = {'difficulty_level', 'concepts', 'contexts', 'question_type'} + + +class DraftFilter(django_filters.FilterSet): + """Filter for drafts extends FilterSet. Allows for filtering identical to questions.""" + + difficulty_level = django_filters.filters.ModelMultipleChoiceFilter( + queryset=DifficultyLevel.objects.order_by('level'), + widget=DifficultyCheckbox, + ) + + concepts = django_filters.filters.ModelMultipleChoiceFilter( + queryset=ProgrammingConcepts.objects.prefetch_related('parent').order_by('number'), + widget=IndentCheckbox, + conjoined=False, + ) + + contexts = django_filters.filters.ModelMultipleChoiceFilter( + queryset=QuestionContexts.objects.prefetch_related('parent').order_by('number'), + widget=IndentCheckbox, + conjoined=False, + ) + + question_type = django_filters.filters.AllValuesMultipleFilter( + widget=TypeCheckbox, + ) + + class Meta: + """Meta options for Filter. Sets which model and fields are filtered.""" + + model = Draft + fields = {'difficulty_level', 'concepts', 'contexts', 'question_type'} diff --git a/codewof/programming/forms.py b/codewof/programming/forms.py new file mode 100644 index 000000000..aa4e413ec --- /dev/null +++ b/codewof/programming/forms.py @@ -0,0 +1,353 @@ +"""Forms for programming pages.""" + +from django import forms +from django.urls import reverse +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Submit, Button, Div, HTML +from crispy_forms.bootstrap import Modal +from programming.models import Draft, DifficultyLevel + +QUESTION_TYPE_CHOICES = [ + ('program', 'Program'), + ('function', 'Function'), + ('parsons', 'Parsons'), + ('debugging', 'Debugging'), +] +QUESTION_DIFFICULTY_CHOICES = [ + (1, 'Easy'), + (2, 'Moderate'), + (3, 'Difficult'), + (4, 'Complex'), +] +TEST_CASE_TYPES = [ + ('normal', 'Normal'), + ('exceptional', 'Exceptional') +] +CONCEPTS = { + "root": [ + ('display-text', 'Display Text'), + ('functions', 'Functions'), + ('inputs', 'Inputs'), + ('conditionals', 'Conditionals'), + ('loops', 'Loops'), + ('string-operations', 'String Operations'), + ('lists', 'Lists'), + ], + "conditionals": [ + ('single-condition', 'Single Condition'), + ('multiple-conditions', 'Multiple Conditions'), + ('advanced-conditionals', 'Advanced Conditionals'), + ], + "loops": [ + ('conditional-loops', 'Conditional Loops'), + ('range-loops', 'Range Loops'), + ], +} +CONTEXTS = { + "root": [ + ("mathematics", "Mathematics"), + ("real-world-applications", "Real World Applications"), + ], + "mathematics": [ + ("simple-mathematics", "Simple Mathematics"), + ("advanced-mathematics", "Advanced Mathematics"), + ], + "geometry": [ + ("basic-geometry", "Basic Geometry"), + ("advanced-geometry", "Advanced Geometry"), + ], +} + + +class MacroForm(forms.Form): + """Form for creating/editing macros for new questions.""" + + macro_help_text = 'Separate values with a comma (i.e. 6,7). You can escape commas with a backslash (i.e. 6\\,7)' + name = forms.CharField(required=True, max_length='20') + possible_values = forms.CharField(widget=forms.Textarea, + required=True, + help_text=macro_help_text) + + def __init__(self, *args, **kwargs): + """Add crispyform helper to form.""" + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.layout = Layout( + Modal( + 'name', + 'possible_values', + Button( + "close_modal", + "Cancel", + id="close_modal", + css_class="btn mt-3", + data_toggle="modal", + data_target="#macro_modal", + ), + Button( + "save_var", + "Save Macro", + css_id="btn_macro_save", + css_class="btn-primary mt-3", + ), + css_id="macro_modal", + title="Randomised Macro", + title_class="w-100 text-center", + ), + ) + + +class TestCaseForm(forms.Form): + """Form for creating/editing test cases for new questions.""" + + testcase_type = forms.ChoiceField(required=True, label='Type', choices=TEST_CASE_TYPES) + testcase_code = forms.CharField(widget=forms.Textarea, label='Input Code', required=True) + + def __init__(self, *args, **kwargs): + """Add crispyform helper to form.""" + expected_output_notice = '' + \ + 'Expected output is generated from your provided solution code.' + \ + '' + + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.layout = Layout( + Modal( + 'testcase_type', + 'testcase_code', + HTML(expected_output_notice), + Button( + "close_modal", + "Cancel", + id="close_modal", + css_class="btn mt-3", + data_toggle="modal", + data_target="#test_case_modal", + ), + Button( + "save_test_case", + "Save Test Case", + css_id="btn_test_case_save", + css_class="btn-primary mt-3", + ), + css_id="test_case_modal", + title="Test Case", + title_class="w-100 text-center", + ), + ) + + +class NewQuestionForm(forms.ModelForm): + """Form for creating or editing new questions.""" + + # Fields specific to a type of question + initial_code = forms.CharField( + widget=forms.Textarea, + required=False + ) + read_only_lines_top = forms.IntegerField( + required=False, + help_text="The number of lines at the top of the initial code to make read-only" + ) + read_only_lines_bottom = forms.IntegerField( + required=False, + help_text="The number of lines at the bottom of the initial code to make read-only" + ) + lines = forms.CharField( + widget=forms.Textarea, + required=False, + label="Extra lines", + help_text="Lines to mix in with solution lines" + ) + + # Many-to-many fields + macros = forms.CharField( + widget=forms.Textarea, + required=False + ) + test_cases = forms.CharField( + widget=forms.Textarea, + required=False, + help_text="Drag test cases up and down to reorder them (may not work with touch devices)" + ) + concepts = forms.MultipleChoiceField( + required=False, + choices=CONCEPTS["root"], + widget=forms.CheckboxSelectMultiple(), + label=False + ) + contexts = forms.MultipleChoiceField( + required=False, + choices=CONTEXTS["root"], + widget=forms.CheckboxSelectMultiple(), + label=False + ) + + # Helpers for concepts/contexts + concept_conditionals = forms.ChoiceField( + required=False, + choices=CONCEPTS["conditionals"], + widget=forms.RadioSelect, + label=False + ) + concept_loops = forms.ChoiceField( + required=False, + choices=CONCEPTS["loops"], + widget=forms.RadioSelect, + label=False + ) + + context_has_geometry = forms.BooleanField( + required=False, + initial=False, + label="Geometry" + ) + context_mathematics = forms.ChoiceField( + required=False, + choices=CONTEXTS["mathematics"], + widget=forms.RadioSelect, + label=False + ) + context_geometry = forms.ChoiceField( + required=False, + choices=CONTEXTS["geometry"], + widget=forms.RadioSelect, + label=False + ) + + # Defining a custom widget for difficulty to enable correct display names for the options + difficulty_level = forms.ChoiceField( + required=False, + choices=QUESTION_DIFFICULTY_CHOICES, + initial=QUESTION_DIFFICULTY_CHOICES[0], + label='Difficulty' + ) + + class Meta: + """Defines attributes for crispy form generation.""" + + model = Draft + fields = [ + "title", + "question_type", + "difficulty_level", + "question_text", + "macros", + "solution", + "concepts", + "contexts", + "lines" + ] + labels = { + "title": "Question Title", + "question_type": "Type", + } + widgets = { + "question_type": forms.Select(choices=QUESTION_TYPE_CHOICES), + } + + def __init__(self, *args, **kwargs): + """Add crispyform helper to form.""" + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + # self.helper.form_action = '' + self.helper.layout = Layout( + 'title', + 'question_type', + 'difficulty_level', + 'question_text', + 'macros', + Div( + Div( + HTML('{% include "programming/question_creation/macros_display_table.html" %}'), + css_class="table-responsive", + ), + Button( + "new_macro", + "New macro", + css_id="btn_new_macro", + css_class="btn-outline-secondary", + type="button", + ), + css_class="text-center", + ), + 'solution', + HTML('{% include "programming/question_components/indentation-warning.html" %}'), + 'initial_code', + 'read_only_lines_top', + 'read_only_lines_bottom', + 'lines', + Modal( + 'concepts', + 'concept_conditionals', + 'concept_loops', + Button( + "close_modal", + "Return to question", + css_id="btn_save_concepts", + css_class="btn-primary ml-auto mt-3", + data_toggle="modal", + data_target="#concept_modal", + ), + css_id="concept_modal", + title="Concepts", + title_class="w-100 text-center", + ), + Modal( + 'contexts', + 'context_has_geometry', + 'context_mathematics', + 'context_geometry', + Button( + "close_modal", + "Return to question", + css_id="btn_save_contexts", + css_class="btn-primary ml-auto mt-3", + data_toggle="modal", + data_target="#context_modal", + ), + css_id="context_modal", + title="Contexts", + title_class="w-100 text-center", + ), + Div( + HTML('{% include "programming/question_creation/tag_modal_indicators.html" %}'), + ), + 'test_cases', + Div( + Div( + HTML('{% include "programming/question_creation/creation_test_case_table.html" %}'), + css_class="table-responsive", + css_id="test_case_table_container", + ), + Button( + "new_test_case", + "New test case", + css_id="btn_new_test_case", + css_class="btn-outline-secondary", + type="button", + ), + css_class="text-center", + ), + Div( + HTML(f'Cancel'), + Submit('submit', 'Save Question', css_class='ml-3'), + css_class='button-tray mt-3', + ), + ) + + def clean(self, *args, **kwargs): + """ + Override the values returned from cleaning the form. + + Difficulty level is a foreign key and would otherwise raise an error. + """ + cleaned_data = self.cleaned_data + cleaned_data['difficulty_level'] = DifficultyLevel.objects.get( + pk=cleaned_data['difficulty_level'] + ) + + return cleaned_data diff --git a/codewof/programming/migrations/0023_draft.py b/codewof/programming/migrations/0023_draft.py new file mode 100644 index 000000000..7b9123cd9 --- /dev/null +++ b/codewof/programming/migrations/0023_draft.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.18 on 2023-10-01 16:35 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0022_testcase_type'), + ] + + operations = [ + migrations.CreateModel( + name='Draft', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('languages', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=10), default=list, size=None)), + ('slug', models.SlugField(unique=True)), + ('title', models.CharField(max_length=100)), + ('question_type', models.CharField(default='Program', max_length=100)), + ('question_text', models.TextField(blank=True)), + ('solution', models.TextField(blank=True)), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='authored_drafts', to='programming.profile')), + ('concepts', models.ManyToManyField(related_name='draft_concepts', to='programming.ProgrammingConcepts')), + ('contexts', models.ManyToManyField(related_name='draft_contexts', to='programming.QuestionContexts')), + ('difficulty_level', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='drafts', to='programming.difficultylevel')), + ], + options={ + 'verbose_name': 'Draft', + 'verbose_name_plural': 'Drafts', + }, + ), + ] diff --git a/codewof/programming/migrations/0024_auto_20231016_0542.py b/codewof/programming/migrations/0024_auto_20231016_0542.py new file mode 100644 index 000000000..ef6d31dd6 --- /dev/null +++ b/codewof/programming/migrations/0024_auto_20231016_0542.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.18 on 2023-10-15 16:42 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0023_draft'), + ] + + operations = [ + migrations.AddField( + model_name='draft', + name='initial_code', + field=models.TextField(null=True), + ), + migrations.AddField( + model_name='draft', + name='lines', + field=models.TextField(null=True), + ), + migrations.AddField( + model_name='draft', + name='read_only_lines_bottom', + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name='draft', + name='read_only_lines_top', + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.CreateModel( + name='DraftTestCase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('languages', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=10), default=list, size=None)), + ('number', models.PositiveSmallIntegerField(default=1)), + ('type', models.CharField(default='Program', max_length=100)), + ('expected_output', models.TextField(blank=True)), + ('test_code', models.TextField()), + ('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='draft_test_cases', to='programming.draft')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/codewof/programming/migrations/0025_draftmacro_draftmacrovalue_macro_macrovalue.py b/codewof/programming/migrations/0025_draftmacro_draftmacrovalue_macro_macrovalue.py new file mode 100644 index 000000000..590dd418c --- /dev/null +++ b/codewof/programming/migrations/0025_draftmacro_draftmacrovalue_macro_macrovalue.py @@ -0,0 +1,55 @@ +# Generated by Django 3.2.18 on 2023-10-19 19:29 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0024_auto_20231016_0542'), + ] + + operations = [ + migrations.CreateModel( + name='DraftMacro', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('placeholder', models.CharField(max_length=100)), + ('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='macros', to='programming.draft')), + ], + ), + migrations.CreateModel( + name='Macro', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('placeholder', models.CharField(max_length=100)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='macros', to='programming.question')), + ], + ), + migrations.CreateModel( + name='MacroValue', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('languages', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=10), default=list, size=None)), + ('value', models.TextField()), + ('macro', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='macro_values', to='programming.macro')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DraftMacroValue', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('languages', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=10), default=list, size=None)), + ('value', models.TextField()), + ('macro', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='macro_values', to='programming.draftmacro')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/codewof/programming/models.py b/codewof/programming/models.py index 3221bb974..0199ec1a4 100644 --- a/codewof/programming/models.py +++ b/codewof/programming/models.py @@ -238,6 +238,115 @@ class Meta: ordering = ["number", "name"] +# ----- Base draft classes ------------------------------------------------- +class Draft(TranslatableModel): + """Class for a draft question.""" + + slug = models.SlugField(unique=True) + title = models.CharField(max_length=SMALL) + question_type = models.CharField(max_length=SMALL, default="Program", null=False) + question_text = models.TextField(blank=True) + solution = models.TextField(blank=True) + + difficulty_level = models.ForeignKey( + DifficultyLevel, + related_name='drafts', + on_delete=models.SET_NULL, + blank=True, + null=True + ) + concepts = models.ManyToManyField( + ProgrammingConcepts, + related_name='draft_concepts' + ) + contexts = models.ManyToManyField( + QuestionContexts, + related_name='draft_contexts' + ) + author = models.ForeignKey( + Profile, + related_name='authored_drafts', + on_delete=models.CASCADE, + blank=True, + null=True + ) + + # Attributes of certain questions + # These are separated to the correct classes once drafts are submitted + lines = models.TextField(null=True) + initial_code = models.TextField(null=True) + read_only_lines_top = models.PositiveSmallIntegerField(default=0) + read_only_lines_bottom = models.PositiveSmallIntegerField(default=0) + + objects = InheritanceManager() + + def get_absolute_url(self): + """Return URL of draft on website. + + Returns: + URL as a string. + """ + return reverse('programming:edit_draft', kwargs={'pk': self.pk}) + + def __str__(self): + """Text representation of a draft.""" + if hasattr(self, 'QUESTION_TYPE'): + return '{}: {}'.format(self.QUESTION_TYPE, self.title) + else: + return self.title + + class Meta: + """Meta information for class.""" + + verbose_name = 'Draft' + verbose_name_plural = 'Drafts' + + +class DraftTestCase(TranslatableModel): + """Base class for a draft for TestCase.""" + + number = models.PositiveSmallIntegerField(default=1) + type = models.CharField(max_length=SMALL, default="Program", null=False) + expected_output = models.TextField(blank=True) + + # These are stored with the draft model, then applied to the specific type once submitted + test_code = models.TextField() + draft = models.ForeignKey( + Draft, + related_name='draft_test_cases', + on_delete=models.CASCADE + ) + + objects = InheritanceManager() + + def __str__(self): + """Text representation of a test case.""" + return self.type + pass + + +class DraftMacro(models.Model): + """A macro for a draft question.""" + + placeholder = models.CharField(max_length=SMALL, null=False) + draft = models.ForeignKey( + Draft, + related_name='macros', + on_delete=models.CASCADE, + ) + + +class DraftMacroValue(TranslatableModel): + """A potential value for a draft macro to take.""" + + macro = models.ForeignKey( + DraftMacro, + related_name='macro_values', + on_delete=models.CASCADE, + ) + value = models.TextField() + + # ----- Base question classes ------------------------------------------------- class Question(TranslatableModel): @@ -314,6 +423,28 @@ def __str__(self): return self.type pass + +class Macro(models.Model): + """A macro for a question.""" + + placeholder = models.CharField(max_length=SMALL, null=False) + question = models.ForeignKey( + Question, + related_name='macros', + on_delete=models.CASCADE, + ) + + +class MacroValue(TranslatableModel): + """A potential value for a macro to take.""" + + macro = models.ForeignKey( + Macro, + related_name='macro_values', + on_delete=models.CASCADE, + ) + value = models.TextField() + # ----- Program question ------------------------------------------------------ diff --git a/codewof/programming/review/en/.gitkeep b/codewof/programming/review/en/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/codewof/programming/review/structure/.gitkeep b/codewof/programming/review/structure/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/codewof/programming/urls.py b/codewof/programming/urls.py index b5aac38f3..427728d4e 100644 --- a/codewof/programming/urls.py +++ b/codewof/programming/urls.py @@ -17,6 +17,11 @@ urlpatterns = [ path('questions/', views.QuestionListView.as_view(), name='question_list'), path('questions/create/', views.CreateView.as_view(), name='create'), + path('questions/created/', views.DraftQuestionListView.as_view(), name='draft_list'), + path('questions/created/new/', views.DraftQuestionView.as_view(), name='new_draft'), + path('questions/created//', views.DraftQuestionView.as_view(), name='edit_draft'), + path('questions/created//submit/', views.SubmitQuestionView.as_view(), name='submit_draft'), + path('questions/created//delete/', views.DeleteQuestionView.as_view(), name='delete_draft'), path('questions//', views.QuestionView.as_view(), name='question'), path('ajax/save_question_attempt/', views.save_question_attempt, name='save_question_attempt'), path('attempts//like', views.like_attempt, name='like_attempt'), diff --git a/codewof/programming/views.py b/codewof/programming/views.py index 091328c8c..e160b59d7 100644 --- a/codewof/programming/views.py +++ b/codewof/programming/views.py @@ -1,14 +1,19 @@ """Views for programming application.""" import json +import yaml +import os from django.contrib.auth.decorators import login_required from django.views import generic +from django.urls import reverse from django.db.models import Count, Max, Exists, OuterRef from django.db.models.functions import Coalesce -from django.http import JsonResponse, Http404, HttpResponse +from django.http import JsonResponse, Http404, HttpResponse, HttpResponseForbidden from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.views.decorators.http import require_http_methods +from django.contrib import messages +from django.shortcuts import redirect from django_filters.views import FilterView from rest_framework import viewsets from rest_framework.permissions import IsAdminUser @@ -20,17 +25,25 @@ AttemptSerializer, LikeSerializer ) +import programming.models from programming.models import ( Profile, Question, + Draft, + DraftTestCase, + DraftMacro, + DraftMacroValue, TestCase, Attempt, TestCaseAttempt, - Like + Like, + ProgrammingConcepts, + QuestionContexts, ) from programming.codewof_utils import add_points, check_achievement_conditions -from programming.filters import QuestionFilter +from programming.filters import QuestionFilter, DraftFilter from programming.utils import create_filter_helper +from programming.forms import NewQuestionForm, MacroForm, TestCaseForm QUESTION_JAVASCRIPT = 'js/question_types/{}.js' @@ -87,6 +100,496 @@ def get_context_data(self, **kwargs): return context +class DraftQuestionListView(LoginRequiredMixin, FilterView): + """View for listing draft questions created by the user.""" + + filterset_class = DraftFilter + model = Draft + context_object_name = 'drafts' + template_name = 'programming/draft_list.html' + + def get_queryset(self): + """Return draft questions objects for page. + + Returns: + Draft question queryset. + """ + drafts = ( + Draft.objects.filter(author_id=self.request.user.profile) + .select_related('difficulty_level') + .prefetch_related( + 'concepts', + 'concepts__parent', + 'contexts', + 'contexts__parent', + ) + .order_by('difficulty_level') + ) + return drafts + + def get_context_data(self, **kwargs): + """Provide the context data for the question list view. + + Returns: Dictionary of context data. + """ + context = super().get_context_data(**kwargs) + context['filter_formatter'] = create_filter_helper("programming:draft_list") + context['filter_button_pressed'] = "submit" in self.request.GET + return context + + +class DeleteQuestionView(LoginRequiredMixin, generic.base.TemplateView): + """A "view" to handle requests to delete drafts.""" + + model = Draft + + def get_object(self, **kwargs): + """Get draft object.""" + try: + draft = Draft.objects.get_subclass( + pk=self.kwargs['pk'] + ) + except Draft.DoesNotExist: + raise Http404("No draft question matches the given ID.") + + return draft + + def post(self, request, *args, **kwargs): + """Check that the user is the author of the draft, and if so delete it.""" + self.object = self.get_object() + if not request.user.is_authenticated or not request.user == self.object.author.user: + return HttpResponseForbidden() + self.object.delete() + messages.info(request, 'Question deleted successfully.') + return redirect(reverse('programming:draft_list')) + + +class SubmitQuestionView(LoginRequiredMixin, generic.CreateView): + """Handles request to create a new question from a draft.""" + + template_name = 'programming/add_question.html' + model = Draft + + def get_object(self, **kwargs): + """Get draft object for submission.""" + try: + draft = Draft.objects.get_subclass( + pk=self.kwargs['pk'] + ) + except Draft.DoesNotExist: + raise Http404("No draft question matches the given ID.") + + return draft + + def create_question_from_draft(self): + """Create a question model based on draft without saving it.""" + # Choose question model appropriately for the type + model_name = f"QuestionType{self.object.question_type.title()}" + class_ = getattr(programming.models, model_name) + question = class_() + + # Populate model with appropriate information + # Default fields + question.slug = self.object.slug + question.languages = self.object.languages + question.title = self.object.title + question.question_type = self.object.question_type + question.question_text = self.object.question_text + question.solution = self.object.solution + question.difficulty_level = self.object.difficulty_level + + # Model-specific fields + if self.object.question_type == 'parsons': + # Parsons + question.lines = self.object.lines + + elif self.object.question_type == 'debugging': + # Debugging + question.initial_code = self.object.initial_code + question.read_only_lines_top = self.object.read_only_lines_top + question.read_only_lines_bottom = self.object.read_only_lines_bottom + + return question + + def is_valid_question(self, request): + """ + Check whether the question created is valid. + + Creates a question from the draft in self.object, adds messages for + any problems to the request, and returns a boolean describing whether + the question is valid. + """ + valid = True + # Create (do NOT save) a question model based on draft + question = self.create_question_from_draft() + + # Verify there is at least one concept and one test case + if len(self.object.concepts.values()) == 0: + messages.error(request, 'Questions must have at least one concept') + valid = False + + if len(self.object.draft_test_cases.values()) == 0: + messages.error(request, 'Questions must have at least one test case') + + # Check the question model + try: + question.full_clean() + except ValidationError as e: + error_map = { + 'title': 'Questions must have a title', + 'type': 'Questions must have a type', + 'difficulty': 'Questions must have a difficulty', + 'question_text': 'Question text cannot be empty', + 'solution': 'Solution cannot be empty', + } + for msg in e.message_dict: + messages.error(request, error_map[msg]) + return False + + # Make new folder (a uniqueness check of the slug) + try: + os.mkdir(f'./programming/review/en/{self.object.slug}') + except FileExistsError: + messages.error(request, 'A question with that title already exists') + return False + except Exception: + messages.error(request, 'An unexpected error occurred') + return False + + # Return boolean of whether it is valid + return valid + + def generate_yaml(self): + """ + Generate the YAML to go in questions.yaml. + + Generates yaml with the following format: + title: + types: + - + [number_of_read_only_lines_top: ] + [number_of_read_only_lines_bottom: ] + [parsons-extra-lines: + - ] + test-cases: + 1: + difficulty: difficulty-<1|2|3|4> + concepts: + - + [contexts: + - ] + """ + # Preparation of different question types + before_test_cases = [ + {'type': [self.object.question_type]}, + ] + + if self.object.question_type == 'parsons': + before_test_cases[0]['types'] = ['function', 'parsons'] + del before_test_cases[0]['type'] + if self.object.lines is not None: + before_test_cases.append({'parsons-extra-lines': [self.object.extra_lines]}) + elif self.object.question_type == 'debugging': + before_test_cases += [ + {'number_of_read_only_lines_top': self.object.number_of_read_only_lines_top}, + {'number_of_read_only_lines_bottom': self.object.number_of_read_only_lines_bottom}, + ] + + # Test cases + test_cases = [] + for test_case in list(self.object.draft_test_cases.values()): + test_cases.append({test_case['number']: test_case['type']}) + test_cases = [{'test-cases': sorted(test_cases, key=lambda x: x.keys())}] + + # Difficulty, concepts, and contexts + after_test_cases = [ + {'difficulty': self.object.difficulty_level.slug}, + {'concepts': [concept['slug'] for concept in list(self.object.concepts.values())]}, + ] + contexts = list(self.object.contexts.values()) + if len(contexts) > 0: + after_test_cases.append({'contexts': [context['slug'] for context in contexts]}) + + # Join together + return [{self.object.title: before_test_cases + test_cases + after_test_cases}] + + def generate_markdown(self): + """Create markdown for question.md file.""" + lines = [f"# {self.object.title}"] + + for line in self.object.question_text.split('

'): + lines.append(line.replace('

', '\n') + .replace('', '**') + .replace('', '**') + .replace('', '`') + .replace('', '`')) + + return '\n'.join(lines) + + def generate_files(self): + """Generate all the stored files for a question.""" + # YAML for structure file + with open(f'./programming/review/structure/{self.object.slug}.yaml', 'w') as file: + yaml.dump(self.generate_YAML(), file) + + # Question text + with open(f'./programming/review/en/{self.object.slug}/question.md', 'w') as file: + file.write(self.generate_markdown()) + + # Question solution + with open(f'./programming/review/en/{self.object.slug}/solution.py', 'w') as file: + # Add a newline to the end of the file if needed + solution = self.object.solution + solution_lines = self.object.solution.split('/n') + if solution_lines[-1] != '': + solution += '\n' + + file.write(solution) + + # Initial code (for debugging) + if self.object.question_type == 'debugging': + with open(f'./programming/review/en/{self.object.slug}/initial.py', 'w') as file: + file.write(self.object.initial_code) + + # Iterate through test cases + test_case_suffix = 'input' if self.object.question_type == 'program' else 'code' + for test_case in list(self.object.draft_test_cases.values()): + test_case_file_prefix = f'./programming/review/en/{self.object.slug}/test-case-{test_case["number"]}' + with open(f'{test_case_file_prefix}-{test_case_suffix}.txt', 'w') as file: + file.write(test_case["test_code"]) + with open(f'{test_case_file_prefix}-output.txt', 'w') as file: + file.write(test_case["expected_output"]) + + # Create YAML file to store the macros + macros = [] + for macro in DraftMacro.objects.filter(draft=self.object): + values = [] + for value in list(macro.macro_values.values()): + values.append(value['value']) + macros.append({macro.placeholder: values}) + if len(macros) > 0: + with open(f'./programming/review/en/{self.object.slug}/macros.yaml', 'w') as file: + yaml.dump(macros, file) + + def post(self, request, *args, **kwargs): + """Handle post request to submit a draft.""" + if not request.user.is_authenticated: + return HttpResponseForbidden() + + self.object = self.get_object() + + if self.is_valid_question(request): + # Generate files + self.generate_files() + + # Delete from database + self.object.delete() + + # Send user to question list page + messages.info(request, 'Question submitted successfully.') + return redirect(reverse('programming:draft_list')) + + # Question was invalid, send user to question creation form to fix the issues + return redirect(reverse('programming:edit_draft', kwargs={'pk': self.kwargs['pk']})) + + +class DraftQuestionView(LoginRequiredMixin, generic.CreateView, generic.UpdateView): + """Display the form for editing a draft question.""" + + template_name = 'programming/add_question.html' + template_name_suffix = '' + form_class = NewQuestionForm + model = Draft + + def get_object(self, **kwargs): + """Get question object for view.""" + try: + if 'pk' in self.kwargs: + draft = Draft.objects.get_subclass( + pk=self.kwargs['pk'] + ) + else: + # Here, we create a new question + draft = None + except Draft.DoesNotExist: + raise Http404("No draft question matches the given ID.") + + return draft + + def get_context_data(self, **kwargs): + """ + Provide the context data for the create/edit question view. + + Returns: Dictionary of context data. + """ + context = super().get_context_data(**kwargs) + context['question'] = self.object + if context['question'] is not None: + test_cases = self.object.draft_test_cases.values() + context['test_cases'] = test_cases + context['test_cases_json'] = json.dumps(list(test_cases)) + + fetched_macros = DraftMacro.objects.filter(draft=self.object) + macros = [] + for macro in fetched_macros: + macro_values = macro.macro_values.values() + macros.append({ + 'placeholder': macro.placeholder, + 'values': [macro_val['value'] for macro_val in macro_values] + }) + + context['macros_json'] = json.dumps(list(macros)) + context['forms'] = { + "main_form": NewQuestionForm(instance=context['question']), + "macro_form": MacroForm(), + "test_case_form": TestCaseForm(), + } + + return context + + def _custom_split(self, string): + """Split on commas, but allow backslashes to escape splitting.""" + parts = string.split(',') + i = 1 + output = [parts[0]] + + while i < len(parts): + if output[-1].endswith('\\'): + output[-1] = output[-1][:-1] + ',' + parts[i] + else: + output.append(parts[i]) + i += 1 + + return output + + def form_valid(self, form, *args, **kwargs): + """Save the draft when a valid form is submitted.""" + if not self.request.user.is_authenticated: + return HttpResponseForbidden() + self.object = self.get_object() + + # First save the draft + draft = form.save(commit=False) + if self.object is None: + # New draft + draft.languages = ['en'] + draft.author_id = self.request.user.id + draft.slug = self._generate_slug(form.cleaned_data) + draft.save() + + # Then fetch/save many-to-many fields + # Concepts + concept_names = form.cleaned_data.get('concepts') + if 'conditionals' in concept_names: + concept_names.remove('conditionals') + concept_names += [form.cleaned_data.get('concept_conditionals')] + if 'loops' in concept_names: + concept_names.remove('loops') + concept_names += [form.cleaned_data.get('concept_loops')] + + # Contexts + context_names = form.cleaned_data.get('contexts') + if 'mathematics' in context_names: + context_names.remove('mathematics') + context_names += [form.cleaned_data.get('context_mathematics')] + context_names.remove('') # Handle the case where only geometry is selected + if form.cleaned_data.get('context_has_geometry'): + context_names += [form.cleaned_data.get('context_geometry')] + + # Test cases + test_case_lines = form.cleaned_data.get('test_cases').split('\n') + saved_test_cases = DraftTestCase.objects.filter(draft=draft) + for i in range(len(test_case_lines)): + parts = test_case_lines[i].split('@@') + if len(parts) != 3: + continue + given_type = parts[0] + code = parts[1] + expected_output = parts[2] + + if i < len(saved_test_cases): + test_case = saved_test_cases[i] + else: + test_case = DraftTestCase() + + # Fill data + test_case.number = i + 1 + test_case.type = given_type + test_case.test_code = code + test_case.expected_output = expected_output + test_case.draft = draft + + test_case.save() + # Remove test cases that have been deleted + for j in range(i + 1, len(saved_test_cases)): + saved_test_cases[j].delete() + + # Macros + macro_lines = form.cleaned_data.get('macros').split('\n') + saved_macros = DraftMacro.objects.filter(draft=draft) + for i in range(len(macro_lines)): + parts = macro_lines[i].split('@@') + if len(parts) != 2: + continue + name = parts[0] + values = self._custom_split(parts[1]) + + if i < len(saved_macros): + macro = saved_macros[i] + else: + macro = DraftMacro() + + macro.placeholder = name + macro.draft = draft + macro.save() + + saved_values = DraftMacroValue.objects.filter(macro=macro) + for j in range(len(values)): + if j < len(saved_values): + possible_value = saved_values[j] + else: + possible_value = DraftMacroValue() + possible_value.macro = macro + possible_value.value = values[j] + possible_value.save() + + # Remove values that have been deleted + for k in range(j + 1, len(saved_values)): + # saved_values[k].delete() + print("Delete value") + # Remove macros that have been deleted + for j in range(i + 1, len(saved_macros)): + # saved_macros[j].delete() + print("Delete macro") + + # Apply many-to-many fields to question + for name in concept_names: + concept_obj = ProgrammingConcepts.objects.get(slug=name) + draft.concepts.add(concept_obj) + + for name in context_names: + context_obj = QuestionContexts.objects.get(slug=name) + draft.contexts.add(context_obj) + + return redirect(self.get_success_url()) + + def _generate_slug(self, cleaned): + """ + Create a slug for new questions in a similar style to existing questions. + + Make title lowercase, replace spaces with hyphens, and add - to the end. + """ + return f"{cleaned['title'].lower().replace(' ', '-')}-{cleaned['question_type']}" + + def form_invalid(self, form): + """Take action if form is invalid.""" + return super().form_invalid(form) + + def get_success_url(self): + """Define the url to visit on a success.""" + return reverse('programming:draft_list') + + class QuestionView(LoginRequiredMixin, generic.DetailView): """Displays a question. diff --git a/codewof/setup.cfg b/codewof/setup.cfg index 8daa6414e..4b078118e 100644 --- a/codewof/setup.cfg +++ b/codewof/setup.cfg @@ -10,6 +10,7 @@ exclude = temp, manage.py, programming/content/en/*/initial.py, + programming/review/en/*/initial.py, show-source = True statistics = True count = True @@ -17,6 +18,7 @@ ignore = Q000, Q001, Q002, W503 per-file-ignores = style/style_checkers/python3_data.py:E501 programming/content/*/*.py:D100,D103 + programming/review/*/*.py:D100,D103,E226 tests/*.py:D100,D101,D102,D103,D107 [pydocstyle] diff --git a/codewof/static/js/question_list/question_selection.js b/codewof/static/js/question_list/question_selection.js new file mode 100644 index 000000000..906c97314 --- /dev/null +++ b/codewof/static/js/question_list/question_selection.js @@ -0,0 +1,26 @@ +let selected = null; + +$(document).ready(function() { + // Select this question when clicked + $('.qc-card-draft').on("click", function() { + select($(this)); + }); +}); + +function select(new_selection) { + if (selected !== null) { + selected.find('.qc-buttons, .qc-delete, .img-selected-true').addClass('d-none'); + selected.find('.qc-tags, .img-selected-false').removeClass('d-none'); + selected.removeClass('qc-complete'); + } + + if (!new_selection.is(selected)) { + new_selection.find('.qc-tags, .img-selected-false').addClass('d-none'); + new_selection.find('.qc-buttons, .qc-delete, .img-selected-true').removeClass('d-none'); + new_selection.addClass('qc-complete'); + + selected = new_selection; + } else { + selected = null; + } +} \ No newline at end of file diff --git a/codewof/static/js/question_types/base.js b/codewof/static/js/question_types/base.js index 49c542f6e..6a66f2ebe 100644 --- a/codewof/static/js/question_types/base.js +++ b/codewof/static/js/question_types/base.js @@ -155,79 +155,82 @@ function scroll_to_element(containerId, element) { } } -var editor = CodeMirror.fromTextArea(document.getElementById("code"), { - mode: { - name: "python", - version: 3, - singleLineStringErrors: false - }, - lineNumbers: true, - textWrapping: false, - styleActiveLine: true, - autofocus: true, - indentUnit: 4, - viewportMargin: Infinity, - // Replace tabs with 4 spaces, and remove all 4 when deleting if possible. - // Taken from https://stackoverflow.com/questions/15183494/codemirror-tabs-to-spaces and - // https://stackoverflow.com/questions/32622128/codemirror-how-to-read-editor-text-before-or-after-cursor-position - extraKeys: { - "Tab": function(cm) { - cm.replaceSelection(" ", "end"); +function create_new_editor(containerId) { + // Allows pages to have multiple editors + return CodeMirror.fromTextArea(document.getElementById(containerId), { + mode: { + name: "python", + version: 3, + singleLineStringErrors: false }, - "Backspace": function(cm) { - doc = cm.getDoc(); - line = doc.getCursor().line; // Cursor line - ch = doc.getCursor().ch; // Cursor character - - if (doc.somethingSelected()) { // Remove user-selected characters - doc.replaceSelection(""); - } else { // Determine the ends of the selection to delete - from = {line, ch}; - to = {line, ch}; - stringToTest = doc.getLine(line).substr(Math.max(ch - 4,0), Math.min(ch, 4)); - - if (stringToTest === " ") { // Remove 4 spaces (dedent) - from = {line, ch: ch - 4}; - } else if (ch == 0) { // Remove last character of previous line - if (line > 0) { - from = {line: line - 1, ch: doc.getLine(line - 1).length}; + lineNumbers: true, + textWrapping: false, + styleActiveLine: true, + autofocus: true, + indentUnit: 4, + viewportMargin: Infinity, + // Replace tabs with 4 spaces, and remove all 4 when deleting if possible. + // Taken from https://stackoverflow.com/questions/15183494/codemirror-tabs-to-spaces and + // https://stackoverflow.com/questions/32622128/codemirror-how-to-read-editor-text-before-or-after-cursor-position + extraKeys: { + "Tab": function(cm) { + cm.replaceSelection(" ", "end"); + }, + "Backspace": function(cm) { + doc = cm.getDoc(); + line = doc.getCursor().line; // Cursor line + ch = doc.getCursor().ch; // Cursor character + + if (doc.somethingSelected()) { // Remove user-selected characters + doc.replaceSelection(""); + } else { // Determine the ends of the selection to delete + from = {line, ch}; + to = {line, ch}; + stringToTest = doc.getLine(line).substr(Math.max(ch - 4,0), Math.min(ch, 4)); + + if (stringToTest === " ") { // Remove 4 spaces (dedent) + from = {line, ch: ch - 4}; + } else if (ch == 0) { // Remove last character of previous line + if (line > 0) { + from = {line: line - 1, ch: doc.getLine(line - 1).length}; + } + } else { // Remove preceding character + from = {line, ch: ch - 1}; } - } else { // Remove preceding character - from = {line, ch: ch - 1}; - } - // Delete the selection - doc.replaceRange("", from, to); - } - }, - "Delete" : function(cm) { - doc = cm.getDoc(); - line = doc.getCursor().line; // Cursor line - ch = doc.getCursor().ch; // Cursor character - - if (doc.somethingSelected()) { // Remove user-selected characters - doc.replaceSelection(""); - } else { // Determine the ends of the selection to delete - from = {line, ch}; - to = {line, ch}; - stringToTest = doc.getLine(line).substr(ch, 4); - - if (stringToTest === " ") { // Remove 4 spaces (dedent) - to = {line, ch: ch + 4}; - } else if (ch == doc.getLine(line).length) { // Remove first character of next line - if (line < doc.size - 1) { - to = {line: line + 1, ch: 0}; - } - } else { // Remove following character - to = {line, ch: ch + 1}; + // Delete the selection + doc.replaceRange("", from, to); } + }, + "Delete" : function(cm) { + doc = cm.getDoc(); + line = doc.getCursor().line; // Cursor line + ch = doc.getCursor().ch; // Cursor character + + if (doc.somethingSelected()) { // Remove user-selected characters + doc.replaceSelection(""); + } else { // Determine the ends of the selection to delete + from = {line, ch}; + to = {line, ch}; + stringToTest = doc.getLine(line).substr(ch, 4); + + if (stringToTest === " ") { // Remove 4 spaces (dedent) + to = {line, ch: ch + 4}; + } else if (ch == doc.getLine(line).length) { // Remove first character of next line + if (line < doc.size - 1) { + to = {line: line + 1, ch: 0}; + } + } else { // Remove following character + to = {line, ch: ch + 1}; + } - // Delete the selection - doc.replaceRange("", from, to); + // Delete the selection + doc.replaceRange("", from, to); + } } } - } -}); + }); +} exports.ajax_request = ajax_request; exports.clear_submission_feedback = clear_submission_feedback; @@ -235,4 +238,4 @@ exports.display_submission_feedback = display_submission_feedback; exports.update_test_case_status = update_test_case_status; exports.run_test_cases = run_test_cases; exports.scroll_to_element = scroll_to_element; -exports.editor = editor; \ No newline at end of file +exports.create_new_editor = create_new_editor; \ No newline at end of file diff --git a/codewof/static/js/question_types/debugging.js b/codewof/static/js/question_types/debugging.js index 70ea22f8d..4e13de8d9 100644 --- a/codewof/static/js/question_types/debugging.js +++ b/codewof/static/js/question_types/debugging.js @@ -13,7 +13,7 @@ $(document).ready(function () { mark_lines_as_read_only(editor); }); - var editor = base.editor; + var editor = base.create_new_editor("code"); mark_lines_as_read_only(editor); diff --git a/codewof/static/js/question_types/delete_modal_config.js b/codewof/static/js/question_types/delete_modal_config.js new file mode 100644 index 000000000..35ebffae2 --- /dev/null +++ b/codewof/static/js/question_types/delete_modal_config.js @@ -0,0 +1,55 @@ +$(document).ready(function() { + $('.delete-modal-button').on("click", function() { + let name = $(this).attr('data-object-name'); + let obj_type = $(this).attr('data-object-type'); + let id = $(this).attr('data-object-id'); + let url = $(this).attr('data-delete-url'); + + open_delete_modal(name, obj_type, id, url) + }); +}); + +function truncate(str, maxlength) { + return (str.length > maxlength) + ? str.slice(0, maxlength - 1) + '…' + : str +} + +function add_delete_listener(button, func=null) { + // Button is a jquery object + button.on("click", function() { + let name = $(this).attr('data-object-name'); + let obj_type = $(this).attr('data-object-type'); + let id = $(this).attr('data-object-id'); + let url = $(this).attr('data-delete-url'); + + open_delete_modal(name, obj_type, id, url, func) + }); +} + +function open_delete_modal(object_name, object_type, object_id, url=null, func=null) { + // Show the object name + $('#delete-modal').find('#modal_title_id').text(`Delete ${object_type}?`) + $('#delete-modal').find('#delete-warning-message').text(`Are you sure you want to delete "${object_name}"?`) + let delete_button = $('#delete-modal').find('#btn_delete_request'); + delete_button.val(`Delete ${object_type}`) + + // Set up the form to request deletion + delete_button.off('click'); + delete_button.attr('form', undefined); + if (url !== null) { + let delete_form = $('#deleteForm'); + delete_form.attr('action', url); + delete_button.attr('form', 'deleteForm'); + } else if (func !== null) { + delete_button.on('click', function() { + func(object_type, object_id); + $('#delete-modal').modal('hide'); + }); + } + + // Show the modal + $('#delete-modal').modal('show'); +} + +exports.add_delete_listener = add_delete_listener; \ No newline at end of file diff --git a/codewof/static/js/question_types/function.js b/codewof/static/js/question_types/function.js index 66c94475e..14a2b6cac 100644 --- a/codewof/static/js/question_types/function.js +++ b/codewof/static/js/question_types/function.js @@ -8,7 +8,7 @@ $(document).ready(function () { run_code(editor, true); }); - var editor = base.editor; + var editor = base.create_new_editor("code"); for (let i = 0; i < test_cases_list.length; i++) { data = test_cases_list[i]; diff --git a/codewof/static/js/question_types/preview.js b/codewof/static/js/question_types/preview.js new file mode 100644 index 000000000..e7a7078e4 --- /dev/null +++ b/codewof/static/js/question_types/preview.js @@ -0,0 +1,878 @@ +var base = require('./base.js'); +var delete_config = require('./delete_modal_config.js'); + +let solution_editor; +let initial_editor; +let preview_editor; +let drag_and_drop_row = null; +let test_cases = {}; +let macros = { + index: 0, + max_index: 0, + aliases: [], + substitutes: [], +}; + +// Constants +const MACRO_ATTR = { + modal_name: '#macro_modal', + fields: [ + { name: '#id_name', value: 0 }, + { name: '#id_possible_values', value: 1}, + ], + table_name: '#macro_table', + save_button: '#btn_macro_save', + save_action: save_macro, +} +const TEST_CASE_ATTR = { + modal_name: '#test_case_modal', + fields: [ + { name: '#id_testcase_type', value: 0 }, + { name: '#id_testcase_code', value: 1}, + ], + table_name: '#test_case_preview', + save_button: '#btn_test_case_save', + save_action: save_test_case, +} + +// Uses "on" for geometry, as it is a checkbox +const PARENT_TAGS = ['conditionals', 'loops', 'mathematics', 'on']; + +const HTML_MAP = { + '<p>': '

', + '</p>': '

', + '<code>': '', + '</code>': '', + '<sup>': '', + '</sup>': '', + '<strong>': '', + '</strong>': '', +} + +// Setup +$(document).ready(function() { + // Perform initial formatting + // Go to preview tab to render visibility-dependent items + $('#preview-select-tab a[href="#preview"]').tab('show'); + preview_editor = base.create_new_editor("code"); + + // Return to details tab + $('#preview-select-tab a[href="#details"]').tab('show'); + solution_editor = base.create_new_editor("id_solution"); + solution_editor.on('blur', function() { + let example_code = solution_editor.getValue(); + run_code(null, example_code); + for (var number in test_cases) { + update_test_case_tables(number, example_code); + } + update_test_cases(); + }); + initial_editor = base.create_new_editor("id_initial_code"); + + // Hide form fields which are displayed differently + $("#id_test_cases").hide(); + $("#id_macros").hide(); + + setup_concepts_contexts(); + setup_drag_and_drop() + setup_form() + + // Bind event-driven functions + $('#preview-tab-button').on('shown.bs.tab', function (event) { + see_preview(); + }); + $('#id_question_type').change(function () { + update_form(); + }); + + // Run once initially as this is the first active tab + load_test_cases(); + load_macros(); + update_macro_table(); + + initial_fill_form(); +}); + +function load_test_cases() { + // Load any initial test cases + if (typeof test_cases_list === 'undefined') { + return; + } + + for (let i = 0; i < test_cases_list.length; i++) { + let data = test_cases_list[i]; + test_cases[data.number] = data; + test_cases[data.number]['saved_input'] = test_cases[data.number].test_code; + // Test code is set by default + if (question_type == 'program') { + test_cases[data.number].test_input = test_cases[data.number]['saved_input']; + delete test_cases[data.number].test_code; + } + } +} + +function load_macros() { + // Load any initial macros + if (typeof macros_list === 'undefined') { + return; + } + + let value_to_store = ""; + + for (let i = 0; i < macros_list.length; i++) { + let macro = macros_list[i]; + macros.aliases.push(macro['placeholder']); + macros.substitutes.push(macro['values']); + value_to_store += `${macro['placeholder']}@@${macro['values'].join(',')}\n`; + } + + // Update the form field + $('#id_macros').val(value_to_store); +} + +function setup_form() { + // Define button actions + $('#btn_new_macro').on("click", function() { + create_edit_sub_form(MACRO_ATTR); + }); + $('#btn_new_test_case').on("click", function() { + create_edit_sub_form(TEST_CASE_ATTR); + }); + $('#btn_save_concepts').on("click", function() { + save_tags('concept'); + }); + $('#btn_save_contexts').on("click", function() { + save_tags('context'); + }); + $('#btn-macro-decrease').on("click", function() { + step_macros(-1); + }); + $('#btn-macro-increase').on("click", function() { + step_macros(1); + }); + $('.btn-edit-test-case').on("click", function() { + edit_sub_form($(this)); + }); + $('.btn-edit-macro').on("click", function() { + edit_sub_form($(this)); + }); +} + +function setup_drag_and_drop(setup_target=null) { + if(setup_target == null) { + setup_target = $('.dnd-interactable'); + } + + // Drag-and-drop table control, from + // https://www.therogerlab.com/sandbox/pages/how-to-reorder-table-rows-in-javascript?s=0ea4985d74a189e8b7b547976e7192ae.4122809346f6a15e41c9a43f6fcb5fd5 + setup_target.on('dragstart', function (event){ + let selected_item = event.target.tagName; + if (selected_item == "TR") { + drag_and_drop_row = event.target; + } else if (selected_item == "TD") { + drag_and_drop_row = event.target.parentNode; + } else { + return; + } + drag_and_drop_row.style.backgroundColor = "lightgrey"; + }); + setup_target.on('dragover', function (event){ + event.preventDefault(); + + if (drag_and_drop_row != null && event.target.tagName == "TD") { + // Make sure only the relevant table can be targeted + if (!event.target.parentNode.classList.contains('dnd-interactable')) { + return; + } + // Sort the list to force consistency + let children = Array.from(event.target.parentNode.parentNode.children).sort(function(a, b) { + // Comparing id of the output-help-text element because it is the most uniquely identifiable + return parseInt(a.querySelector('p').id.slice(10,11)) - parseInt(b.querySelector('p').id.slice(10,11)); + }); + if (children.indexOf(event.target.parentNode)${name}`; + let values_cell = `${possible_values}`; + + let edit_cell = `Edit`; + let delete_cell = `Delete`; + return `${name_cell + values_cell + edit_cell + delete_cell}` +} + +function save_macro(id = null) { + let value_to_store = ''; + + // Get values from form + let site_modal = $('#macro_modal'); + let name = escapeHtml(site_modal.find('#id_name').val()); + let possible_values = escapeHtml(site_modal.find('#id_possible_values').val()); + let stored_value = escapeHtml($('#id_macros').val()); + let count = id === null ? $('#macro_table').children('tbody').children().length : id; + + let new_row = create_macro_table_row(name, possible_values, count + 1) + + // Alter the main form and the display + if(id === null) { + // New + value_to_store = stored_value + `${name}@@${possible_values}\n`; + $('#id_macros').val(value_to_store); + $('#macro_table').children('tbody').append(new_row); + + // Setup edit and delete buttons + $('#macro_table').children('tbody').children('tr:last-child').children('.btn-edit-macro').on('click', function(event) { + edit_sub_form($(this)); + }); + delete_config.add_delete_listener($('#macro_table').children('tbody').children('tr:last-child').children('.btn-delete-macro'), func=delete_object); + } else { + // Edit + let lines = stored_value.split('\n').slice(0, -1); + lines[id] = `${name}@@${possible_values}`; + value_to_store = lines.join('\n') + '\n'; + + $('#id_macros').val(value_to_store); + $('#macro_table').children('tbody').children().eq(id).replaceWith(new_row); + + // Setup edit and delete buttons + $('#macro_table').children('tbody').children().eq(id).children('.btn-edit-macro').on('click', function(event) { + edit_sub_form($(this)); + }); + delete_config.add_delete_listener($('#macro_table').children('tbody').children().eq(id).children('.btn-delete-macro'), func=delete_object); + } + site_modal.modal('hide'); + +} + +function create_test_case_row(type, code, count) { + let type_cell = `${type}`; + let code_cell = `
${code}
`; + + let error_paragraph = `

An error has occurred once our test code has been added to the end of yours. You may not have terminated your strings correctly (is there a closing quote for every opening quote?)

`; + let output_cell = `
${error_paragraph}`;
+
+    let edit_cell = `Edit`;
+    let delete_cell = `Delete`;
+    return `${type_cell + code_cell + output_cell + edit_cell + delete_cell}`;
+}
+
+function update_test_case_list(id, number, type, given_code) {
+    // Update JS list
+    new_test_case = {
+        id: id,
+        number: number,
+        expected_output: '',
+        type: type,
+        received_output: '',
+        saved_input: given_code,
+    }
+
+    if (question_type == 'program') {
+        new_test_case.test_input = given_code;
+    } else {
+        new_test_case.test_code = given_code;
+    }
+
+    test_cases[new_test_case.number] = new_test_case;
+}
+
+function save_test_case(edit_id = null) {
+    let value_to_store = '';
+
+    // Get values from form
+    let site_modal = $('#test_case_modal');
+    let given_type = escapeHtml(site_modal.find('#id_testcase_type').val());
+    let given_code = escapeHtml(site_modal.find('#id_testcase_code').val());
+    let stored_value = escapeHtml($('#id_test_cases').val());
+    let count = edit_id === null ? $('#test_case_preview').children('tbody').children().length + 1: parseInt(edit_id) + 1;
+
+    // Expected output
+    update_test_case_list(edit_id, count, given_type, given_code);
+    let user_code = solution_editor.getValue();
+    run_code([test_cases[count]], user_code);
+
+    let generated_output = test_cases[count].received_output.trim();
+
+    if(edit_id === null) {
+        // New
+        let new_row = create_test_case_row(given_type, given_code, count);
+        value_to_store = stored_value + `${given_type}@@${given_code}@@${generated_output}\n`;
+
+        // Update form and display
+        $('#id_test_cases').val(value_to_store);
+        $('#test_case_preview').children('tbody').append(new_row);
+        setup_drag_and_drop($('#test_case_preview').children('tbody').last(new_row));
+        delete_config.add_delete_listener($('#test_case_preview').children('tbody').children('tr:last-child').children('.btn-delete-test-case'), func=delete_object);
+        $('#test_case_preview').children('tbody').children('tr:last-child').children('.btn-edit-test-case').on('click', function() {
+            edit_sub_form($(this));
+        });
+    } else {
+        // Edit
+        let new_row = create_test_case_row(given_type, given_code, parseInt(edit_id) + 1);
+        let lines = stored_value.split('\n').slice(0, -1);
+        lines[edit_id] = `${given_type}@@${given_code}@@${generated_output}`;
+        value_to_store = lines.join('\n') + '\n';
+
+        // Update form and display
+        $('#id_test_cases').val(value_to_store);
+        $('#test_case_preview').children('tbody').children().eq(edit_id).replaceWith(new_row);
+        delete_config.add_delete_listener($('#test_case_preview').children('tbody').children().eq(edit_id).children('.btn-delete-test-case'), func=delete_object);
+        $('#test_case_preview').children('tbody').children().eq(edit_id).children('.btn-edit-test-case').off('click').on('click', function(){
+            edit_sub_form($(this));
+        });
+
+        // Has to be looked up again because replaceWith returns the original element
+        // setup_drag_and_drop($('#test_case_preview').children('tbody').children().eq(id));
+    }
+    site_modal.modal('hide');
+    update_test_case_tables(count, user_code);
+}
+
+function save_tags(type) {
+    $(`#${type}_tray`).empty();
+    $(`#div_id_${type}s`).find(':checked').each(function() {
+        if(!PARENT_TAGS.includes($(this).val())) {
+            let name = $(this).siblings('label').text();
+            let tag_class = $(this).attr('data-relevant-class');
+
+            $(`#${type}_tray`).append(`${name}`);
+        }
+    });
+}
+
+function renumber_table(table, start) {
+    // Helper function for deleting. Requires table to be a jquery object
+    let rows = table.children('tbody').children()
+    for (let index = parseInt(start); index < rows.length; index++) {
+        let row = rows.eq(index);
+        row.children('.btn-delete-macro').attr('data-object-id', index + 1);
+        row.children('.btn-delete-test-case').attr('data-object-id', index + 1);
+
+        row.children('.horizontal-overflow-cell').eq(0).children('pre').attr('id', `test-case-${index + 1}-test-code`);
+        row.children('.horizontal-overflow-cell').eq(1).children('pre').attr('id', `test-case-${index + 1}-output`);
+        row.children('.horizontal-overflow-cell').eq(1).children('p').attr('id', `test-case-${index + 1}-output-help-text`);
+    }
+}
+
+function delete_object(type, id) {
+    /* This is called by the modal to remove a macro or test case */
+    let table;
+    let field;
+    if (type === 'macro') {
+        table = $('#macro_table');
+        field = $('#id_macros');
+    } else if (type === 'test case') {
+        table = $('#test_case_preview');
+        field = $('#id_test_cases');
+    } else {
+        return;
+    }
+    table.children('tbody').children().eq(id - 1).remove();
+    renumber_table(table, id - 1);
+
+    // Update form field
+    let lines = escapeHtml(field.val()).split('\n').slice(0, -1);
+    lines.splice(id - 1, 1);
+    let value_to_store = lines.join('\n');
+    if (lines.length > 0) {
+        value_to_store += '\n';
+    }
+    field.val(value_to_store);
+}
+
+// Preview tab
+function see_preview() {
+    // Match preview table and form input to any re-ordering
+    update_test_cases();
+
+    fill_from_form();
+
+    update_macros();
+    substitute_macros();
+}
+
+function generate_table_row(count, parts) {
+    return `
+        
+            
${escapeHtml(parts[1])}
+
${parts[2]}
`; +} + +function fill_from_form() { + // Reset the content of this tab to match the form + preview_editor.setValue(solution_editor.getValue()); + preview_editor.setOption('readOnly', 'nocursor'); + preview_editor.refresh(); + + let q_text = safeQuestionText($('#id_question_text').val()); + $("#preview").find('.question-text').html(q_text); + + // Reset test case table + let parent = $('#test-case-table').children('tbody').empty(); + let count = 1; + for(let test_case of $('#id_test_cases').val().split('\n').slice(0,-1)) { + parts = test_case.split('@@'); + parent.append(generate_table_row(count, parts)); + count ++; + } +} + +function custom_split(str) { + // Splits str on comma UNLESS it is preceded by a backslash + // Done this way as lookbehind regex is not supported on all browsers. + + // Split on each comma + let parts = str.split(','); + let output = []; + for(let i = 0; i < parts.length; i++) { + let part = parts[i]; + // If any of the split strings ends in a backslash, the comma was escaped and we rejoin the strings + while (part.slice(-1) === '\\') { + i++; + if (i >= parts.length) { + break; + } + part = part.slice(0, -1) + ',' + parts[i]; + } + output.push(part); + } + return output; +} + +function update_macros() { + let macro_form_field = $('#id_macros').val(); + macros.max_index = 0; + if (macro_form_field !== "") { + let lines = macro_form_field.split('\n').slice(0,-1); + macros.aliases = []; + macros.substitutes = []; + let max_usable_length = null; + for(let line of lines) { + let parts = line.split('@@') + let substitution = custom_split(escapeHtml(parts[1])); + macros.aliases.push(parts[0]); + macros.substitutes.push(substitution); + if (max_usable_length == null || max_usable_length > substitution.length) { + max_usable_length = substitution.length; + } + } + macros.max_index = Math.max(0, max_usable_length - 1); + macros.index = 0; + $('#btn-macro-decrease').attr("disabled", true); + $('#btn-macro-increase').attr("disabled", false); + $('#macro-cycle-text').text(macros.index); + if (macros.max_index == 0) { + $('#btn-macro-increase').attr("disabled", true); + $('#macro-cycle-text').text('Disabled'); + } + } +} + +function step_macros(change) { + macros.index += change; + + // Safeguarding + if (macros.index < 0 || macros.index > macros.max_index) { + macros.index -= change; + return; + } + + // Reset the preview, then perform substitution + fill_from_form(); + substitute_macros(); + $('#macro-cycle-text').text(macros.index); + + // Disable buttons if we've reached the end of the list + $('#btn-macro-decrease').attr("disabled", false); + $('#btn-macro-increase').attr("disabled", false); + if (macros.index == 0) { + $('#btn-macro-decrease').attr("disabled", true); + } else if (macros.index == macros.max_index) { + $('#btn-macro-increase').attr("disabled", true); + } +} + +function substitute_macros() { + // Fetch the parts to substitute + let example_code = preview_editor.getValue(); + let question_text = $("#preview").find('.question-text').html(); + let preview_test_cases = []; + let test_cases_text = []; + $('#test-case-table').find('.macro-substitution').each(function () { + preview_test_cases.push($(this)); + test_cases_text.push($(this).text().trim()); + }); + + let input_type = question_type === "program" ? 'test_input' : 'test_code' ; + + // Perform replacement + for (var number in test_cases) { + if (test_cases.hasOwnProperty(number)) { + test_cases[number][input_type] = test_cases[number]['saved_input']; + } + } + for (let i = 0; i < macros.aliases.length; i++) { + let pattern = '@' + macros.aliases[i]; + let substitute = macros.substitutes[i][macros.index]; + + example_code = example_code.replaceAll(pattern, substitute); + question_text = question_text.replaceAll(pattern, substitute); + for (let j = 0; j < test_cases_text.length; j++) { + test_cases_text[j] = test_cases_text[j].replaceAll(pattern, substitute); + } + for (var number in test_cases) { + if (test_cases.hasOwnProperty(number)) { + test_cases[number][input_type] = test_cases[number][input_type].replaceAll(pattern, substitute) + } + } + } + + // Update page + $("#preview").find('.question-text').html(safeQuestionText(question_text)); + preview_editor.setValue(example_code); + run_code(null, example_code, macro_update=false); + for (var number in test_cases) { + preview_test_cases[number].children().text(test_cases_text[number]); + update_test_case_tables(number, example_code); + } +} + +// Running Python code +function run_code(cases_to_run=null, user_code, macro_update=true) { + if (cases_to_run === null) { + cases_to_run = test_cases; + } + + if (macro_update) { + update_macros(); + } + for (var id in cases_to_run) { + if (test_cases.hasOwnProperty(id)) { + var test_case = cases_to_run[id]; + test_case['received_output'] = ''; + test_case.runtime_error = false; + } + } + + // Check indentation + if (user_code.includes("\t")) { + // contains tabs + $("#indentation-warning").removeClass("d-none"); + return; // do not run tests + } else { + $("#indentation-warning").addClass("d-none"); + } + cases_to_run = run_draft_test_cases(cases_to_run, user_code, run_python_code); + // Manually update the test case + for (var id in cases_to_run) { + if (test_cases.hasOwnProperty(id)) { + var test_case = cases_to_run[id]; + } + } +} + +function run_draft_test_cases(cases_to_run, user_code, code_function) { + // Currently runs in sequential order. + for (var number in cases_to_run) { + if (cases_to_run.hasOwnProperty(number)) { + var test_case = cases_to_run[number]; + var code = user_code; + if (test_case.hasOwnProperty('test_code')) { + code = code + '\n' + test_case.test_code; + } + if (test_case.hasOwnProperty('test_input')) { + test_case.test_input_list = test_case.test_input.split('\n'); + } + code_function(code, test_case); + } + } + return cases_to_run; +} + +function run_python_code(user_code, test_case) { + // Configure Skulpt for running Python code + Sk.configure({ + // Setup Skulpt to read internal library files + read: function (x) { + if (Sk.builtinFiles === undefined || Sk.builtinFiles["files"][x] === undefined) + throw "File not found: '" + x + "'"; + return Sk.builtinFiles["files"][x]; + }, + inputfun: function (str) { + if (question_type != "program") { + return prompt(str); + } + + // Program questions + if (test_case.test_input_list.length > 0) { + return test_case['test_input_list'].shift(); + } else { + return ''; + } + }, + inputfunTakesPrompt: true, + // Append print() statements for test case + output: function (received_output) { + test_case['received_output'] += received_output; + }, + python3: true, + execLimit: 1000, + }); + if (typeof user_code == 'string' && user_code.trim()) { + try { + Sk.importMainWithBody("", false, user_code, true); + } catch (error) { + if (error.hasOwnProperty('traceback')) { + test_case.received_output = error.toString(); + test_case.runtime_error = true; + } else { + throw error; + } + } + } else { + test_case.received_output = 'No Python code provided.'; + test_case.runtime_error = true; + } +} + +function update_test_case_tables(number, user_code) { + let received_output = test_cases[number].received_output.replace(/\s*$/, ''); + + // Update output cells + let output_element = $('#test-case-' + number + '-output, #test-case-' + number + '-expected-output'); + let output_element_help_text = $('#test-case-' + number + '-output-help-text'); + output_element.text(received_output); + if (test_cases[number].runtime_error) { + output_element.addClass('error') + // the following is implemented because of https://github.com/uccser/codewof/issues/351 + regex_match = /line (\d+)/.exec(received_output) // looking for line number + if (regex_match !== null) { + error_line_number = regex_match[1] // first capture group - should be the line number + num_user_code_lines = user_code.split('\n').length; // number of lines in the users code + if (error_line_number > num_user_code_lines) { + output_element_help_text.removeClass('d-none'); + } + } + } else { + output_element.removeClass('error') + output_element_help_text.addClass('d-none'); + } +} \ No newline at end of file diff --git a/codewof/static/js/question_types/program.js b/codewof/static/js/question_types/program.js index f49e9087e..dc65fbcd8 100644 --- a/codewof/static/js/question_types/program.js +++ b/codewof/static/js/question_types/program.js @@ -8,7 +8,7 @@ $(document).ready(function () { run_code(editor, true); }); - var editor = base.editor; + var editor = base.create_new_editor("code"); for (let i = 0; i < test_cases_list.length; i++) { data = test_cases_list[i]; diff --git a/codewof/static/scss/_question-card.scss b/codewof/static/scss/_question-card.scss index 6b7832559..cec7d7955 100644 --- a/codewof/static/scss/_question-card.scss +++ b/codewof/static/scss/_question-card.scss @@ -50,6 +50,16 @@ } } +.qc-card.qc-card-draft { + grid-template-areas: + "qc-checkbox qc-type qc-delete" + "qc-checkbox qc-title qc-delete" + "qc-checkbox qc-details qc-delete" + "qc-tags qc-tags qc-tags" + "qc-buttons qc-buttons qc-buttons"; + grid-template-rows: auto auto auto; +} + .qc-checkbox { grid-area: qc-checkbox; display:flex; @@ -81,3 +91,20 @@ margin-top: 0.5rem; grid-area: qc-tags; } + +.qc-buttons { + align-self: end; + margin-top: 0.5rem; + display: flex; + grid-area: qc-buttons; +} + +.qc-delete { + border: 0px; + grid-area: qc-delete; + height: max-content; + + svg { + width: 24px; + } +} \ No newline at end of file diff --git a/codewof/static/scss/website.scss b/codewof/static/scss/website.scss index 5721a6d9a..bfb2b893e 100644 --- a/codewof/static/scss/website.scss +++ b/codewof/static/scss/website.scss @@ -160,6 +160,150 @@ $parsons-hover-colour: #2196f35c; opacity: 0.5; } +// Question adding +$form-border: $gray-300; +$form-light: #fffcf8; +$form-medium: #efece8; +$form-dark: #dfdcd8; + +.header-button-tray{ + display: flex; + margin-bottom: 8px; + + .link-vertical-center { + display: flex; + align-items: center; + } +} + +.tab-content { + border: 1px solid $form-border; + border-radius: 0px 10px 10px; + padding: 10px; + + .new-question-form-container { + margin:auto; + min-width: none; + max-width: 100%; + + @media (min-width: 768px) { + min-width: max-content; + max-width: 60%; + } + } +} + +.nav-tabs { + border-bottom: 1px; +} + +#preview-select-tab .nav-link.active { + background-color: $form-light; + border-bottom: 1px solid $form-light; +} + +#macro-container { + border: 1px solid $form-border; + border-radius: 5px; + padding: 5px; + text-align: center; +} + +.variable-cycler { + background-color: $form-medium; + margin: auto; + width: 95%; + + button { + border: 0px; + background-color: $form-medium; + + &:active, &:hover { + background-color: $form-dark; + } + } +} + +.preview-code { + width: 95%; + margin: 5px auto; + textarea { + width: 100%; + height: max-content; + resize: none; + } +} + +.question-tag-tray { + display: flex; + align-items: center; + border: solid $form-border; + border-width: 1px 0px; + margin: -2px 0px 10px; +} + +#div_id_concept_conditionals, #div_id_concept_loops, #div_id_context_has_geometry, #div_id_context_mathematics { + margin-left: 30px; + margin-bottom: 0px; +} + +#div_id_context_geometry { + margin-left: 60px; + margin-bottom: 0px; +} + +#div_id_macros, #div_id_test_cases { + margin-bottom: 0px; + + label { + margin-bottom: 0px; + } +} + +.hover-button { + background-color: $form-light; + cursor: default; + + &:hover { + background-color: $form-medium; + } +} + +.creation_form_table { + width: 100%; + border-collapse: collapse; + user-select: none; + text-align: left; + border-bottom: 1px solid $form-border; + margin-bottom: 5px; + + .dnd-interactable { + cursor: move; + } + + td { + padding: 3px 5px; + } + + .btn-cell { + text-align: right; + width: 0px; + border-left: 1px dashed $form-border; + padding: 1px 5px; + cursor: default; + } + + .horizontal-overflow-cell { + white-space: nowrap; + overflow-x: auto; + max-width: 20em; + } +} + +.button-tray { + text-align: right; +} + // Other strong { diff --git a/codewof/static/svg/delete-icon-24.svg b/codewof/static/svg/delete-icon-24.svg new file mode 100644 index 000000000..428aec600 --- /dev/null +++ b/codewof/static/svg/delete-icon-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codewof/static/svg/delete-icon-24.svg:Zone.Identifier b/codewof/static/svg/delete-icon-24.svg:Zone.Identifier new file mode 100644 index 000000000..d0ed21d47 --- /dev/null +++ b/codewof/static/svg/delete-icon-24.svg:Zone.Identifier @@ -0,0 +1,3 @@ +[ZoneTransfer] +ZoneId=3 +HostUrl=chrome-extension://naeaaedieihlkmdajjefioajbbdbdjgp/ diff --git a/codewof/templates/base.html b/codewof/templates/base.html index 2fefaf5d9..1433a24c9 100644 --- a/codewof/templates/base.html +++ b/codewof/templates/base.html @@ -59,9 +59,12 @@ Questions - + Style Checkers + + Created Questions + {% if RESEARCH %} Research diff --git a/codewof/templates/programming/add_question.html b/codewof/templates/programming/add_question.html new file mode 100644 index 000000000..915f39cb8 --- /dev/null +++ b/codewof/templates/programming/add_question.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load static %} +{% load crispy_forms_tags %} + +{% block title %}Edit New Question{% endblock %} + +{% block page_heading %} +

New Question

+{% endblock page_heading %} + +{% block content %} + +
+ + {% include "programming/question_creation/configurable_delete_modal.html" %} + +
+
+
+ {% crispy forms.macro_form %} + {% crispy forms.test_case_form %} + {% crispy forms.main_form %} +
+
+ +
+
+
+

{{ question.title }}

+
+ {{ question.question_text|safe }} +
+ +
Solution Code
+ {% include "programming/question_components/editor-python.html" %} +
+ + {% include "programming/question_creation/test-case-preview-table.html" %} +
+
+ {% include "programming/question_creation/preview-macros.html" %} +
+
+
+
+ + +{% endblock %} + +{% block scripts %} + {% csrf_token %} + + + +{% endblock scripts %} \ No newline at end of file diff --git a/codewof/templates/programming/draft_list.html b/codewof/templates/programming/draft_list.html new file mode 100644 index 000000000..9dacb847c --- /dev/null +++ b/codewof/templates/programming/draft_list.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% load static crispy_forms_tags %} + +{% block title %}My Questions{% endblock %} + +{% block page_heading %} +

My Created Questions

+{% endblock page_heading %} + +{% block content %} +

Currently all questions are based in Python 3.

+
+

All questions

+ New Question +
+ {% include "programming/question_creation/configurable_delete_modal.html" %} + +
+ + Filter Questions +
+
+
+
+ {% crispy filter.form filter_formatter %} +
+ + {% if filter.qs %} +
+ {% for draft in filter.qs %} +
+ {% include "programming/question_creation/draft-question-card.html" %} +
+ {% endfor %} +
+ {% else %} +

+ Sorry!
+ No questions found matching the selected filters. +

+ {% endif %} + +{% endblock content %} + +{% block scripts %} + + + +{% endblock scripts %} diff --git a/codewof/templates/programming/question.html b/codewof/templates/programming/question.html index d080dc6b4..42fa632b3 100644 --- a/codewof/templates/programming/question.html +++ b/codewof/templates/programming/question.html @@ -52,9 +52,7 @@

{{ question.title }}

{% endif %} -
- It looks like you have used tab characters to indent your code. It is good Python style to use spaces, please remove any tab characters and try again. -
+ {% include "programming/question_components/indentation-warning.html" %}
+
+ + + + \ No newline at end of file diff --git a/codewof/templates/programming/question_creation/creation_test_case_table.html b/codewof/templates/programming/question_creation/creation_test_case_table.html new file mode 100644 index 000000000..d94d6388d --- /dev/null +++ b/codewof/templates/programming/question_creation/creation_test_case_table.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + {% if test_cases %} + {% for test_case in test_cases %} + + + + + + + + {% endfor %} + {% endif %} + +
+ Type + + Test + + Expected output +
+ {{ test_case.type }} + +
{% if test_case.test_code %}{{ test_case.test_code }}{% elif test_case.test_input %}{{ test_case.test_input }}{% endif %}
+
+
{{ test_case.expected_output }}
+

+ An error has occurred once our test code has been added to the end of yours. You may not have terminated your strings correctly (is there a closing quote for every opening quote?) +

+
+ Edit + + Delete +
\ No newline at end of file diff --git a/codewof/templates/programming/question_creation/draft-question-card.html b/codewof/templates/programming/question_creation/draft-question-card.html new file mode 100644 index 000000000..00c8cfc29 --- /dev/null +++ b/codewof/templates/programming/question_creation/draft-question-card.html @@ -0,0 +1,43 @@ +{% load static svg %} + +
+
+
+ + +
+
+ + {{ draft.question_type|capfirst }} + +
+
+ {{ draft.title }} +
+
+ {% include "programming/question_components/badges/difficulty.html" %} + {% for concept in draft.concepts.all %} + {% if not concept.has_children %} + {% include "programming/question_components/badges/concept.html" %} + {% endif %} + {% endfor %} + {% for context in draft.contexts.all %} + {% if not context.has_children %} + {% include "programming/question_components/badges/context.html" %} + {% endif %} + {% endfor %} +
+
+ View/Edit +
+ {% csrf_token %} + +
+ +
+ +
+
diff --git a/codewof/templates/programming/question_creation/macros_display_table.html b/codewof/templates/programming/question_creation/macros_display_table.html new file mode 100644 index 000000000..e09220380 --- /dev/null +++ b/codewof/templates/programming/question_creation/macros_display_table.html @@ -0,0 +1,35 @@ + + + + + + + + + + + {% if macros %} + {% for macro in macros %} + + + + + + + {% endfor %} + {% endif %} + +
+ Name + + Possible Values +
+ {{ macro.name }} + + {{ macro.value }} + + Edit + + Delete +
\ No newline at end of file diff --git a/codewof/templates/programming/question_creation/preview-macros.html b/codewof/templates/programming/question_creation/preview-macros.html new file mode 100644 index 000000000..915e13e8e --- /dev/null +++ b/codewof/templates/programming/question_creation/preview-macros.html @@ -0,0 +1,5 @@ +
+
Preview Macros
+
+ Disabled
+
\ No newline at end of file diff --git a/codewof/templates/programming/question_creation/tag_modal_indicators.html b/codewof/templates/programming/question_creation/tag_modal_indicators.html new file mode 100644 index 000000000..2d63ed50b --- /dev/null +++ b/codewof/templates/programming/question_creation/tag_modal_indicators.html @@ -0,0 +1,22 @@ + +
+
+ {% for concept in question.concepts.all %} + {% if not concept.has_children %} + {% include "programming/question_components/badges/concept.html" with link=False %} + {% endif %} + {% endfor %} +
+ +
+ +
+
+ {% for context in question.contexts.all %} + {% if not context.has_children %} + {% include "programming/question_components/badges/context.html" with link=False %} + {% endif %} + {% endfor %} +
+ +
\ No newline at end of file diff --git a/codewof/templates/programming/question_creation/test-case-preview-table.html b/codewof/templates/programming/question_creation/test-case-preview-table.html new file mode 100644 index 000000000..2d826c89e --- /dev/null +++ b/codewof/templates/programming/question_creation/test-case-preview-table.html @@ -0,0 +1,25 @@ +
+ + + + + + + + + {% for test_case in test_cases %} + + + + + {% endfor %} + +
TestExpected output
+
{{ test_case.test_code }}
+
+
{{ test_case.expected_output }}
+

An error has occurred once our test code has been added to the end of yours. You may not have terminated your strings correctly (is there a closing quote for every opening quote?)

+
+
diff --git a/docs/adding-questions.md b/docs/adding-questions.md index ecc5c9347..42af48127 100644 --- a/docs/adding-questions.md +++ b/docs/adding-questions.md @@ -43,62 +43,28 @@ There are multiple types of questions available: - **Parsons:** Drag and drop code creator from a set of given blocks. - **Debugging:** Existing code with errors, some code is read only. -A question can only be one type, expect for questions that can be both function and parson types. -## Adding a question - -There are three stages to adding a question: - -1. Add question metadata (language independent) -2. Add question content (language dependent) -3. Add question tags (difficulty, concepts, contexts) - -### Adding question metadata - -Open file: `codewof/programming/content/structure/questions.yaml` - -The file contains key information about the question. - -*We recommend copying a question with the same type as your new question.* - -Each question requires a slug, a unique identifier made from lower case letters, numbers, and dashes. -Each question then has a `type` or `types`. - -Each question also has a number of test cases. -Each test case is either `normal` or `exceptional`. -All test cases are normal, but exceptional test cases are when unexpected values are checked (like a wrong data type). -If in doubt, mark a test case as 'normal'. - -Parson questions have the following additional item: - -- `parsons-extra-lines` - -Debugging questions have the following additional items: - -- `number_of_read_only_lines_top` -- `number_of_read_only_lines_bottom` - -## Adding question content - -Open directory: `codewof/programming/content/en/` +## Adding a question -Create a directory with the slug you added in the previous step. +There are three stages to a question being added: -Each question directory should have the following files: +1. Creating a draft +2. Submitting a draft +3. Reviewing a draft -- `question.md` - Markdown file containing question title and description. -- `solution.py` - Python file that is the solution to the question. -- Test cases: +### Creating a draft +Visit `codewof.co.nz/questions/created` to see the list of drafts you have yet to submit. On this page is a button to create a new question. Clicking this will take you to an interface allowing you to enter the form data. - - `test-case-N-input.txt` (program type only) - Input for test case. - - `test-case-N-code.txt` (non-program types only) - Code to append for test case. - - `test-case-N-output.txt` - Expected output for test case. +Pressing save on a draft will not submit it, but will instead save it and take you back to the list of drafts page. -- `initial.py` (debugging type only) - Python file for initial code to display. +### Submitting a draft +To submit a draft, click on it on the list page mentioned above and then click Submit. Don't be surprised if you get brought back to the form for creating the question - the rules for submitting a question are more strict than for saving one. -## Adding question tags +### Reviewing a draft +Once a draft has been submitted, it can be reviewed by an administrator user via the process described in reviewing-questions.md. +## Question tags Each question must be tagged by difficulty, and can be tagged by programming concepts and programming contexts. This allows users to easily search for questions of a specific type. diff --git a/docs/question-storage.md b/docs/question-storage.md new file mode 100644 index 000000000..66d670178 --- /dev/null +++ b/docs/question-storage.md @@ -0,0 +1,129 @@ +# Questions in codeWOF + +This guide provides a brief overview of how questions are stored in codeWOF. +The system we use for storing questions in our repository more complicated than you might expect, but we have used this system successfully for many years as it handles translations very well. + +Most questions on the website so far can be completed in a few lines, and only test one or a couple of skills at a time. + +Currently only Python 3 questions are supported on the website. + +Skills can include: + +- Strings (creating, manipulation, etc) +- Numbers (mathematics, etc) +- Input +- Output +- Types (casting, etc) +- Conditionals +- Repetition +- Basic data structures (lists) + +Currently we do not test advanced skills, such as: +- Dictionaries +- Sets +- Coding style +- Exceptions + +**Note:** *We currently cannot include questions requiring the `round()` function due to a bug.* + +## Question type + +There are multiple types of questions available: + +- **Program:** Runs the submitted code, and can ask/receive input. +- **Function:** Appends a function call to the submitted code. +- **Parsons:** Drag and drop code creator from a set of given blocks. +- **Debugging:** Existing code with errors, some code is read only. + +A question can only be one type, except for questions that can be both function and parson types. + +## Components of a question + +There are three major components of a question: + +1. Question metadata (language independent) +2. Question content (language dependent) +3. Question tags (difficulty, concepts, contexts) + +## Question metadata + +The file `codewof/programming/content/structure/questions.yaml` contains key information about the question. + +Each question requires a slug, a unique identifier made from lower case letters, numbers, and dashes. + +Each question then has a `type` or `types`. + +Each question also has a number of test cases. +Each test case is either `normal` or `exceptional`. +All test cases are normal, but exceptional test cases are when unexpected values are checked (like a wrong data type). +If in doubt, mark a test case as 'normal'. + +Parson questions have the following additional item: + +- `parsons-extra-lines` + +Debugging questions have the following additional items: + +- `number_of_read_only_lines_top` +- `number_of_read_only_lines_bottom` + +## Question content + +In the directory `codewof/programming/content/en/`, there must be a directory with the slug from `questions.yaml` (see Question metadata). + +Each question directory should have the following files: + +- `question.md` - Markdown file containing question title and description. +- `solution.py` - Python file that is the solution to the question. +- Test cases: + + - `test-case-N-input.txt` (program type only) - Input for test case. + - `test-case-N-code.txt` (non-program types only) - Code to append for test case. + - `test-case-N-output.txt` - Expected output for test case. + +- `initial.py` (debugging type only) - Python file for initial code to display. + +Question directories may optionally have a `macros.yaml` file. See randomisation.md for more details. + +## Question tags + +Each question must be tagged by difficulty, and can be tagged by programming concepts and programming contexts. +This allows users to easily search for questions of a specific type. + +These are defined in the file `codewof/programming/content/structure/questions.yaml` + +Each question **requires** a `difficulty`, either: +- `difficulty-0` - Easy +- `difficulty-1` - Moderate +- `difficulty-2` - Difficult +- `difficulty-3` - Complex + +If applicable, one or more `concepts` should be added to the question from the following +(i.e. you cannot have a question with the "Conditionals" concept, it needs to be a sub-category such as `single-condition`): + +- `display-text` - Display Text +- `functions` - Functions +- `inputs` - Inputs +- Conditionals + - `single-condition` - Single Condition + - `multiple-conditions` - Multiple Conditions + - `advanced-conditionals` - Advanced Conditionals +- Loops + - `conditional-loops` - Conditional Loops + - `range-loops` - Range Loops +- `string-operations` - String Operations +- `lists` - Lists + +If applicable, one or more `contexts` should be added to the question from the following +(i.e. you cannot have a question with the "Geometry" context, it needs to be a sub-category such as `basic-geometry`): + +- Mathematics + - Geometry + - `basic-geometry` - Basic Geometry + - `advanced-geometry` - Advanced Geometry + - `simple-mathematics` - Simple Mathematics + - `advanced-mathematics` - Advanced Mathematics +- `real-world-applications` - Real World Applications + +## Draft questions +Draft questions are stored as a more generic instance of a question, and are more strictly typed when submitting. This is to allow drafts to be saved with very little information. Once submitted for review, drafts are stored as files until they have been reviewed (see reviewing-questions.md). \ No newline at end of file diff --git a/docs/randomisation.md b/docs/randomisation.md new file mode 100644 index 000000000..dbf9a1422 --- /dev/null +++ b/docs/randomisation.md @@ -0,0 +1,17 @@ +# Randomisation in CodeWOF questions + +CodeWOF has the ability to **save** macros to be substituted in at question generation, however as yet it does not make use of these. Macros are intended to be chosen by index to form combinations, e.g. all values with index 0 would be chosen. + +## Storage +Macros are stored as `macros.yaml` in the `en/question_name/` directory of the question, with the following format: +`placeholder-0`: + - `value-00` + - `value-01` +`placeholder-1`: + - `value-10` + - `value-11` + +## Selection +The intended functionality for macros is for index-based combinations. The above example gives two possible variations, with either `value-00` and `value-10` being chosen together or `value-01` and `value-11` being chosen together. + +Currently, macros are not utilised by the CodeWOF system. \ No newline at end of file diff --git a/docs/reviewing-questions.md b/docs/reviewing-questions.md new file mode 100644 index 000000000..00375a722 --- /dev/null +++ b/docs/reviewing-questions.md @@ -0,0 +1,34 @@ +# Reviewing Submitted CodeWOF Questions + +This guide provides a brief overview of how to review questions for CodeWOF. This is only possible for administrator users. + +## Finding questions to review + +Submitted questions are currently stored as files on the server. To view them, log into the production server and navigate to `programming/review/`. From here, each individual question has a file in `structure/` which contains the YAML describing it, and a folder in `en/` which contain the question files. + +## Reviewing a question +Pick a question from its file in `structure/` - e.g. `question_name.yaml`. This file and the accompanying folder in `en/` (named `question_name/`) are the components of the question. Check the following: +- `structure/question_name.yaml`: this file needs to be valid YAML syntax. It should meet the criteria described in reviewing-questions.md. +- `en/question_name/`: this **must** contain the following files. + - `question.md`: This should contain the title and the question text, in valid markdown. The only HTML tag that should be visible is . + - `solution.py`: This must be valid python code. + - `test-case--code.txt` or `test-case--input.txt`: the file should contain input if the question type is program, otherwise it should contain code. Check that this file is either valid python code, or reasonable input for the question accordingly. + - `test-case--output.txt`: this should contain the expected output of test case n + If the question is a debugging question, the folder must also contain + - `initial.py`: This should be valid python code which will need to be debugged. +- For the files described above, check that: + - The question text makes the requirements clear; + - The solution code correctly answers the question and is stylistically correct (especially check that there is whitespace around operators); + - The test cases adequately describe the solution (e.g. boundary cases); + - Any initial code does not pass all test cases; + - No Python code uses the `round()` function (currently broken) + +Additionally, check that the question being reviewed does not contain macros (look for a `macros.yaml` file in the `en/question_name/` directory). These are not currently used when serving questions, so would return broken questions to the user. + +### Passing review +To move a question from a review state to being used, copy the contents of `question_name.yaml` to `programming/content/structure/questions.yaml` and delete `question_name.yaml`. Then, move the folder `programming/review/en/question_name/` to `programming/content/en/question_name/`. + +To avoid any potential issues with automated deployment, push these changes to the git repository. + +### Failing review +If the reviewed question needs some minor work, feel free to make the small changes required and then review the question again. Otherwise, delete the file `question_name.yaml` from `review/structure/` and the folder `question_name/` from `/review/en`. \ No newline at end of file