From afa1379ce40680ccd5b072492c5560030954a5a2 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 7 Apr 2025 09:12:30 +0100 Subject: [PATCH] start setting up shorts --- generic/views.py | 15 + rad/settings.py | 1 + rad/static/css/anatomy.css | 2 +- rad/urls.py | 1 + rapids/views.py | 80 +- shorts/__init__.py | 0 shorts/admin.py | 44 ++ shorts/apps.py | 6 + shorts/decorators.py | 32 + shorts/filters.py | 99 +++ shorts/forms.py | 251 ++++++ shorts/migrations/0001_initial.py | 148 ++++ shorts/migrations/__init__.py | 0 shorts/models.py | 401 ++++++++++ shorts/tables.py | 91 +++ shorts/templates/shorts/base.html | 55 ++ shorts/templates/shorts/exam_overview.html | 56 ++ shorts/templates/shorts/exams.html | 15 + shorts/templates/shorts/question_detail.html | 6 + .../shorts/question_display_block.html | 417 ++++++++++ shorts/templates/shorts/question_form.html | 343 ++++++++ .../shorts/question_link_header.html | 25 + shorts/tests.py | 3 + shorts/urls.py | 112 +++ shorts/views.py | 731 ++++++++++++++++++ templates/base.html | 3 + 26 files changed, 2896 insertions(+), 41 deletions(-) create mode 100644 shorts/__init__.py create mode 100644 shorts/admin.py create mode 100644 shorts/apps.py create mode 100644 shorts/decorators.py create mode 100755 shorts/filters.py create mode 100644 shorts/forms.py create mode 100644 shorts/migrations/0001_initial.py create mode 100644 shorts/migrations/__init__.py create mode 100644 shorts/models.py create mode 100644 shorts/tables.py create mode 100644 shorts/templates/shorts/base.html create mode 100644 shorts/templates/shorts/exam_overview.html create mode 100644 shorts/templates/shorts/exams.html create mode 100755 shorts/templates/shorts/question_detail.html create mode 100755 shorts/templates/shorts/question_display_block.html create mode 100755 shorts/templates/shorts/question_form.html create mode 100644 shorts/templates/shorts/question_link_header.html create mode 100644 shorts/tests.py create mode 100644 shorts/urls.py create mode 100644 shorts/views.py diff --git a/generic/views.py b/generic/views.py index 8938a82b..333d3c41 100644 --- a/generic/views.py +++ b/generic/views.py @@ -130,6 +130,8 @@ from physics.models import ( from physics.models import Exam as PhysicsExam from physics.models import UserAnswer as PhysicsUserAnswer +from shorts.models import Question as ShortsQuestion, ExamQuestionDetail as ShortsExamQuestionDetail, UserAnswer as ShortsUserAnswer, Exam as ShortsExam + from django.db.models import Case, When from django.conf import settings @@ -179,6 +181,7 @@ def get_user_exams(user, supervisor_view=False): "physics": (PhysicsUserAnswer, PhysicsExam, "physics_exams"), "anatomy": (AnatomyUserAnswer, AnatomyExam, "anatomy_exams"), "rapids": (RapidsUserAnswer, RapidsExam, "rapids_exams"), + "shorts": (ShortsUserAnswer, ShortsExam, "shorts_exams"), "longs": (LongsUserAnswer, LongsExam, "longs_exams"), "sbas": (SbasUserAnswer, SbasExam, "sbas_exams"), } @@ -215,6 +218,7 @@ def get_exam_model_from_app_name(app_name: str) -> ExamBase: "physics": PhysicsExam, "anatomy": AnatomyExam, "rapids": RapidsExam, + "shorts": ShortsExam, "longs": LongsExam, "sbas": SbasExam, } @@ -247,6 +251,9 @@ def get_question_and_content_type(question_type): if question_type == "rapid": question = RapidQuestion content_type = ContentType.objects.get(model="rapid") + elif question_type == "shorts": + question = ShortsQuestion + content_type = ContentType.objects.get(model="rapid") elif question_type == "anatomy": question = AnatomyQuestion content_type = ContentType.objects.get(model="anatomyquestion") @@ -352,6 +359,9 @@ def generic_exam_json_edit(request): if question_type == "rapids": Exam = RapidsExam Question = RapidQuestion + elif question_type == "shorts": + Exam = ShortsExam + Question = ShortsQuestion elif question_type == "longs": Exam = LongsExam Question = LongQuestion @@ -1437,6 +1447,10 @@ class ExamViews(View, LoginRequiredMixin): question_detail = RapidsExamQuestionDetail.objects.get( rapid=question, exam=exam ) + case "shorts": + question_detail = ShortsExamQuestionDetail.objects.get( + question=question, exam=exam + ) case "longs": question_detail = LongsExamQuestionDetail.objects.get( question=question, exam=exam @@ -2590,6 +2604,7 @@ class GenericViewBase: "longs": "longs_checker", "sbas": "sba_checker", "physics": "physics_checker", + "shorts": "shorts_checker", } self.checker_group = g[app_name] diff --git a/rad/settings.py b/rad/settings.py index da7005b5..88e3a7d7 100644 --- a/rad/settings.py +++ b/rad/settings.py @@ -45,6 +45,7 @@ INSTALLED_APPS = [ "anatomy", "physics", "rapids", + "shorts", "longs", "sbas", "wally", diff --git a/rad/static/css/anatomy.css b/rad/static/css/anatomy.css index 4abb256a..c846e2af 100644 --- a/rad/static/css/anatomy.css +++ b/rad/static/css/anatomy.css @@ -255,7 +255,7 @@ button a { right: 50px; } -#save-annotations.save-rapid-annotations { +#save-annotations.save-rapid-annotations, #save-annotations.save-shorts-annotations { position: relative; } diff --git a/rad/urls.py b/rad/urls.py index 742b41a6..8a522fc9 100644 --- a/rad/urls.py +++ b/rad/urls.py @@ -45,6 +45,7 @@ urlpatterns = [ path("physics/", include("physics.urls"), name="physics"), # path("sbas/", include("sbas.urls"), name="sbas"), path("rapids/", include("rapids.urls"), name="rapids"), + path("shorts/", include("shorts.urls"), name="shorts"), path("longs/", include("longs.urls"), name="longs"), path("sbas/", include("sbas.urls"), name="sbas"), path("generic/", include("generic.urls"), name="generic"), diff --git a/rapids/views.py b/rapids/views.py index dca60576..4bd4408c 100755 --- a/rapids/views.py +++ b/rapids/views.py @@ -200,46 +200,46 @@ class RapidCreationDefaultView(LoginRequiredMixin, UpdateView): # 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 - - if ( - request.user not in new_item.get_author_objects() - and not request.user.is_superuser - ): - raise PermissionDenied() # or Http404 - - 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) +#@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 +# +# if ( +# request.user not in new_item.get_author_objects() +# and not request.user.is_superuser +# ): +# raise PermissionDenied() # or Http404 +# +# 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(RevisionMixin, LoginRequiredMixin, CreateView): diff --git a/shorts/__init__.py b/shorts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shorts/admin.py b/shorts/admin.py new file mode 100644 index 00000000..2165b8fc --- /dev/null +++ b/shorts/admin.py @@ -0,0 +1,44 @@ +from django.contrib import admin + +# Register your models here. + +from .models import Question, SampleAnswer, Exam, ExamUserStatus, UserAnswer + +admin.site.register(Question) +admin.site.register(SampleAnswer) +admin.site.register(UserAnswer) + +#class RapidImageInline(admin.TabularInline): +# model = QuestionImage +# extra = 1 +# readonly_fields = [ +# "image_tag", +# ] +# +#class ExamInline(admin.TabularInline): +# model = Question.exams.through +# extra = 1 +# +#class QuestionAdmin(): +# form = RapidAdminForm +# +# filter_horizontal = ( +# "abnormality", +# "region", +# #"examination", +# ) +# save_on_top = True +# save_as = True +# view_on_site = True +# +# inlines = [ +# RapidImageInline, +# RapidAnswersInline, +# ExamInline, +# ] + + +class ExamAdmin(admin.ModelAdmin): + save_as = True + +admin.site.register(Exam, ExamAdmin) \ No newline at end of file diff --git a/shorts/apps.py b/shorts/apps.py new file mode 100644 index 00000000..d0435cde --- /dev/null +++ b/shorts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ShortsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'shorts' diff --git a/shorts/decorators.py b/shorts/decorators.py new file mode 100644 index 00000000..c55507f0 --- /dev/null +++ b/shorts/decorators.py @@ -0,0 +1,32 @@ +from django.core.exceptions import PermissionDenied +from .models import Exam, Question + +def user_is_author_or_shorts_checker(function): + def wrap(request, *args, **kwargs): + if "pk" in kwargs: + question_id = kwargs["pk"] + elif "question_id" in kwargs: + question_id = kwargs["question_id"] + else: + raise PermissionDenied + + question = Question.objects.get(pk=question_id) + if request.user in question.author.all() or request.user.groups.filter(name='shorts_checker').exists(): + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap + + +def user_is_exam_author_or_shorts_checker(function): + def wrap(request, *args, **kwargs): + exam = Exam.objects.get(pk=kwargs['pk']) + if request.user in exam.author.all() or request.user.groups.filter(name='shorts_checker').exists(): + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap \ No newline at end of file diff --git a/shorts/filters.py b/shorts/filters.py new file mode 100755 index 00000000..113844f9 --- /dev/null +++ b/shorts/filters.py @@ -0,0 +1,99 @@ +import django_filters + +from .models import Question, UserAnswer, Exam +from django.contrib.auth.models import User + + +def get_exams(request): + if request is None: + return Exam.objects.none() + + if not request.user.groups.filter(name="shorts_checker").exists(): + queryset = Exam.objects.filter(author__id=request.user.id) + else: + queryset = Exam.objects.all() + + return queryset + + +def get_authors(request): + if request is None: + return User.objects.none() + + if not request.user.groups.filter(name="shorts_checker").exists(): + queryset = User.objects.filter(id=request.user.id) + else: + queryset = User.objects.all() + + return queryset + + +class QuestionFilter(django_filters.FilterSet): + exams = django_filters.ModelMultipleChoiceFilter( + queryset=get_exams, null_label="No exam" + ) + author = django_filters.ModelMultipleChoiceFilter( + queryset=get_authors, null_label="No author" + ) + + class Meta: + model = Question + fields = { + "abnormality": ["exact"], + "region": ["exact"], + "examination": ["exact"], + "laterality": ["exact"], + # "site", + "created_date": ["exact"], + "open_access": ["exact"], + } + + def __init__( + self, + data=None, + queryset=None, + prefix=None, + strict=None, + user=None, + request=None, + ): + if not request.user.groups.filter(name="shorts_checker").exists(): + queryset = queryset.filter(open_access=True) | queryset.filter( + author__id=request.user.id + ) + super(QuestionFilter, self).__init__( + data=data, queryset=queryset, prefix=prefix, request=request + ) + pass + + +class QuestionUserAnswerFilter(django_filters.FilterSet): + class Meta: + model = UserAnswer + fields = ( + "cid", + "user", + "answer", + # "answer_compare", + "exam", + "created", + "updated", + ) + + def __init__( + self, + data=None, + queryset=None, + prefix=None, + strict=None, + user=None, + request=None, + ): + if not request.user.groups.filter(name="shorts_checker").exists(): + queryset = queryset.filter(open_access=True) | queryset.filter( + author__id=request.user.id + ) + super(QuestionUserAnswerFilter, self).__init__( + data=data, queryset=queryset, prefix=prefix, request=request + ) + pass diff --git a/shorts/forms.py b/shorts/forms.py new file mode 100644 index 00000000..bc76ace3 --- /dev/null +++ b/shorts/forms.py @@ -0,0 +1,251 @@ +from django.forms import ( + Form, + ModelForm, + ModelMultipleChoiceField, + ModelChoiceField, + ChoiceField, + CharField, +) +from django.forms import inlineformset_factory +from generic.forms import ExamAuthorFormMixin, ExamFormMixin, ExamGroupsFormMixin, ExamMarkerFormMixin + +from shorts.models import ( + Abnormality, + Examination, + Question, + QuestionImage, + Region, + SampleAnswer, + UserAnswer, + Exam, +) + +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms.widgets import RadioSelect, TextInput, Textarea +from django.contrib.auth.models import User + +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Submit, Layout, Div, Field, HTML +from crispy_forms.bootstrap import InlineRadios + + +class QuestionAnswerForm(ModelForm): + class Meta: + model = UserAnswer + fields = ("answer",) + + +class MarkQuestionForm(Form): + # correct = forms.CharField(required=False) + # half_correct = forms.CharField(required=False) + # incorrect = forms.CharField(required=False) + marked_answers = CharField(required=False) + + +class QuestionAnswerForm(ModelForm): + class Meta: + model = Question + fields = [] + +class QuestionForm(ModelForm): + + # exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False)) + 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", "js/upload_form_helpers.js"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop( + "user" + ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole + if kwargs.get("instance"): + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + initial = kwargs.setdefault("initial", {}) + # The widget for a ModelMultipleChoiceField expects + # a list of primary key for the selected data. + initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] + + ModelForm.__init__(self, *args, **kwargs) + super(QuestionForm, self).__init__(*args, **kwargs) + + + self.helper = FormHelper() + self.helper.form_id = 'id-anatomy-question-form' + self.helper.form_tag = False + + self.helper.layout = Layout( + Div( + HTML("

Abnormality

"), + Field('abnormality', css_class='form-control'), + HTML("

Region

"), + Field('region', css_class='form-control'), + css_class='clearfix abnormal-fields' + ), + HTML("

Examination*

"), + Field('examination', css_class='form-control'), + Div(InlineRadios('laterality', css_class='form-control'), css_class='clearfix clear-both'), + Field('history', css_class='form-control'), + Field('feedback', css_class='form-control'), + Field('open_access', css_class='form-control'), + HTML("

Exams

"), + Field('exams', css_class='form-control'), + ) + + self.fields["abnormality"] = ModelMultipleChoiceField( + required=False, + queryset=Abnormality.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Abnormality", is_stacked=False), + ) + self.fields["abnormality"].label = "" + + self.fields["region"] = ModelMultipleChoiceField( + required=False, + queryset=Region.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Region", is_stacked=False), + ) + self.fields["region"].label = "" + + self.fields["examination"] = ModelMultipleChoiceField( + queryset=Examination.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Examination", is_stacked=False), + ) + self.fields["examination"].label = "" + + self.fields["laterality"] = ChoiceField( + choices=Question.LATERALITY_CHOICES, required=False, widget=RadioSelect() + ) + + if self.user.groups.filter(name="shorts_checker").exists(): + exam_queryset = Exam.objects.all() + else: + exam_queryset = Exam.objects.filter( + author__id=self.user.id + ) | Exam.objects.filter(open_access=True) + + self.fields["exams"] = ModelMultipleChoiceField( + required=False, + queryset=exam_queryset.distinct(), + widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), + ) + self.fields["exams"].label = "" + + self.fields["history"].widget = Textarea( + attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 1} + ) + + def save(self, commit=True): + instance = ModelForm.save(self, False) + instance.save() + + old_exams = instance.exams.all() + + new_exams = self.cleaned_data["exams"] + + for exam in old_exams: + if exam not in new_exams: + exam.exam_questions.remove(instance) + + for exam in new_exams: + exam.exam_questions.add(instance) + #exam.save() + + # # Prepare a 'save_m2m' method for the form, + # old_save_m2m = self.save_m2m + # + # def save_m2m(): + # old_save_m2m() + # # This is where we actually link the pizza with toppings + # instance.exams.clear() + # for exam in self.cleaned_data["exams"]: + # instance.exams.add(exam) + # + # self.save_m2m = save_m2m + # + # # Do we need to save all changes now? + # # if commit: + # instance.save() + self.save_m2m() + + return instance + + class Meta: + model = Question + # fields = ['due_back'] + # fields = '__all__' + fields = [ + "abnormality", + "region", + "laterality", + "examination", + # "site", + "feedback", + "history", + "open_access", + ] + # fields = ['question', 'feedback', 'subspecialty', 'references'] + +ImageFormSet = inlineformset_factory( + Question, + QuestionImage, + fields=["image", "feedback_image", "filename", "description"], + exclude=[], + can_delete=True, + extra=1, + max_num=10, +) + +#AnswerFormSet = inlineformset_factory( +# Question, +# SampleAnswer, +# fields=["answer", "status"], +# widgets={ +# "answer": Textarea( +# attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 3} +# ) +# }, +# exclude=[], +# can_delete=True, +# extra=0, +# max_num=10, +#) +# +#AnswerUpdateFormSet = inlineformset_factory( +# Question, +# SampleAnswer, +# fields=["answer", "status"], +# widgets={ +# "answer": Textarea( +# attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 3} +# ) +# }, +# exclude=[], +# can_delete=True, +# extra=0, +# max_num=10, +#) + + +# This should be made generic? +class ExamForm(ExamFormMixin, ModelForm): + class Meta(ExamFormMixin.Meta): + model = Exam + +class ExamAuthorForm(ExamAuthorFormMixin): + class Meta(ExamAuthorFormMixin.Meta): + model = Exam + +class ExamMarkerForm(ExamMarkerFormMixin): + class Meta(ExamMarkerFormMixin.Meta): + model = Exam + +class ExamGroupsForm(ExamGroupsFormMixin): + class Meta(ExamGroupsFormMixin.Meta): + model = Exam \ No newline at end of file diff --git a/shorts/migrations/0001_initial.py b/shorts/migrations/0001_initial.py new file mode 100644 index 00000000..bcbb920b --- /dev/null +++ b/shorts/migrations/0001_initial.py @@ -0,0 +1,148 @@ +# Generated by Django 5.1.4 on 2025-03-31 10:53 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +import generic.mixins +import shorts.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('generic', '0025_cimarseriesthumbnail'), + ('rapids', '0016_exam_results_supervisor_visible'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Exam', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Name of the exam/collection', max_length=200)), + ('start_date', models.DateTimeField(blank=True, help_text='Date (and time) the exam/collection starts', null=True)), + ('end_date', models.DateTimeField(blank=True, help_text='Date (and time) the exam/collection ends', null=True)), + ('restrict_to_dates', models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times')), + ('active', models.BooleanField(default=False, help_text='If an exam/collection should be available to take')), + ('publish_results', models.BooleanField(default=False, help_text='If an exam/collections results should be available')), + ('archive', models.BooleanField(default=False, help_text='Archived exams/collections will remain on the test system but will not be displayed by default')), + ('open_access', models.BooleanField(default=False, help_text='If the exam/collection is freely accessible (to view and edit on the test system)')), + ('candidates_only', models.BooleanField(default=True, help_text='If the exam/collection is only available to candidates who have been added')), + ('authors_only', models.BooleanField(default=False, help_text='If true only exam/collection authors will be able to view.')), + ('results_supervisor_visible', models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.')), + ('exam_open_access', models.BooleanField(default=False, help_text='Set to true if you want any registered user to be able to take the exam.')), + ('exam_mode', models.BooleanField(default=False, help_text='If an exam should be taken in exam mode (users results will be submited to the server for marking)')), + ('include_history', models.BooleanField(default=False, help_text='If an exam should include history when taking')), + ('recreate_json', models.BooleanField(default=False, help_text='If the json cache needs updating')), + ('json_creation_time', models.DateTimeField(blank=True, default=None, null=True)), + ('exam_json_id', models.IntegerField(default=1, help_text='auto incrementing field when json recreated')), + ('stats_mean', models.FloatField(default=0)), + ('stats_mode', models.CharField(default=0, max_length=25)), + ('stats_median', models.FloatField(default=0)), + ('stats_candidates', models.FloatField(default=0)), + ('stats_min', models.FloatField(default=0)), + ('stats_max', models.FloatField(default=0)), + ('stats_max_possible', models.FloatField(default=0)), + ('stats_graph', models.TextField(default=0)), + ('user_scores', models.JSONField(blank=True, default=dict)), + ('exam_results_emailed', models.DateTimeField(blank=True, default=None, null=True)), + ('time_limit', models.IntegerField(default=7200, help_text='Exam time limit (in seconds). Default is 2 hours')), + ('author', models.ManyToManyField(blank=True, help_text='Author of exam', related_name='shorts_exam_author', to=settings.AUTH_USER_MODEL)), + ('cid_user_groups', models.ManyToManyField(blank=True, help_text='These groups define which candidates are able to be added to the exams/collection.', related_name='shorts_cid_user_groups', to='generic.cidusergroup')), + ('examcollection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shorts_exams', to='generic.examcollection')), + ('markers', models.ManyToManyField(blank=True, help_text='Authorised markers for the exam', related_name='shorts_exam_markers', to=settings.AUTH_USER_MODEL)), + ('user_user_groups', models.ManyToManyField(blank=True, help_text='These groups define which candidates are able to be added to the exams/collection.', related_name='shorts_user_user_groups', to='generic.userusergroup')), + ('valid_cid_users', models.ManyToManyField(blank=True, related_name='shorts_exams', to='generic.ciduser')), + ('valid_user_users', models.ManyToManyField(blank=True, related_name='user_shorts_exams', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, generic.mixins.AuthorMixin), + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('authors_only', models.BooleanField(default=False, help_text='If true only question authors will be able to view.')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('open_access', models.BooleanField(default=True, help_text='If a question should be freely available to browse')), + ('feedback', models.TextField(blank=True, help_text='Question Feedback', null=True)), + ('history', models.TextField(blank=True, null=True)), + ('laterality', models.CharField(choices=[('NONE', 'None'), ('LEFT', 'Left'), ('RIGHT', 'Right'), ('BILAT', 'Bilateral')], default='NONE', help_text='Applies to the answer, not the examination', max_length=20)), + ('abnormality', models.ManyToManyField(blank=True, help_text='The abnormality (laterality and region independent). Used for categorisation but does not affect the answer', to='rapids.abnormality')), + ('author', models.ManyToManyField(blank=True, help_text='Author of question', related_name='shorts_authored_questions', to=settings.AUTH_USER_MODEL)), + ('examination', models.ManyToManyField(help_text='Name of the (primary) examination', to='rapids.examination')), + ('region', models.ManyToManyField(blank=True, help_text='Region of the abnormality (laterality independent)', to='rapids.region')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, generic.mixins.AuthorMixin, generic.mixins.QuestionMixin), + ), + migrations.CreateModel( + name='ExamQuestionDetail', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(default=1000)), + ('exam', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shorts.exam')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shorts.question')), + ], + options={ + 'ordering': ('sort_order',), + }, + ), + migrations.AddField( + model_name='exam', + name='exam_questions', + field=models.ManyToManyField(related_name='exams', through='shorts.ExamQuestionDetail', to='shorts.question'), + ), + migrations.CreateModel( + name='QuestionImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.FileField(upload_to=shorts.models.image_directory_path)), + ('image_annotations', models.TextField(blank=True, help_text='Stores a JSON representation of annotations to be applied by cornerstonetools', null=True)), + ('feedback_image', models.BooleanField(default=False)), + ('filename', models.CharField(blank=True, help_text='Name of the original file when uploaded', max_length=255, null=True)), + ('description', models.CharField(blank=True, max_length=255, null=True)), + ('image_md5_hash', models.CharField(blank=True, max_length=32, null=True)), + ('is_dicom', models.BooleanField(default=False)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='shorts.question')), + ], + ), + migrations.CreateModel( + name='SampleAnswer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answer', models.TextField()), + ('score', models.IntegerField(blank=True, default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])), + ('proposed', models.BooleanField(default=False)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sample_answers', to='shorts.question')), + ], + ), + migrations.CreateModel( + name='UserAnswer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cid', models.BigIntegerField(blank=True, help_text='Candidate ID (limitied by BigIntegerField size)', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('answer', models.TextField(blank=True)), + ('score', models.IntegerField(blank=True, default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])), + ('callstate', models.CharField(blank=True, choices=[('C', 'Correct Call'), ('O', 'Overcall'), ('U', 'Undercall'), ('W', 'Incorrect Call'), ('P', 'Partial Call')], max_length=1, null=True)), + ('exam', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cid_user_answers', to='shorts.exam')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cid_user_answers', to='shorts.question')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_shorts_user_answers', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['cid'], + 'abstract': False, + }, + ), + ] diff --git a/shorts/migrations/__init__.py b/shorts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shorts/models.py b/shorts/models.py new file mode 100644 index 00000000..ac229044 --- /dev/null +++ b/shorts/models.py @@ -0,0 +1,401 @@ +from collections import defaultdict +from django.db import models +from django.urls import reverse +from django.conf import settings +from rad.settings import REMOTE_URL +from django.utils.translation import gettext_lazy as _ +import dicognito +import json + +# Create your models here. +from generic.models import CidUser, CidUserGroup, ExamBase, ExamCollection, ExamUserStatus, QuestionBase, UserAnswerBase, UserUserGroup +from django.contrib.contenttypes.fields import GenericRelation +from django.core.validators import MaxValueValidator, MinValueValidator +from django.utils.html import mark_safe + +from rapids.models import Abnormality, Examination, Region + +from helpers.images import get_image_hash, image_as_base64 + +def image_directory_path(instance, filename): + # return u"{0}".format(filename) + return "shorts/picture/{0}".format(filename) + +class Question(QuestionBase): + """ + """ + + history = models.TextField(null=True, blank=True, + help_text="Single line history for the question, e.g. Age 14, male. Referral from ED. History: Painful wrist after falling off skateboard.") + + 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", + ) + + author = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + help_text="Author of question", + related_name="shorts_authored_questions", + ) + + def get_app_name(self): + return "shorts" + + def get_absolute_url(self): + return reverse("shorts:question_detail", kwargs={"pk": self.pk}) + + def get_images(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 get_image_urls(self): + return ",".join([f"{REMOTE_URL}{i.url}" for i in self.get_images()]) + + def get_image_url_array(self): + return json.dumps([f"{REMOTE_URL}{i.url}" for i in self.get_images()]) + + # def GetNonFeedbackQuestionImages(self): + # return self.get_images() + + def get_image_annotations(self): + return json.dumps( + [i.image_annotations for i in self.images.all() if not i.feedback_image] + ) + + def get_laterality_string(self): + if self.laterality == self.NONE: + s = "" + elif self.laterality == self.BILATERAL: + s = "bilateral " + elif self.laterality == self.RIGHT: + s = "right " + elif self.laterality == self.LEFT: + s = "left " + + return s + + def anonymise_images(self): + anonymizer = dicognito.anonymizer.Anonymizer() + + for image in self.images.all(): + file_path = os.path.join(settings.MEDIA_ROOT, image.image.name) + + try: + with pydicom.dcmread(file_path) as dataset: + anonymizer.anonymize(dataset) + dataset.save_as(file_path) + except pydicom.errors.InvalidDicomError: + pass + +class QuestionImage(models.Model): + question = models.ForeignKey(Question, related_name="images", on_delete=models.CASCADE) + image = models.FileField(upload_to=image_directory_path) + + image_annotations = models.TextField( + blank=True, + null=True, + help_text="Stores a JSON representation of annotations to be applied by cornerstonetools", + ) + + feedback_image = models.BooleanField(default=False) + + filename = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="Name of the original file when uploaded", + ) + + description = models.CharField(max_length=255, null=True, blank=True) + + image_md5_hash = models.CharField(max_length=32, null=True, blank=True) + + is_dicom = 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" + + def save(self, *args, **kwargs): + """Override save method to add image hash""" + if self.image: + image_hash, is_dicom = get_image_hash(self.image, hash_type="md5", direct_pixel_data=False) + self.is_dicom = is_dicom + self.image_md5_hash = image_hash + + # Hack for tests + if image_hash != "12345ABCD": + super().save(*args, **kwargs) # Call the "real" save() method. + +class SampleAnswer(models.Model): + question = models.ForeignKey( + Question, related_name="sample_answers", on_delete=models.CASCADE + ) + answer = models.TextField() + + score = models.IntegerField(default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)]) + + proposed = models.BooleanField(default=False) + + def __str__(self): + return self.answer + +class ExamQuestionDetail(models.Model): + exam = models.ForeignKey("Exam", on_delete=models.CASCADE) + question = models.ForeignKey(Question, on_delete=models.CASCADE) + + sort_order = models.IntegerField(default=1000) + + class Meta: + ordering = ("sort_order",) + +class Exam(ExamBase): + app_name = "shorts" + + exam_questions = models.ManyToManyField(Question, through=ExamQuestionDetail, related_name="exams") + + time_limit = models.IntegerField( + help_text="Exam time limit (in seconds). Default is 2 hours", + default=2 * 60 * 60, + ) + + author = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + help_text="Author of exam", + related_name="shorts_exam_author", + ) + + markers = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + help_text="Authorised markers for the exam", + related_name="shorts_exam_markers", + ) + + valid_cid_users = models.ManyToManyField( + CidUser, blank=True, related_name="shorts_exams" + ) + + valid_user_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="user_shorts_exams" + ) + + cid_user_groups = models.ManyToManyField( + CidUserGroup, + blank=True, + help_text="These groups define which candidates are able to be added to the exams/collection.", + related_name="shorts_cid_user_groups", + ) + + user_user_groups = models.ManyToManyField( + UserUserGroup, + blank=True, + help_text="These groups define which candidates are able to be added to the exams/collection.", + related_name="shorts_user_user_groups", + ) + + exam_user_status = GenericRelation(ExamUserStatus, related_query_name="shorts_exams") + cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="shorts_exams") + + examcollection = models.ForeignKey(ExamCollection, blank=True, null=True, on_delete=models.SET_NULL, related_name="shorts_exams") + + + def get_app_name(self): + return "shorts" + + def get_exam_json(self, based=True): + # TODO + + return {} + questions = self.get_questions() + + exam_questions = defaultdict(dict) + + exam_order = [] + + q: Rapid + for q in questions: + exam_order.append(q.id) + + # Loop through rapidimage associations + images = [] + feedback_images = [] + image_titles = [] + for i in q.images.all(): + if i.feedback_image == True: + if based: + feedback_images.append(image_as_base64(i.image)) + else: + feedback_images.append( + "{}/{}".format(settings.REMOTE_URL, i.image.url) + ) + else: + if based: + images.append(image_as_base64(i.image)) + else: + images.append("{}/{}".format(settings.REMOTE_URL, i.image.url)) + + if i.description: + image_titles.append(i.description) + else: + image_titles.append("") + + exam_questions[q.id] = { + "images": images, + # "feedback_image": [], + # "annotations": [str(q.image_annotations)], + "type": "rapid", + } + + if not self.exam_mode: + exam_questions[q.id]["normal"] = q.normal + exam_questions[q.id]["feedback_images"] = feedback_images + exam_questions[q.id]["answers"] = list( + q.get_correct_unstripped_answers() + ) + + if self.include_history and q.history: + exam_questions[q.id]["history"] = q.history + + if any(image_titles): + exam_questions[q.id]["image_titles"] = image_titles + + # if feedback_images: + # exam_questions[q.id]["feedback_image"] = feedback_images + + exam_json = { + "eid": "rapid/{}".format(self.id), + "cached": False, + "exam_type": "rapid", + "exam_name": self.name, + "exam_mode": self.exam_mode, + "exam_order": exam_order, + "questions": exam_questions, + } + + if self.time_limit: + exam_json["exam_time"] = self.time_limit + + return exam_json + + def get_absolute_url(self): + return reverse("shorts:exam_overview", kwargs={"pk": self.pk}) + +class UserAnswer(UserAnswerBase): + """User answers by candidate""" + + app_name = "shorts" + + question = models.ForeignKey( + Question, related_name="cid_user_answers", on_delete=models.CASCADE + ) + + answer = models.TextField(blank=True) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="user_shorts_user_answers", + ) + + # Each user answer is associated with a particular exam + exam = models.ForeignKey( + Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True + ) + + score = models.IntegerField(default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)]) + + class CallStateOptions(models.TextChoices): + CORRECT = "C", _("Correct Call") + OVERCALL = "O", _("Overcall") + UNDERCALL = "U", _("Undercall") + INCORRECTCALL = "W", _("Incorrect Call") + PARTIALCALL = "P", _("Partial Call") + + callstate = models.CharField( + max_length=1, choices=CallStateOptions.choices, blank=True, null=True + ) + + def __str__(self): + name = self.get_candidate_name() + + try: + exam = self.exam + except (Exam.DoesNotExist, KeyError) as e: + exam = "None" + return "{}/{}/{}: {}".format( + exam, name, self.question.get_primary_answer(), self.answer + ) + + def save(self, *args, **kwargs): + self.clean() + return super(UserAnswer, self).save(*args, **kwargs) + + def clean(self): + if self.answer: + self.answer = self.answer.strip() + else: + self.answer = "" + + # 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(self): + return self.answer + + def get_answer_callstate(self): + if not self.callstate: + return "" + return self.callstate.label + + def get_answer_score(self, cached=True): + return self.score \ No newline at end of file diff --git a/shorts/tables.py b/shorts/tables.py new file mode 100644 index 00000000..0f2d8f74 --- /dev/null +++ b/shorts/tables.py @@ -0,0 +1,91 @@ + +import django_tables2 as tables +from django_tables2.utils import A + +from generic.tables import FirstImageColumn + +from .models import Question, UserAnswer + +from django.utils.html import format_html + +from easy_thumbnails.files import get_thumbnailer + +from easy_thumbnails.exceptions import InvalidImageFormatError + + + + +class QuestionTable(tables.Table): + edit = tables.LinkColumn('shorts:question_update', + text='Edit', + args=[A('pk')], + orderable=False) + view = tables.LinkColumn('shorts:question_detail', + text='View', + args=[A('pk')], + orderable=False) + clone = tables.LinkColumn('shorts:question_clone', + text='Clone', + args=[A('pk')], + orderable=False) + delete = tables.LinkColumn('shorts:question_delete', + text='Delete', + args=[A('pk')], + orderable=False) + images = FirstImageColumn("images", orderable=False) + + exams = tables.ManyToManyColumn(verbose_name="Exams") + + selection = tables.CheckBoxColumn(accessor="pk", orderable=False) + + class Meta: + model = Question + template_name = "django_tables2/bootstrap4.html" + fields = ("normal", "abnormality", "region", "examination", + "laterality", + #"site", + "created_date", "open_access", "author") + sequence = ("view", "images", "exams") + order_by = "-created_date" + + def __init__(self, data=None, *args, **kwargs): + super().__init__( + data.prefetch_related( + "abnormality", "region", "examination", "images", "exams", "author" + ), + *args, + **kwargs, + ) + +class QuestionUserAnswerTable(tables.Table): + select = tables.CheckBoxColumn(accessor=("pk")) + delete = tables.LinkColumn( + "shorts:user_answer_delete", text="Delete", args=[A("pk")], orderable=False + ) + view = tables.LinkColumn('shorts:user_answer_view', + text='View', + args=[A('pk')], + orderable=False) + class Meta: + model = UserAnswer + template_name = "django_tables2/bootstrap4.html" + fields = ( + "cid", + "user", + "question", + "answer", + #"answer_compare", + "exam", + "created", + "updated", + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__( + data.prefetch_related( + "exam", "question" + ), + *args, + **kwargs, + ) + \ No newline at end of file diff --git a/shorts/templates/shorts/base.html b/shorts/templates/shorts/base.html new file mode 100644 index 00000000..2182f6f9 --- /dev/null +++ b/shorts/templates/shorts/base.html @@ -0,0 +1,55 @@ +{% extends 'base.html' %} + +{% block title %} + Shorts +{% endblock %} + +{% block css %} +{% endblock %} + +{% block js %} +{% endblock %} + + + +{% block content %} + {% if simple_content %} + {{simple_content}} + {% endif %} +{% endblock %} +{% block navigation %} + +{% endblock %} + diff --git a/shorts/templates/shorts/exam_overview.html b/shorts/templates/shorts/exam_overview.html new file mode 100644 index 00000000..c2357c3f --- /dev/null +++ b/shorts/templates/shorts/exam_overview.html @@ -0,0 +1,56 @@ +{% extends 'shorts/exams.html' %} +{% block navigation %} + {{ block.super }} + {% include 'generic/exam_overview_headers.html' %} +{% endblock navigation %} + +{% block content %} + + {% load thumbnail %} +
+ + {% if exam.exam_mode %} + +

+ {% endif %} + +
    + {% for question in questions.all %} + +
  1. + + + {% for image in question.get_images %} + thumbail + {% endfor %} + + + + {% if not question.normal %} + Abnormality: {{ question.get_abnormalities }} Region: {{ question.get_regions }} +
    + {{ question.get_primary_answer }} + {% else %} + Normal + {% endif %} +
    + Examination: {{ question.get_examinations }}, + {% if exam.exam_mode %} + Mark + {% endif %} + [id: {{question.pk}}] +
    +
  2. + {% endfor %} +
+
+ Author: {% for author in exam.author.all %} + {{ author }}, + {% endfor %} +
+ {% include 'generic/exam_footer.html' %} + +
+ {% include 'exam_overview_js.html' %} + +{% endblock %} \ No newline at end of file diff --git a/shorts/templates/shorts/exams.html b/shorts/templates/shorts/exams.html new file mode 100644 index 00000000..ad7c880a --- /dev/null +++ b/shorts/templates/shorts/exams.html @@ -0,0 +1,15 @@ +{% extends 'shorts/base.html' %} + +{% block navigation %} + {{block.super}} +
+ Exams: {{exam}}-> + Overview / + {% if exam.exam_mode %} + Mark / + Scores / + Candidates / + Stats / + {% endif %} + Add New Question +{% endblock %} diff --git a/shorts/templates/shorts/question_detail.html b/shorts/templates/shorts/question_detail.html new file mode 100755 index 00000000..96eef8fc --- /dev/null +++ b/shorts/templates/shorts/question_detail.html @@ -0,0 +1,6 @@ +{% extends 'shorts/base.html' %} + +{% block content %} + {% include "shorts/question_link_header.html" %} + {% include 'shorts/question_display_block.html' %} +{% endblock %} \ No newline at end of file diff --git a/shorts/templates/shorts/question_display_block.html b/shorts/templates/shorts/question_display_block.html new file mode 100755 index 00000000..3fc87db2 --- /dev/null +++ b/shorts/templates/shorts/question_display_block.html @@ -0,0 +1,417 @@ +
+ {% comment %} {% endcomment %} +
+ {{ question.created_date|date:"d/m/Y" }} +
+
+ ID: {{ question.id }} +
+ +

History: {{ question.history }}

+

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.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}: +
+
+ {% endfor %} +
+
+ Exam(s): {% for exam in question.exams.all %} + {{ exam }}, + {% endfor %} + + + +
+

Open Access: {{ question.open_access }}

+

Feedback: {{ question.feedback }}

+

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

+

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

+

+

+ + Answers: + + + + {% for answer in question.answers.all|dictsortreversed:"status" %} + + + + {% endfor %} +
AnswerScore
+ + {{ answer }} + + + {{answer.status}}
+
+

+
+
+ + {% if view_feedback %} + {% include 'question_notes.html' %} + {% if not question.normal %} +
+ + Suggested answers + +
    + {% for ans in question.get_suggested_answers %} +
  • {{ans}}
  • + {% endfor %} +
+
+ {% endif %} + {% endif %} +
+ +
+ + Image viewer + +
+
+ +
+ +
+ + Image annotations: + + {% for image in question.images.all %} + + Image {{ forloop.counter }}: {{image.image_annotations}}
+
+ {% endfor %} +
+Anonymise dicoms
+ + \ No newline at end of file diff --git a/shorts/templates/shorts/question_form.html b/shorts/templates/shorts/question_form.html new file mode 100755 index 00000000..bff27991 --- /dev/null +++ b/shorts/templates/shorts/question_form.html @@ -0,0 +1,343 @@ +{% extends "shorts/base.html" %} +{% load static from static %} +{% load crispy_forms_tags %} + +{% block js %} + + + + +{{ form.media }} +{% endblock %} + +{% block content %} + +{% if object %} + {% include "shorts/question_link_header.html" %} +{% endif %} + +

Submit Shorts Question

+

+

Instructions

+Abnormality, Region and Laterality are used to categorise within the system (they are not used for marking). Multiple images can be added to a question. +If the feedback image box is checked they will not be displayed when taking the question. +

+ +
+ {% comment %} {% csrf_token %} {% endcomment %} + + + + + {% crispy form form.helper %} + +

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 %} + + +{% block css %} + +{% endblock css %} + \ No newline at end of file diff --git a/shorts/templates/shorts/question_link_header.html b/shorts/templates/shorts/question_link_header.html new file mode 100644 index 00000000..b3081d93 --- /dev/null +++ b/shorts/templates/shorts/question_link_header.html @@ -0,0 +1,25 @@ + +
+ + Edit + Clone + Delete + Edit Answers + Add Note + {% if request.user.is_superuser %} + Admin Edit + User answers + {% endif %} + {% if exam %} +
+ + {% if previous > -1 %} + Previous question + {% endif %} + Viewing question as part of exam: {{exam}} [{{pos}}/{{exam_length}}] + {% if next %} + Next question + {% endif %} +
+ {% endif %} +
\ No newline at end of file diff --git a/shorts/tests.py b/shorts/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/shorts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/shorts/urls.py b/shorts/urls.py new file mode 100644 index 00000000..b3ad0c22 --- /dev/null +++ b/shorts/urls.py @@ -0,0 +1,112 @@ + +from django.urls import path, include + +from . import views + +from django.views.decorators.cache import cache_page, cache_control +from generic.urls import generic_exam_urls, generic_view_urls + +app_name = "shorts" + +urlpatterns = [ + #path("", views.GenericExamViews.index, name="index"), + #path("exam", generic_exam_urls(views.GenericExamViews), name="index"), + path("question_viewer", views.question_viewer, name="question_viewer"), + path("question/", views.QuestionView.as_view(), name="question_view"), + path("question/hash", views.get_question_by_hash, name="question_by_hash"), + # path("unchecked/", views.unchecked_list, name="unchecked_list"), + # path("verified//", views.verified_detail, name="verified_detail"), + path("question//json", views.question_json, name="question_json"), + path( + "question//json/unbased", + views.question_json_unbased, + name="question_json_unbased", + ), + path( + "question//save_annotation", + views.question_save_annotation, + name="question_save_annotation", + ), + path("question//split", views.question_split, name="question_split"), + path("question//clone", views.QuestionClone.as_view(), name="question_clone"), + # path("verified/", views.verified, name="verified"), + # path("all_questions/", views.all_questions, name="all_questions"), + path( + "question//delete", + views.QuestionDelete.as_view(), + name="question_delete", + ), + path( + "question//anon", + views.question_anonymise_dicom, + name="question_anonymise_dicom", + ), + path( + "question//add_exam", + views.question_add_exam, + name="question_add_exam", + ), + #path("answer//confirm", views.confirm_answer, name="confirm_answer"), + #path("answer//delete", views.delete_answer, name="delete_answer"), + path("exam///mark", views.mark, name="mark"), + path("exam///mark/all", views.mark_all, name="mark_all"), + path( + "exam///mark/review", views.mark_review, name="mark_review" + ), + path("exam//markers", views.ExamMarkersUpdate.as_view(), name="exam_markers"), + path("exam//groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"), + path("exam//authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"), + path("exam/create", views.ExamCreate.as_view(), name="exam_create"), + path("exam//clone", views.ExamClone.as_view(), name="exam_clone"), + path("exam//update", views.ExamUpdate.as_view(), name="exam_update"), + path("exam//delete", views.ExamDelete.as_view(), name="exam_delete"), + path("create/", views.QuestionCreate.as_view(), name="question_create"), + path("create/exam/", views.QuestionCreate.as_view(), name="question_create_exam"), + #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.QuestionUpdate.as_view(), + name="question_update", + ), + path( + "question//update_answers", + views.QuestionAnswerUpdate.as_view(), + name="question_answer_update", + ), + path( + "user_answers/", + views.UserAnswerTableView.as_view(), + name="user_answer_table_view", + ), + path( + "user_answers/", views.UserAnswerView.as_view(), name="user_answer_view" + ), + path( + "user_answers//delete", + views.UserAnswerDelete.as_view(), + name="user_answer_delete", + ), + #path( + # "answer-autocomplete", + # views.AnswerAutocomplete.as_view( + # model=Answer, create_field="answer" + # ), + # name="answer-autocomplete", + #), +] + +urlpatterns.extend(generic_view_urls(views.GenericViews)) +urlpatterns.extend(generic_exam_urls(views.GenericExamViews)) \ No newline at end of file diff --git a/shorts/views.py b/shorts/views.py new file mode 100644 index 00000000..d7fc9375 --- /dev/null +++ b/shorts/views.py @@ -0,0 +1,731 @@ +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.forms.models import model_to_dict + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic.detail import DetailView + +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 django.utils.safestring import mark_safe +# Create your views here. + +from reversion.views import RevisionMixin + +from generic.views import ( + AuthorRequiredMixin, + ExamCloneMixin, + ExamCreateBase, + ExamDeleteBase, + ExamGroupsUpdateBase, + ExamUpdateBase, + ExamViews, + GenericViewBase, + UpdateQuestionMixin, +) +from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin +from shorts.decorators import user_is_author_or_shorts_checker + +from .models import ( + Exam, + Question, + QuestionImage, + UserAnswer, + SampleAnswer, + ) + +from .forms import ( + #AnswerFormSet, + #AnswerUpdateFormSet, + ExamAuthorForm, + ExamGroupsForm, + ExamMarkerForm, + QuestionForm, + QuestionAnswerForm, + MarkQuestionForm, + ExamForm, + ImageFormSet, + +) + +from .tables import QuestionTable, QuestionUserAnswerTable +from .filters import QuestionFilter, QuestionUserAnswerFilter + +from django_tables2 import SingleTableView, SingleTableMixin +from django_filters.views import FilterView + +from dal import autocomplete + +def normaliseScore(score): + return score + #if score == 49: + # return 4.5 + #elif score in [50, 51]: + # return 5 + #elif score in [52, 53]: + # return 5.5 + #elif score in [54]: + # return 6 + #elif score in [55, 56]: + # return 6.5 + #elif score in [57, 58]: + # return 7 + #elif score in [59]: + # return 7.5 + #elif score in [60]: + # return 8 + + #return 4 + + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user.groups.filter(name="shorts_checker").exists(): + return obj + if self.request.user not in obj.author.all(): + raise PermissionDenied() # or Http404 + return obj + + +@login_required +def question_split(request, pk): + question = get_object_or_404(Question, pk=pk) + + if ( + not request.user.groups.filter(name="shorts_checker").exists() + and request.user not in question.author.all() + ): + raise PermissionDenied() + + images = question.images.all() + + old_abnormality = question.abnormality.all() + old_region = question.region.all() + old_examination = question.examination.all() + # old_site = rapid.site.all() + old_author = question.author.all() + + if not images: + raise Http404 + + for n in range(len(images) - 1): + question.pk = None + + question.save() + + question.abnormality.set(old_abnormality) + question.region.set(old_region) + question.examination.set(old_examination) + # question.site.set(old_site) + question.author.set(old_author) + images[n].question = question + images[n].save() + + # images[-1].rapid + + # if request.user not in rapid.author.all(): + # raise PermissionDenied + + # logging.debug(rapid.subspecialty.first().name.all()) + return render(request, "shorts/question_detail.html", {"question": question}) + + + +class QuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): + model = Question + form_class = QuestionForm + + # Sending user object to the form, to verify which fields to display/remove (depending on group) + def get_form_kwargs(self): + kwargs = super(QuestionCreateBase, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(QuestionCreateBase, self).get_context_data(**kwargs) + if self.request.POST: + context["image_formset"] = ImageFormSet( + self.request.POST, self.request.FILES + ) + context["image_formset"].full_clean() + else: + context["image_formset"] = ImageFormSet() + 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"] + if image_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = self.object + image_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("shorts:rapid_clone", pk=self.object.pk) + + else: + return super().form_invalid(form) + + +# @login_required +class QuestionCreate(QuestionCreateBase): + + # initial = {"laterality": Question.NONE} + + def get_initial(self): + if "pk" in self.kwargs: + initial = super().get_initial() + exam = get_object_or_404(Exam, pk=self.kwargs["pk"]) + + initial["exams"] = [exam.id] + + return initial + + ## 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 QuestionAnswerUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView): + model = Question + form_class = QuestionAnswerForm + template_name = "shorts/shortsquestionanswer_form.html" + + def get_context_data(self, **kwargs): + context = super(QuestionAnswerUpdate, self).get_context_data(**kwargs) + context["question"] = context["object"] + return context + + def form_valid(self, form): + return super().form_valid(form) + + +class QuestionUpdate( + UpdateQuestionMixin +): + model = Question + form_class = QuestionForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + + def get_context_data(self, **kwargs): + context = super(QuestionUpdate, 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() + else: + context["image_formset"] = ImageFormSet(instance=self.object) + context["question"] = context["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"] + # logger.debug(formset.is_valid()) + if image_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = self.object + image_formset.save() + + # restore exam orders + # wanted_exams = form["exams"].data + # for exam in self.object.exams.all(): + # if exam in exam_orders and self.object in exam_orders[exam]: + # print(exam_orders[exam]) + # exam.exam_questions.set(exam_orders[exam]) + # exam.save() + + return response + else: + return super().form_invalid(form) + + +class QuestionClone(QuestionCreateBase): + """Clones a existing shorts question""" + + def get_initial(self): + # print(self.request) + old_object = get_object_or_404(Question, pk=self.kwargs["pk"]) + initial_data = model_to_dict(old_object, exclude=["id"]) + + return initial_data + + +class QuestionView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Question + table_class = QuestionTable + template_name = "question_table_view.html" + + filterset_class = QuestionFilter + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["app_name"] = "shorts" + return context + + +@login_required +def mark_all(request, exam_pk, sk): + return mark(request, exam_pk, sk, unmarked_exam_answers_only=False) + + +@login_required +def mark_review(request, exam_pk, sk): + return mark(request, exam_pk, sk, unmarked_exam_answers_only=False, review=True) + + +# @user_passes_test(user_is_admin, login_url="/accounts/login") +@login_required +def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): + return HttpResponse("Not implemented") + exam = get_object_or_404(Exam, pk=exam_pk) + + if not GenericExamViews.check_user_marker_access(request.user, exam_pk): + raise PermissionDenied + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + mark_url = "shorts:mark" + if review: + mark_url = "shorts:mark_review" + elif not unmarked_exam_answers_only: + mark_url = "shorts:mark_all" + + questions = exam.get_questions() + + n = sk + + question_details = { + "total": len(questions), + "current": n + 1, + } + + try: + question = questions[sk] + except IndexError: + raise Http404("Exam question does not exist") + + if request.method == "POST": + + form = MarkQuestionForm(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(mark_url, exam_pk=exam_pk, sk=n + 1) + + cd = form.cleaned_data + + marked_answers = json.loads(cd.get("marked_answers")) + + # This should probably use json (or something else...) + # ajax..... + to_run = ( + ("correct", Answer.MarkOptions.CORRECT), + ("half-correct", Answer.MarkOptions.HALF_MARK), + ("incorrect", Answer.MarkOptions.INCORRECT), + ) + + for status_string, status in to_run: + for ans in marked_answers[status_string]: + 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 = status + a.save() + + if "next" in request.POST: + return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) + elif "previous" in request.POST: + return redirect(mark_url, exam_pk=exam_pk, sk=n - 1) + + # Reset user answers (relies on the functional javascript) + unmarked_user_answers = set() + else: + form = MarkQuestionForm() + + # answers_dict = {} + + # for ans in question.answers.all(): + # answers_dict[ans.answer_compare] = ans + + correct_answers = [] + half_mark_answers = [] + incorrect_answers = [] + review_user_answers = [] + unmarked_answers_bool = False + unmarked_user_answers = [] + # this could be improved + if question.normal: + incorrect_answers = question.get_user_answers(exam_pk=exam_pk) + if "" in incorrect_answers: + incorrect_answers.remove("") + if "normal" in incorrect_answers: + incorrect_answers.remove("normal") + else: + if review: + unmarked_user_answers = question.get_user_answers( + exam_pk=exam_pk, include_normal=False + ) + elif unmarked_exam_answers_only: + unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk) + else: + unmarked_user_answers = question.get_unmarked_user_answers() + + if not unmarked_exam_answers_only: + 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 + ) + + if review: + for ans in unmarked_user_answers: + # duplicated from user answer model.... + marked_ans = question.answers.filter(answer_compare__iexact=ans).first() + mark = "unmarked" + if marked_ans is not None: + if marked_ans.status == Answer.MarkOptions.CORRECT: + mark = "correct" + elif marked_ans.status == Answer.MarkOptions.HALF_MARK: + mark = "half-correct" + elif marked_ans.status == Answer.MarkOptions.INCORRECT: + mark = "incorrect" + if mark == "unmarked": + unmarked_answers_bool = True + review_user_answers.append((ans, mark)) + + return render( + request, + "shorts/mark.html", + { + "exam": exam, + "form": form, + "review": review, + "question": question, + "question_number": n, + "question_details": question_details, + "unmarked_answers_bool": unmarked_answers_bool, + "user_answers": unmarked_user_answers, + "review_user_answers": review_user_answers, + "correct_answers": correct_answers, + "half_mark_answers": half_mark_answers, + "incorrect_answers": incorrect_answers, + "unmarked_exam_answers_only": unmarked_exam_answers_only, + }, + ) + + +class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView): + model = Question + success_url = reverse_lazy("shorts:question_view") + + +GenericExamViews = ExamViews( + Exam, Question, None, UserAnswer, "shorts", "shorts", normalise_score=normaliseScore +) + + +class ExamCreate(ExamCreateBase): + model = Exam + form_class = ExamForm + + +class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin): + model = Exam + form_class = ExamForm + + +class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase): + model = Exam + + +class ExamAuthorUpdate( + RevisionMixin, + CheckCanEditMixin, + LoginRequiredMixin, + AuthorRequiredMixin, + UpdateView, +): + model = Exam + form_class = ExamAuthorForm + template_name = "author_form.html" + + +class ExamMarkersUpdate( + RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView +): + model = Exam + form_class = ExamMarkerForm + + template_name = "markers_form.html" + +class ExamGroupsUpdate(ExamGroupsUpdateBase): + model = Exam + form_class = ExamGroupsForm + +class UserAnswerView(LoginRequiredMixin, DetailView): + model = UserAnswer + + +class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = UserAnswer + table_class = QuestionUserAnswerTable + template_name = "shorts/user_answer_question_view.html" + + filterset_class = QuestionUserAnswerFilter + + +class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): + model = UserAnswer + template_name = "user_answer_delete.html" + success_url = reverse_lazy("shorts:user_answer_table_view") + + +GenericViews = GenericViewBase("shorts", Question, UserAnswer, Exam) + + +@user_is_author_or_shorts_checker +def question_anonymise_dicom(request, pk): + question = get_object_or_404(Question, pk=pk) + + question.anonymise_images() + + return redirect("shorts:question_detail", pk=pk) + + +@user_is_author_or_shorts_checker +def question_save_annotation(request, pk): + if request.method == "POST": + question = get_object_or_404(Question, pk=pk) + + image_annotations = json.loads(request.POST.get("annotation")) + + question_images = question.images.filter(feedback_image=False) + + for n, image in enumerate(question_images): + annotation = image_annotations[n] + if annotation != "undefined": + image.image_annotations = annotation + else: + image.image_annotations = "" + image.save() + + data = {"status": "success"} + return JsonResponse(data, status=200) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + +class ExamClone(ExamCloneMixin, ExamCreate): + """Clone exam view""" + + +@login_required +def get_question_by_hash(request): + if request.method == "POST": + + hash = request.POST.get("hash") + + try: + question = QuestionImage.objects.get(image_md5_hash=hash) + data = { + "status": "success", + "id": question.pk, + "url": question.question.get_absolute_url(), + } + return JsonResponse(data, status=200) + except QuestionImage.DoesNotExist: + data = {"status": "success", "id": False} + return JsonResponse(data, status=200) + + +# TODO: better permissions +@login_required +def question_json_unbased(request, pk): + """ + No (file based) caching is enabled for unbased quesitons + """ + question = get_object_or_404(Question, pk=pk) + + question_json = question.get_question_json(based=False) + return JsonResponse(question_json) + + +@login_required +def question_json(request, pk): + question = get_object_or_404(Question, pk=pk) + + question_json = question.get_question_json(based=True) + return JsonResponse(question_json) + + +@login_required +def question_viewer(request): + # question = get_object_or_404(Question, pk=pk) + + ids = request.GET.get("ids").split(",") + + return render( + request, + "shorts/question_viewer.html", + { + "ids": ids, + }, + ) + + +#class AnswerAutocomplete(autocomplete.Select2QuerySetView): +# def get_queryset(self): +# # Don't forget to filter out results depending on the visitor ! +# if not self.request.user.is_authenticated: +# return Answer.objects.none() +# +# qs = Answer.objects.all() +# +# if self.q: +# qs = qs.filter(answer__istartswith=self.q) +# +# return qs + + +#def confirm_answer(request, answer_id: int): +# if request.htmx: +# answer = get_object_or_404(Answer, pk=answer_id) +# +# # Check if the user has permission to confirm the answer +# if not answer.can_edit(request.user): +# return HttpResponse("Invalid permissions", status=403) +# +# answer.clean() +# answer.proposed = False +# answer.save() +# return HttpResponse("Answer accepted") +# +# raise PermissionDenied() +# +#def delete_answer(request, answer_id: int): +# if request.htmx: +# answer = get_object_or_404(Answer, pk=answer_id) +# +# # Check if the user has permission to confirm the answer +# if not answer.can_edit(request.user): +# return HttpResponse("Invalid permissions", status=403) +# +# answer.delete() +# return HttpResponse("Answer deleted") +# raise PermissionDenied() + +def question_add_exam(request, question_id: int): + if request.htmx: + question = get_object_or_404(Question, pk=question_id) + + if not question.can_edit(request.user): + return HttpResponse("Invalid permissions", status=403) + + + if request.POST: + exam_id = request.POST.get("exam_id", None) + + if exam_id is None: + return HttpResponse("No exam id provided", status=400) + + exam = get_object_or_404(Exam, pk=exam_id) + + if request.POST.get("remove", False) == "true": + question.exams.remove(exam) + return HttpResponse("Question removed from exam.") + else: + question.exams.add(exam) + + return HttpResponse("Question added to exam.") + else: + # Return a list of exams that we can add to the question + + exams = Exam.objects.filter(author=request.user) + exams = exams.difference(question.exams.all()) + + if not exams: + return HttpResponse("No exams available to add") + + html = "Exams to add to question:
" + for exam in exams: + html = html + (f"
" + ) + html = html + "
Exams to remove from question:
" + for exam in question.exams.all(): + html = html + (f"
" + ) + html = html + "" + + return HttpResponse(mark_safe(html)) + + + raise PermissionDenied() \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 32956f8d..9b87cb2e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -109,6 +109,9 @@
  • Rapids
  • +
  • + Shorts +
  • Longs