diff --git a/generic/views.py b/generic/views.py index 460a47cf..96768612 100644 --- a/generic/views.py +++ b/generic/views.py @@ -1891,7 +1891,7 @@ class GenericViewBase: "rapids": "rapid_checker", "anatomy": "anatomy_checker", "longs": "longs_checker", - "sbas": "sbas_checker", + "sbas": "sba_checker", "physics": "physics_checker", } diff --git a/sbas/forms.py b/sbas/forms.py index a6801c5c..525d1704 100755 --- a/sbas/forms.py +++ b/sbas/forms.py @@ -14,11 +14,14 @@ from .models import ( Question, CidUserAnswer, Exam, + Category ) from django.contrib.admin.widgets import FilteredSelectMultiple from django.forms.widgets import RadioSelect, TextInput, Textarea +from tinymce.widgets import TinyMCE + class CidUserAnswerForm(ModelForm): class Meta: @@ -36,4 +39,119 @@ class ExamForm(ExamFormMixin, ModelForm): class ExamAuthorForm(ExamAuthorFormMixin): class Meta(ExamAuthorFormMixin.Meta): - model = Exam \ No newline at end of file + model = Exam + + +class QuestionForm(ModelForm): + + # exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all()) + + class Media: + # Django also includes a few javascript files necessary + # for the operation of this form element. You need to + # include + # in the template. + css = { + "all": ["css/widgets.css"], + } + # Adding this javascript is crucial + js = ["jsi18n.js", "tesseract.min.js"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop( + "user" + ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole + if kwargs.get("instance"): + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + initial = kwargs.setdefault("initial", {}) + # The widget for a ModelMultipleChoiceField expects + # a list of primary key for the selected data. + initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] + + ModelForm.__init__(self, *args, **kwargs) + + super(QuestionForm, self).__init__(*args, **kwargs) + # self.fields['question'].widget.attrs = {'class': 'question-form', 'rows': 10, 'cols': 100} + # self.fields['feedback'].widget.attrs = {'class': 'feedback-form', 'rows': 10, 'cols': 100} + self.fields["category"] = ModelChoiceField( + required=True, + queryset=Category.objects.all(), + initial=1, + ) + + + if self.user.groups.filter(name="sba_checker").exists(): + exam_queryset = Exam.objects.all() + else: + exam_queryset = Exam.objects.filter( + author__id=self.user.id + ) | Exam.objects.filter(open_access=True) + + self.fields["exams"] = ModelMultipleChoiceField( + required=False, + queryset=exam_queryset, + widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), + ) + + def save(self, commit=True): + # Get the unsaved Pizza instance + instance = ModelForm.save(self, False) + instance.save() + + old_exams = instance.exams.all() + + new_exams = self.cleaned_data["exams"] + + for exam in old_exams: + if exam not in new_exams: + exam.exam_questions.remove(instance) + + for exam in new_exams: + exam.exam_questions.add(instance) + + self.save_m2m() + + return instance + + class Meta: + model = Question + + fields = [ + "stem", + "feedback", + "category", + "a_answer", + "a_feedback", + "b_answer", + "b_feedback", + "c_answer", + "c_feedback", + "d_answer", + "d_feedback", + "e_answer", + "e_feedback", + "best_answer", + ] + + widgets = { + # "normal": RadioSelect( + # choices=[(True, 'Yes'), + # (False, 'No')]) + "stem" : TinyMCE(attrs={'cols': 80, 'rows': 30}), + "feedback" : TinyMCE(attrs={'cols': 80, 'rows': 30}), + "a_answer" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 140}), + "a_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 5}, mce_attrs={'height': 140}), + "b_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}), + "c_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}), + "d_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}), + "e_answer" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}), + "b_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}), + "c_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}), + "d_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}), + "e_feedback" : TinyMCE(attrs={'cols': 80, 'rows': 4}, mce_attrs={'height': 140}), + } + + #widgets = { + # "structure": autocomplete.ModelSelect2(url="anatomy:structure-autocomplete") + #} diff --git a/sbas/models.py b/sbas/models.py index 51dce7a2..d961d55b 100644 --- a/sbas/models.py +++ b/sbas/models.py @@ -124,6 +124,19 @@ class Question(models.Model): self.e_answer, ) + def get_answer_by_choice(self, choice): + if choice == "a": + return self.a_answer + if choice == "b": + return self.b_answer + if choice == "c": + return self.c_answer + if choice == "d": + return self.d_answer + if choice == "e": + return self.e_answer + + def get_correct_answer(self): if self.best_answer == "a": return self.a_answer diff --git a/sbas/templates/sbas/base.html b/sbas/templates/sbas/base.html index 1b4c0ea0..44904176 100644 --- a/sbas/templates/sbas/base.html +++ b/sbas/templates/sbas/base.html @@ -8,7 +8,9 @@ Sbas: {% if request.user.is_authenticated %} Exams / - Questions + Create Exam / + Questions / + Create Question {% if request.user.is_superuser %} / Answers {% endif %} diff --git a/sbas/templates/sbas/exam_scores_user.html b/sbas/templates/sbas/exam_scores_user.html index 1dc39ed9..d91ab6b3 100644 --- a/sbas/templates/sbas/exam_scores_user.html +++ b/sbas/templates/sbas/exam_scores_user.html @@ -1,21 +1,28 @@ {% extends 'sbas/base.html' %} {% block content %} -
-

Exam: {{ exam.name }}

-

Candidate: {{ cid }}

- Answers: - -
Total mark: {{ total_score }} / {{max_score}} -
- Other exams +
+

Exam: {{ exam.name }}

+

Candidate: {{ cid }}

+ {% if not exam.publish_results %} + + {% endif %} + Answers: +
    + {% for question, a, score, correct_answer, chosen_answer in answers_and_marks %} +
  • Question + {{forloop.counter}} - {{ question.stem |safe}}
  • + Correct answer: {{correct_answer|safe}}
    Chosen answer: {{a}} {{chosen_answer|safe}} + {% if exam.publish_results or view_all_results %} + (Score: {{score}}) + {% endif %} + +
    + {% endfor %} +
+
Total mark: {{ total_score }} / {{max_score}} +
+ Other exams +
-
{% endblock %} \ No newline at end of file diff --git a/sbas/templates/sbas/exam_take.html b/sbas/templates/sbas/exam_take.html index e2791abc..6d9db8ea 100755 --- a/sbas/templates/sbas/exam_take.html +++ b/sbas/templates/sbas/exam_take.html @@ -1,104 +1,213 @@ {% extends 'base.html' %} {% block content %} -CID: {{cid}} -
-

{{exam.name}}: Question [{{pos|add:1}}/{{exam_length}}]

- {% if exam.publish_results %} - + {% if request.user.is_authenticated %} + User: {{request.user}} + {% else %} + CID: {{cid}} {% endif %} - -
-

{{question.stem|safe}}

+
+ Allocated time over.
-
    -
  • {{question.a_answer|safe}}
  • -
  • {{question.b_answer|safe}}
  • -
  • {{question.c_answer|safe}}
  • -
  • {{question.d_answer|safe}}
  • -
  • {{question.e_answer|safe}}
  • -
- - -
- -
{% csrf_token %} -
- {{form}} +
+ Time remaining: + {% comment %}
+ +
Days
+
{% endcomment %} +
+ + Hours +
+
+ + Minutes +
+
+ + Seconds +
+
+
+

{{exam.name}}: Question [{{pos|add:1}}/{{exam_length}}]

+ {% if exam.publish_results %} + + {% endif %} - {% if previous > -1 %} - - {% endif %} - {% if next %} - - {% else %} - {% if not exam.publish_results %} - - {% endif %} - {% endif %} +
+

{{question.stem|safe}}

+
+
    +
  • {{question.a_answer|safe}}
  • +
  • {{question.b_answer|safe}}
  • +
  • {{question.c_answer|safe}}
  • +
  • {{question.d_answer|safe}}
  • +
  • {{question.e_answer|safe}}
  • +
- {% if exam.publish_results and question.feedback %} -

Feedback

-

{{question.feedback|safe}}

- {% endif %} -
- - - + +
+ +
{% csrf_token %} +
+ {{form}} +
+ + {% if previous > -1 %} + + {% endif %} + {% if next %} + + {% else %} + {% if not exam.publish_results %} + + {% endif %} + {% endif %} + + {% if exam.publish_results and question.feedback %} +

Feedback

+

{{question.feedback|safe}}

+ {% endif %} +
+ + +
+
-
-

Questions

- {% endblock %} {% block js %} - + $("ul.sba-answer-list li").each((n, el) => { + $(el).click((e) => { + $("#id_answer").val(el.dataset.ans); + $("ul.sba-answer-list li").removeClass("selected"); + $(el).addClass("selected") + }) + }) + {% else %} + $("ul.sba-answer-list li[data-ans='{{question.best_answer}}']").addClass("correct"); + {% endif %} + + {% if saved_answer %} + $("ul.sba-answer-list li[data-ans='{{saved_answer}}']").addClass("selected"); {% endif %} + + for (let i = 0; i < {{ exam_length }}; i++) { + $("#menu-list").append($( + ``)); + + } + $("button.question-menu-item").on("click", (e) => { + console.log(e.currentTarget.dataset.qn); + document.getElementById("goto-button").value = e.currentTarget.dataset.qn; + $("#goto-button").click(); + }); + + + function getTimeRemaining(endtime) { + const total = Date.parse(endtime) - Date.parse(new Date()); + const seconds = Math.floor((total / 1000) % 60); + const minutes = Math.floor((total / 1000 / 60) % 60); + const hours = Math.floor((total / (1000 * 60 * 60)) % 24); + //const days = Math.floor(total / (1000 * 60 * 60 * 24)); + + return { + total, + //days, + hours, + minutes, + seconds + }; + } + + function initializeClock(id, endtime) { + console.log("Start clock", endtime) + const clock = document.getElementById(id); + clock.style.display = "block" + const daysSpan = clock.querySelector('.days'); + const hoursSpan = clock.querySelector('.hours'); + const minutesSpan = clock.querySelector('.minutes'); + const secondsSpan = clock.querySelector('.seconds'); + + function updateClock() { + const t = getTimeRemaining(endtime); + + //daysSpan.innerHTML = t.days; + hoursSpan.innerHTML = ('0' + t.hours).slice(-2); + minutesSpan.innerHTML = ('0' + t.minutes).slice(-2); + secondsSpan.innerHTML = ('0' + t.seconds).slice(-2); + + if (t.total <= 0) { + clearInterval(timeinterval); + timeup.style.display = "block" + clock.style.display = "none" + } + } + + const timeinterval = setInterval(updateClock, 1000); + updateClock(); + } + /* beautify ignore:end */ + }) + {% endblock %} {% block css %} - + .selected { + border: 1px solid purple; + } + + #clockdiv{ + font-family: sans-serif; + display: inline-block; + font-weight: 100; + text-align: center; + font-size: 12px; + display: none; + color: darkgray; + } + + #clockdiv > div{ + padding: 1px; + border-radius: 3px; + {% comment %} background: purple; {% endcomment %} + display: inline-block; + } + #clockdiv div > span.num{ + color: white; + padding: 1px; + border-radius: 3px; + display: inline-block; + } + + #timeup { + display: none; + color: red; + font-weight: 100; + text-align: center; + font-size: 12px; + } + {% endblock %} \ No newline at end of file diff --git a/sbas/templates/sbas/question_detail.html b/sbas/templates/sbas/question_detail.html index 95193765..f22fc347 100644 --- a/sbas/templates/sbas/question_detail.html +++ b/sbas/templates/sbas/question_detail.html @@ -2,6 +2,9 @@ {% block content %}
+ Edit + Clone + Delete Admin Edit Add Note diff --git a/sbas/templates/sbas/question_form.html b/sbas/templates/sbas/question_form.html new file mode 100644 index 00000000..20b6a262 --- /dev/null +++ b/sbas/templates/sbas/question_form.html @@ -0,0 +1,24 @@ +{% extends "sbas/base.html" %} + +{% load static %} + +{% block js %} + + + + +{{ form.media }} +{% endblock %} +{% block content %} +

Submit Question

+
+ {% csrf_token %} + + + {{ form.as_table }} +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/sbas/urls.py b/sbas/urls.py index 47a751aa..2fb30b1b 100644 --- a/sbas/urls.py +++ b/sbas/urls.py @@ -10,7 +10,20 @@ app_name = "sbas" urlpatterns = [ path("question/", views.QuestionView.as_view(), name="question_view"), path("question//", views.question_detail, name="question_detail"), - # path("question/create/", views.QuestionCreate.as_view(), name="anatomy_question_create"), + path("question/create/", views.QuestionCreate.as_view(), name="question_create"), + path( + "question//update", + views.QuestionUpdate.as_view(), + name="question_update", + ), + path( + "question//clone", views.QuestionClone.as_view(), name="question_clone" + ), + path( + "question//delete", + views.QuestionDelete.as_view(), + name="question_delete", + ), # path("question//update", views.QuestionUpdate.as_view(), name="anatomy_question_update"), # path("exam///mark", views.mark, name="mark"), # path("exam//mark", views.mark_overview, name="mark_overview"), @@ -25,9 +38,12 @@ urlpatterns = [ views.exam_take_overview, name="exam_take_overview", ), - path("exam//authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"), + path( + "exam//authors", views.ExamAuthorUpdate.as_view(), name="exam_authors" + ), path("exam//clone", views.ExamClone.as_view(), name="exam_clone"), path("exam/available", views.active_exams, name="active_exams"), + path("exam/create", views.ExamUpdate.as_view(), name="exam_create"), path("exam//update", views.ExamUpdate.as_view(), name="exam_update"), path("exam//delete", views.ExamDelete.as_view(), name="exam_delete"), # path("exam/json/", views.exam_json, name="exam_json"), @@ -45,7 +61,17 @@ urlpatterns = [ views.UserAnswerDelete.as_view(), name="user_answer_delete", ), + path( + "exam//scores///", + views.exam_scores_cid_user, + name="exam_scores_cid_user", + ), + path( + "exam//scores/", + views.exam_scores_cid_user, + name="exam_scores_user", + ), ] urlpatterns.extend(generic_view_urls(views.GenericViews)) -urlpatterns.extend(generic_exam_urls(views.GenericExamViews)) \ No newline at end of file +urlpatterns.extend(generic_exam_urls(views.GenericExamViews)) diff --git a/sbas/views.py b/sbas/views.py index ea2f22ea..2f5d4d13 100644 --- a/sbas/views.py +++ b/sbas/views.py @@ -7,6 +7,7 @@ from django.shortcuts import render, get_object_or_404, redirect from django.views.decorators.csrf import csrf_exempt from django import forms from django.utils import timezone +from django.forms.models import model_to_dict # from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required, user_passes_test @@ -40,7 +41,7 @@ import json import statistics import plotly.express as px -from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamUpdateBase, ExamViews, GenericViewBase +from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamUpdateBase, ExamViews, GenericViewBase, exam_inactive from rest_framework import viewsets, permissions from rest_framework.pagination import PageNumberPagination @@ -50,6 +51,11 @@ from django.core.exceptions import PermissionDenied from .tables import QuestionTable, UserAnswerTable from .filters import QuestionFilter, UserAnswerFilter +from .forms import ( + QuestionForm, + ExamForm, +) + class AuthorOrCheckerRequiredMixin(object): def get_object(self, *args, **kwargs): @@ -120,7 +126,8 @@ def exam_scores_cid_user(request, pk, cid, passcode): answers_marks.append(answer_score) merged_ans = (ans, answer_score, correct_answer) - answers_and_marks.append((q, *merged_ans)) + chosen_answer = q.get_answer_by_choice(ans) + answers_and_marks.append((q, *merged_ans, chosen_answer)) total_score = sum(answers_marks) @@ -148,6 +155,7 @@ def exam_start(request, pk): exam = get_object_or_404(Exam, pk=pk) if not exam.active: + return exam_inactive(request, context={"exam": exam}) raise Http404("Exam not found") return render( @@ -163,7 +171,7 @@ def exam_take_overview(request, pk, cid, passcode): exam = get_object_or_404(Exam, pk=pk) if not exam.active: - raise Http404("Exam not found") + return exam_inactive(request, context={"exam": exam}) if not exam.check_cid_user(cid, passcode): raise Http404("Error accessing exam") @@ -299,9 +307,102 @@ class QuestionView(LoginRequiredMixin, SingleTableMixin, FilterView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["app_name"] = "anatomy" + context["app_name"] = "sbas" return context +class QuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): + model = Question + form_class = QuestionForm + + def get_form_kwargs(self): + kwargs = super(QuestionCreateBase, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(QuestionCreateBase, self).get_context_data(**kwargs) + #if self.request.POST: + # context["answer_formset"] = AnswerFormSet( + # self.request.POST, self.request.FILES + # ) + #else: + # context["answer_formset"] = AnswerFormSet() + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + + response = super().form_valid(form) + # If the normal submit button is pressed we save as normal + if "submit" in self.request.POST: + return response + # else we redirect to the clone url + else: + return redirect("sbas:question_clone", pk=self.object.pk) + + +class QuestionCreate(QuestionCreateBase): + + # initial = {'laterality': AnatomyQuestion.NONE} + + def get_initial(self): + if "pk" in self.kwargs: + initial = super().get_initial() + exam = get_object_or_404(Exam, pk=self.kwargs["pk"]) + + initial["exams"] = [exam.id] + + return initial + +class QuestionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView): + model = Question + form_class = QuestionForm + + def get_form_kwargs(self): + kwargs = super(QuestionUpdate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(QuestionUpdate, self).get_context_data(**kwargs) + return context + + def form_valid(self, form): + + # save exam orders (there must be a better way to do this) + #exam_orders = {} + #for exam in self.object.exams.all(): + # exam_orders[exam] = list(exam.exam_questions.all()) + # print(exam_orders[exam]) + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + response = super().form_valid(form) + return response + +class QuestionClone(QuestionCreateBase): + """Clones a existing question""" + + def get_initial(self): + # print(self.request) + old_object = get_object_or_404(Question, pk=self.kwargs["pk"]) + initial_data = model_to_dict(old_object, exclude=["id"]) + + # We don't want to clone the exam details + # exams = old_object.exams.all().values_list("id", flat=True) + + # initial_data["exams"] = list(exams) + + return initial_data class UserAnswerView(LoginRequiredMixin, DetailView): model = CidUserAnswer diff --git a/templates/404.html b/templates/404.html index 754a63f9..d7d100d7 100644 --- a/templates/404.html +++ b/templates/404.html @@ -5,6 +5,6 @@

404 error

{{ reason }} -

a {{resolved}}

+

{{resolved}}

{% endblock %} \ No newline at end of file