start setting up shorts
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ShortsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'shorts'
|
||||
@@ -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
|
||||
Executable
+99
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
{% extends 'shorts/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% include "shorts/question_link_header.html" %}
|
||||
{% include 'shorts/question_display_block.html' %}
|
||||
{% endblock %}
|
||||
+417
@@ -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>
|
||||
Executable
+343
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
+112
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user