diff --git a/anatomy/admin.py b/anatomy/admin.py index a203e573..b053714b 100644 --- a/anatomy/admin.py +++ b/anatomy/admin.py @@ -7,6 +7,7 @@ from .models import ( CidUserAnswer, Exam, Modality, + Region, BodyPart, Structure, ) @@ -42,6 +43,8 @@ class AnatomyAdmin(VersionAdmin): ExamInline, ] + exclude = [ "image_annotations" ] + admin.site.register(AnatomyQuestion, AnatomyAdmin) admin.site.register(Examination) @@ -49,6 +52,7 @@ admin.site.register(QuestionType) admin.site.register(CidUserAnswer) admin.site.register(Exam) admin.site.register(Modality) +admin.site.register(Region) admin.site.register(BodyPart) admin.site.register(Structure) diff --git a/anatomy/forms.py b/anatomy/forms.py index 100d6834..3a3921db 100644 --- a/anatomy/forms.py +++ b/anatomy/forms.py @@ -1,16 +1,109 @@ -from django import forms +from django.forms import ( + Form, + ModelForm, + ModelMultipleChoiceField, + ModelChoiceField, + ChoiceField, + CharField, +) +from django.forms import inlineformset_factory -from .models import CidUserAnswer, AnatomyQuestion +from .models import ( + Answer, + CidUserAnswer, + AnatomyQuestion, + Examination, + BodyPart, + Structure, + Region, + Modality, + QuestionType, +) + +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms.widgets import RadioSelect, TextInput -class AnatomyAnswerForm(forms.ModelForm): +class AnatomyAnswerForm(ModelForm): class Meta: model = CidUserAnswer fields = ("answer",) -class MarkAnatomyQuestionForm(forms.Form): +class MarkAnatomyQuestionForm(Form): # correct = forms.CharField(required=False) # half_correct = forms.CharField(required=False) # incorrect = forms.CharField(required=False) - marked_answers = forms.CharField(required=False) + marked_answers = CharField(required=False) + + +class AnatomyQuestionForm(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(AnatomyQuestionForm, 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["question_type"] = ModelChoiceField( + required=True, + queryset=QuestionType.objects.all(), + initial=1, + ) + + self.fields["region"] = ModelChoiceField( + required=False, + queryset=Region.objects.all(), + ) + + self.fields["modality"] = ModelChoiceField( + required=True, + queryset=Modality.objects.all(), + ) + + self.fields["structure"] = ModelChoiceField( + required=False, + queryset=Structure.objects.all(), + ) + + self.fields["examination"] = ModelChoiceField( + required=False, + queryset=Examination.objects.all(), + ) + + self.fields["body_part"] = ModelChoiceField( + required=False, + queryset=BodyPart.objects.all(), + ) + + class Meta: + model = AnatomyQuestion + + fields = ["question_type", "image", "description"] + + widgets = {} + + +AnswerFormSet = inlineformset_factory( + AnatomyQuestion, + Answer, + fields=["answer", "status"], + widgets={ + "answer": TextInput( + attrs={"required": "true", "minlength": 2, "maxlength": 500} + ) + }, + exclude=[], + can_delete=True, + extra=1, + max_num=10, + field_classes="testing", +) diff --git a/anatomy/migrations/0010_auto_20201206_1716.py b/anatomy/migrations/0010_auto_20201206_1716.py new file mode 100644 index 00000000..410dc1cd --- /dev/null +++ b/anatomy/migrations/0010_auto_20201206_1716.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1.3 on 2020-12-06 17:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('anatomy', '0009_anatomyquestion_image_annotations'), + ] + + operations = [ + migrations.CreateModel( + name='Region', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('region', models.CharField(max_length=200)), + ], + ), + migrations.AddField( + model_name='anatomyquestion', + name='region', + field=models.ForeignKey(blank=True, help_text='Region the image covers', null=True, on_delete=django.db.models.deletion.SET_NULL, to='anatomy.region'), + ), + ] diff --git a/anatomy/migrations/0011_anatomyquestion_author.py b/anatomy/migrations/0011_anatomyquestion_author.py new file mode 100644 index 00000000..4d4b03c8 --- /dev/null +++ b/anatomy/migrations/0011_anatomyquestion_author.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.3 on 2020-12-06 18:12 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('anatomy', '0010_auto_20201206_1716'), + ] + + operations = [ + migrations.AddField( + model_name='anatomyquestion', + name='author', + field=models.ManyToManyField(blank=True, help_text='Author(s) of question', related_name='anatomy_authored_questions', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/anatomy/models.py b/anatomy/models.py index 6c773d03..2a196336 100644 --- a/anatomy/models.py +++ b/anatomy/models.py @@ -45,6 +45,13 @@ class Structure(models.Model): return self.structure +class Region(models.Model): + region = models.CharField(max_length=200) + + def __str__(self): + return self.region + + class Modality(models.Model): modality = models.CharField(max_length=200) @@ -63,46 +70,62 @@ class AnatomyQuestion(models.Model): # author = models.ForeignKey('auth.User', on_delete=models.CASCADE) # image = models.ImageField() # feedback = models.TextField(null=True) - question_type = models.ForeignKey(QuestionType, - on_delete=models.SET_NULL, - null=True) + question_type = models.ForeignKey( + QuestionType, on_delete=models.SET_NULL, null=True + ) - image = models.ImageField(upload_to=image_directory_path, - storage=image_storage) + image = models.ImageField(upload_to=image_directory_path, storage=image_storage) - image_annotations = models.TextField(blank=True, help_text="Stores a JSON representation of annotations to be applied by cornerstonetools") + image_annotations = models.TextField( + blank=True, + help_text="Stores a JSON representation of annotations to be applied by cornerstonetools", + ) description = models.CharField( max_length=400, - help_text= - "Short description of the image e.g. 'Sagittal CT Chest, Abdomen & Pelvis', will be displayed as the title." + help_text="Short description of the image e.g. 'Sagittal CT Chest, Abdomen & Pelvis', will be displayed as the title.", ) - examination = models.ForeignKey(Examination, - on_delete=models.SET_NULL, - null=True, - blank=True) - modality = models.ForeignKey(Modality, - on_delete=models.SET_NULL, - null=True, - help_text="Modality of the image") - body_part = models.ForeignKey(BodyPart, - on_delete=models.SET_NULL, - null=True, - blank=True) - structure = models.ForeignKey(Structure, - on_delete=models.SET_NULL, - null=True, - blank=True) + examination = models.ForeignKey( + Examination, on_delete=models.SET_NULL, null=True, blank=True + ) + modality = models.ForeignKey( + Modality, + on_delete=models.SET_NULL, + null=True, + help_text="Modality of the image", + ) + region = models.ForeignKey( + Region, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Region the image covers", + ) + body_part = models.ForeignKey( + BodyPart, on_delete=models.SET_NULL, null=True, blank=True + ) + structure = models.ForeignKey( + Structure, on_delete=models.SET_NULL, null=True, blank=True + ) created_date = models.DateTimeField(default=timezone.now) - - open_access = models.BooleanField(help_text="If an question should be freely available to browse", - default=True) + + open_access = models.BooleanField( + help_text="If an question should be freely available to browse", default=True + ) + + author = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + help_text="Author(s) of question", + related_name="anatomy_authored_questions", + ) def __str__(self): # Get first answer - return "{} [{}, {}]".format(self.GetPrimaryAnswer(), self.modality, - self.structure) + return "{} [{}, {}]".format( + self.GetPrimaryAnswer(), self.modality, self.structure + ) # Get associated exams e = self.exams.all().values_list("name", flat=True) exams = ", ".join(e) @@ -110,18 +133,24 @@ class AnatomyQuestion(models.Model): if a is None: return "({})".format(exams) else: - return "{} ({}) [{}, {}]".format(a.answer, exams, self.modality, - self.structure) + return "{} ({}) [{}, {}]".format( + a.answer, exams, self.modality, self.structure + ) + + def get_absolute_url(self): + return reverse("anatomy:question_detail", kwargs={"pk": self.pk}) def GetPrimaryAnswer(self): - return self.answers.all().first().answer + if len(self.answers.all()) > 0: + return self.answers.all().first().answer + 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() @@ -129,11 +158,14 @@ class AnatomyQuestion(models.Model): return "No answers to mark" return format_html( "{} answer unmarked: {}".format( - len(unmarked_answers), ", ".join(unmarked_answers))) + len(unmarked_answers), ", ".join(unmarked_answers) + ) + ) def GetUnmarkedAnswers(self): user_answers = set( - [i.get_compare_string() for i in self.cid_user_answers.all()]) + [i.get_compare_string() for i in self.cid_user_answers.all()] + ) unmarked_answers = user_answers - self.GetMarkedAnswers() return unmarked_answers @@ -142,30 +174,40 @@ class AnatomyQuestion(models.Model): return len(self.GetUnmarkedAnswers()) def GetMarkedAnswers(self): - return set([i.get_compare_string() for i in self.answers.all() if i.status != i.MarkOptions.UNMARKED]) + return set( + [ + i.get_compare_string() + for i in self.answers.all() + if i.status != i.MarkOptions.UNMARKED + ] + ) correct_answers = set([i.answer.lower() for i in self.answers.all()]) half_mark_answers = set( - [i.answer.lower() for i in self.half_mark_answers.all()]) + [i.answer.lower() for i in self.half_mark_answers.all()] + ) incorrect_answers = set( - [i.answer.lower() for i in self.incorrect_answers.all()]) + [i.answer.lower() for i in self.incorrect_answers.all()] + ) marked_answers = correct_answers | half_mark_answers | incorrect_answers return marked_answers class Answer(models.Model): - question = models.ForeignKey(AnatomyQuestion, - related_name="answers", - on_delete=models.CASCADE) + question = models.ForeignKey( + AnatomyQuestion, related_name="answers", on_delete=models.CASCADE + ) answer = models.CharField(max_length=500) class MarkOptions(models.TextChoices): - UNMARKED = '', _('Unmarked') - INCORRECT = '0', _('Incorrect') - HALF_MARK = '1', _('Half mark') - CORRECT = '2', _('Correct') + 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) + status = models.CharField( + max_length=1, choices=MarkOptions.choices, default=MarkOptions.UNMARKED + ) def __str__(self): return self.answer @@ -178,7 +220,7 @@ class Answer(models.Model): return self.answer.lower() -#class HalfMarkAnswers(models.Model): +# class HalfMarkAnswers(models.Model): # question = models.ForeignKey(AnatomyQuestion, # related_name="half_mark_answers", # on_delete=models.CASCADE) @@ -192,7 +234,7 @@ class Answer(models.Model): # self.answer.strip() # # -#class IncorrectAnswers(models.Model): +# class IncorrectAnswers(models.Model): # question = models.ForeignKey(AnatomyQuestion, # related_name="incorrect_answers", # on_delete=models.CASCADE) @@ -208,14 +250,17 @@ class Answer(models.Model): class Exam(models.Model): name = models.CharField(max_length=200) - exam_questions = SortedManyToManyField(AnatomyQuestion, - related_name="exams", blank="true") + exam_questions = SortedManyToManyField( + AnatomyQuestion, related_name="exams", blank="true" + ) - active = models.BooleanField(help_text="If an exam should be available", - default=True) + active = models.BooleanField( + help_text="If an exam should be available", default=True + ) - recreate_json = models.BooleanField(help_text="If the json cache needs updating", - default=False) + recreate_json = models.BooleanField( + help_text="If the json cache needs updating", default=False + ) def __str__(self): return self.name @@ -224,26 +269,24 @@ class Exam(models.Model): return self.name def get_json_url(self): - return reverse('anatomy:exam_json', args=(self.pk, )) + return reverse("anatomy:exam_json", args=(self.pk,)) class UserAnswer(models.Model): - question = models.ForeignKey(AnatomyQuestion, - related_name="user_answers", - on_delete=models.CASCADE) + question = models.ForeignKey( + AnatomyQuestion, related_name="user_answers", on_delete=models.CASCADE + ) answer = models.CharField(max_length=500, blank=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, - null=True, - blank=True, - on_delete=models.SET_NULL) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL + ) cid = models.IntegerField(blank=True, null=True) # Each user answer is associated with a particular exam - exam = models.ForeignKey(Exam, - related_name="exam", - on_delete=models.CASCADE, - null=True) + exam = models.ForeignKey( + Exam, related_name="exam", on_delete=models.CASCADE, null=True + ) flagged = models.BooleanField(default=False) @@ -255,8 +298,9 @@ class UserAnswer(models.Model): exam = self.exam except (Exam.DoesNotExist, KeyError) as e: exam = "None" - return "{}/{}/{}: {}".format(exam, self.cid, self.question.GetPrimaryAnswer(), - self.answer) + return "{}/{}/{}: {}".format( + exam, self.cid, self.question.GetPrimaryAnswer(), self.answer + ) def clean(self): if self.answer: @@ -265,18 +309,18 @@ class UserAnswer(models.Model): class CidUserAnswer(models.Model): """User answers by candidate""" - question = models.ForeignKey(AnatomyQuestion, - related_name="cid_user_answers", - on_delete=models.CASCADE) + + question = models.ForeignKey( + AnatomyQuestion, related_name="cid_user_answers", on_delete=models.CASCADE + ) answer = models.CharField(max_length=500, blank=True) cid = models.IntegerField(blank=True, null=True) # Each user answer is associated with a particular exam - exam = models.ForeignKey(Exam, - related_name="cid_user_answers", - on_delete=models.CASCADE, - null=True) + 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) @@ -286,8 +330,9 @@ class CidUserAnswer(models.Model): exam = self.exam except (Exam.DoesNotExist, KeyError) as e: exam = "None" - return "{}/{}/{}: {}".format(exam, self.cid, self.question.GetPrimaryAnswer(), - self.answer) + return "{}/{}/{}: {}".format( + exam, self.cid, self.question.GetPrimaryAnswer(), self.answer + ) def clean(self): if self.answer: @@ -299,9 +344,19 @@ class CidUserAnswer(models.Model): def get_answer_score(self): q = self.question ans = self.answer - if q.answers.filter(answer__iexact=ans, status=Answer.MarkOptions.CORRECT).first() is not None: + if ( + q.answers.filter( + answer__iexact=ans, status=Answer.MarkOptions.CORRECT + ).first() + is not None + ): mark = 2 - elif q.answers.filter(answer__iexact=ans, status=Answer.MarkOptions.HALF_MARK).first() is not None: + elif ( + q.answers.filter( + answer__iexact=ans, status=Answer.MarkOptions.HALF_MARK + ).first() + is not None + ): mark = 1 else: mark = 0 diff --git a/anatomy/static/css/anatomy.css b/anatomy/static/css/anatomy.css index 1d8249f2..f9b9385a 100644 --- a/anatomy/static/css/anatomy.css +++ b/anatomy/static/css/anatomy.css @@ -166,4 +166,113 @@ button a { #save-annotations { position: absolute; left: 0; +} + +.drop-target { + border: 1px dashed white; +} + +#drop-container { + width: 100%; + width: 100vw; + height: 100px; + text-align: center; + font-size: larger; + position: sticky; + bottom: 0; + left: 0%; + right: 50%; + margin-left: -50vw; + margin-right: -50vw; +} + +.submit-button { + position: sticky; + bottom: 0; +} + +#feedback-drop-target { + float: right; + height: 100%; + width: 30%; +} + +.drop-target-active { + background-color: #427aa1; +} + +#drop-filenames span { + font-size: xx-small; + color: gray; + padding-right: 1em; + +} + +.image-formset { + border: 1px solid white; +} + +select, input { + background-color: #05668d; + color: white; + border: none; +} + +table.table { + color: white; +} + +#view-filter-options { + float: right; +} + +.rapid-img { + width: 50%; +} + +.feedback-img { + border: 1px solid #05668d; +} + +img.uploading { + margin-top: -20px; + height: 75%; + float: right; +} + +img.uploading:hover { + height: 95%; + position: fixed; + top: 50%; + left: 50%; + /* bring your own prefixes */ + transform: translate(-50%, -50%) +} + +.image-ident-warning { + border: 2px solid red; +} + + +.image-ident-loading { + background: rgba(142, 68, 173, 1); + box-shadow: 0 0 0 0 rgba(142, 68, 173, 1); + animation: pulse-purple 2s infinite; +} + +@keyframes pulse-purple { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(142, 68, 173, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba(142, 68, 173, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(142, 68, 173, 0); + } } \ No newline at end of file diff --git a/anatomy/templates/anatomy/anatomyquestion_form.html b/anatomy/templates/anatomy/anatomyquestion_form.html new file mode 100644 index 00000000..ae94937a --- /dev/null +++ b/anatomy/templates/anatomy/anatomyquestion_form.html @@ -0,0 +1,260 @@ +{% extends "anatomy/base.html" %} + +{% load static %} + +{% block js %} + + + + +{{ form.media }} +{% endblock %} +{% block content %} +

Submit Question

+
+ {% csrf_token %} + + + + + + {{ form.as_table }} +
+
Drop image here +
+
+

Answers:

+ +
+ {% for form in answer_formset %} + + {% endfor %} +
+ {{ answer_formset.management_form }} + + +
+ +{% endblock %} \ No newline at end of file diff --git a/anatomy/templates/anatomy/base.html b/anatomy/templates/anatomy/base.html index 1bef86ee..cd70b5f0 100644 --- a/anatomy/templates/anatomy/base.html +++ b/anatomy/templates/anatomy/base.html @@ -16,7 +16,8 @@ - + {% block js %} + {% endblock %} diff --git a/anatomy/urls.py b/anatomy/urls.py index 3bb772fa..1f70a860 100644 --- a/anatomy/urls.py +++ b/anatomy/urls.py @@ -7,6 +7,8 @@ urlpatterns = [ # path('', views.question_list, name='question_list'), path("", views.index, name="index"), path("question//", views.question_detail, name="question_detail"), + path("question/create/", views.AnatomyQuestionCreate.as_view(), name="anatomy_question_create"), + path("question//update", views.AnatomyQuestionUpdate.as_view(), name="anatomy_question_create"), path("question//save_annotation", views.question_save_annotation, name="question_save_annotation"), path("question//answer/", views.answer_question, diff --git a/anatomy/views.py b/anatomy/views.py index 169f6715..c2bbf47c 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -6,14 +6,22 @@ from django import forms from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.models import User +from django.contrib.auth.mixins import LoginRequiredMixin + +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic import ListView + from django.db.models.functions import Lower from django.core.cache import cache +from django.urls import reverse_lazy, reverse + from django.http import Http404, JsonResponse +from django.http import HttpResponseRedirect, HttpResponse -from .forms import AnatomyAnswerForm, MarkAnatomyQuestionForm +from .forms import AnatomyAnswerForm, AnswerFormSet, MarkAnatomyQuestionForm, AnatomyQuestionForm from .models import ( AnatomyQuestion, CidUserAnswer, @@ -748,4 +756,107 @@ def cid_scores(request, pk): "exams": exams, "cid": cid, }, - ) \ No newline at end of file + ) + +class AnatomyQuestionCreateBase(LoginRequiredMixin, CreateView): + model = AnatomyQuestion + form_class = AnatomyQuestionForm + + def get_context_data(self, **kwargs): + context = super(AnatomyQuestionCreateBase, 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) + + context = self.get_context_data(form=form) + formset = context['answer_formset'] + + if formset.is_valid(): + response = super().form_valid(form) + formset.instance = self.object + 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('AnatomyQuestions:AnatomyQuestion_clone', pk=self.object.pk) + + else: + return super().form_invalid(form) + + +#@login_required +class AnatomyQuestionCreate(AnatomyQuestionCreateBase): + + #initial = {'laterality': AnatomyQuestion.NONE} + + def get_initial(self): + pass +# # There has to be a better way... +# try: +# s = (i.pk for i in self.request.user.AnatomyQuestion_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 AnatomyQuestionUpdate(LoginRequiredMixin, AuthorOrCheckerRequiredMixin, +class AnatomyQuestionUpdate(LoginRequiredMixin, UpdateView): + model = AnatomyQuestion + form_class = AnatomyQuestionForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def get_context_data(self, **kwargs): + context = super(AnatomyQuestionUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context['answer_formset'] = AnswerFormSet(self.request.POST, + self.request.FILES, + instance=self.object) + context['answer_formset'].full_clean() + else: + 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) + formset = context['answer_formset'] + #logger.debug(formset.is_valid()) + if formset.is_valid(): + response = super().form_valid(form) + formset.instance = self.object + formset.save() + return response + else: + return super().form_invalid(form) \ No newline at end of file