From 38134e5fbc32975fad2d0f0b17ce6b18b88f7fb9 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 2 Feb 2021 09:33:50 +0000 Subject: [PATCH] clone rapids app into longs --- longs/__init__.py | 0 longs/admin.py | 73 ++ longs/apps.py | 5 + longs/decorators.py | 13 + longs/filters.py | 10 + longs/forms.py | 169 +++ longs/models.py | 475 +++++++ longs/tables.py | 48 + longs/templates/admin/base_site.html | 5 + longs/templates/rapids/author_list.html | 8 + longs/templates/rapids/base.html | 27 + longs/templates/rapids/category_detail.html | 13 + longs/templates/rapids/create_simple.html | 5 + longs/templates/rapids/exam.html | 79 ++ longs/templates/rapids/exam_list.html | 30 + longs/templates/rapids/exam_overview.html | 141 +++ longs/templates/rapids/exam_scores.html | 65 + longs/templates/rapids/exam_scores_user.html | 18 + longs/templates/rapids/exams.html | 7 + longs/templates/rapids/index.html | 11 + longs/templates/rapids/index_old.html | 23 + longs/templates/rapids/mark.html | 67 + longs/templates/rapids/mark_overview.html | 20 + longs/templates/rapids/note_form.html | 12 + longs/templates/rapids/rapid_detail.html | 26 + .../templates/rapids/rapid_display_block.html | 38 + longs/templates/rapids/rapid_form.html | 292 +++++ .../rapids/rapidcreationdefault_form.html | 10 + longs/templates/rapids/view.html | 18 + longs/tests.py | 3 + longs/urls.py | 63 + longs/views.py | 1126 +++++++++++++++++ 32 files changed, 2900 insertions(+) create mode 100755 longs/__init__.py create mode 100755 longs/admin.py create mode 100755 longs/apps.py create mode 100755 longs/decorators.py create mode 100755 longs/filters.py create mode 100755 longs/forms.py create mode 100644 longs/models.py create mode 100755 longs/tables.py create mode 100755 longs/templates/admin/base_site.html create mode 100755 longs/templates/rapids/author_list.html create mode 100755 longs/templates/rapids/base.html create mode 100755 longs/templates/rapids/category_detail.html create mode 100755 longs/templates/rapids/create_simple.html create mode 100644 longs/templates/rapids/exam.html create mode 100644 longs/templates/rapids/exam_list.html create mode 100644 longs/templates/rapids/exam_overview.html create mode 100644 longs/templates/rapids/exam_scores.html create mode 100644 longs/templates/rapids/exam_scores_user.html create mode 100644 longs/templates/rapids/exams.html create mode 100644 longs/templates/rapids/index.html create mode 100755 longs/templates/rapids/index_old.html create mode 100644 longs/templates/rapids/mark.html create mode 100644 longs/templates/rapids/mark_overview.html create mode 100755 longs/templates/rapids/note_form.html create mode 100755 longs/templates/rapids/rapid_detail.html create mode 100755 longs/templates/rapids/rapid_display_block.html create mode 100755 longs/templates/rapids/rapid_form.html create mode 100755 longs/templates/rapids/rapidcreationdefault_form.html create mode 100755 longs/templates/rapids/view.html create mode 100755 longs/tests.py create mode 100755 longs/urls.py create mode 100755 longs/views.py diff --git a/longs/__init__.py b/longs/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/longs/admin.py b/longs/admin.py new file mode 100755 index 00000000..6d259e65 --- /dev/null +++ b/longs/admin.py @@ -0,0 +1,73 @@ +from django.contrib import admin +from .models import Rapid, RapidImage, Examination, Site, Abnormality, Region, Note, RapidCreationDefault, Answer, Exam + +import tagulous.admin + +from django.forms import ModelForm + +from reversion.admin import VersionAdmin +from django.forms.widgets import RadioSelect + +# Register your models here. +admin.site.register(Examination) +admin.site.register(Site) +admin.site.register(Exam) +admin.site.register(Abnormality) +admin.site.register(Region) +admin.site.register(Note) +admin.site.register(RapidCreationDefault) +admin.site.register(Answer) + +class RapidAnswersInline(admin.TabularInline): + model = Answer + extra = 1 + + +class RapidImageInline(admin.TabularInline): + model = RapidImage + extra = 1 + readonly_fields = [ + "image_tag", + ] + +class ExamInline(admin.TabularInline): + model = Rapid.exams.through + extra = 1 + + +class RapidAdminForm(ModelForm): + class Meta: + model = Rapid + + fields = [ + "normal", "abnormality", "region", "laterality", "examination", + "site", "sign", "condition", "feedback", "author" + ] + + widgets = { + "laterality": RadioSelect(attrs={'style': 'display: inline-flex'}), + } + + +class RapidAdmin(VersionAdmin): + form = RapidAdminForm + + filter_horizontal = ( + "abnormality", + "region", + #"examination", + ) + save_on_top = True + save_as = True + view_on_site = True + + inlines = [ + RapidImageInline, + RapidAnswersInline, + ExamInline, + ] + + +admin.site.register(Rapid, RapidAdmin) + +#tagulous.admin.register(Rapid.condition) diff --git a/longs/apps.py b/longs/apps.py new file mode 100755 index 00000000..4f99b06e --- /dev/null +++ b/longs/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class RapidsConfig(AppConfig): + name = 'rapids' diff --git a/longs/decorators.py b/longs/decorators.py new file mode 100755 index 00000000..72c1c36b --- /dev/null +++ b/longs/decorators.py @@ -0,0 +1,13 @@ +from django.core.exceptions import PermissionDenied +from .models import Rapid + +def user_is_author_or_rapid_checker(function): + def wrap(request, *args, **kwargs): + rapid = Rapid.objects.get(pk=kwargs['pk']) + if request.user in rapid.author.all() or request.user.groups.filter(name='rapid_checker').exists(): + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap diff --git a/longs/filters.py b/longs/filters.py new file mode 100755 index 00000000..3f10b4f6 --- /dev/null +++ b/longs/filters.py @@ -0,0 +1,10 @@ +import django_filters + +from .models import Rapid + + +class RapidFilter(django_filters.FilterSet): + class Meta: + model = Rapid + fields = ("normal", "abnormality", "region", "examination", + "laterality", "site", "created_date", "author") diff --git a/longs/forms.py b/longs/forms.py new file mode 100755 index 00000000..c98304c5 --- /dev/null +++ b/longs/forms.py @@ -0,0 +1,169 @@ +from django.forms import ( + Form, + ModelForm, + ModelMultipleChoiceField, + ModelChoiceField, + ChoiceField, + CharField, +) +from django.forms import inlineformset_factory + +from rapids.models import ( + Abnormality, + Examination, + Note, + Rapid, + RapidCreationDefault, + RapidImage, + Region, + Answer, + CidUserAnswer, + Exam, +) + +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"] + + +class NoteForm(ModelForm): + class Meta: + model = Note + fields = ["note"] + + +class RegionForm(ModelForm): + class Meta: + model = Region + fields = ["name"] + + +class AbnormalityForm(ModelForm): + class Meta: + model = Abnormality + fields = ["name"] + + +class RapidCreationDefaultForm(ModelForm): + class Meta: + model = RapidCreationDefault + fields = ["site"] + exclude = ["author"] + + +class RapidForm(ModelForm): + 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): + 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( + required=False, + queryset=Abnormality.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Abnormality", is_stacked=False), + ) + + self.fields["region"] = ModelMultipleChoiceField( + required=False, + queryset=Region.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Region", is_stacked=False), + ) + + self.fields["examination"] = ModelMultipleChoiceField( + queryset=Examination.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Examination", is_stacked=False), + ) + + self.fields["laterality"] = ChoiceField( + choices=Rapid.LATERALITY_CHOICES, required=True, widget=RadioSelect() + ) + + class Meta: + model = Rapid + # fields = ['due_back'] + # fields = '__all__' + fields = [ + "normal", + "abnormality", + "region", + "laterality", + "examination", + "site", + "feedback", + ] + # fields = ['question', 'feedback', 'subspecialty', 'references'] + widgets = { + # "normal": RadioSelect( + # choices=[(True, 'Yes'), + # (False, 'No')]) + } + + +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, + Answer, + fields=["answer", "status"], + widgets={ + "answer": Textarea( + attrs={"required": "true", "minlength": 2, "maxlength": 500, "rows": 3} + ) + }, + exclude=[], + can_delete=True, + extra=1, + max_num=10, +) + +AnswerUpdateFormSet = inlineformset_factory( + Rapid, + Answer, + fields=["answer", "status"], + widgets={ + "answer": Textarea( + attrs={"required": "true", "minlength": 2, "maxlength": 500, "rows": 3} + ) + }, + exclude=[], + can_delete=True, + extra=0, + max_num=10, +) \ No newline at end of file diff --git a/longs/models.py b/longs/models.py new file mode 100644 index 00000000..83fbb41b --- /dev/null +++ b/longs/models.py @@ -0,0 +1,475 @@ +from django.db import models +from django.utils import timezone +import tagulous +import tagulous.models + + +from django.core.files.storage import FileSystemStorage +from django.conf import settings +from django.utils.html import format_html + +from django.urls import reverse + +from django.utils.translation import ugettext_lazy as _ +from django.utils.html import mark_safe + +from sortedm2m.fields import SortedManyToManyField + +import string + +image_storage = FileSystemStorage( + # Physical file location ROOT + location=u"{0}/rapids/".format(settings.MEDIA_ROOT), + # Url for file + base_url=u"{0}rapids/".format(settings.MEDIA_URL), +) + +def image_directory_path(instance, filename): + return u"picture/{0}".format(filename) + + + +class Abnormality(models.Model): + name = models.CharField(max_length=200, + unique=True, + help_text="Primary abnormality on the film(s)") + + def __str__(self): + return self.name + + class Meta: + ordering = ('name', ) + + +class Region(models.Model): + name = models.CharField( + max_length=200, + unique=True, + help_text="Region of the abnormality (not including lateralitiy)", + blank=True) + + def __str__(self): + return self.name + + class Meta: + ordering = ('name', ) + + +class Examination(models.Model): + examination = models.CharField(max_length=200) + + def __str__(self): + return self.examination + + class Meta: + ordering = ('examination', ) + + +class Site(models.Model): + site = models.CharField(max_length=200) + initials = models.CharField(max_length=200) + + def __str__(self): + return self.site + + +class Condition(tagulous.models.TagModel): + pass + +class Sign(tagulous.models.TagModel): + pass + +class Answer(models.Model): + question = models.ForeignKey( + "Rapid", related_name="answers", on_delete=models.CASCADE + ) + answer = models.TextField(max_length=500) + answer_compare = models.TextField(max_length=500) + + class MarkOptions(models.TextChoices): + UNMARKED = "", _("Unmarked") + INCORRECT = "0", _("Incorrect") + HALF_MARK = "1", _("Half mark") + CORRECT = "2", _("Correct") + + status = models.CharField( + max_length=1, choices=MarkOptions.choices, default=MarkOptions.UNMARKED + ) + + def __str__(self): + return self.answer + + def save(self, *args, **kwargs): + self.clean() + return super(Answer, self).save(*args, **kwargs) + + def clean(self): + if self.answer: + self.answer = self.answer.strip() + + s = self.answer.lower() + s = s.translate(str.maketrans('', '', string.punctuation)) + + self.answer_compare = s + + +class Rapid(models.Model): + #author = models.ForeignKey('auth.User', on_delete=models.CASCADE) + #image = models.ImageField() + question = models.TextField(null=True, blank=True) + feedback = models.TextField(null=True, blank=True) + + normal = models.BooleanField(default=False, help_text="Tick if true") + + NONE = "NONE" + LEFT = "LEFT" + RIGHT = "RIGHT" + BILATERAL = "BILAT" + + LATERALITY_CHOICES = ( + (NONE, "None"), + (LEFT, "Left"), + (RIGHT, "Right"), + (BILATERAL, "Bilateral"), + ) + + abnormality = models.ManyToManyField( + Abnormality, + blank=True, + help_text="The abnormality (laterality and region independent). Used for categorisation but does not affect the answer", + ) + region = models.ManyToManyField( + Region, + blank=True, + help_text="Region of the abnormality (laterality independent)") + examination = models.ManyToManyField( + Examination, help_text="Name of the (primary) examination") + laterality = models.CharField( + max_length=20, + choices=LATERALITY_CHOICES, + default=NONE, + help_text="Applies to the answer, not the examination") + condition = models.CharField(max_length=255, blank=True) + #condition = tagulous.models.TagField( + # to=Condition, + # blank=True, + # help_text= + # "Associated condition. Will allow searching / filtering and tips / hints to be displayed. Conditions with spaces must be enclosed in quotes \"...\"" + #) + + sign = models.CharField(max_length=255, blank=True) + #sign = tagulous.models.TagField( + # to=Sign, blank=True, help_text='Radiological signs in the question') + + DEFAULT_SITE_ID = 1 + site = models.ManyToManyField( + Site, + blank=True, + default=DEFAULT_SITE_ID, + help_text= + "If we know the source of the image") + verified = models.BooleanField(default=False) + created_date = models.DateTimeField(default=timezone.now) + published_date = models.DateTimeField(blank=True, null=True) + author = models.ManyToManyField(settings.AUTH_USER_MODEL, + blank=True, + help_text='Author of question', + related_name="rapid_authored_questions") + + scrapped = models.BooleanField( + default=False, + help_text='Question has been scrapped and will not be shown') + + open_access = models.BooleanField( + help_text="If an question should be freely available to browse", default=True + ) + + + def get_absolute_url(self): + return reverse('rapids:rapid_detail', kwargs={'pk': self.pk}) + + def get_authors(self): + """Returns a comma seperated text list of authors""" + authors = ", ".join([i.username for i in self.author.all()]) + return authors + + def get_regions(self): + """Returns a comma seperated text list of regions""" + regions = ", ".join([i.name for i in self.region.all()]) + return regions + + def get_abnormalities(self): + """Returns a comma seperated text list of regions""" + abnormalities = ", ".join([i.name for i in self.abnormality.all()]) + return abnormalities + + def get_examinations(self): + """Returns a comma seperated text list of regions""" + examinations = ", ".join([i.examination for i in self.examination.all()]) + return examinations + + def __str__(self): + n = "Normal" + if not self.normal: + #n = self.answers.first() + n = "{} / {}".format(self.abnormality.first(), self.region.first()) + exams = self.get_examinations() + lat = "" + if self.laterality != "NONE": + lat = self.laterality + return "{} / {} {}".format(exams, lat, n) + + def GetPrimaryAnswer(self): + if len(self.answers.all()) > 0: + return self.answers.all().first().answer + elif self.normal: + return "Normal" + else: + return "None yet..." + + def GetExams(self): + e = self.exams.all().values_list("name", flat=True) + exams = ", ".join(e) + return exams + + def GetUnmarkedAnswersString(self): + unmarked_answers = self.GetUnmarkedAnswers() + + if not unmarked_answers: + return "No answers to mark" + return format_html( + "{} answer unmarked: {}".format( + len(unmarked_answers), ", ".join(unmarked_answers) + ) + ) + + def GetUnmarkedAnswers(self): + # If normal no answers to mark + if self.normal: + return [] + + user_answers = set( + [i.answer_compare for i in self.cid_user_answers.all() if i.normal == False] + ) + unmarked_answers = user_answers - self.GetMarkedAnswers() + + return unmarked_answers + + def GetUnmarkedAnswerCount(self): + return len(self.GetUnmarkedAnswers()) + + def GetMarkedAnswers(self): + return set( + [ + i.answer_compare + for i in self.answers.all() + if i.status != i.MarkOptions.UNMARKED + ] + ) + correct_answers = set( + [i.answer.get_compare_string() for i in self.answers.all()] + ) + half_mark_answers = set( + [i.answer.get_compare_string() for i in self.half_mark_answers.all()] + ) + incorrect_answers = set( + [i.answer.get_compare_string() for i in self.incorrect_answers.all()] + ) + + marked_answers = correct_answers | half_mark_answers | incorrect_answers + return marked_answers + + def GetImages(self, feedback=False): + qs = self.images.all() + + if not feedback: + images = [i.image for i in qs if not i.feedback_image] + else: + images = [i.image for i in qs] + + return images + + def GetImageUrls(self): + return ",".join(["http://penracourses.org.uk{}".format(i.url) for i in self.GetImages()]) + #def GetNonFeedbackQuestionImages(self): + #return self.GetImages() + + +class RapidImage(models.Model): + rapid = models.ForeignKey(Rapid, + related_name="images", + on_delete=models.CASCADE) + image = models.ImageField(upload_to=image_directory_path, storage=image_storage) + + feedback_image = models.BooleanField(default=False) + + def image_tag(self): + if self.image: + return mark_safe( + 'Click and hold to zoom' + .format(self.image.url)) + else: + return "" + + image_tag.short_description = 'Image' + + +class Note(models.Model): + rapid = models.ForeignKey(Rapid, + related_name="rapid_notes", + on_delete=models.CASCADE, + null=True) + #author = models.ForeignKey(User, + author = models.ForeignKey( settings.AUTH_USER_MODEL, + related_name="rapid_notes", + on_delete=models.CASCADE) + note = models.TextField() + created_on = models.DateTimeField(auto_now_add=True) + + def get_absolute_url(self): + self.pk = self.rapid_id + return reverse('rapids:rapid_detail', kwargs={'pk': self.pk}) + + def __str__(self): + return "{} [{}] {}".format(self.rapid, self.author, self.created_on) + + +class RapidCreationDefault(models.Model): + #author = models.OneToOneField(User, + author = models.OneToOneField( + settings.AUTH_USER_MODEL, + related_name="rapid_default", + on_delete=models.CASCADE) + + site = models.ManyToManyField( + Site, + blank=True, + default=1, + help_text="Default site to use when creating a new rapid") + + def get_absolute_url(self): + return reverse('rapids:rapid_create') + + +class Exam(models.Model): + name = models.CharField(max_length=200) + exam_questions = SortedManyToManyField( + Rapid, related_name="exams", blank="true" + ) + + active = models.BooleanField( + help_text="If an exam should be available", default=True + ) + + publish_results = models.BooleanField( + help_text="If an exams results should be available", default=True + ) + + recreate_json = models.BooleanField( + help_text="If the json cache needs updating", default=False + ) + + time_limit = models.IntegerField( + help_text="Exam time limit (in seconds). Default is 2100 secondse (35 minutes)", default=2100 + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("rapids:exam_overview", kwargs={"pk": self.pk}) + + def get_exam_name(self): + return self.name + + def get_json_url(self): + return reverse("rapids:exam_json", args=(self.pk,)) + + def get_question_index(self, question): + return list(self.exam_questions.all()).index(question) + +class CidUserAnswer(models.Model): + """User answers by candidate""" + + question = models.ForeignKey( + Rapid, related_name="cid_user_answers", on_delete=models.CASCADE + ) + + # For rapids the answer can be normal in which case the below field is true + normal = models.BooleanField(default=False) + answer = models.TextField(max_length=500, blank=True) + + answer_compare = models.TextField(max_length=500, blank=True) + + cid = models.BigIntegerField(blank=True, null=True, help_text="Candidate ID (limitied by BigIntegerField size)") + + # Each user answer is associated with a particular exam + exam = models.ForeignKey( + Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True + ) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + try: + exam = self.exam + except (Exam.DoesNotExist, KeyError) as e: + exam = "None" + return "{}/{}/{}: {}".format( + exam, self.cid, self.question.GetPrimaryAnswer(), self.answer + ) + + def save(self, *args, **kwargs): + self.clean() + return super(CidUserAnswer, self).save(*args, **kwargs) + + def clean(self): + if self.answer: + self.answer = self.answer.strip() + + s = self.answer.lower() + s = s.translate(str.maketrans('', '', string.punctuation)) + + self.answer_compare = s + + # def get_compare_string(self): + # # strip here should be unneccasry (providing clean is now working) + # s = self.answer.lower().strip() + # s = s.translate(str.maketrans('', '', string.punctuation)) + # return s + + def get_answer_score(self): + q = self.question + + # First step we check that the normal/abnormal states match + if q.normal != self.normal: + # If they don't match the answer is wrong (score is 0) + return 0 + # If both are normal full marks + elif q.normal and self.normal: + return 2 + + # Then compare answer strings (as per anatomy questions) + ans = self.answer_compare + if ( + q.answers.filter( + answer_compare__iexact=ans, status=Answer.MarkOptions.CORRECT + ).first() + is not None + ): + mark = 2 + elif ( + q.answers.filter( + answer_compare__iexact=ans, status=Answer.MarkOptions.HALF_MARK + ).first() + is not None + ): + mark = 1 + elif q.answers.filter( + answer_compare__iexact=ans, status=Answer.MarkOptions.INCORRECT + ).first(): + mark = 0 + else: + mark = "unmarked" + return mark \ No newline at end of file diff --git a/longs/tables.py b/longs/tables.py new file mode 100755 index 00000000..edcebe21 --- /dev/null +++ b/longs/tables.py @@ -0,0 +1,48 @@ +import django_tables2 as tables +from django_tables2.utils import A + +from .models import Rapid + +from django.utils.html import format_html + +from easy_thumbnails.files import get_thumbnailer + +from easy_thumbnails.exceptions import InvalidImageFormatError + + +class ImageColumn(tables.Column): + def render(self, value): + obj = value.first() + + if obj is None: + return format_html('No image') + + image_object = obj.image + try: + thumbnailer = get_thumbnailer(image_object) + return format_html('', thumbnailer["exam-list"]) + except InvalidImageFormatError: + return format_html('Invalid image url', image_object) + + +class RapidTable(tables.Table): + edit = tables.LinkColumn('rapids:rapid_update', + text='Edit', + args=[A('pk')], + orderable=False) + view = tables.LinkColumn('rapids:rapid_detail', + text='View', + args=[A('pk')], + orderable=False) + clone = tables.LinkColumn('rapids:rapid_clone', + text='Clone', + args=[A('pk')], + orderable=False) + images = ImageColumn("images", orderable=False) + + class Meta: + model = Rapid + template_name = "django_tables2/bootstrap4.html" + fields = ("normal", "abnormality", "region", "examination", + "laterality", "site", "created_date", "author") + sequence = ("view", "images") \ No newline at end of file diff --git a/longs/templates/admin/base_site.html b/longs/templates/admin/base_site.html new file mode 100755 index 00000000..792249f6 --- /dev/null +++ b/longs/templates/admin/base_site.html @@ -0,0 +1,5 @@ +{% extends "admin/base_site.html" %} +{% load static %} +{% block extrahead %} + +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/author_list.html b/longs/templates/rapids/author_list.html new file mode 100755 index 00000000..82564136 --- /dev/null +++ b/longs/templates/rapids/author_list.html @@ -0,0 +1,8 @@ +{% extends 'rapids/base.html' %} + +{% block content %} + +{% for author in authors %} +

Author: {{author.username}},

+{% endfor %} +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/base.html b/longs/templates/rapids/base.html new file mode 100755 index 00000000..9b974595 --- /dev/null +++ b/longs/templates/rapids/base.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} + +{% block title %} +Rapids +{% endblock %} + +{% block css %} +{% endblock %} + +{% block js %} +{% endblock %} + + + +{% block content %} +{% endblock %} +{% block navigation %} +Rapids: +{% if request.user.is_authenticated %} +Exams / +Questions / +Create Question +{% endif %} +{% comment %}
+Questions by: +author {% endcomment %} +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/category_detail.html b/longs/templates/rapids/category_detail.html new file mode 100755 index 00000000..0d8e840e --- /dev/null +++ b/longs/templates/rapids/category_detail.html @@ -0,0 +1,13 @@ +{% extends 'rapids/base.html' %} + +{% block content %} +{{category}}: + + +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/create_simple.html b/longs/templates/rapids/create_simple.html new file mode 100755 index 00000000..14e906af --- /dev/null +++ b/longs/templates/rapids/create_simple.html @@ -0,0 +1,5 @@ +

Add new {{name}}

+
{% csrf_token %} + {{ form.as_p }} + +
\ No newline at end of file diff --git a/longs/templates/rapids/exam.html b/longs/templates/rapids/exam.html new file mode 100644 index 00000000..5636dc22 --- /dev/null +++ b/longs/templates/rapids/exam.html @@ -0,0 +1,79 @@ +{% extends 'rapids/base.html' %} + +{% block content %} + + +

Question {{question_details.current}} of {{question_details.total}}

+

{{ question.question_type.first }}

+ + + + + +
+
+
+ +
{% csrf_token %} + {{ form.as_p }} + {% if question_details.current > 1 %} + + {% endif %} + {% if question_details.current >= question_details.total %} + + {% else %} + + {% endif %} + +
+
+
+ +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/exam_list.html b/longs/templates/rapids/exam_list.html new file mode 100644 index 00000000..ff2cefdf --- /dev/null +++ b/longs/templates/rapids/exam_list.html @@ -0,0 +1,30 @@ +{% extends 'rapids/base.html' %} + +{% block content %} +

Examinations

+
+ Active exams:
+
    +{% for exam in exams %} + {% if exam.active %} +
  • + {{exam.name}} (mark) (scores) [Results are {% if not exam.publish_results %} not {% endif %}published] +
  • + {% endif %} +{% endfor %} +
+ + Inactive exams:
+
    +{% for exam in exams %} + {% if not exam.active %} +
  • + {{exam.name}} (mark) (scores) [Results are {% if not exam.publish_results %} not {% endif %}published] +
  • + {% endif %} +{% endfor %} +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/exam_overview.html b/longs/templates/rapids/exam_overview.html new file mode 100644 index 00000000..5c3fa9ce --- /dev/null +++ b/longs/templates/rapids/exam_overview.html @@ -0,0 +1,141 @@ +{% extends 'rapids/exams.html' %} + +{% block content %} + +{% load thumbnail %} +
+ Admin Edit +

Exam: {{ exam.name }}

+ This exam has {{question_number}} questions. Time limit: {{exam.time_limit}} seconds. + +
+ Exam active: [When checked the exam will be available to take in the test system] +
+
+ Publish results: [When checked the exam results will be available on this site] +
+

+ +
    + {% for question in questions.all %} + +
  1. + {% for image in question.GetImages %} + thumbail + {% endfor %} + {% if not question.normal %} + Abnormality: {{ question.get_abnormalities }} Region: {{ question.get_regions }} +
    + {{ question.GetPrimaryAnswer }} + {% else %} + Normal + {% endif %} +
    + Examination: {{ question.get_examinations }}, View, Mark +
  2. + {% endfor %} +
+ JSON + Refresh JSON cache + + + +
+ +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/exam_scores.html b/longs/templates/rapids/exam_scores.html new file mode 100644 index 00000000..918eba05 --- /dev/null +++ b/longs/templates/rapids/exam_scores.html @@ -0,0 +1,65 @@ +{% extends 'rapids/exams.html' %} + +{% block content %} +
+

{{ exam.name }}

+ + {% if unmarked %} + The following questions need marking + {% for exam_index in unmarked %} + {{ exam_index|add:1 }} + {% endfor %} + + {% endif %} + +
+
+

Stats

+ Candidates: {{cids|length}}
+ Max score: {{max_score}}
+ Mean: {{mean}}, Median {{median}}, Mode {{mode}} + +
{{plot|safe}}
+
+
+ + + + + + {% for user, value in user_answers_marks.items %} + + + + + {% endfor %} +
Candidate IDScore
{{user}}{{user_scores|get_item:user}}
+
+
+

Answers as a table

+ + + + {% for cid in cids %} + + {% endfor %} + + + {% for question in questions %} + + + {% for ans, score in by_question|get_item:question %} + + {% endfor %} + + {% endfor %} + + + {% for score in user_scores_list %} + + {% endfor %} + +
Candidate{{cid}}
Question {{forloop.counter}}{{ans}}
Score:{{score}}
+ +
+{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/exam_scores_user.html b/longs/templates/rapids/exam_scores_user.html new file mode 100644 index 00000000..2860266f --- /dev/null +++ b/longs/templates/rapids/exam_scores_user.html @@ -0,0 +1,18 @@ +{% extends 'rapids/base.html' %} + +{% block content %} +
+

Exam: {{ exam.name }}

+

Candidate: {{ cid }}

+ Answers: +
    {% for ans, score, correct_answer in answers_and_marks %} +
  • Question {{forloop.counter}} - Correct answer: {{ correct_answer }}
  • +
    {{ans}}
    ({{score}})
    + {% endfor %} +
+
Total mark: {{ total_score }} / {{max_score}} + +
+{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/exams.html b/longs/templates/rapids/exams.html new file mode 100644 index 00000000..117e6f8a --- /dev/null +++ b/longs/templates/rapids/exams.html @@ -0,0 +1,7 @@ +{% extends 'rapids/base.html' %} + +{% block navigation %} +{{block.super}} +
+Exams: {{exam.name}}-> Overview / Mark / Scores +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/index.html b/longs/templates/rapids/index.html new file mode 100644 index 00000000..a69a1c57 --- /dev/null +++ b/longs/templates/rapids/index.html @@ -0,0 +1,11 @@ +{% extends 'rapids/base.html' %} + +{% block content %} +{% for exam in exams %} +
+

Exam: {{ exam.name }}

+ {% if request.user.is_staff %}Mark answers{% endif %} + {% if request.user.is_staff %}Scores{% endif %} +
+{% endfor %} +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/index_old.html b/longs/templates/rapids/index_old.html new file mode 100755 index 00000000..8bc76cd7 --- /dev/null +++ b/longs/templates/rapids/index_old.html @@ -0,0 +1,23 @@ +{% extends 'rapids/base.html' %} + +{% block content %} +My Questions: + + +Other Questions: + + +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/mark.html b/longs/templates/rapids/mark.html new file mode 100644 index 00000000..d0c7a519 --- /dev/null +++ b/longs/templates/rapids/mark.html @@ -0,0 +1,67 @@ +{% extends 'rapids/exams.html' %} + +{% block content %} +

Marking question {{question_details.current}} of {{question_details.total}}

+Edit Admin + Edit +{% if question.normal %} +

This question is normal

+Answers will be automatically marked. +{% else %} +

This question is abnormal

+Answers marked as normal will be automatically marked. +{% endif %} +
+
+
+
{% csrf_token %} + {% if not question.normal %} + Click each answer to toggle through marks awarded (as per colour) +
+ Unmarked: +
    + {% for answer in user_answers %} +
  • +
    {{ answer }}
    +
  • + {% endfor %} +
+ Marked: +
    + {% for answer in correct_answers %} +
  • +
    {{ answer }}
    +
  • + {% endfor %} + {% for answer in half_mark_answers %} +
  • +
    {{ answer }}
    +
  • + {% endfor %} + {% for answer in incorrect_answers %} +
  • +
    {{ answer }}
    +
  • + {% endfor %} +
+
Key: 2 Marks, 1 + Mark, 0 Marks
+
+ {% endif %} + {% if question_details.current > 1 %} + + {% endif %} + + {% if question_details.current >= question_details.total %} + {% else %} + + + {% endif %} + + {{ form.as_p }} + +
+
+{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/mark_overview.html b/longs/templates/rapids/mark_overview.html new file mode 100644 index 00000000..37849233 --- /dev/null +++ b/longs/templates/rapids/mark_overview.html @@ -0,0 +1,20 @@ +{% extends 'rapids/exams.html' %} + +{% block content %} +
+

Marking exam: {{ exam.name }}

+ You can start marking from a particular question by clicking on it below. + +
+ +
+ + + +
+{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/note_form.html b/longs/templates/rapids/note_form.html new file mode 100755 index 00000000..89dcd1ae --- /dev/null +++ b/longs/templates/rapids/note_form.html @@ -0,0 +1,12 @@ +{% extends "rapids/base.html" %} + +{% block content %} +

Add Note

+
+ {% csrf_token %} + + {{ form.as_table }} +
+ +
+{% endblock %} diff --git a/longs/templates/rapids/rapid_detail.html b/longs/templates/rapids/rapid_detail.html new file mode 100755 index 00000000..3768b778 --- /dev/null +++ b/longs/templates/rapids/rapid_detail.html @@ -0,0 +1,26 @@ +{% extends 'rapids/base.html' %} + +{% block content %} + + +{% if exam %} +
+ +{% if previous > -1 %} +Previous question +{% endif %} + This question is part of exam: {{exam.name}} [{{pos}}/{{exam_length}}] +{% if next %} +Next question +{% endif %} +
+{% endif %} + +Edit +Clone + Add Note +{% if request.user.is_superuser %} +Admin Edit +{% endif %} +{% include 'rapids/rapid_display_block.html' %} +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/rapid_display_block.html b/longs/templates/rapids/rapid_display_block.html new file mode 100755 index 00000000..03dde6a1 --- /dev/null +++ b/longs/templates/rapids/rapid_display_block.html @@ -0,0 +1,38 @@ +
+
+ {{ question.created_date }} +
+ +

Rapid: {{ question }}

+

Region: {{ question.get_regions }}

+

Examination: {{ question.get_examinations }}

+

Laterality: {{ question.laterality }}

+

Abnormality: {{ question.get_abnormalities }}

+
Images: + {% for image in question.images.all %} + + Image {{ forloop.counter }}{% if image.feedback_image %} [feedback image]{% endif %}: +
+
+ {% endfor %} +
+

Feedback: {{ question.feedback }}

+

Author(s): {% for author in question.author.all %} {{author}}, {% endfor %}

+

Checked by: {% for verified in question.verified.all %} {{verified}}, {% endfor %}

+

Scrapped: {{ question.scrapped }} (toggle) +

Answers: {{ question.GetMarkedAnswers }}

+

+
+
+ Notes: +
    + {% for note in question.rapid_notes.all %} +
  • + {{ note.created_on }} by {{ note.author }}: {{ note.note }} +
  • + {% endfor %} +
+ +
\ No newline at end of file diff --git a/longs/templates/rapids/rapid_form.html b/longs/templates/rapids/rapid_form.html new file mode 100755 index 00000000..24964892 --- /dev/null +++ b/longs/templates/rapids/rapid_form.html @@ -0,0 +1,292 @@ +{% extends "rapids/base.html" %} +{% load static from static %} + +{% block js %} + + + + +{{ form.media }} +{% endblock %} +{% block content %} +

Submit Rapid

+Edit defaults +
+ {% csrf_token %} + + + + + + {{ form.as_table }} +
+

Answers:

+ +
+ {% for form in answer_formset %} +
    + {{form.non_field_errors}} + {{form.errors}} + {{ form.as_ul }} +
+ {% endfor %} +
+ {{ answer_formset.management_form }} +

Images:

+ +
Drop images here (or use the buttons below)
Feedback image?
drop those here
+
+
+
+ {% for form in image_formset %} +
    + {{form.non_field_errors}} + {{form.errors}} + {{ form.as_ul }} +
+ {% endfor %} +
+ {{ image_formset.management_form }} + + +
+ + +{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/rapidcreationdefault_form.html b/longs/templates/rapids/rapidcreationdefault_form.html new file mode 100755 index 00000000..d5ec635d --- /dev/null +++ b/longs/templates/rapids/rapidcreationdefault_form.html @@ -0,0 +1,10 @@ +{% extends "rapids/base.html" %} + + +{% block content %} +

Defaults for creating new rapids

+
{% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} \ No newline at end of file diff --git a/longs/templates/rapids/view.html b/longs/templates/rapids/view.html new file mode 100755 index 00000000..2d6dddd3 --- /dev/null +++ b/longs/templates/rapids/view.html @@ -0,0 +1,18 @@ +{% extends 'rapids/base.html' %} + +{% load render_table from django_tables2 %} +{% block css %} +{% endblock %} + +{% block content %} + +
+

Filter Rapids

+
+ {{ filter.form }} + +
+
+{% render_table table %} + +{% endblock %} \ No newline at end of file diff --git a/longs/tests.py b/longs/tests.py new file mode 100755 index 00000000..7ce503c2 --- /dev/null +++ b/longs/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/longs/urls.py b/longs/urls.py new file mode 100755 index 00000000..65c4cc51 --- /dev/null +++ b/longs/urls.py @@ -0,0 +1,63 @@ +from django.urls import path, include +from . import views + +app_name = "rapids" + +urlpatterns = [ + # path('', views.question_list, name='question_list'), + 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("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("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("region/create/", views.create_region, name="create_region"), + path("region/ajax/get_region_id", + views.get_region_id, + name="get_region_id"), + path("abnormality/create/", + views.create_abnormality, + name="create_abnormality"), + path("abnormality/ajax/get_abnormality_id", + views.get_abnormality_id, + name="get_abnormality_id"), + path("examination/create/", + views.create_examination, + name="create_examination"), + path("examination/ajax/get_examination_id", + views.get_examination_id, + name="get_examination_id"), + path( + "question//update", + views.RapidUpdate.as_view(), + name="rapid_update", + ), + path("/add_note", views.AddNote.as_view(), name="rapid_add_note"), +] diff --git a/longs/views.py b/longs/views.py new file mode 100755 index 00000000..ce4b5c05 --- /dev/null +++ b/longs/views.py @@ -0,0 +1,1126 @@ +import json +from django.shortcuts import render, get_object_or_404, redirect +from django import forms + +# from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied + +from django.contrib.auth.mixins import LoginRequiredMixin + +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic import ListView +from django.views.decorators.csrf import csrf_exempt + +from django.urls import reverse_lazy, reverse + +from django.http import Http404, JsonResponse +from django.http import HttpResponseRedirect, HttpResponse + +from .forms import ( + RapidForm, + MarkRapidQuestionForm, + ImageFormSet, + NoteForm, + RegionForm, + AbnormalityForm, + ExaminationForm, + AnswerFormSet, + AnswerUpdateFormSet, +) +from .models import ( + Rapid, + Note, + Abnormality, + Region, + Examination, + Exam, + Answer, + CidUserAnswer, +) +from .tables import RapidTable +from .filters import RapidFilter + +from django_tables2 import SingleTableView, SingleTableMixin +from django_filters.views import FilterView + +from .decorators import user_is_author_or_rapid_checker + +from collections import defaultdict +import json +import statistics +import plotly.express as px + +from django.core.cache import cache + +from helpers.images import image_as_base64 + +import logging +from copy import deepcopy +from django.forms.models import model_to_dict +from rapids.forms import RapidCreationDefaultForm +from rapids.models import RapidCreationDefault + +logger = logging.getLogger(__name__) + + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super(UpdateView, self).get_object(*args, **kwargs) + if self.request.user.groups.filter(name="rapid_checker").exists(): + return obj + if self.request.user not in obj.author.all(): + raise PermissionDenied() # or Http404 + return obj + + +@login_required +def index(request): + exams = Exam.objects.all() + return render(request, "rapids/index.html", {"exams": exams}) + + +# def index(request): +# other_rapids = Rapid.objects.exclude(author=request.user.pk) +# user_rapids = Rapid.objects.filter(author=request.user.pk) +# return render( +# request, +# "rapids/index.html", +# {"other_rapids": other_rapids, "user_rapids": user_rapids}, +# ) + + +@login_required +def rapid_detail(request, pk): + rapid = get_object_or_404(Rapid, pk=pk) + + # if request.user not in rapid.author.all(): + # raise PermissionDenied + + # logging.debug(rapid.rapid_notes.first()) + # logging.debug(rapid.subspecialty.first().name.all()) + return render(request, "rapids/rapid_detail.html", {"question": rapid}) + + +@login_required +def rapid_split(request, pk): + rapid = get_object_or_404(Rapid, pk=pk) + + images = rapid.images.all() + + old_abnormality = rapid.abnormality.all() + old_region = rapid.region.all() + old_examination = rapid.examination.all() + old_site = rapid.site.all() + old_author = rapid.author.all() + + if not images: + raise Http404 + + for n in range(len(images) - 1): + rapid.pk = None + + rapid.save() + + rapid.abnormality.set(old_abnormality) + rapid.region.set(old_region) + rapid.examination.set(old_examination) + rapid.site.set(old_site) + rapid.author.set(old_author) + images[n].rapid = rapid + images[n].save() + + # images[-1].rapid + + # if request.user not in rapid.author.all(): + # raise PermissionDenied + + # logging.debug(rapid.rapid_notes.first()) + # logging.debug(rapid.subspecialty.first().name.all()) + return render(request, "rapids/rapid_detail.html", {"question": rapid}) + + +@login_required +def author_detail(request, pk): + # logging.debug(Author.objects.all()) + # author = get_object_or_404(Author, pk=pk) + author = User.objects.get(pk=pk) + + rapids = Rapid.objects.filter(author=pk) + + return render( + request, "rapids/category_detail.html", {"category": author, "rapids": rapids} + ) + + +def author_list(request): + authors = User.objects.all() + + return render(request, "rapids/author_list.html", {"authors": authors}) + + +class RapidCreationDefaultView(LoginRequiredMixin, UpdateView): + model = RapidCreationDefault + form_class = RapidCreationDefaultForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + # def dispatch(self, request, *args, **kwargs): + # """ + # Overridden so we can make sure the `Rapid` instance exists + # before going any further. + # """ + # self.pk = get_object_or_404(Rapid, pk=kwargs['pk']) + # return super().dispatch(request, *args, **kwargs) + def get_object(self, queryset=None): + obj, create = RapidCreationDefault.objects.get_or_create( + author=self.request.user + ) + + return obj + + def form_valid(self, form): + + model = form.save(commit=False) + + model.author = self.request.user + model.save() + + response = super().form_valid(form) + + return response + + # form.instance.author.add(self.request.user.id) + + +class AddNote(LoginRequiredMixin, CreateView): + model = Note + form_class = NoteForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def dispatch(self, request, *args, **kwargs): + """ + Overridden so we can make sure the `Rapid` instance exists + before going any further. + """ + self.pk = get_object_or_404(Rapid, pk=kwargs["pk"]) + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + + note = form.save(commit=False) + + note.rapid = self.pk + + note.author = self.request.user + note.save() + + response = super().form_valid(form) + + return response + + # form.instance.author.add(self.request.user.id) + + +@login_required +def rapid_clone(request, pk): + new_item = get_object_or_404(Rapid, pk=pk) + new_item.pk = None # autogen a new pk (item_id) + # new_item.name = "Copy of " + new_item.name #need to change uniques + + form = RapidForm(request.POST or None, instance=new_item) + + image_formset = ImageFormSet() + answer_formset = AnswerFormSet() + + if form.is_valid(): + form.instance.author.add(request.user.id) + + # logger.debug(formset.is_valid()) + if image_formset.is_valid() and answer_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = obj + image_formset.save() + + answer_formset.instance = obj + answer_formset.save() + return response + else: + return super().form_invalid(form) + + context = { + "form": form, + "image_formset": image_formset, + "answer_formset": answer_formset + # other context + } + + return render(request, "rapids/rapid_form.html", context) + + +class RapidCreateBase(LoginRequiredMixin, CreateView): + model = Rapid + form_class = RapidForm + + def get_context_data(self, **kwargs): + context = super(RapidCreateBase, self).get_context_data(**kwargs) + if self.request.POST: + context["image_formset"] = ImageFormSet( + self.request.POST, self.request.FILES + ) + context["image_formset"].full_clean() + context["answer_formset"] = AnswerFormSet(self.request.POST) + context["answer_formset"].full_clean() + else: + context["image_formset"] = ImageFormSet() + 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) + + context = self.get_context_data(form=form) + image_formset = context["image_formset"] + answer_formset = context["answer_formset"] + if image_formset.is_valid() and answer_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = self.object + image_formset.save() + answer_formset.instance = self.object + answer_formset.save() + # 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("rapids:rapid_clone", pk=self.object.pk) + + else: + return super().form_invalid(form) + + +# @login_required +class RapidCreate(RapidCreateBase): + + initial = {"laterality": Rapid.NONE} + + def get_initial(self): + # There has to be a better way... + try: + s = (i.pk for i in self.request.user.rapid_default.site.all()) + self.initial.update({"site": s}) + except AttributeError: + pass + return self.initial + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + + # self.object = form.save(commit=False) + # self.object.save() + + # form.instance.author.add(self.request.user.id) + # return super().form_valid(form) + + +class RapidUpdate(LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView): + model = Rapid + form_class = RapidForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def get_context_data(self, **kwargs): + context = super(RapidUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["image_formset"] = ImageFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["image_formset"].full_clean() + context["answer_formset"] = AnswerFormSet( + self.request.POST, instance=self.object + ) + context["answer_formset"].full_clean() + else: + context["image_formset"] = ImageFormSet(instance=self.object) + context["answer_formset"] = AnswerFormSet(instance=self.object) + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + image_formset = context["image_formset"] + answer_formset = context["answer_formset"] + # logger.debug(formset.is_valid()) + if image_formset.is_valid() and answer_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = self.object + image_formset.save() + answer_formset.instance = self.object + answer_formset.save() + return response + else: + return super().form_invalid(form) + + +class RapidClone(RapidCreateBase): + """Clones a existing rapid""" + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def get_initial(self): + # print(self.request) + old_object = get_object_or_404(Rapid, pk=self.kwargs["pk"]) + initial_data = model_to_dict(old_object, exclude=["id"]) + + return initial_data + + +@login_required +@user_is_author_or_rapid_checker +def rapid_scrap(request, pk): + try: + rapid = Rapid.objects.get(pk=pk) + except Rapid.DoesNotExist: + raise Http404("Rapid does not exist") + + rapid.scrapped = not rapid.scrapped + rapid.save() + return HttpResponseRedirect(reverse("rapids:rapid_detail", args=(pk,))) + + +# @login_required +# def edit_abnormality_popup(request): +# instance = get_object_or_404(Abnormality, pk = pk) +# form = AbnormalityForm(request.POST or None) +# if form.is_valid(): +# instance = form.save() +# return HttpResponse('' % (instance.pk, instance)) +# return render(request, "rapids/create_simple.html", {'form': form}) + + +@login_required +def create_abnormality(request): + form = AbnormalityForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "rapids/create_simple.html", {"form": form, "name": "Abnormality"} + ) + + +@csrf_exempt +def get_abnormality_id(request): + if request.is_ajax(): + abnormality_name = request.GET["abnormality_name"] + abnormality_id = Abnormality.objects.get(name=abnormality_name).id + data = { + "abnormality_id": abnormality_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +@login_required +def create_examination(request): + form = ExaminationForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "rapids/create_simple.html", {"form": form, "name": "Examination"} + ) + + +@csrf_exempt +def get_examination_id(request): + if request.is_ajax(): + examination_name = request.GET["examination_name"] + examination_id = Examination.objects.get(name=examination_name).id + data = { + "examination_id": examination_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +@login_required +def create_region(request): + form = RegionForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "rapids/create_simple.html", {"form": form, "name": "Region"} + ) + + +@csrf_exempt +def get_region_id(request): + if request.is_ajax(): + region_name = request.GET["region_name"] + region_id = Region.objects.get(name=region_name).id + data = { + "region_id": region_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +class RapidView(SingleTableMixin, FilterView): + model = Rapid + table_class = RapidTable + template_name = "rapids/view.html" + + filterset_class = RapidFilter + + +def loadJsonAnswer(answer): + eid = int(answer["eid"].split("/")[1]) + + # As access is not restricted make sure the data appears valid + if (not isinstance(answer["cid"], int)) or ( + not isinstance(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 eid > max_int: + return JsonResponse({"success": False, "error": "invalid cid"}) + + exam = get_object_or_404(Exam, pk=eid) + + if not exam.active: + return JsonResponse( + {"success": False, "error": "No active exam: {}".format(eid)} + ) + + exiting_answers = CidUserAnswer.objects.filter( + question__id=answer["qid"], exam__id=eid, cid=answer["cid"] + ) + + posted_answer = answer["ans"] + + # Normal answers are just posted with the answer "Normal" + normal = False + if posted_answer == "Normal": + normal = True + posted_answer = "" + # We can ignore the first ab + elif answer["qidn"] == "1" and posted_answer == "Abnormal": + return False + + + if not exiting_answers: + ans = CidUserAnswer(answer=posted_answer, normal=normal, cid=answer["cid"]) + ans.question_id = answer["qid"] + ans.exam_id = 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.normal = normal + ans.answer = posted_answer + 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 + + for answer in json.loads(request.POST.get("answers")): + ret = loadJsonAnswer(answer) + + #if ret is not True: + # return ret + + if 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, json=True): + exams = Exam.objects.all() + + 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()), + "type": "rapid", + } + ) + + if json == False: + return active_exams["exams"] + + 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("rapids_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) + + # Loop through rapidimage associations + images = [] + feedback_images = [] + for i in q.images.all(): + if i.feedback_image == True: + feedback_images.append(image_as_base64(i.image)) + # feedback_images.append(i.image.url) + else: + images.append(image_as_base64(i.image)) + #images.append(i.image.url) + + + + exam_questions[q.id] = { + "images": images, + #"feedback_image": [], + #"annotations": [str(q.image_annotations)], + "type": "rapid", + } + + #if feedback_images: + # exam_questions[q.id]["feedback_image"] = feedback_images + + + exam_json = { + "eid": "rapid/{}".format(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("rapids_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 + elif s.normal: + ans = "Normal" + else: + 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 or user_answer is None: + # skip if no answer + answers_marks.append(0) + #answers.append("") + answer_score = 0 + ans = "Not answered" + else: + if user_answer.normal: + ans = "Normal" + 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, + }, + ) + + +@login_required +def exam_question_detail(request, pk, sk): + exam = get_object_or_404(Exam, pk=pk) + + question = exam.exam_questions.all()[sk] + + exam_length = len(exam.exam_questions.all()) + + pos = exam.get_question_index(question) + 1 + + print(question.exams.through.sort_value) + + previous = -1 + if sk > 0: + previous = sk - 1 + next = sk + 1 + if sk == exam_length - 1: + next = False + + return render( + request, + "rapids/rapid_detail.html", + { + "question": question, + "exam": exam, + "next": next, + "previous": previous, + "exam_length": exam_length, + "pos": pos, + }, + ) + + +@login_required +def exam_list(request): + exams = Exam.objects.all() + return render(request, "rapids/exam_list.html", {"exams": exams}) + + +@login_required +def exam_overview(request, pk): + print(type(pk)) + #print(Exam.objects.all()) + #exams = Exam.objects.all() + #print("test", Exam.objects.all().get(id=pk)) + exam = get_object_or_404(Exam, pk=pk) + print("exam", exam) + + questions = exam.exam_questions.all() + + question_number = len(questions) + + return render( + request, + "rapids/exam_overview.html", + {"exam": exam, "questions": questions, "question_number": question_number}, + ) \ No newline at end of file