start setting up shorts

This commit is contained in:
Ross
2025-04-07 09:12:30 +01:00
parent bcc449eb34
commit afa1379ce4
26 changed files with 2896 additions and 41 deletions
+15
View File
@@ -130,6 +130,8 @@ from physics.models import (
from physics.models import Exam as PhysicsExam from physics.models import Exam as PhysicsExam
from physics.models import UserAnswer as PhysicsUserAnswer 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.db.models import Case, When
from django.conf import settings from django.conf import settings
@@ -179,6 +181,7 @@ def get_user_exams(user, supervisor_view=False):
"physics": (PhysicsUserAnswer, PhysicsExam, "physics_exams"), "physics": (PhysicsUserAnswer, PhysicsExam, "physics_exams"),
"anatomy": (AnatomyUserAnswer, AnatomyExam, "anatomy_exams"), "anatomy": (AnatomyUserAnswer, AnatomyExam, "anatomy_exams"),
"rapids": (RapidsUserAnswer, RapidsExam, "rapids_exams"), "rapids": (RapidsUserAnswer, RapidsExam, "rapids_exams"),
"shorts": (ShortsUserAnswer, ShortsExam, "shorts_exams"),
"longs": (LongsUserAnswer, LongsExam, "longs_exams"), "longs": (LongsUserAnswer, LongsExam, "longs_exams"),
"sbas": (SbasUserAnswer, SbasExam, "sbas_exams"), "sbas": (SbasUserAnswer, SbasExam, "sbas_exams"),
} }
@@ -215,6 +218,7 @@ def get_exam_model_from_app_name(app_name: str) -> ExamBase:
"physics": PhysicsExam, "physics": PhysicsExam,
"anatomy": AnatomyExam, "anatomy": AnatomyExam,
"rapids": RapidsExam, "rapids": RapidsExam,
"shorts": ShortsExam,
"longs": LongsExam, "longs": LongsExam,
"sbas": SbasExam, "sbas": SbasExam,
} }
@@ -247,6 +251,9 @@ def get_question_and_content_type(question_type):
if question_type == "rapid": if question_type == "rapid":
question = RapidQuestion question = RapidQuestion
content_type = ContentType.objects.get(model="rapid") content_type = ContentType.objects.get(model="rapid")
elif question_type == "shorts":
question = ShortsQuestion
content_type = ContentType.objects.get(model="rapid")
elif question_type == "anatomy": elif question_type == "anatomy":
question = AnatomyQuestion question = AnatomyQuestion
content_type = ContentType.objects.get(model="anatomyquestion") content_type = ContentType.objects.get(model="anatomyquestion")
@@ -352,6 +359,9 @@ def generic_exam_json_edit(request):
if question_type == "rapids": if question_type == "rapids":
Exam = RapidsExam Exam = RapidsExam
Question = RapidQuestion Question = RapidQuestion
elif question_type == "shorts":
Exam = ShortsExam
Question = ShortsQuestion
elif question_type == "longs": elif question_type == "longs":
Exam = LongsExam Exam = LongsExam
Question = LongQuestion Question = LongQuestion
@@ -1437,6 +1447,10 @@ class ExamViews(View, LoginRequiredMixin):
question_detail = RapidsExamQuestionDetail.objects.get( question_detail = RapidsExamQuestionDetail.objects.get(
rapid=question, exam=exam rapid=question, exam=exam
) )
case "shorts":
question_detail = ShortsExamQuestionDetail.objects.get(
question=question, exam=exam
)
case "longs": case "longs":
question_detail = LongsExamQuestionDetail.objects.get( question_detail = LongsExamQuestionDetail.objects.get(
question=question, exam=exam question=question, exam=exam
@@ -2590,6 +2604,7 @@ class GenericViewBase:
"longs": "longs_checker", "longs": "longs_checker",
"sbas": "sba_checker", "sbas": "sba_checker",
"physics": "physics_checker", "physics": "physics_checker",
"shorts": "shorts_checker",
} }
self.checker_group = g[app_name] self.checker_group = g[app_name]
+1
View File
@@ -45,6 +45,7 @@ INSTALLED_APPS = [
"anatomy", "anatomy",
"physics", "physics",
"rapids", "rapids",
"shorts",
"longs", "longs",
"sbas", "sbas",
"wally", "wally",
+1 -1
View File
@@ -255,7 +255,7 @@ button a {
right: 50px; right: 50px;
} }
#save-annotations.save-rapid-annotations { #save-annotations.save-rapid-annotations, #save-annotations.save-shorts-annotations {
position: relative; position: relative;
} }
+1
View File
@@ -45,6 +45,7 @@ urlpatterns = [
path("physics/", include("physics.urls"), name="physics"), path("physics/", include("physics.urls"), name="physics"),
# path("sbas/", include("sbas.urls"), name="sbas"), # path("sbas/", include("sbas.urls"), name="sbas"),
path("rapids/", include("rapids.urls"), name="rapids"), path("rapids/", include("rapids.urls"), name="rapids"),
path("shorts/", include("shorts.urls"), name="shorts"),
path("longs/", include("longs.urls"), name="longs"), path("longs/", include("longs.urls"), name="longs"),
path("sbas/", include("sbas.urls"), name="sbas"), path("sbas/", include("sbas.urls"), name="sbas"),
path("generic/", include("generic.urls"), name="generic"), path("generic/", include("generic.urls"), name="generic"),
+40 -40
View File
@@ -200,46 +200,46 @@ class RapidCreationDefaultView(LoginRequiredMixin, UpdateView):
# form.instance.author.add(self.request.user.id) # form.instance.author.add(self.request.user.id)
@login_required #@login_required
def rapid_clone(request, pk): #def rapid_clone(request, pk):
new_item = get_object_or_404(Rapid, pk=pk) # new_item = get_object_or_404(Rapid, pk=pk)
new_item.pk = None # autogen a new pk (item_id) # new_item.pk = None # autogen a new pk (item_id)
# new_item.name = "Copy of " + new_item.name #need to change uniques # # new_item.name = "Copy of " + new_item.name #need to change uniques
#
if ( # if (
request.user not in new_item.get_author_objects() # request.user not in new_item.get_author_objects()
and not request.user.is_superuser # and not request.user.is_superuser
): # ):
raise PermissionDenied() # or Http404 # raise PermissionDenied() # or Http404
#
form = RapidForm(request.POST or None, instance=new_item) # form = RapidForm(request.POST or None, instance=new_item)
#
image_formset = ImageFormSet() # image_formset = ImageFormSet()
answer_formset = AnswerFormSet() # answer_formset = AnswerFormSet()
#
if form.is_valid(): # if form.is_valid():
form.instance.author.add(request.user.id) # form.instance.author.add(request.user.id)
#
# logger.debug(formset.is_valid()) # # logger.debug(formset.is_valid())
if image_formset.is_valid() and answer_formset.is_valid(): # if image_formset.is_valid() and answer_formset.is_valid():
response = super().form_valid(form) # response = super().form_valid(form)
image_formset.instance = obj # image_formset.instance = obj
image_formset.save() # image_formset.save()
#
answer_formset.instance = obj # answer_formset.instance = obj
answer_formset.save() # answer_formset.save()
return response # return response
else: # else:
return super().form_invalid(form) # return super().form_invalid(form)
#
context = { # context = {
"form": form, # "form": form,
"image_formset": image_formset, # "image_formset": image_formset,
"answer_formset": answer_formset, # "answer_formset": answer_formset,
# other context # # other context
} # }
#
return render(request, "rapids/rapid_form.html", context) # return render(request, "rapids/rapid_form.html", context)
class RapidCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): class RapidCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
View File
+44
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ShortsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'shorts'
+32
View File
@@ -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
+99
View File
@@ -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
+251
View File
@@ -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 <script src="/admin/jsi18n"></script>
# 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("<h4 class='clearfix' id='abnormality-label'>Abnormality</h4>"),
Field('abnormality', css_class='form-control'),
HTML("<h4 class='clear-both' id='region-label'>Region</h4>"),
Field('region', css_class='form-control'),
css_class='clearfix abnormal-fields'
),
HTML("<h4 class='clear-both' id='examination-label'>Examination*</h4>"),
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("<h4 class='clear-both' id='exams-label'>Exams</h4>"),
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
+148
View File
@@ -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,
},
),
]
View File
+401
View File
@@ -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(
'<img src="{}" class="admin-rapid-image" /><span class="admin-rapid-image-info">Click and hold to zoom<span>'.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
+91
View File
@@ -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,
)
+55
View File
@@ -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 %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'shorts:index' %}">Shorts</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'shorts:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'shorts:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:user_answer_table_view' %}" title="User answers">Answers</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</nav>
{% endblock %}
@@ -0,0 +1,56 @@
{% extends 'shorts/exams.html' %}
{% block navigation %}
{{ block.super }}
{% include 'generic/exam_overview_headers.html' %}
{% endblock navigation %}
{% block content %}
{% load thumbnail %}
<div class="shorts">
{% if exam.exam_mode %}
<a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a>
<a href="{% url 'shorts:mark_review' exam_pk=exam.pk sk=0 %}"><button>Review exam</button></a></p>
{% endif %}
<ol id="full-question-list" class="sortable">
{% for question in questions.all %}
<li data-question_pk={{question.pk}}>
<span class="flex-col">
<a href="{% url 'shorts:exam_question_detail' pk=exam.pk sk=forloop.counter0 %}">
{% for image in question.get_images %}
<img src="{{ image|thumbnail_url:'exam-list' }}" alt="thumbail" />
{% endfor %}
</a>
</span>
<span class="flex-col-4">
{% if not question.normal %}
<b>Abnormality:</b> {{ question.get_abnormalities }} <b>Region:</b> {{ question.get_regions }}
<br />
{{ question.get_primary_answer }}
{% else %}
<b>Normal</b>
{% endif %}
<br />
Examination: {{ question.get_examinations }},
{% if exam.exam_mode %}
<a href="{% url 'shorts:mark' exam_pk=exam.pk sk=forloop.counter0 %}">Mark</a>
{% endif %}
<span class="id"><a href="{% url 'shorts:question_detail' pk=question.pk %}">[id: {{question.pk}}]</a></span>
</span>
</li>
{% endfor %}
</ol>
<div>
Author: {% for author in exam.author.all %}
{{ author }},
{% endfor %}
</div>
{% include 'generic/exam_footer.html' %}
</div>
{% include 'exam_overview_js.html' %}
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends 'shorts/base.html' %}
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'shorts:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %}
<a href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %}
<a href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add New Question</a>
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% extends 'shorts/base.html' %}
{% block content %}
{% include "shorts/question_link_header.html" %}
{% include 'shorts/question_display_block.html' %}
{% endblock %}
+417
View File
@@ -0,0 +1,417 @@
<div>
{% comment %} <button id="edit-button">Inline Edit</button> {% endcomment %}
<div class="date">
{{ question.created_date|date:"d/m/Y" }}
</div>
<div class="id">
ID: {{ question.id }}
</div>
<p class="pre-whitespace"><b>History:</b> {{ question.history }}</p>
<p class="pre-whitespace"><b>Region:</b> {{ question.get_regions }}</p>
<p class="pre-whitespace"><b>Examination:</b> {{ question.get_examinations }}</p>
<p class="pre-whitespace"><b>Laterality:</b> {{ question.laterality }}
<button class="toggle-laterality-button toggle-button" data-lat="LEFT">Left</button>
<button class="toggle-laterality-button toggle-button" data-lat="RIGHT">Right</button>
<button class="toggle-laterality-button toggle-button" data-lat="BILAT">Bilateral</button>
<button class="toggle-laterality-button toggle-button" data-lat="NONE">None</button>
</p>
<p class="pre-whitespace"><b>Abnormality:</b> {{ question.get_abnormalities }}</p>
<div class="pre-whitespace multi-image-block"><b>Images:</b>
{% for image in question.images.all %}
<span class="image-block">
Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}:
<div class="dicom-image shorts-img {% if image.feedback_image %}feedback-img{% endif %}"
data-url="{{ remote_url }}{{ image.image.url}}"></div>
</span>
{% endfor %}
</div>
<div>
Exam(s): {% for exam in question.exams.all %}
<a href="{% url 'shorts:exam_overview' pk=exam.pk %}">{{ exam }}</a>,
{% endfor %}
<button class="btn btn-sm" hx-get="{% url 'shorts:question_add_exam' question_id=question.pk %}"
hx-target="#exam-list"
hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span>
</div>
<p class="pre-whitespace"><b>Open Access:</b> {{ question.open_access }}</p>
<p class="pre-whitespace"><b>Feedback:</b> {{ question.feedback }}</p>
<p><b>Author(s):</b> {% for author in question.author.all %} <a
href="{% url 'shorts:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
<p><b>Checked by:</b> {% for verified in question.verified.all %} <a
href="{% url 'shorts:verified_detail' pk=verified.pk %}">{{verified}}</a>, {% endfor %}</p>
<p class="pre-whitespace">
<details>
<summary>
Answers:
</summary>
<table>
<tr><th>Answer</th><th>Score</th></tr>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
<td>
<span {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="shorts"
{% endif %}>
{{ answer }}
</span>
<td>
<td>{{answer.status}}</td>
</tr>
{% endfor %}
</table>
</details>
</p>
</div>
<div>
{% if view_feedback %}
{% include 'question_notes.html' %}
{% if not question.normal %}
<details>
<summary>
Suggested answers
</summary>
<ul class="suggested_answers">
{% for ans in question.get_suggested_answers %}
<li data-string="{{ans}}">{{ans}}</li>
{% endfor %}
</ul>
</details>
{% endif %}
{% endif %}
</div>
<details open>
<summary>
Image viewer
</summary>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ question.get_image_url_array }}"
data-annotations='{{question.get_image_annotations}}'>
</div>
<button id="save-annotations" class="save-shorts-annotations">Save Annotations</button>
</details>
<details>
<summary>
Image annotations:
</summary>
{% for image in question.images.all %}
<span class="image-block">
Image {{ forloop.counter }}: {{image.image_annotations}}<br />
</span>
{% endfor %}
</details>
<a href="{% url 'shorts:question_anonymise_dicom' pk=question.pk %}"
title="Anonymise dicom images">Anonymise dicoms</a><br />
<script>
$(document).ready(function () {
$("#edit-button").click(() => {
$(".toggle-button").toggle();
});
$(".toggle-button").hide();
// send request to change the is_private state on customSwitches toggle
$("#save-annotations").click(function () {
let el = $(".cornerstone-element").get(0);
let c = cornerstone.getEnabledElement(el);
let toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager;
//let image_id = c.image.imageId;
let toolstate = toolStateManager.saveToolState();
let images = JSON.parse(document.getElementById("single-dicom-viewer").dataset.images);
let json_toolstates = [];
for (const image of images) {
if (image in toolstate) {
json_toolstates.push(JSON.stringify(toolstate[image]));
} else if ("wadouri:" + image in toolstate) {
json_toolstates.push(JSON.stringify(toolstate["wadouri:" + image]));
}
}
console.log("json_toolstates", json_toolstates)
$.ajax({
url: "{% url 'shorts:question_save_annotation' pk=question.pk %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
// Yes we do double encode the json....
annotation: JSON.stringify(json_toolstates),
},
type: "POST",
dataType: "json",
error: function (e) {
toastr.warning(`Error saving annotations`)
console.log(e);
},
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
// show some message according to the response.
// For eg. A message box showing that the status has been changed
if (data.status == "success") {
toastr.info('Annotations saved')
} else {
toastr.warning('Error saving annotations')
}
})
.always(function () {
console.log('[Done]');
})
})
{% comment %} $("#cancel-add-to-exam").click(function (evt) {
$("#add-to-exam").toggle();
$("#cancel-add-to-exam").toggle();
$("#exam-options").empty();
})
$("#add-to-exam").click(function (evt) {
$("#add-to-exam").toggle();
$("#cancel-add-to-exam").toggle();
var jqxhr = $.get(evt.target.dataset.exam_list_url, function (data) {
console.log(data);
$("#exam-options").empty();
data.forEach((obj, n) => {
$("#exam-options").append($(document.createElement('button')).prop({
type: 'button',
innerHTML: obj.name,
class: '',
}).data("exam-id", obj.id).click((evt) => {
addToExam(evt)
}))
})
})
.done(function () {
//alert( "second success" );
})
.fail(function () {
//alert( "error" );
})
.always(function () {
//alert( "finished" );
});
return;
function addToExam(evt) {
let exam_button = document.getElementById("add-to-exam");
let ids = [];
ids.push(exam_button.dataset.qid);
// if no question selected
if (ids.length < 1) {
toastr.info('No question selected.');
return
}
let post_url = exam_button.dataset.exam_json_edit_url;
let type = exam_button.dataset.type;
let csrf = exam_button.dataset.csrf;
$.ajax({
url: post_url,
data: {
csrfmiddlewaretoken: csrf,
add_exam_questions: JSON.stringify(ids),
type: type,
exam_id: $(evt.target).data("exam-id"),
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.status == "success") {
toastr.info('Questions added to exams.')
}
})
.always(function () {
console.log('[Done]');
$("#exam-options").empty();
})
}
});
//var $crf_token = $('[name="csrfmiddlewaretoken"]').attr('value');
/* beautify ignore:start */
n = !{{ question.normal | lower }}
/* beautify ignore:end */
$("#toggle-normal-button").click(function () {
$.ajax({
url: "{% url 'shorts-detail' question.id %}",
type: 'PATCH',
headers: {
"X-CSRFToken": "{{ csrf_token }}"
},
timeout: 3000,
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
normal: n,
}
})
.done(function (data) {
console.log(data);
toastr.info('Answer updated')
})
.fail(function () {
alert('Error updating this model instance.');
});
});
$(".toggle-laterality-button").each((n, el) => {
$(el).click(function () {
$.ajax({
url: "{% url 'shorts-detail' question.id %}",
type: 'PATCH',
headers: {
"X-CSRFToken": "{{ csrf_token }}"
},
timeout: 3000,
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
laterality: el.dataset.lat,
}
})
.done(function (data) {
console.log(data);
toastr.info('Answer updated')
})
.fail(function () {
alert('Error updating this model instance.');
});
});
});
{% endcomment %}
$(".suggested_answers li").each((n, el) => {
$(el).append($("<span class='correct'>[Add Correct]</span>").on("click", function () {
$.ajax({
url: "{% url 'answer_submit' %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
//active: this.checked // true if checked else false
question_type: "shorts",
qid: "{{question.pk}}",
status: 2,
answer: el.dataset.string,
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.success) {
toastr.info('Answer saved')
$(el).find(".correct").remove()
}
// show some message according to the response.
// For eg. A message box showing that the status has been changed
})
.always(function () {
console.log('[Done]');
})
}))
});
// send request to change the is_private state on customSwitches toggle
$(".proposed-answer").each((n, el) => {
// Add button to confirm answer is correct
$(el).append($("<span class='confirm'>[Add Correct]</span>").on("click", function () {
$.ajax({
url: "{% url 'answer_suggestion_confirm' %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
//active: this.checked // true if checked else false
question_type: "shorts",
aid: el.dataset.aid,
status: 2,
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.success) {
toastr.info('Answer saved')
$(el).find(".confirm").remove()
$(el).removeClass("proposed-answer")
}
// show some message according to the response.
// For eg. A message box showing that the status has been changed
})
.always(function () {
console.log('[Done]');
})
}))
// Add button to confirm answer is incorrect
$(el).append($("<span class='confirm'>[Incorrect]</span>").on("click", function () {
$.ajax({
url: "{% url 'answer_suggestion_confirm' %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
//active: this.checked // true if checked else false
question_type: "shorts",
aid: el.dataset.aid,
status: 0,
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.success) {
toastr.info('Answer saved')
$(el).find(".confirm").remove()
$(el).removeClass("proposed-answer")
}
// show some message according to the response.
// For eg. A message box showing that the status has been changed
})
.always(function () {
console.log('[Done]');
})
}))
});
});
</script>
<style>
.toggle-button {
font-size: x-small;
opacity: 30%;
}
.toggle-button:hover {
font-size: x-small;
opacity: 100%;
}
#cancel-add-to-exam {
border-style: dashed;
}
#cancel-add-to-exam:hover {
background-color: purple;
}
</style>
+343
View File
@@ -0,0 +1,343 @@
{% extends "shorts/base.html" %}
{% load static from static %}
{% load crispy_forms_tags %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript">
let csrf_token = "{{csrf_token}}";
let hash_url = "{% url 'shorts:question_by_hash' %}";
//let monitor_directory_handler = false;
//let monitor_interval_id = false;
// set to hold the files that are still being processed
let load_viewer = false;
// async function listAllFilesAndDirs(dirHandle, files_only) {
// const files = [];
// for await (let [name, handle] of dirHandle) {
// const {
// kind
// } = handle;
// if (handle.kind === 'directory') {
// if (files_only != true) {
// files.push({
// name,
// handle,
// kind
// });
// }
// files.push(...await listAllFilesAndDirs(handle));
// } else {
// files.push({
// name,
// handle,
// kind
// });
// }
// }
// return files;
// }
document.addEventListener('drop', function (e) {
e.preventDefault();
}, false);
function add_answers_input_form() {
var form_idx = $('#id_answers-TOTAL_FORMS').val();
$('#answer_form_set').append($('#answer_empty_form').html().replace(/__prefix__/g, form_idx));
$(`#id_answers-${form_idx}-status`).val(2);
$('#id_answers-TOTAL_FORMS').val(parseInt(form_idx) + 1);
}
$(document).ready(function () {
$("#add_abnormality").appendTo($("#abnormality-label"));
$("#add_examination").appendTo($("#examination-label"));
$("#add_region").appendTo($("#region-label"));
dropContainer = document.getElementById("drop-container");
dropContainer.ondragover = function (evt) {
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondragenter = function (evt) {
console.log("ENTER")
console.log(evt)
$(evt.target).addClass("drop-target-active")
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondragleave = function (evt) {
console.log("ENTER")
console.log(evt)
$(evt.target).removeClass("drop-target-active")
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondrop = function (evt) {
$(evt.target).removeClass("drop-target-active");
file_drop_number = evt.dataTransfer.files.length;
if (file_drop_number < 1) {
toastr.warning(`Drop failed (no files), try again.`);
} else {
toastr.info(`Loading ${file_drop_number} dropped image(s).`);
}
// Get all input elements
inputs = extendInputs("rapid-form", file_drop_number);
console.log("drop inputs", inputs, evt);
// Loop through each dropped file and try to assign to an
// input element
[...evt.dataTransfer.files].forEach((f) => {
feedback = false;
if (evt.target.id == "feedback-drop-target") {
feedback = true;
}
addFile(f, feedback)
})
evt.preventDefault();
evt.stopPropagation();
};
// $('#directory-picker-button').click(() => {
// window.showDirectoryPicker().then((handler) => {
// monitor_directory_handler = handler;
// monitor_interval_id = setInterval(function () {
// listAllFilesAndDirs(monitor_directory_handler).then(async function (
// items) {
// files_to_add = new Set();
// current_files = getCurrentFormFiles();
// current_files_tuple = new Set()
// for (let elem of current_files) {
// current_files_tuple.add(
// `${elem.size}, ${elem.name}, ${elem.lastModified}`
// );
// }
//
// console.log(current_files_tuple)
// for (var i = 0; i < items.length; i++) {
//
// f = await items[i].handle.getFile()
// console.log("f", f)
// if (!current_files_tuple.has(
// `${f.size}, ${f.name}, ${f.lastModified}`
// )) {
// files_to_add.add(f);
// }
// }
//
// // file objects differ enough that the following does not work
// //let distinct_set = new Set([...files_to_add].filter(
// // x => !current_files.has(x)));
//
//
// extendInputs(files_to_add.size);
// files_to_add.forEach((file) => {
// addFile(file, false);
// });
// });
// }, 5000);
// $("#directory-picker-display").text(monitor_directory_handler.name)
// .after(
// $(
// " <span>[clear]</span>").click((evt) => {
// $(evt.target).remove();
// $("#directory-picker-display").text("");
// monitor_directory_handler = false;
// clearInterval(monitor_interval_id);
// //const files = await listAllFilesAndDirs(directoryHandle);
// }));
// });
// });
$('#add_more_images').click(() => {
add_input_form()
});
$('#add_more_answers').click(() => {
add_answers_input_form()
});
$("input[type=file]").on('change', input_change);
$("#rapid-form").on("submit", (evt) => {
submit = true;
if (!$normal) {
if ($('#id_answers-TOTAL_FORMS').val() == "0") {
submit = false;
add_answers_input_form();
toastr.error(`Question that are not normal require an answer`);
evt.preventDefault();
}
}
if (!$("#id_examination_to option").length) {
submit = false;
toastr.error("Please add examination type")
evt.preventDefault();
}
if (submit) {
toastr.info(`Submiting question... please wait`, '', {
"closeButton": false,
"debug": false,
"newestOnTop": false,
"progressBar": true,
"positionClass": "toast-top-full-width",
"preventDuplicates": false,
"onclick": null,
"showDuration": "300",
"hideDuration": "1000",
"timeOut": "5000",
"extendedTimeOut": "1000",
"showEasing": "swing",
"hideEasing": "linear",
"showMethod": "fadeIn",
"hideMethod": "fadeOut"
});
}
})
});
function extractDicomDetails(byteArray) {
if (!$("#id_examination_to option").length) {
// We need to setup a try/catch block because parseDicom will throw an exception
// if you attempt to parse a non dicom part 10 file (or one that is corrupted)
try {
// parse byteArray into a DataSet object using the parseDicom library
var dataSet = dicomParser.parseDicom(byteArray);
// dataSet contains the parsed elements. Each element is available via a property
// in the dataSet.elements object. The property name is based on the elements group
// and element in the following format: xggggeeee where gggg is the group number
// and eeee is the element number with lowercase hex characters.
// To access the data for an element, we need to know its type and its tag.
// We will get the sopInstanceUid from the file which is a string and
// has the tag (0020,000D)
var study_description = dataSet.string('x00081030').toLowerCase();
console.log("STUDY", study_description);
// try to match with a study description (if not already selected)
if ($(`#id_examination_to option`).length < 1) {
option = $(`#id_examination_from option[title*='${study_description}' i]`);
if (option.length) {
option.appendTo($("#id_examination_to"));
toastr.success(`Examination set to ${option.attr('title')} from dicom data`);
} else {
toastr.warning(
`Unable to set examination ${study_description} from dicom data (it needs creating)`);
}
}
} catch (err) {
console.log(err);
}
}
}
</script>
{{ form.media }}
{% endblock %}
{% block content %}
{% if object %}
{% include "shorts/question_link_header.html" %}
{% endif %}
<h2>Submit Shorts Question</h2>
<p>
<h3>Instructions</h3>
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.
</p>
<!-- <div>
To monitor a directory select it here.<br />
<button id="directory-picker-button">Pick a directory</button>
<span id="directory-picker-display">No directory monitored</span>
</div> -->
<form action="" method="post" enctype="multipart/form-data" id="rapid-form">
{% comment %} {% csrf_token %} {% endcomment %}
<a href="/rapids/abnormality/create" id="add_abnormality" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
<a href="/rapids/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
<a href="/rapids/region/create" id="add_region" class="add-popup" onclick="return showAddPopup(this);"><img
src="{% static '/img/icon-addlink.svg' %}"></a>
{% crispy form form.helper %}
<h3>Images:</h3>
<div id="drop-container" class="drop-target">Drop images here (or use the buttons below)<div
id="feedback-drop-target">Feedback image?<br />drop those here</div>
<div id="drop-filenames"></div>
</div>
<input type="button" value="Add More Images" id="add_more_images">
<div id="image_form_set">
{% for form in image_formset %}
<ul class="no-error image-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form.as_ul }}
</ul>
{% endfor %}
</div>
{{ image_formset.management_form }}
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<div id="empty_form" style="display:none">
<ul class='no_error image-formset'>
{{ image_formset.empty_form.as_ul }}
</ul>
</div>
<div id="answer_empty_form" style="display:none">
<ul class='no_error answer-formset'>
{{ answer_formset.empty_form.as_ul }}
</ul>
</div>
<div id="single-dicom-viewer" class="rapid-dicom-viewer" data-images="" data-annotations='' style="display:none"></div>
{% endblock %}
{% block css %}
<style>
.add-popup {
float: left;
}
</style>
{% endblock css %}
@@ -0,0 +1,25 @@
<div class="floating-header">
<a href="{% url 'shorts:question_update' pk=question.pk %}" title="Edit the Question">Edit</a>
<a href="{% url 'shorts:question_clone' pk=question.pk %}" title="Clone the Question (duplicate everything but the images)">Clone</a>
<a href="{% url 'shorts:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
<a href="{% url 'shorts:question_answer_update' pk=question.pk %}" title="Update the question answers">Edit Answers</a>
<a href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='rapid' pk=question.pk %}')"> Add Note</a>
{% if request.user.is_superuser %}
<a href="{% url 'admin:shorts_question_change' question.id %}" title="Edit the Question using the admin interface">Admin Edit</a>
<a href="{% url 'shorts:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
{% endif %}
{% if exam %}
<div>
{% if previous > -1 %}
<a href="{% url 'shorts:exam_question_detail' exam.id previous %}">Previous question</a>
{% endif %}
Viewing question as part of exam: <a href="{% url 'shorts:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
{% if next %}
<a href="{% url 'shorts:exam_question_detail' exam.id next %}">Next question</a>
{% endif %}
</div>
{% endif %}
</div>
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+112
View File
@@ -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/<int:pk>/", views.verified_detail, name="verified_detail"),
path("question/<int:pk>/json", views.question_json, name="question_json"),
path(
"question/<int:pk>/json/unbased",
views.question_json_unbased,
name="question_json_unbased",
),
path(
"question/<int:pk>/save_annotation",
views.question_save_annotation,
name="question_save_annotation",
),
path("question/<int:pk>/split", views.question_split, name="question_split"),
path("question/<int:pk>/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/<int:pk>/delete",
views.QuestionDelete.as_view(),
name="question_delete",
),
path(
"question/<int:pk>/anon",
views.question_anonymise_dicom,
name="question_anonymise_dicom",
),
path(
"question/<int:question_id>/add_exam",
views.question_add_exam,
name="question_add_exam",
),
#path("answer/<int:answer_id>/confirm", views.confirm_answer, name="confirm_answer"),
#path("answer/<int:answer_id>/delete", views.delete_answer, name="delete_answer"),
path("exam/<int:exam_pk>/<int:sk>/mark", views.mark, name="mark"),
path("exam/<int:exam_pk>/<int:sk>/mark/all", views.mark_all, name="mark_all"),
path(
"exam/<int:exam_pk>/<int:sk>/mark/review", views.mark_review, name="mark_review"
),
path("exam/<int:pk>/markers", views.ExamMarkersUpdate.as_view(), name="exam_markers"),
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
path("exam/<int:pk>/authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"),
path("exam/create", views.ExamCreate.as_view(), name="exam_create"),
path("exam/<int:exam_id>/clone", views.ExamClone.as_view(), name="exam_clone"),
path("exam/<int:pk>/update", views.ExamUpdate.as_view(), name="exam_update"),
path("exam/<int:pk>/delete", views.ExamDelete.as_view(), name="exam_delete"),
path("create/", views.QuestionCreate.as_view(), name="question_create"),
path("create/exam/<int:pk>", 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/<int:pk>/update",
views.QuestionUpdate.as_view(),
name="question_update",
),
path(
"question/<int:pk>/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/<int:pk>", views.UserAnswerView.as_view(), name="user_answer_view"
),
path(
"user_answers/<int:pk>/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))
+731
View File
@@ -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: <br>"
for exam in exams:
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><button hx-post=\"{request.path}\""
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<br>Exams to remove from question: <br>"
for exam in question.exams.all():
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><input name='remove' value='true' type='hidden'><button hx-post=\"{request.path}\""
f"hx-confirm='Are you sure you want to remove this exam from the question?'"
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<button _='on click remove #htmx-exam-list'>Cancel</button>"
return HttpResponse(mark_safe(html))
raise PermissionDenied()
+3
View File
@@ -109,6 +109,9 @@
<li> <li>
<a class="dropdown-item" href="{% url 'rapids:index' %}">Rapids</a> <a class="dropdown-item" href="{% url 'rapids:index' %}">Rapids</a>
</li> </li>
<li>
<a class="dropdown-item" href="{% url 'shorts:index' %}">Shorts</a>
</li>
<li> <li>
<a class="dropdown-item" href="{% url 'longs:index' %}">Longs</a> <a class="dropdown-item" href="{% url 'longs:index' %}">Longs</a>
</li> </li>