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 %} +