From 4893b1e90b7898f87b72cf6fada81deeea823005 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 25 Jan 2021 14:13:51 +0000 Subject: [PATCH] rapid exams --- anatomy/views.py | 40 ++-- rapids/forms.py | 109 ++++++---- rapids/urls.py | 27 ++- rapids/views.py | 521 ++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 631 insertions(+), 66 deletions(-) diff --git a/anatomy/views.py b/anatomy/views.py index d57f25c5..a7f106a1 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -572,21 +572,6 @@ def active_exams(request): return JsonResponse(active_exams) -def question_save_annotation(request, pk): - if request.is_ajax() and request.method == "POST": - question = get_object_or_404(AnatomyQuestion, pk=pk) - - question.image_annotations = request.POST.get("annotation") - print(question.image_annotations) - - question.save() - data = {"status": "success"} - return JsonResponse(data, status=200) - else: - data = {"status": "error"} - return JsonResponse(data, status=400) - - def exam_json(request, pk): exam = get_object_or_404(Exam, pk=pk) @@ -813,13 +798,6 @@ def exam_scores_cid_user(request, pk, sk): ) -def cid_selector(request): - return render( - request, - "anatomy/cid_selector.html", - ) - - # @login_required # def exam_scores(request, pk): # exam = get_object_or_404(Exam, pk=pk) @@ -1155,4 +1133,20 @@ class AnatomyQuestionView(SingleTableMixin, FilterView): table_class = AnatomyQuestionTable template_name = "anatomy/anatomy_question_view.html" - filterset_class = AnatomyQuestionFilter \ No newline at end of file + filterset_class = AnatomyQuestionFilter + + +def question_save_annotation(request, pk): + if request.is_ajax() and request.method == "POST": + question = get_object_or_404(AnatomyQuestion, pk=pk) + + question.image_annotations = request.POST.get("annotation") + print(question.image_annotations) + + question.save() + data = {"status": "success"} + return JsonResponse(data, status=200) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + diff --git a/rapids/forms.py b/rapids/forms.py index 73f15940..a2869a1c 100755 --- a/rapids/forms.py +++ b/rapids/forms.py @@ -1,40 +1,69 @@ -from django.forms import ModelForm, ModelMultipleChoiceField, ModelChoiceField, ChoiceField +from django.forms import ( + ModelForm, + ModelMultipleChoiceField, + ModelChoiceField, + ChoiceField, +) from django.forms import inlineformset_factory -from rapids.models import Abnormality, Examination, Note, Rapid, RapidCreationDefault, RapidImage, Region, Answer +from rapids.models import ( + Abnormality, + Examination, + Note, + Rapid, + RapidCreationDefault, + RapidImage, + Region, + Answer, + CidUserAnswer, + Examm, +) from django.contrib.admin.widgets import FilteredSelectMultiple from django.forms.widgets import RadioSelect, TextInput, Textarea +class RapidAnswerForm(ModelForm): + class Meta: + model = CidUserAnswer + fields = ("answer",) + + +class MarkRapidQuestionForm(Form): + # correct = forms.CharField(required=False) + # half_correct = forms.CharField(required=False) + # incorrect = forms.CharField(required=False) + marked_answers = CharField(required=False) + + class ExaminationForm(ModelForm): class Meta: model = Examination - fields = ['examination'] + fields = ["examination"] class NoteForm(ModelForm): class Meta: model = Note - fields = ['note'] + fields = ["note"] class RegionForm(ModelForm): class Meta: model = Region - fields = ['name'] + fields = ["name"] class AbnormalityForm(ModelForm): class Meta: model = Abnormality - fields = ['name'] + fields = ["name"] class RapidCreationDefaultForm(ModelForm): class Meta: model = RapidCreationDefault - fields = ['site'] + fields = ["site"] exclude = ["author"] @@ -45,46 +74,50 @@ class RapidForm(ModelForm): # include # in the template. css = { - 'all': ['css/widgets.css'], + "all": ["css/widgets.css"], } # Adding this javascript is crucial - js = ['jsi18n.js', "tesseract.min.js"] + js = ["jsi18n.js", "tesseract.min.js"] def __init__(self, *args, **kwargs): super(RapidForm, 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['abnormality'] = ModelMultipleChoiceField( + # 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["abnormality"] = ModelMultipleChoiceField( required=False, queryset=Abnormality.objects.all(), - widget=FilteredSelectMultiple(verbose_name="Abnormality", - is_stacked=False)) + widget=FilteredSelectMultiple(verbose_name="Abnormality", is_stacked=False), + ) - self.fields['region'] = ModelMultipleChoiceField( + self.fields["region"] = ModelMultipleChoiceField( required=False, queryset=Region.objects.all(), - widget=FilteredSelectMultiple(verbose_name="Region", - is_stacked=False)) + widget=FilteredSelectMultiple(verbose_name="Region", is_stacked=False), + ) - self.fields['examination'] = ModelMultipleChoiceField( + self.fields["examination"] = ModelMultipleChoiceField( queryset=Examination.objects.all(), - widget=FilteredSelectMultiple(verbose_name="Examination", - is_stacked=False)) + widget=FilteredSelectMultiple(verbose_name="Examination", is_stacked=False), + ) - self.fields['laterality'] = ChoiceField( - choices=Rapid.LATERALITY_CHOICES, - required=True, - widget=RadioSelect()) + self.fields["laterality"] = ChoiceField( + choices=Rapid.LATERALITY_CHOICES, required=True, widget=RadioSelect() + ) class Meta: model = Rapid - #fields = ['due_back'] - #fields = '__all__' + # fields = ['due_back'] + # fields = '__all__' fields = [ - "normal", "abnormality", "region", "laterality", "examination", - "site", "feedback" + "normal", + "abnormality", + "region", + "laterality", + "examination", + "site", + "feedback", ] - #fields = ['question', 'feedback', 'subspecialty', 'references'] + # fields = ['question', 'feedback', 'subspecialty', 'references'] widgets = { # "normal": RadioSelect( # choices=[(True, 'Yes'), @@ -92,14 +125,16 @@ class RapidForm(ModelForm): } -ImageFormSet = inlineformset_factory(Rapid, - RapidImage, - fields=['image', 'feedback_image'], - exclude=[], - can_delete=True, - extra=4, - max_num=10, - field_classes="testing") +ImageFormSet = inlineformset_factory( + Rapid, + RapidImage, + fields=["image", "feedback_image"], + exclude=[], + can_delete=True, + extra=4, + max_num=10, + field_classes="testing", +) AnswerFormSet = inlineformset_factory( Rapid, diff --git a/rapids/urls.py b/rapids/urls.py index ea47966a..f701c8f1 100755 --- a/rapids/urls.py +++ b/rapids/urls.py @@ -8,19 +8,36 @@ urlpatterns = [ path("", views.index, name="index"), path("author//", views.author_detail, name="author_detail"), path("author/", views.author_list, name="author_list"), + path("question/", views.RapidView.as_view(), name="rapid_view"), #path("unchecked/", views.unchecked_list, name="unchecked_list"), #path("verified//", views.verified_detail, name="verified_detail"), - path("/", views.rapid_detail, name="rapid_detail"), - path("/split", views.rapid_split, name="rapid_split"), - path("/clone", views.RapidClone.as_view(), name="rapid_clone"), + path("question//", views.rapid_detail, name="rapid_detail"), + path("question//split", views.rapid_split, name="rapid_split"), + path("question//clone", views.RapidClone.as_view(), name="rapid_clone"), #path("verified/", views.verified, name="verified"), #path("all_questions/", views.all_questions, name="all_questions"), - path("/scrap", views.rapid_scrap, name="rapid_scrap"), + path("question//scrap", views.rapid_scrap, name="rapid_scrap"), + path("exam///mark", views.mark, name="mark"), + path("exam//mark", views.mark_overview, name="mark_overview"), + path("exam///", views.exam_take, name="exam_take"), + path("exam//question//", views.exam_question_detail, name="exam_question_detail"), + path("exam//", views.exam_overview, name="exam_overview"), + path("exam//json_edit", views.exam_json_edit, name="exam_json_edit"), + path("exam//scores", views.exam_scores_cid, + name="exam_scores_cid"), + path("exam//scores//", views.exam_scores_cid_user, + name="exam_scores_cid_user"), + path("exam//toggle_active", views.exam_toggle_active, name="exam_toggle_active"), + path("exam//toggle_results_published", views.exam_toggle_results_published, name="exam_toggle_results_published"), + path("exam/submit", views.postExamAnswers, name="exam_answers_submit"), + path("exam/", views.exam_list, name="exam_list"), + path("exam/json/", views.active_exams, name="active_exams"), + path("exam/json/", views.exam_json, name="exam_json"), + path("exam/json//recreate", views.exam_json_recreate, name="exam_json_recreate"), path("create/", views.RapidCreate.as_view(), name="rapid_create"), path("create/defaults", views.RapidCreationDefaultView.as_view(), name="rapid_create_defaults"), - path("view/", views.RapidView.as_view(), name="rapid_view"), path("region/create/", views.create_region, name="create_region"), path("region/ajax/get_region_id", views.get_region_id, diff --git a/rapids/views.py b/rapids/views.py index 75914dc1..e04f1b5a 100755 --- a/rapids/views.py +++ b/rapids/views.py @@ -481,4 +481,523 @@ class RapidView(SingleTableMixin, FilterView): table_class = RapidTable template_name = "rapids/view.html" - filterset_class = RapidFilter \ No newline at end of file + filterset_class = RapidFilter + + +def loadJsonAnswer(answer): + # As access is not restricted make sure the data appears valid + if (not isinstance(answer["cid"], int)) or ( + not isinstance(answer["eid"], int) or (not isinstance(answer["ans"], str)) + ): + return JsonResponse({"success": False, "error": "cid or eid or answers not defined"}) + + # The model should catch invalid data but this should be less intensive + max_int = 999999999999999999999 + if answer["cid"] > max_int or answer["eid"] > max_int: + return JsonResponse({"success": False, "error": "invalid cid"}) + + exam = get_object_or_404(Exam, pk=answer["eid"]) + + if not exam.active: + return JsonResponse( + {"success": False, "error": "No active exam: {}".format(answer["eid"])} + ) + + exiting_answers = CidUserAnswer.objects.filter( + question__id=answer["qid"], exam__id=answer["eid"], cid=answer["cid"] + ) + + if not exiting_answers: + ans = CidUserAnswer(answer=answer["ans"], cid=answer["cid"]) + ans.question_id = answer["qid"] + ans.exam_id = answer["eid"] + + ans.full_clean() + + ans.save() + else: + # Update an existing answer + # should never be more than one (famous last words) + ans = exiting_answers[0] + ans.answer = answer["ans"] + ans.full_clean() + ans.save() + + return True + + +def exam_json_edit(request, pk): + if request.is_ajax() and request.method == "POST": + + exam = get_object_or_404(Exam, pk=pk) + + if request.POST.get("set_open_access") == "true": + state = True + else: + state = False + + questions_changed = [] + for q in exam.exam_questions.all(): + q.open_access = state + q.save() + questions_changed.append(q.pk) + + data = {"status": "success", "questions": questions_changed, "state": state} + return JsonResponse(data, status=200) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + +@csrf_exempt +def postExamAnswers(request): + if request.is_ajax and request.method == "POST": + + n = 0 + # horrible but it works + for k in request.POST.dict(): + print(k) + for answer in json.loads(k): + ret = loadJsonAnswer(answer) + + if ret is not True: + return ret + n = n + 1 + + # print(UserAnswer.objects.filter(exam__id=q["eid"])) + # print(request.urlencode()) + + return JsonResponse({"success": True, "question_count": n}) + return JsonResponse({"success": False, "error": "Invalid data"}) + + +# @user_passes_test(user_is_admin, login_url="/accounts/login") +@login_required +def mark_overview(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + questions = exam.exam_questions.all() + + return render( + request, "rapids/mark_overview.html", {"exam": exam, "questions": questions} + ) + + +# @user_passes_test(user_is_admin, login_url="/accounts/login") +@login_required +def mark(request, pk, sk): + exam = get_object_or_404(Exam, pk=pk) + + questions = exam.exam_questions.all() + + n = sk + + question_details = { + "total": len(questions), + "current": n + 1, + } + + try: + question = questions[sk] + except IndexError: + raise Http404("Exam question does not exist") + + answers_dict = {} + + for ans in question.answers.all(): + answers_dict[ans.answer_compare] = ans + + marked_answers_set = set(answers_dict.keys()) + + # correct_answers = [i.answer.lower() for i in question.answers.all()] + # half_correct_answers = [ + # i.answer.lower() for i in question.half_mark_answers.all() + # ] + # incorrect_answers = [ + # i.answer.lower() for i in question.incorrect_answers.all() + # ] + + unmarked_user_answers = question.GetUnmarkedAnswers() + + if request.method == "POST": + + form = MarkRapidQuestionForm(request.POST) + # ******************************* + # TODO: convert to JSON request + # ******************************* + if form.is_valid(): + # If skip button is pressed skip the question without marking + if "skip" in request.POST: + return redirect("rapids:mark", pk=pk, sk=n + 1) + + cd = form.cleaned_data + # correct = cd.get("correct") + # half_correct = cd.get("half_correct") + # incorrect = cd.get("incorrect") + marked_answers = json.loads(cd.get("marked_answers")) + + ## This is mildly dangerous as we delete all answers + # question.answers.all().delete() + # question.half_mark_answers.all().delete() + # question.incorrect_answers.all().delete() + + # This should probably use json (or something else...) + # ajax..... + for ans in marked_answers["correct"]: + ans = ans.strip() + if ans == "": + continue + + a = Answer.objects.filter( + answer__iexact=ans, question_id=question.pk + ).first() + + if a is None: + a = Answer() + a.question_id = question.pk + a.answer = ans + + a.status = Answer.MarkOptions.CORRECT + a.save() + + # for ans in half_correct.split("--//--"): + for ans in marked_answers["half-correct"]: + ans = ans.strip() + if ans == "": + continue + + a = Answer.objects.filter( + answer__iexact=ans, question_id=question.pk + ).first() + + if a is None: + a = Answer() + a.question_id = question.pk + a.answer = ans + + a.status = Answer.MarkOptions.HALF_MARK + a.save() + + for ans in marked_answers["incorrect"]: + ans = ans.strip() + if ans == "": + continue + + a = Answer.objects.filter( + answer__iexact=ans, question_id=question.pk + ).first() + + if a is None: + a = Answer() + a.question_id = question.pk + a.answer = ans + + a.status = Answer.MarkOptions.INCORRECT + a.save() + # answer = form.save(commit=False) + # answer.user = request.user + # answer.question = question + # answer.published_date = timezone.now() + # answer.save() + if "next" in request.POST: + return redirect("rapids:mark", pk=pk, sk=n + 1) + elif "previous" in request.POST: + return redirect("rapids:mark", pk=pk, sk=n - 1) + + # Reset user answers (relies on the functional javascript) + unmarked_user_answers = set() + else: + form = MarkRapidQuestionForm() + + correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT) + half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK) + incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT) + + return render( + request, + "rapids/mark.html", + { + "exam": exam, + "form": form, + "question": question, + "question_details": question_details, + "user_answers": unmarked_user_answers, + "correct_answers": correct_answers, + "half_mark_answers": half_mark_answers, + "incorrect_answers": incorrect_answers, + }, + ) + + +def exam_toggle_results_published(request, pk): + if request.is_ajax() and request.method == "POST": + + exam = get_object_or_404(Exam, pk=pk) + + exam.publish_results = ( + True if request.POST.get("publish_results") == "true" else False + ) + exam.save() + data = {"status": "success", "publish_results": exam.publish_results} + return JsonResponse(data, status=200) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + +def exam_toggle_active(request, pk): + if request.is_ajax() and request.method == "POST": + + exam = get_object_or_404(Exam, pk=pk) + + exam.active = True if request.POST.get("active") == "true" else False + exam.save() + data = {"status": "success", "active": exam.active} + return JsonResponse(data, status=200) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + +def active_exams(request): + exams = Exam.objects.all() + print(exams) + + active_exams = {"exams": []} + + for exam in exams: + if exam.active: + active_exams["exams"].append( + { + "name": exam.get_exam_name(), + "url": request.build_absolute_uri(exam.get_json_url()), + } + ) + + return JsonResponse(active_exams) + + +def exam_json(request, pk): + + exam = get_object_or_404(Exam, pk=pk) + + if not exam.active: + raise Http404("No available exam") + + exam_json_cache = cache.get("exam_json_{}".format(pk)) + + if exam_json_cache is not None and not exam.recreate_json: + exam_json_cache["cached"] = True + return JsonResponse(exam_json_cache) + + questions = exam.exam_questions.all() + + exam_questions = defaultdict(dict) + + exam_order = [] + + for q in questions: + exam_order.append(q.id) + + exam_questions[q.id] = { + "title": "{}".format(q.description), + "question": str(q.question_type), + "images": [image_as_base64(q.image)], + "annotations": [str(q.image_annotations)], + "type": "rapid", + } + + exam_json = { + "eid": exam.id, + "cached": False, + "exam_type": "rapid", + "exam_name": exam.name, + "exam_mode": True, + "exam_order": exam_order, + "questions": exam_questions, + } + + if exam.time_limit: + exam_json["exam_time"] = exam.time_limit + + exam.recreate_json = False + exam.save() + + cache.set("exam_json_{}".format(pk), exam_json, 3600) + + return JsonResponse(exam_json) + + +@login_required +def exam_json_recreate(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + exam.recreate_json = True + exam.save() + + return redirect("rapids:exam_overview", pk=pk) + + +@login_required +def exam_scores_cid(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + questions = exam.exam_questions.all() + + cids = ( + CidUserAnswer.objects.filter(question__in=questions) + .order_by("cid") + .values_list("cid", flat=True) + .distinct() + ) + + user_answers_and_marks = defaultdict(list) + user_answers_marks = defaultdict(list) + user_answers = defaultdict(list) + user_names = {} + + by_question = defaultdict(list) + unmarked = set() + + # Loop through all candidates + for cid in cids: + # Convoluted (probably...) + user_names[cid] = cid + for q in questions: + # Get user answer + s = q.cid_user_answers.filter(cid=cid).first() + if not s: + # skip if no answer + user_answers_marks[cid].append(0) + user_answers[cid].append("") + by_question[q].append(("", 0)) + continue + + ans = s.answer + answer_score = s.get_answer_score() + if answer_score == "unmarked": + index = exam.get_question_index(q) + unmarked.add(index) + user_answers[cid].append(ans) + user_answers_marks[cid].append(answer_score) + user_answers_and_marks[cid].append((ans, answer_score)) + + by_question[q].append((ans, answer_score)) + + user_scores = {} + for user in user_answers_marks: + user_scores[user] = sum( + [i for i in user_answers_marks[user] if i != "unmarked"] + ) + + user_scores_list = list(user_scores.values()) + + if len(user_scores_list) < 1: + mean = 0 + median = 0 + mode = 0 + fig_html = "" + else: + mean = statistics.mean(user_scores_list) + median = statistics.median(user_scores_list) + try: + mode = statistics.mode(user_scores_list) + except statistics.StatisticsError: + mode = "No unique mode" + + df = user_scores_list + fig = px.histogram( + df, + x=0, + title="{}: distribution of scores".format(exam), + labels={"0": "Score"}, + height=400, + width=600, + ) + fig_html = fig.to_html() + + max_score = len(questions) * 2 + + return render( + request, + "rapids/exam_scores.html", + { + "cids": cids, + "exam": exam, + "unmarked": unmarked, + "questions": questions, + "by_question": by_question, + "user_answers": dict(user_answers), + "user_answers_marks": dict(user_answers_marks), + "user_scores": user_scores, + "user_scores_list": user_scores_list, + "user_names": user_names, + "user_answers_and_marks": user_answers_and_marks, + "max_score": max_score, + "mean": mean, + "median": median, + "mode": mode, + "plot": fig_html, + }, + ) + + +def exam_scores_cid_user(request, pk, sk): + exam = get_object_or_404(Exam, pk=pk) + + # TODO:Need some kind of test for cid + cid = sk + + questions = exam.exam_questions.all() + + answers_and_marks = [] + answers_marks = [] + answers = [] + + for q in questions: + # Get user answer + user_answer = q.cid_user_answers.filter(cid=cid).first() + if not user_answer: + # skip if no answer + answers_marks.append(0) + answers.append("") + answer_score = 0 + ans = "Not answered" + else: + ans = user_answer.answer + + answer_score = user_answer.get_answer_score() + + correct_answer = q.GetPrimaryAnswer() + + if not exam.publish_results: + correct_answer = "*****" + answer_score = 0 + answers.append(ans) + answers_marks.append(answer_score) + answers_and_marks.append((ans, answer_score, correct_answer)) + + if "unmarked" in answers_marks: + answered = [i for i in answers_marks if type(i) == int] + total_score = sum(answered) + unmarked_number = len(answers_marks) - len(answered) + total_score = "{} ({} unmarked)".format(total_score, unmarked_number) + else: + total_score = sum(answers_marks) + + max_score = len(questions) * 2 + + return render( + request, + "rapids/exam_scores_user.html", + { + "exam": exam, + "cid": cid, + "questions": questions, + "answers": answers, + "answers_marks": answers_marks, + "total_score": total_score, + "max_score": max_score, + "answers_and_marks": answers_and_marks, + }, + ) \ No newline at end of file