start setting up shorts

This commit is contained in:
Ross
2025-04-07 09:12:30 +01:00
parent bcc449eb34
commit afa1379ce4
26 changed files with 2896 additions and 41 deletions
View File
+44
View File
@@ -0,0 +1,44 @@
from django.contrib import admin
# Register your models here.
from .models import Question, SampleAnswer, Exam, ExamUserStatus, UserAnswer
admin.site.register(Question)
admin.site.register(SampleAnswer)
admin.site.register(UserAnswer)
#class RapidImageInline(admin.TabularInline):
# model = QuestionImage
# extra = 1
# readonly_fields = [
# "image_tag",
# ]
#
#class ExamInline(admin.TabularInline):
# model = Question.exams.through
# extra = 1
#
#class QuestionAdmin():
# form = RapidAdminForm
#
# filter_horizontal = (
# "abnormality",
# "region",
# #"examination",
# )
# save_on_top = True
# save_as = True
# view_on_site = True
#
# inlines = [
# RapidImageInline,
# RapidAnswersInline,
# ExamInline,
# ]
class ExamAdmin(admin.ModelAdmin):
save_as = True
admin.site.register(Exam, ExamAdmin)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ShortsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'shorts'
+32
View File
@@ -0,0 +1,32 @@
from django.core.exceptions import PermissionDenied
from .models import Exam, Question
def user_is_author_or_shorts_checker(function):
def wrap(request, *args, **kwargs):
if "pk" in kwargs:
question_id = kwargs["pk"]
elif "question_id" in kwargs:
question_id = kwargs["question_id"]
else:
raise PermissionDenied
question = Question.objects.get(pk=question_id)
if request.user in question.author.all() or request.user.groups.filter(name='shorts_checker').exists():
return function(request, *args, **kwargs)
else:
raise PermissionDenied
wrap.__doc__ = function.__doc__
wrap.__name__ = function.__name__
return wrap
def user_is_exam_author_or_shorts_checker(function):
def wrap(request, *args, **kwargs):
exam = Exam.objects.get(pk=kwargs['pk'])
if request.user in exam.author.all() or request.user.groups.filter(name='shorts_checker').exists():
return function(request, *args, **kwargs)
else:
raise PermissionDenied
wrap.__doc__ = function.__doc__
wrap.__name__ = function.__name__
return wrap
+99
View File
@@ -0,0 +1,99 @@
import django_filters
from .models import Question, UserAnswer, Exam
from django.contrib.auth.models import User
def get_exams(request):
if request is None:
return Exam.objects.none()
if not request.user.groups.filter(name="shorts_checker").exists():
queryset = Exam.objects.filter(author__id=request.user.id)
else:
queryset = Exam.objects.all()
return queryset
def get_authors(request):
if request is None:
return User.objects.none()
if not request.user.groups.filter(name="shorts_checker").exists():
queryset = User.objects.filter(id=request.user.id)
else:
queryset = User.objects.all()
return queryset
class QuestionFilter(django_filters.FilterSet):
exams = django_filters.ModelMultipleChoiceFilter(
queryset=get_exams, null_label="No exam"
)
author = django_filters.ModelMultipleChoiceFilter(
queryset=get_authors, null_label="No author"
)
class Meta:
model = Question
fields = {
"abnormality": ["exact"],
"region": ["exact"],
"examination": ["exact"],
"laterality": ["exact"],
# "site",
"created_date": ["exact"],
"open_access": ["exact"],
}
def __init__(
self,
data=None,
queryset=None,
prefix=None,
strict=None,
user=None,
request=None,
):
if not request.user.groups.filter(name="shorts_checker").exists():
queryset = queryset.filter(open_access=True) | queryset.filter(
author__id=request.user.id
)
super(QuestionFilter, self).__init__(
data=data, queryset=queryset, prefix=prefix, request=request
)
pass
class QuestionUserAnswerFilter(django_filters.FilterSet):
class Meta:
model = UserAnswer
fields = (
"cid",
"user",
"answer",
# "answer_compare",
"exam",
"created",
"updated",
)
def __init__(
self,
data=None,
queryset=None,
prefix=None,
strict=None,
user=None,
request=None,
):
if not request.user.groups.filter(name="shorts_checker").exists():
queryset = queryset.filter(open_access=True) | queryset.filter(
author__id=request.user.id
)
super(QuestionUserAnswerFilter, self).__init__(
data=data, queryset=queryset, prefix=prefix, request=request
)
pass
+251
View File
@@ -0,0 +1,251 @@
from django.forms import (
Form,
ModelForm,
ModelMultipleChoiceField,
ModelChoiceField,
ChoiceField,
CharField,
)
from django.forms import inlineformset_factory
from generic.forms import ExamAuthorFormMixin, ExamFormMixin, ExamGroupsFormMixin, ExamMarkerFormMixin
from shorts.models import (
Abnormality,
Examination,
Question,
QuestionImage,
Region,
SampleAnswer,
UserAnswer,
Exam,
)
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.forms.widgets import RadioSelect, TextInput, Textarea
from django.contrib.auth.models import User
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, Layout, Div, Field, HTML
from crispy_forms.bootstrap import InlineRadios
class QuestionAnswerForm(ModelForm):
class Meta:
model = UserAnswer
fields = ("answer",)
class MarkQuestionForm(Form):
# correct = forms.CharField(required=False)
# half_correct = forms.CharField(required=False)
# incorrect = forms.CharField(required=False)
marked_answers = CharField(required=False)
class QuestionAnswerForm(ModelForm):
class Meta:
model = Question
fields = []
class QuestionForm(ModelForm):
# exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False))
class Media:
# Django also includes a few javascript files necessary
# for the operation of this form element. You need to
# include <script src="/admin/jsi18n"></script>
# in the template.
css = {
"all": ["css/widgets.css"],
}
# Adding this javascript is crucial
js = ["jsi18n.js", "tesseract.min.js", "js/upload_form_helpers.js"]
def __init__(self, *args, **kwargs):
self.user = kwargs.pop(
"user"
) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
if kwargs.get("instance"):
# We get the 'initial' keyword argument or initialize it
# as a dict if it didn't exist.
initial = kwargs.setdefault("initial", {})
# The widget for a ModelMultipleChoiceField expects
# a list of primary key for the selected data.
initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()]
ModelForm.__init__(self, *args, **kwargs)
super(QuestionForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_id = 'id-anatomy-question-form'
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
HTML("<h4 class='clearfix' id='abnormality-label'>Abnormality</h4>"),
Field('abnormality', css_class='form-control'),
HTML("<h4 class='clear-both' id='region-label'>Region</h4>"),
Field('region', css_class='form-control'),
css_class='clearfix abnormal-fields'
),
HTML("<h4 class='clear-both' id='examination-label'>Examination*</h4>"),
Field('examination', css_class='form-control'),
Div(InlineRadios('laterality', css_class='form-control'), css_class='clearfix clear-both'),
Field('history', css_class='form-control'),
Field('feedback', css_class='form-control'),
Field('open_access', css_class='form-control'),
HTML("<h4 class='clear-both' id='exams-label'>Exams</h4>"),
Field('exams', css_class='form-control'),
)
self.fields["abnormality"] = ModelMultipleChoiceField(
required=False,
queryset=Abnormality.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Abnormality", is_stacked=False),
)
self.fields["abnormality"].label = ""
self.fields["region"] = ModelMultipleChoiceField(
required=False,
queryset=Region.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Region", is_stacked=False),
)
self.fields["region"].label = ""
self.fields["examination"] = ModelMultipleChoiceField(
queryset=Examination.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Examination", is_stacked=False),
)
self.fields["examination"].label = ""
self.fields["laterality"] = ChoiceField(
choices=Question.LATERALITY_CHOICES, required=False, widget=RadioSelect()
)
if self.user.groups.filter(name="shorts_checker").exists():
exam_queryset = Exam.objects.all()
else:
exam_queryset = Exam.objects.filter(
author__id=self.user.id
) | Exam.objects.filter(open_access=True)
self.fields["exams"] = ModelMultipleChoiceField(
required=False,
queryset=exam_queryset.distinct(),
widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False),
)
self.fields["exams"].label = ""
self.fields["history"].widget = Textarea(
attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 1}
)
def save(self, commit=True):
instance = ModelForm.save(self, False)
instance.save()
old_exams = instance.exams.all()
new_exams = self.cleaned_data["exams"]
for exam in old_exams:
if exam not in new_exams:
exam.exam_questions.remove(instance)
for exam in new_exams:
exam.exam_questions.add(instance)
#exam.save()
# # Prepare a 'save_m2m' method for the form,
# old_save_m2m = self.save_m2m
#
# def save_m2m():
# old_save_m2m()
# # This is where we actually link the pizza with toppings
# instance.exams.clear()
# for exam in self.cleaned_data["exams"]:
# instance.exams.add(exam)
#
# self.save_m2m = save_m2m
#
# # Do we need to save all changes now?
# # if commit:
# instance.save()
self.save_m2m()
return instance
class Meta:
model = Question
# fields = ['due_back']
# fields = '__all__'
fields = [
"abnormality",
"region",
"laterality",
"examination",
# "site",
"feedback",
"history",
"open_access",
]
# fields = ['question', 'feedback', 'subspecialty', 'references']
ImageFormSet = inlineformset_factory(
Question,
QuestionImage,
fields=["image", "feedback_image", "filename", "description"],
exclude=[],
can_delete=True,
extra=1,
max_num=10,
)
#AnswerFormSet = inlineformset_factory(
# Question,
# SampleAnswer,
# fields=["answer", "status"],
# widgets={
# "answer": Textarea(
# attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 3}
# )
# },
# exclude=[],
# can_delete=True,
# extra=0,
# max_num=10,
#)
#
#AnswerUpdateFormSet = inlineformset_factory(
# Question,
# SampleAnswer,
# fields=["answer", "status"],
# widgets={
# "answer": Textarea(
# attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 3}
# )
# },
# exclude=[],
# can_delete=True,
# extra=0,
# max_num=10,
#)
# This should be made generic?
class ExamForm(ExamFormMixin, ModelForm):
class Meta(ExamFormMixin.Meta):
model = Exam
class ExamAuthorForm(ExamAuthorFormMixin):
class Meta(ExamAuthorFormMixin.Meta):
model = Exam
class ExamMarkerForm(ExamMarkerFormMixin):
class Meta(ExamMarkerFormMixin.Meta):
model = Exam
class ExamGroupsForm(ExamGroupsFormMixin):
class Meta(ExamGroupsFormMixin.Meta):
model = Exam
+148
View File
@@ -0,0 +1,148 @@
# Generated by Django 5.1.4 on 2025-03-31 10:53
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import generic.mixins
import shorts.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('generic', '0025_cimarseriesthumbnail'),
('rapids', '0016_exam_results_supervisor_visible'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Exam',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name of the exam/collection', max_length=200)),
('start_date', models.DateTimeField(blank=True, help_text='Date (and time) the exam/collection starts', null=True)),
('end_date', models.DateTimeField(blank=True, help_text='Date (and time) the exam/collection ends', null=True)),
('restrict_to_dates', models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times')),
('active', models.BooleanField(default=False, help_text='If an exam/collection should be available to take')),
('publish_results', models.BooleanField(default=False, help_text='If an exam/collections results should be available')),
('archive', models.BooleanField(default=False, help_text='Archived exams/collections will remain on the test system but will not be displayed by default')),
('open_access', models.BooleanField(default=False, help_text='If the exam/collection is freely accessible (to view and edit on the test system)')),
('candidates_only', models.BooleanField(default=True, help_text='If the exam/collection is only available to candidates who have been added')),
('authors_only', models.BooleanField(default=False, help_text='If true only exam/collection authors will be able to view.')),
('results_supervisor_visible', models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.')),
('exam_open_access', models.BooleanField(default=False, help_text='Set to true if you want any registered user to be able to take the exam.')),
('exam_mode', models.BooleanField(default=False, help_text='If an exam should be taken in exam mode (users results will be submited to the server for marking)')),
('include_history', models.BooleanField(default=False, help_text='If an exam should include history when taking')),
('recreate_json', models.BooleanField(default=False, help_text='If the json cache needs updating')),
('json_creation_time', models.DateTimeField(blank=True, default=None, null=True)),
('exam_json_id', models.IntegerField(default=1, help_text='auto incrementing field when json recreated')),
('stats_mean', models.FloatField(default=0)),
('stats_mode', models.CharField(default=0, max_length=25)),
('stats_median', models.FloatField(default=0)),
('stats_candidates', models.FloatField(default=0)),
('stats_min', models.FloatField(default=0)),
('stats_max', models.FloatField(default=0)),
('stats_max_possible', models.FloatField(default=0)),
('stats_graph', models.TextField(default=0)),
('user_scores', models.JSONField(blank=True, default=dict)),
('exam_results_emailed', models.DateTimeField(blank=True, default=None, null=True)),
('time_limit', models.IntegerField(default=7200, help_text='Exam time limit (in seconds). Default is 2 hours')),
('author', models.ManyToManyField(blank=True, help_text='Author of exam', related_name='shorts_exam_author', to=settings.AUTH_USER_MODEL)),
('cid_user_groups', models.ManyToManyField(blank=True, help_text='These groups define which candidates are able to be added to the exams/collection.', related_name='shorts_cid_user_groups', to='generic.cidusergroup')),
('examcollection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shorts_exams', to='generic.examcollection')),
('markers', models.ManyToManyField(blank=True, help_text='Authorised markers for the exam', related_name='shorts_exam_markers', to=settings.AUTH_USER_MODEL)),
('user_user_groups', models.ManyToManyField(blank=True, help_text='These groups define which candidates are able to be added to the exams/collection.', related_name='shorts_user_user_groups', to='generic.userusergroup')),
('valid_cid_users', models.ManyToManyField(blank=True, related_name='shorts_exams', to='generic.ciduser')),
('valid_user_users', models.ManyToManyField(blank=True, related_name='user_shorts_exams', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
bases=(models.Model, generic.mixins.AuthorMixin),
),
migrations.CreateModel(
name='Question',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('authors_only', models.BooleanField(default=False, help_text='If true only question authors will be able to view.')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('open_access', models.BooleanField(default=True, help_text='If a question should be freely available to browse')),
('feedback', models.TextField(blank=True, help_text='Question Feedback', null=True)),
('history', models.TextField(blank=True, null=True)),
('laterality', models.CharField(choices=[('NONE', 'None'), ('LEFT', 'Left'), ('RIGHT', 'Right'), ('BILAT', 'Bilateral')], default='NONE', help_text='Applies to the answer, not the examination', max_length=20)),
('abnormality', models.ManyToManyField(blank=True, help_text='The abnormality (laterality and region independent). Used for categorisation but does not affect the answer', to='rapids.abnormality')),
('author', models.ManyToManyField(blank=True, help_text='Author of question', related_name='shorts_authored_questions', to=settings.AUTH_USER_MODEL)),
('examination', models.ManyToManyField(help_text='Name of the (primary) examination', to='rapids.examination')),
('region', models.ManyToManyField(blank=True, help_text='Region of the abnormality (laterality independent)', to='rapids.region')),
],
options={
'abstract': False,
},
bases=(models.Model, generic.mixins.AuthorMixin, generic.mixins.QuestionMixin),
),
migrations.CreateModel(
name='ExamQuestionDetail',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sort_order', models.IntegerField(default=1000)),
('exam', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shorts.exam')),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shorts.question')),
],
options={
'ordering': ('sort_order',),
},
),
migrations.AddField(
model_name='exam',
name='exam_questions',
field=models.ManyToManyField(related_name='exams', through='shorts.ExamQuestionDetail', to='shorts.question'),
),
migrations.CreateModel(
name='QuestionImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.FileField(upload_to=shorts.models.image_directory_path)),
('image_annotations', models.TextField(blank=True, help_text='Stores a JSON representation of annotations to be applied by cornerstonetools', null=True)),
('feedback_image', models.BooleanField(default=False)),
('filename', models.CharField(blank=True, help_text='Name of the original file when uploaded', max_length=255, null=True)),
('description', models.CharField(blank=True, max_length=255, null=True)),
('image_md5_hash', models.CharField(blank=True, max_length=32, null=True)),
('is_dicom', models.BooleanField(default=False)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='shorts.question')),
],
),
migrations.CreateModel(
name='SampleAnswer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('answer', models.TextField()),
('score', models.IntegerField(blank=True, default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('proposed', models.BooleanField(default=False)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sample_answers', to='shorts.question')),
],
),
migrations.CreateModel(
name='UserAnswer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cid', models.BigIntegerField(blank=True, help_text='Candidate ID (limitied by BigIntegerField size)', null=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('answer', models.TextField(blank=True)),
('score', models.IntegerField(blank=True, default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('callstate', models.CharField(blank=True, choices=[('C', 'Correct Call'), ('O', 'Overcall'), ('U', 'Undercall'), ('W', 'Incorrect Call'), ('P', 'Partial Call')], max_length=1, null=True)),
('exam', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cid_user_answers', to='shorts.exam')),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cid_user_answers', to='shorts.question')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_shorts_user_answers', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['cid'],
'abstract': False,
},
),
]
View File
+401
View File
@@ -0,0 +1,401 @@
from collections import defaultdict
from django.db import models
from django.urls import reverse
from django.conf import settings
from rad.settings import REMOTE_URL
from django.utils.translation import gettext_lazy as _
import dicognito
import json
# Create your models here.
from generic.models import CidUser, CidUserGroup, ExamBase, ExamCollection, ExamUserStatus, QuestionBase, UserAnswerBase, UserUserGroup
from django.contrib.contenttypes.fields import GenericRelation
from django.core.validators import MaxValueValidator, MinValueValidator
from django.utils.html import mark_safe
from rapids.models import Abnormality, Examination, Region
from helpers.images import get_image_hash, image_as_base64
def image_directory_path(instance, filename):
# return u"{0}".format(filename)
return "shorts/picture/{0}".format(filename)
class Question(QuestionBase):
"""
"""
history = models.TextField(null=True, blank=True,
help_text="Single line history for the question, e.g. Age 14, male. Referral from ED. History: Painful wrist after falling off skateboard.")
NONE = "NONE"
LEFT = "LEFT"
RIGHT = "RIGHT"
BILATERAL = "BILAT"
LATERALITY_CHOICES = (
(NONE, "None"),
(LEFT, "Left"),
(RIGHT, "Right"),
(BILATERAL, "Bilateral"),
)
abnormality = models.ManyToManyField(
Abnormality,
blank=True,
help_text="The abnormality (laterality and region independent). Used for categorisation but does not affect the answer",
)
region = models.ManyToManyField(
Region,
blank=True,
help_text="Region of the abnormality (laterality independent)",
)
examination = models.ManyToManyField(
Examination, help_text="Name of the (primary) examination"
)
laterality = models.CharField(
max_length=20,
choices=LATERALITY_CHOICES,
default=NONE,
help_text="Applies to the answer, not the examination",
)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
help_text="Author of question",
related_name="shorts_authored_questions",
)
def get_app_name(self):
return "shorts"
def get_absolute_url(self):
return reverse("shorts:question_detail", kwargs={"pk": self.pk})
def get_images(self, feedback=False):
qs = self.images.all()
if not feedback:
images = [i.image for i in qs if not i.feedback_image]
else:
images = [i.image for i in qs]
return images
def get_image_urls(self):
return ",".join([f"{REMOTE_URL}{i.url}" for i in self.get_images()])
def get_image_url_array(self):
return json.dumps([f"{REMOTE_URL}{i.url}" for i in self.get_images()])
# def GetNonFeedbackQuestionImages(self):
# return self.get_images()
def get_image_annotations(self):
return json.dumps(
[i.image_annotations for i in self.images.all() if not i.feedback_image]
)
def get_laterality_string(self):
if self.laterality == self.NONE:
s = ""
elif self.laterality == self.BILATERAL:
s = "bilateral "
elif self.laterality == self.RIGHT:
s = "right "
elif self.laterality == self.LEFT:
s = "left "
return s
def anonymise_images(self):
anonymizer = dicognito.anonymizer.Anonymizer()
for image in self.images.all():
file_path = os.path.join(settings.MEDIA_ROOT, image.image.name)
try:
with pydicom.dcmread(file_path) as dataset:
anonymizer.anonymize(dataset)
dataset.save_as(file_path)
except pydicom.errors.InvalidDicomError:
pass
class QuestionImage(models.Model):
question = models.ForeignKey(Question, related_name="images", on_delete=models.CASCADE)
image = models.FileField(upload_to=image_directory_path)
image_annotations = models.TextField(
blank=True,
null=True,
help_text="Stores a JSON representation of annotations to be applied by cornerstonetools",
)
feedback_image = models.BooleanField(default=False)
filename = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Name of the original file when uploaded",
)
description = models.CharField(max_length=255, null=True, blank=True)
image_md5_hash = models.CharField(max_length=32, null=True, blank=True)
is_dicom = models.BooleanField(default=False)
def image_tag(self):
if self.image:
return mark_safe(
'<img src="{}" class="admin-rapid-image" /><span class="admin-rapid-image-info">Click and hold to zoom<span>'.format(
self.image.url
)
)
else:
return ""
image_tag.short_description = "Image"
def save(self, *args, **kwargs):
"""Override save method to add image hash"""
if self.image:
image_hash, is_dicom = get_image_hash(self.image, hash_type="md5", direct_pixel_data=False)
self.is_dicom = is_dicom
self.image_md5_hash = image_hash
# Hack for tests
if image_hash != "12345ABCD":
super().save(*args, **kwargs) # Call the "real" save() method.
class SampleAnswer(models.Model):
question = models.ForeignKey(
Question, related_name="sample_answers", on_delete=models.CASCADE
)
answer = models.TextField()
score = models.IntegerField(default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)])
proposed = models.BooleanField(default=False)
def __str__(self):
return self.answer
class ExamQuestionDetail(models.Model):
exam = models.ForeignKey("Exam", on_delete=models.CASCADE)
question = models.ForeignKey(Question, on_delete=models.CASCADE)
sort_order = models.IntegerField(default=1000)
class Meta:
ordering = ("sort_order",)
class Exam(ExamBase):
app_name = "shorts"
exam_questions = models.ManyToManyField(Question, through=ExamQuestionDetail, related_name="exams")
time_limit = models.IntegerField(
help_text="Exam time limit (in seconds). Default is 2 hours",
default=2 * 60 * 60,
)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
help_text="Author of exam",
related_name="shorts_exam_author",
)
markers = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
help_text="Authorised markers for the exam",
related_name="shorts_exam_markers",
)
valid_cid_users = models.ManyToManyField(
CidUser, blank=True, related_name="shorts_exams"
)
valid_user_users = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="user_shorts_exams"
)
cid_user_groups = models.ManyToManyField(
CidUserGroup,
blank=True,
help_text="These groups define which candidates are able to be added to the exams/collection.",
related_name="shorts_cid_user_groups",
)
user_user_groups = models.ManyToManyField(
UserUserGroup,
blank=True,
help_text="These groups define which candidates are able to be added to the exams/collection.",
related_name="shorts_user_user_groups",
)
exam_user_status = GenericRelation(ExamUserStatus, related_query_name="shorts_exams")
cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="shorts_exams")
examcollection = models.ForeignKey(ExamCollection, blank=True, null=True, on_delete=models.SET_NULL, related_name="shorts_exams")
def get_app_name(self):
return "shorts"
def get_exam_json(self, based=True):
# TODO
return {}
questions = self.get_questions()
exam_questions = defaultdict(dict)
exam_order = []
q: Rapid
for q in questions:
exam_order.append(q.id)
# Loop through rapidimage associations
images = []
feedback_images = []
image_titles = []
for i in q.images.all():
if i.feedback_image == True:
if based:
feedback_images.append(image_as_base64(i.image))
else:
feedback_images.append(
"{}/{}".format(settings.REMOTE_URL, i.image.url)
)
else:
if based:
images.append(image_as_base64(i.image))
else:
images.append("{}/{}".format(settings.REMOTE_URL, i.image.url))
if i.description:
image_titles.append(i.description)
else:
image_titles.append("")
exam_questions[q.id] = {
"images": images,
# "feedback_image": [],
# "annotations": [str(q.image_annotations)],
"type": "rapid",
}
if not self.exam_mode:
exam_questions[q.id]["normal"] = q.normal
exam_questions[q.id]["feedback_images"] = feedback_images
exam_questions[q.id]["answers"] = list(
q.get_correct_unstripped_answers()
)
if self.include_history and q.history:
exam_questions[q.id]["history"] = q.history
if any(image_titles):
exam_questions[q.id]["image_titles"] = image_titles
# if feedback_images:
# exam_questions[q.id]["feedback_image"] = feedback_images
exam_json = {
"eid": "rapid/{}".format(self.id),
"cached": False,
"exam_type": "rapid",
"exam_name": self.name,
"exam_mode": self.exam_mode,
"exam_order": exam_order,
"questions": exam_questions,
}
if self.time_limit:
exam_json["exam_time"] = self.time_limit
return exam_json
def get_absolute_url(self):
return reverse("shorts:exam_overview", kwargs={"pk": self.pk})
class UserAnswer(UserAnswerBase):
"""User answers by candidate"""
app_name = "shorts"
question = models.ForeignKey(
Question, related_name="cid_user_answers", on_delete=models.CASCADE
)
answer = models.TextField(blank=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="user_shorts_user_answers",
)
# Each user answer is associated with a particular exam
exam = models.ForeignKey(
Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True
)
score = models.IntegerField(default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)])
class CallStateOptions(models.TextChoices):
CORRECT = "C", _("Correct Call")
OVERCALL = "O", _("Overcall")
UNDERCALL = "U", _("Undercall")
INCORRECTCALL = "W", _("Incorrect Call")
PARTIALCALL = "P", _("Partial Call")
callstate = models.CharField(
max_length=1, choices=CallStateOptions.choices, blank=True, null=True
)
def __str__(self):
name = self.get_candidate_name()
try:
exam = self.exam
except (Exam.DoesNotExist, KeyError) as e:
exam = "None"
return "{}/{}/{}: {}".format(
exam, name, self.question.get_primary_answer(), self.answer
)
def save(self, *args, **kwargs):
self.clean()
return super(UserAnswer, self).save(*args, **kwargs)
def clean(self):
if self.answer:
self.answer = self.answer.strip()
else:
self.answer = ""
# def get_compare_string(self):
# # strip here should be unneccasry (providing clean is now working)
# s = self.answer.lower().strip()
# s = s.translate(str.maketrans('', '', string.punctuation))
# return s
def get_answer(self):
return self.answer
def get_answer_callstate(self):
if not self.callstate:
return ""
return self.callstate.label
def get_answer_score(self, cached=True):
return self.score
+91
View File
@@ -0,0 +1,91 @@
import django_tables2 as tables
from django_tables2.utils import A
from generic.tables import FirstImageColumn
from .models import Question, UserAnswer
from django.utils.html import format_html
from easy_thumbnails.files import get_thumbnailer
from easy_thumbnails.exceptions import InvalidImageFormatError
class QuestionTable(tables.Table):
edit = tables.LinkColumn('shorts:question_update',
text='Edit',
args=[A('pk')],
orderable=False)
view = tables.LinkColumn('shorts:question_detail',
text='View',
args=[A('pk')],
orderable=False)
clone = tables.LinkColumn('shorts:question_clone',
text='Clone',
args=[A('pk')],
orderable=False)
delete = tables.LinkColumn('shorts:question_delete',
text='Delete',
args=[A('pk')],
orderable=False)
images = FirstImageColumn("images", orderable=False)
exams = tables.ManyToManyColumn(verbose_name="Exams")
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
class Meta:
model = Question
template_name = "django_tables2/bootstrap4.html"
fields = ("normal", "abnormality", "region", "examination",
"laterality",
#"site",
"created_date", "open_access", "author")
sequence = ("view", "images", "exams")
order_by = "-created_date"
def __init__(self, data=None, *args, **kwargs):
super().__init__(
data.prefetch_related(
"abnormality", "region", "examination", "images", "exams", "author"
),
*args,
**kwargs,
)
class QuestionUserAnswerTable(tables.Table):
select = tables.CheckBoxColumn(accessor=("pk"))
delete = tables.LinkColumn(
"shorts:user_answer_delete", text="Delete", args=[A("pk")], orderable=False
)
view = tables.LinkColumn('shorts:user_answer_view',
text='View',
args=[A('pk')],
orderable=False)
class Meta:
model = UserAnswer
template_name = "django_tables2/bootstrap4.html"
fields = (
"cid",
"user",
"question",
"answer",
#"answer_compare",
"exam",
"created",
"updated",
)
def __init__(self, data=None, *args, **kwargs):
super().__init__(
data.prefetch_related(
"exam", "question"
),
*args,
**kwargs,
)
+55
View File
@@ -0,0 +1,55 @@
{% extends 'base.html' %}
{% block title %}
Shorts
{% endblock %}
{% block css %}
{% endblock %}
{% block js %}
{% endblock %}
{% block content %}
{% if simple_content %}
{{simple_content}}
{% endif %}
{% endblock %}
{% block navigation %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'shorts:index' %}">Shorts</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'shorts:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'shorts:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:user_answer_table_view' %}" title="User answers">Answers</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</nav>
{% endblock %}
@@ -0,0 +1,56 @@
{% extends 'shorts/exams.html' %}
{% block navigation %}
{{ block.super }}
{% include 'generic/exam_overview_headers.html' %}
{% endblock navigation %}
{% block content %}
{% load thumbnail %}
<div class="shorts">
{% if exam.exam_mode %}
<a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a>
<a href="{% url 'shorts:mark_review' exam_pk=exam.pk sk=0 %}"><button>Review exam</button></a></p>
{% endif %}
<ol id="full-question-list" class="sortable">
{% for question in questions.all %}
<li data-question_pk={{question.pk}}>
<span class="flex-col">
<a href="{% url 'shorts:exam_question_detail' pk=exam.pk sk=forloop.counter0 %}">
{% for image in question.get_images %}
<img src="{{ image|thumbnail_url:'exam-list' }}" alt="thumbail" />
{% endfor %}
</a>
</span>
<span class="flex-col-4">
{% if not question.normal %}
<b>Abnormality:</b> {{ question.get_abnormalities }} <b>Region:</b> {{ question.get_regions }}
<br />
{{ question.get_primary_answer }}
{% else %}
<b>Normal</b>
{% endif %}
<br />
Examination: {{ question.get_examinations }},
{% if exam.exam_mode %}
<a href="{% url 'shorts:mark' exam_pk=exam.pk sk=forloop.counter0 %}">Mark</a>
{% endif %}
<span class="id"><a href="{% url 'shorts:question_detail' pk=question.pk %}">[id: {{question.pk}}]</a></span>
</span>
</li>
{% endfor %}
</ol>
<div>
Author: {% for author in exam.author.all %}
{{ author }},
{% endfor %}
</div>
{% include 'generic/exam_footer.html' %}
</div>
{% include 'exam_overview_js.html' %}
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends 'shorts/base.html' %}
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'shorts:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %}
<a href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %}
<a href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add New Question</a>
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% extends 'shorts/base.html' %}
{% block content %}
{% include "shorts/question_link_header.html" %}
{% include 'shorts/question_display_block.html' %}
{% endblock %}
+417
View File
@@ -0,0 +1,417 @@
<div>
{% comment %} <button id="edit-button">Inline Edit</button> {% endcomment %}
<div class="date">
{{ question.created_date|date:"d/m/Y" }}
</div>
<div class="id">
ID: {{ question.id }}
</div>
<p class="pre-whitespace"><b>History:</b> {{ question.history }}</p>
<p class="pre-whitespace"><b>Region:</b> {{ question.get_regions }}</p>
<p class="pre-whitespace"><b>Examination:</b> {{ question.get_examinations }}</p>
<p class="pre-whitespace"><b>Laterality:</b> {{ question.laterality }}
<button class="toggle-laterality-button toggle-button" data-lat="LEFT">Left</button>
<button class="toggle-laterality-button toggle-button" data-lat="RIGHT">Right</button>
<button class="toggle-laterality-button toggle-button" data-lat="BILAT">Bilateral</button>
<button class="toggle-laterality-button toggle-button" data-lat="NONE">None</button>
</p>
<p class="pre-whitespace"><b>Abnormality:</b> {{ question.get_abnormalities }}</p>
<div class="pre-whitespace multi-image-block"><b>Images:</b>
{% for image in question.images.all %}
<span class="image-block">
Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}:
<div class="dicom-image shorts-img {% if image.feedback_image %}feedback-img{% endif %}"
data-url="{{ remote_url }}{{ image.image.url}}"></div>
</span>
{% endfor %}
</div>
<div>
Exam(s): {% for exam in question.exams.all %}
<a href="{% url 'shorts:exam_overview' pk=exam.pk %}">{{ exam }}</a>,
{% endfor %}
<button class="btn btn-sm" hx-get="{% url 'shorts:question_add_exam' question_id=question.pk %}"
hx-target="#exam-list"
hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span>
</div>
<p class="pre-whitespace"><b>Open Access:</b> {{ question.open_access }}</p>
<p class="pre-whitespace"><b>Feedback:</b> {{ question.feedback }}</p>
<p><b>Author(s):</b> {% for author in question.author.all %} <a
href="{% url 'shorts:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
<p><b>Checked by:</b> {% for verified in question.verified.all %} <a
href="{% url 'shorts:verified_detail' pk=verified.pk %}">{{verified}}</a>, {% endfor %}</p>
<p class="pre-whitespace">
<details>
<summary>
Answers:
</summary>
<table>
<tr><th>Answer</th><th>Score</th></tr>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
<td>
<span {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="shorts"
{% endif %}>
{{ answer }}
</span>
<td>
<td>{{answer.status}}</td>
</tr>
{% endfor %}
</table>
</details>
</p>
</div>
<div>
{% if view_feedback %}
{% include 'question_notes.html' %}
{% if not question.normal %}
<details>
<summary>
Suggested answers
</summary>
<ul class="suggested_answers">
{% for ans in question.get_suggested_answers %}
<li data-string="{{ans}}">{{ans}}</li>
{% endfor %}
</ul>
</details>
{% endif %}
{% endif %}
</div>
<details open>
<summary>
Image viewer
</summary>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ question.get_image_url_array }}"
data-annotations='{{question.get_image_annotations}}'>
</div>
<button id="save-annotations" class="save-shorts-annotations">Save Annotations</button>
</details>
<details>
<summary>
Image annotations:
</summary>
{% for image in question.images.all %}
<span class="image-block">
Image {{ forloop.counter }}: {{image.image_annotations}}<br />
</span>
{% endfor %}
</details>
<a href="{% url 'shorts:question_anonymise_dicom' pk=question.pk %}"
title="Anonymise dicom images">Anonymise dicoms</a><br />
<script>
$(document).ready(function () {
$("#edit-button").click(() => {
$(".toggle-button").toggle();
});
$(".toggle-button").hide();
// send request to change the is_private state on customSwitches toggle
$("#save-annotations").click(function () {
let el = $(".cornerstone-element").get(0);
let c = cornerstone.getEnabledElement(el);
let toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager;
//let image_id = c.image.imageId;
let toolstate = toolStateManager.saveToolState();
let images = JSON.parse(document.getElementById("single-dicom-viewer").dataset.images);
let json_toolstates = [];
for (const image of images) {
if (image in toolstate) {
json_toolstates.push(JSON.stringify(toolstate[image]));
} else if ("wadouri:" + image in toolstate) {
json_toolstates.push(JSON.stringify(toolstate["wadouri:" + image]));
}
}
console.log("json_toolstates", json_toolstates)
$.ajax({
url: "{% url 'shorts:question_save_annotation' pk=question.pk %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
// Yes we do double encode the json....
annotation: JSON.stringify(json_toolstates),
},
type: "POST",
dataType: "json",
error: function (e) {
toastr.warning(`Error saving annotations`)
console.log(e);
},
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
// show some message according to the response.
// For eg. A message box showing that the status has been changed
if (data.status == "success") {
toastr.info('Annotations saved')
} else {
toastr.warning('Error saving annotations')
}
})
.always(function () {
console.log('[Done]');
})
})
{% comment %} $("#cancel-add-to-exam").click(function (evt) {
$("#add-to-exam").toggle();
$("#cancel-add-to-exam").toggle();
$("#exam-options").empty();
})
$("#add-to-exam").click(function (evt) {
$("#add-to-exam").toggle();
$("#cancel-add-to-exam").toggle();
var jqxhr = $.get(evt.target.dataset.exam_list_url, function (data) {
console.log(data);
$("#exam-options").empty();
data.forEach((obj, n) => {
$("#exam-options").append($(document.createElement('button')).prop({
type: 'button',
innerHTML: obj.name,
class: '',
}).data("exam-id", obj.id).click((evt) => {
addToExam(evt)
}))
})
})
.done(function () {
//alert( "second success" );
})
.fail(function () {
//alert( "error" );
})
.always(function () {
//alert( "finished" );
});
return;
function addToExam(evt) {
let exam_button = document.getElementById("add-to-exam");
let ids = [];
ids.push(exam_button.dataset.qid);
// if no question selected
if (ids.length < 1) {
toastr.info('No question selected.');
return
}
let post_url = exam_button.dataset.exam_json_edit_url;
let type = exam_button.dataset.type;
let csrf = exam_button.dataset.csrf;
$.ajax({
url: post_url,
data: {
csrfmiddlewaretoken: csrf,
add_exam_questions: JSON.stringify(ids),
type: type,
exam_id: $(evt.target).data("exam-id"),
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.status == "success") {
toastr.info('Questions added to exams.')
}
})
.always(function () {
console.log('[Done]');
$("#exam-options").empty();
})
}
});
//var $crf_token = $('[name="csrfmiddlewaretoken"]').attr('value');
/* beautify ignore:start */
n = !{{ question.normal | lower }}
/* beautify ignore:end */
$("#toggle-normal-button").click(function () {
$.ajax({
url: "{% url 'shorts-detail' question.id %}",
type: 'PATCH',
headers: {
"X-CSRFToken": "{{ csrf_token }}"
},
timeout: 3000,
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
normal: n,
}
})
.done(function (data) {
console.log(data);
toastr.info('Answer updated')
})
.fail(function () {
alert('Error updating this model instance.');
});
});
$(".toggle-laterality-button").each((n, el) => {
$(el).click(function () {
$.ajax({
url: "{% url 'shorts-detail' question.id %}",
type: 'PATCH',
headers: {
"X-CSRFToken": "{{ csrf_token }}"
},
timeout: 3000,
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
laterality: el.dataset.lat,
}
})
.done(function (data) {
console.log(data);
toastr.info('Answer updated')
})
.fail(function () {
alert('Error updating this model instance.');
});
});
});
{% endcomment %}
$(".suggested_answers li").each((n, el) => {
$(el).append($("<span class='correct'>[Add Correct]</span>").on("click", function () {
$.ajax({
url: "{% url 'answer_submit' %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
//active: this.checked // true if checked else false
question_type: "shorts",
qid: "{{question.pk}}",
status: 2,
answer: el.dataset.string,
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.success) {
toastr.info('Answer saved')
$(el).find(".correct").remove()
}
// show some message according to the response.
// For eg. A message box showing that the status has been changed
})
.always(function () {
console.log('[Done]');
})
}))
});
// send request to change the is_private state on customSwitches toggle
$(".proposed-answer").each((n, el) => {
// Add button to confirm answer is correct
$(el).append($("<span class='confirm'>[Add Correct]</span>").on("click", function () {
$.ajax({
url: "{% url 'answer_suggestion_confirm' %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
//active: this.checked // true if checked else false
question_type: "shorts",
aid: el.dataset.aid,
status: 2,
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.success) {
toastr.info('Answer saved')
$(el).find(".confirm").remove()
$(el).removeClass("proposed-answer")
}
// show some message according to the response.
// For eg. A message box showing that the status has been changed
})
.always(function () {
console.log('[Done]');
})
}))
// Add button to confirm answer is incorrect
$(el).append($("<span class='confirm'>[Incorrect]</span>").on("click", function () {
$.ajax({
url: "{% url 'answer_suggestion_confirm' %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
//active: this.checked // true if checked else false
question_type: "shorts",
aid: el.dataset.aid,
status: 0,
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.success) {
toastr.info('Answer saved')
$(el).find(".confirm").remove()
$(el).removeClass("proposed-answer")
}
// show some message according to the response.
// For eg. A message box showing that the status has been changed
})
.always(function () {
console.log('[Done]');
})
}))
});
});
</script>
<style>
.toggle-button {
font-size: x-small;
opacity: 30%;
}
.toggle-button:hover {
font-size: x-small;
opacity: 100%;
}
#cancel-add-to-exam {
border-style: dashed;
}
#cancel-add-to-exam:hover {
background-color: purple;
}
</style>
+343
View File
@@ -0,0 +1,343 @@
{% extends "shorts/base.html" %}
{% load static from static %}
{% load crispy_forms_tags %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript">
let csrf_token = "{{csrf_token}}";
let hash_url = "{% url 'shorts:question_by_hash' %}";
//let monitor_directory_handler = false;
//let monitor_interval_id = false;
// set to hold the files that are still being processed
let load_viewer = false;
// async function listAllFilesAndDirs(dirHandle, files_only) {
// const files = [];
// for await (let [name, handle] of dirHandle) {
// const {
// kind
// } = handle;
// if (handle.kind === 'directory') {
// if (files_only != true) {
// files.push({
// name,
// handle,
// kind
// });
// }
// files.push(...await listAllFilesAndDirs(handle));
// } else {
// files.push({
// name,
// handle,
// kind
// });
// }
// }
// return files;
// }
document.addEventListener('drop', function (e) {
e.preventDefault();
}, false);
function add_answers_input_form() {
var form_idx = $('#id_answers-TOTAL_FORMS').val();
$('#answer_form_set').append($('#answer_empty_form').html().replace(/__prefix__/g, form_idx));
$(`#id_answers-${form_idx}-status`).val(2);
$('#id_answers-TOTAL_FORMS').val(parseInt(form_idx) + 1);
}
$(document).ready(function () {
$("#add_abnormality").appendTo($("#abnormality-label"));
$("#add_examination").appendTo($("#examination-label"));
$("#add_region").appendTo($("#region-label"));
dropContainer = document.getElementById("drop-container");
dropContainer.ondragover = function (evt) {
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondragenter = function (evt) {
console.log("ENTER")
console.log(evt)
$(evt.target).addClass("drop-target-active")
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondragleave = function (evt) {
console.log("ENTER")
console.log(evt)
$(evt.target).removeClass("drop-target-active")
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondrop = function (evt) {
$(evt.target).removeClass("drop-target-active");
file_drop_number = evt.dataTransfer.files.length;
if (file_drop_number < 1) {
toastr.warning(`Drop failed (no files), try again.`);
} else {
toastr.info(`Loading ${file_drop_number} dropped image(s).`);
}
// Get all input elements
inputs = extendInputs("rapid-form", file_drop_number);
console.log("drop inputs", inputs, evt);
// Loop through each dropped file and try to assign to an
// input element
[...evt.dataTransfer.files].forEach((f) => {
feedback = false;
if (evt.target.id == "feedback-drop-target") {
feedback = true;
}
addFile(f, feedback)
})
evt.preventDefault();
evt.stopPropagation();
};
// $('#directory-picker-button').click(() => {
// window.showDirectoryPicker().then((handler) => {
// monitor_directory_handler = handler;
// monitor_interval_id = setInterval(function () {
// listAllFilesAndDirs(monitor_directory_handler).then(async function (
// items) {
// files_to_add = new Set();
// current_files = getCurrentFormFiles();
// current_files_tuple = new Set()
// for (let elem of current_files) {
// current_files_tuple.add(
// `${elem.size}, ${elem.name}, ${elem.lastModified}`
// );
// }
//
// console.log(current_files_tuple)
// for (var i = 0; i < items.length; i++) {
//
// f = await items[i].handle.getFile()
// console.log("f", f)
// if (!current_files_tuple.has(
// `${f.size}, ${f.name}, ${f.lastModified}`
// )) {
// files_to_add.add(f);
// }
// }
//
// // file objects differ enough that the following does not work
// //let distinct_set = new Set([...files_to_add].filter(
// // x => !current_files.has(x)));
//
//
// extendInputs(files_to_add.size);
// files_to_add.forEach((file) => {
// addFile(file, false);
// });
// });
// }, 5000);
// $("#directory-picker-display").text(monitor_directory_handler.name)
// .after(
// $(
// " <span>[clear]</span>").click((evt) => {
// $(evt.target).remove();
// $("#directory-picker-display").text("");
// monitor_directory_handler = false;
// clearInterval(monitor_interval_id);
// //const files = await listAllFilesAndDirs(directoryHandle);
// }));
// });
// });
$('#add_more_images').click(() => {
add_input_form()
});
$('#add_more_answers').click(() => {
add_answers_input_form()
});
$("input[type=file]").on('change', input_change);
$("#rapid-form").on("submit", (evt) => {
submit = true;
if (!$normal) {
if ($('#id_answers-TOTAL_FORMS').val() == "0") {
submit = false;
add_answers_input_form();
toastr.error(`Question that are not normal require an answer`);
evt.preventDefault();
}
}
if (!$("#id_examination_to option").length) {
submit = false;
toastr.error("Please add examination type")
evt.preventDefault();
}
if (submit) {
toastr.info(`Submiting question... please wait`, '', {
"closeButton": false,
"debug": false,
"newestOnTop": false,
"progressBar": true,
"positionClass": "toast-top-full-width",
"preventDuplicates": false,
"onclick": null,
"showDuration": "300",
"hideDuration": "1000",
"timeOut": "5000",
"extendedTimeOut": "1000",
"showEasing": "swing",
"hideEasing": "linear",
"showMethod": "fadeIn",
"hideMethod": "fadeOut"
});
}
})
});
function extractDicomDetails(byteArray) {
if (!$("#id_examination_to option").length) {
// We need to setup a try/catch block because parseDicom will throw an exception
// if you attempt to parse a non dicom part 10 file (or one that is corrupted)
try {
// parse byteArray into a DataSet object using the parseDicom library
var dataSet = dicomParser.parseDicom(byteArray);
// dataSet contains the parsed elements. Each element is available via a property
// in the dataSet.elements object. The property name is based on the elements group
// and element in the following format: xggggeeee where gggg is the group number
// and eeee is the element number with lowercase hex characters.
// To access the data for an element, we need to know its type and its tag.
// We will get the sopInstanceUid from the file which is a string and
// has the tag (0020,000D)
var study_description = dataSet.string('x00081030').toLowerCase();
console.log("STUDY", study_description);
// try to match with a study description (if not already selected)
if ($(`#id_examination_to option`).length < 1) {
option = $(`#id_examination_from option[title*='${study_description}' i]`);
if (option.length) {
option.appendTo($("#id_examination_to"));
toastr.success(`Examination set to ${option.attr('title')} from dicom data`);
} else {
toastr.warning(
`Unable to set examination ${study_description} from dicom data (it needs creating)`);
}
}
} catch (err) {
console.log(err);
}
}
}
</script>
{{ form.media }}
{% endblock %}
{% block content %}
{% if object %}
{% include "shorts/question_link_header.html" %}
{% endif %}
<h2>Submit Shorts Question</h2>
<p>
<h3>Instructions</h3>
Abnormality, Region and Laterality are used to categorise within the system (they are not used for marking). Multiple images can be added to a question.
If the feedback image box is checked they will not be displayed when taking the question.
</p>
<!-- <div>
To monitor a directory select it here.<br />
<button id="directory-picker-button">Pick a directory</button>
<span id="directory-picker-display">No directory monitored</span>
</div> -->
<form action="" method="post" enctype="multipart/form-data" id="rapid-form">
{% comment %} {% csrf_token %} {% endcomment %}
<a href="/rapids/abnormality/create" id="add_abnormality" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
<a href="/rapids/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
<a href="/rapids/region/create" id="add_region" class="add-popup" onclick="return showAddPopup(this);"><img
src="{% static '/img/icon-addlink.svg' %}"></a>
{% crispy form form.helper %}
<h3>Images:</h3>
<div id="drop-container" class="drop-target">Drop images here (or use the buttons below)<div
id="feedback-drop-target">Feedback image?<br />drop those here</div>
<div id="drop-filenames"></div>
</div>
<input type="button" value="Add More Images" id="add_more_images">
<div id="image_form_set">
{% for form in image_formset %}
<ul class="no-error image-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form.as_ul }}
</ul>
{% endfor %}
</div>
{{ image_formset.management_form }}
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<div id="empty_form" style="display:none">
<ul class='no_error image-formset'>
{{ image_formset.empty_form.as_ul }}
</ul>
</div>
<div id="answer_empty_form" style="display:none">
<ul class='no_error answer-formset'>
{{ answer_formset.empty_form.as_ul }}
</ul>
</div>
<div id="single-dicom-viewer" class="rapid-dicom-viewer" data-images="" data-annotations='' style="display:none"></div>
{% endblock %}
{% block css %}
<style>
.add-popup {
float: left;
}
</style>
{% endblock css %}
@@ -0,0 +1,25 @@
<div class="floating-header">
<a href="{% url 'shorts:question_update' pk=question.pk %}" title="Edit the Question">Edit</a>
<a href="{% url 'shorts:question_clone' pk=question.pk %}" title="Clone the Question (duplicate everything but the images)">Clone</a>
<a href="{% url 'shorts:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
<a href="{% url 'shorts:question_answer_update' pk=question.pk %}" title="Update the question answers">Edit Answers</a>
<a href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='rapid' pk=question.pk %}')"> Add Note</a>
{% if request.user.is_superuser %}
<a href="{% url 'admin:shorts_question_change' question.id %}" title="Edit the Question using the admin interface">Admin Edit</a>
<a href="{% url 'shorts:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
{% endif %}
{% if exam %}
<div>
{% if previous > -1 %}
<a href="{% url 'shorts:exam_question_detail' exam.id previous %}">Previous question</a>
{% endif %}
Viewing question as part of exam: <a href="{% url 'shorts:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
{% if next %}
<a href="{% url 'shorts:exam_question_detail' exam.id next %}">Next question</a>
{% endif %}
</div>
{% endif %}
</div>
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+112
View File
@@ -0,0 +1,112 @@
from django.urls import path, include
from . import views
from django.views.decorators.cache import cache_page, cache_control
from generic.urls import generic_exam_urls, generic_view_urls
app_name = "shorts"
urlpatterns = [
#path("", views.GenericExamViews.index, name="index"),
#path("exam", generic_exam_urls(views.GenericExamViews), name="index"),
path("question_viewer", views.question_viewer, name="question_viewer"),
path("question/", views.QuestionView.as_view(), name="question_view"),
path("question/hash", views.get_question_by_hash, name="question_by_hash"),
# path("unchecked/", views.unchecked_list, name="unchecked_list"),
# path("verified/<int:pk>/", views.verified_detail, name="verified_detail"),
path("question/<int:pk>/json", views.question_json, name="question_json"),
path(
"question/<int:pk>/json/unbased",
views.question_json_unbased,
name="question_json_unbased",
),
path(
"question/<int:pk>/save_annotation",
views.question_save_annotation,
name="question_save_annotation",
),
path("question/<int:pk>/split", views.question_split, name="question_split"),
path("question/<int:pk>/clone", views.QuestionClone.as_view(), name="question_clone"),
# path("verified/", views.verified, name="verified"),
# path("all_questions/", views.all_questions, name="all_questions"),
path(
"question/<int:pk>/delete",
views.QuestionDelete.as_view(),
name="question_delete",
),
path(
"question/<int:pk>/anon",
views.question_anonymise_dicom,
name="question_anonymise_dicom",
),
path(
"question/<int:question_id>/add_exam",
views.question_add_exam,
name="question_add_exam",
),
#path("answer/<int:answer_id>/confirm", views.confirm_answer, name="confirm_answer"),
#path("answer/<int:answer_id>/delete", views.delete_answer, name="delete_answer"),
path("exam/<int:exam_pk>/<int:sk>/mark", views.mark, name="mark"),
path("exam/<int:exam_pk>/<int:sk>/mark/all", views.mark_all, name="mark_all"),
path(
"exam/<int:exam_pk>/<int:sk>/mark/review", views.mark_review, name="mark_review"
),
path("exam/<int:pk>/markers", views.ExamMarkersUpdate.as_view(), name="exam_markers"),
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
path("exam/<int:pk>/authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"),
path("exam/create", views.ExamCreate.as_view(), name="exam_create"),
path("exam/<int:exam_id>/clone", views.ExamClone.as_view(), name="exam_clone"),
path("exam/<int:pk>/update", views.ExamUpdate.as_view(), name="exam_update"),
path("exam/<int:pk>/delete", views.ExamDelete.as_view(), name="exam_delete"),
path("create/", views.QuestionCreate.as_view(), name="question_create"),
path("create/exam/<int:pk>", views.QuestionCreate.as_view(), name="question_create_exam"),
#path("region/create/", views.create_region, name="create_region"),
#path("region/ajax/get_region_id", views.get_region_id, name="get_region_id"),
#path("abnormality/create/", views.create_abnormality, name="create_abnormality"),
#path(
# "abnormality/ajax/get_abnormality_id",
# views.get_abnormality_id,
# name="get_abnormality_id",
#),
#path("examination/create/", views.create_examination, name="create_examination"),
#path(
# "examination/ajax/get_examination_id",
# views.get_examination_id,
# name="get_examination_id",
#),
path(
"question/<int:pk>/update",
views.QuestionUpdate.as_view(),
name="question_update",
),
path(
"question/<int:pk>/update_answers",
views.QuestionAnswerUpdate.as_view(),
name="question_answer_update",
),
path(
"user_answers/",
views.UserAnswerTableView.as_view(),
name="user_answer_table_view",
),
path(
"user_answers/<int:pk>", views.UserAnswerView.as_view(), name="user_answer_view"
),
path(
"user_answers/<int:pk>/delete",
views.UserAnswerDelete.as_view(),
name="user_answer_delete",
),
#path(
# "answer-autocomplete",
# views.AnswerAutocomplete.as_view(
# model=Answer, create_field="answer"
# ),
# name="answer-autocomplete",
#),
]
urlpatterns.extend(generic_view_urls(views.GenericViews))
urlpatterns.extend(generic_exam_urls(views.GenericExamViews))
+731
View File
@@ -0,0 +1,731 @@
import json
from django.shortcuts import render, get_object_or_404, redirect
from django import forms
# from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.forms.models import model_to_dict
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.detail import DetailView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic import ListView
from django.views.decorators.csrf import csrf_exempt
from django.urls import reverse_lazy, reverse
from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.utils.safestring import mark_safe
# Create your views here.
from reversion.views import RevisionMixin
from generic.views import (
AuthorRequiredMixin,
ExamCloneMixin,
ExamCreateBase,
ExamDeleteBase,
ExamGroupsUpdateBase,
ExamUpdateBase,
ExamViews,
GenericViewBase,
UpdateQuestionMixin,
)
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from shorts.decorators import user_is_author_or_shorts_checker
from .models import (
Exam,
Question,
QuestionImage,
UserAnswer,
SampleAnswer,
)
from .forms import (
#AnswerFormSet,
#AnswerUpdateFormSet,
ExamAuthorForm,
ExamGroupsForm,
ExamMarkerForm,
QuestionForm,
QuestionAnswerForm,
MarkQuestionForm,
ExamForm,
ImageFormSet,
)
from .tables import QuestionTable, QuestionUserAnswerTable
from .filters import QuestionFilter, QuestionUserAnswerFilter
from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView
from dal import autocomplete
def normaliseScore(score):
return score
#if score == 49:
# return 4.5
#elif score in [50, 51]:
# return 5
#elif score in [52, 53]:
# return 5.5
#elif score in [54]:
# return 6
#elif score in [55, 56]:
# return 6.5
#elif score in [57, 58]:
# return 7
#elif score in [59]:
# return 7.5
#elif score in [60]:
# return 8
#return 4
class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
if self.request.user.groups.filter(name="shorts_checker").exists():
return obj
if self.request.user not in obj.author.all():
raise PermissionDenied() # or Http404
return obj
@login_required
def question_split(request, pk):
question = get_object_or_404(Question, pk=pk)
if (
not request.user.groups.filter(name="shorts_checker").exists()
and request.user not in question.author.all()
):
raise PermissionDenied()
images = question.images.all()
old_abnormality = question.abnormality.all()
old_region = question.region.all()
old_examination = question.examination.all()
# old_site = rapid.site.all()
old_author = question.author.all()
if not images:
raise Http404
for n in range(len(images) - 1):
question.pk = None
question.save()
question.abnormality.set(old_abnormality)
question.region.set(old_region)
question.examination.set(old_examination)
# question.site.set(old_site)
question.author.set(old_author)
images[n].question = question
images[n].save()
# images[-1].rapid
# if request.user not in rapid.author.all():
# raise PermissionDenied
# logging.debug(rapid.subspecialty.first().name.all())
return render(request, "shorts/question_detail.html", {"question": question})
class QuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
model = Question
form_class = QuestionForm
# Sending user object to the form, to verify which fields to display/remove (depending on group)
def get_form_kwargs(self):
kwargs = super(QuestionCreateBase, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs):
context = super(QuestionCreateBase, self).get_context_data(**kwargs)
if self.request.POST:
context["image_formset"] = ImageFormSet(
self.request.POST, self.request.FILES
)
context["image_formset"].full_clean()
else:
context["image_formset"] = ImageFormSet()
return context
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
context = self.get_context_data(form=form)
image_formset = context["image_formset"]
if image_formset.is_valid():
response = super().form_valid(form)
image_formset.instance = self.object
image_formset.save()
# If the normal submit button is pressed we save as normal
if "submit" in self.request.POST:
return response
# else we redirect to the clone url
else:
return redirect("shorts:rapid_clone", pk=self.object.pk)
else:
return super().form_invalid(form)
# @login_required
class QuestionCreate(QuestionCreateBase):
# initial = {"laterality": Question.NONE}
def get_initial(self):
if "pk" in self.kwargs:
initial = super().get_initial()
exam = get_object_or_404(Exam, pk=self.kwargs["pk"])
initial["exams"] = [exam.id]
return initial
## There has to be a better way...
# try:
# s = (i.pk for i in self.request.user.rapid_default.site.all())
# self.initial.update({"site": s})
# except AttributeError:
# pass
# return self.initial
# fields = '__all__'
# #fields = [ 'condition' ]
# #initial = {'date_of_death': '05/01/2018'}
# exclude = [ 'created_date', 'published_date' ]
# self.object = form.save(commit=False)
# self.object.save()
# form.instance.author.add(self.request.user.id)
# return super().form_valid(form)
class QuestionAnswerUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView):
model = Question
form_class = QuestionAnswerForm
template_name = "shorts/shortsquestionanswer_form.html"
def get_context_data(self, **kwargs):
context = super(QuestionAnswerUpdate, self).get_context_data(**kwargs)
context["question"] = context["object"]
return context
def form_valid(self, form):
return super().form_valid(form)
class QuestionUpdate(
UpdateQuestionMixin
):
model = Question
form_class = QuestionForm
# fields = '__all__'
# #fields = [ 'condition' ]
# #initial = {'date_of_death': '05/01/2018'}
# exclude = [ 'created_date', 'published_date' ]
def get_context_data(self, **kwargs):
context = super(QuestionUpdate, self).get_context_data(**kwargs)
if self.request.POST:
context["image_formset"] = ImageFormSet(
self.request.POST, self.request.FILES, instance=self.object
)
context["image_formset"].full_clean()
else:
context["image_formset"] = ImageFormSet(instance=self.object)
context["question"] = context["object"]
return context
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
context = self.get_context_data(form=form)
image_formset = context["image_formset"]
# logger.debug(formset.is_valid())
if image_formset.is_valid():
response = super().form_valid(form)
image_formset.instance = self.object
image_formset.save()
# restore exam orders
# wanted_exams = form["exams"].data
# for exam in self.object.exams.all():
# if exam in exam_orders and self.object in exam_orders[exam]:
# print(exam_orders[exam])
# exam.exam_questions.set(exam_orders[exam])
# exam.save()
return response
else:
return super().form_invalid(form)
class QuestionClone(QuestionCreateBase):
"""Clones a existing shorts question"""
def get_initial(self):
# print(self.request)
old_object = get_object_or_404(Question, pk=self.kwargs["pk"])
initial_data = model_to_dict(old_object, exclude=["id"])
return initial_data
class QuestionView(LoginRequiredMixin, SingleTableMixin, FilterView):
model = Question
table_class = QuestionTable
template_name = "question_table_view.html"
filterset_class = QuestionFilter
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["app_name"] = "shorts"
return context
@login_required
def mark_all(request, exam_pk, sk):
return mark(request, exam_pk, sk, unmarked_exam_answers_only=False)
@login_required
def mark_review(request, exam_pk, sk):
return mark(request, exam_pk, sk, unmarked_exam_answers_only=False, review=True)
# @user_passes_test(user_is_admin, login_url="/accounts/login")
@login_required
def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
return HttpResponse("Not implemented")
exam = get_object_or_404(Exam, pk=exam_pk)
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
mark_url = "shorts:mark"
if review:
mark_url = "shorts:mark_review"
elif not unmarked_exam_answers_only:
mark_url = "shorts:mark_all"
questions = exam.get_questions()
n = sk
question_details = {
"total": len(questions),
"current": n + 1,
}
try:
question = questions[sk]
except IndexError:
raise Http404("Exam question does not exist")
if request.method == "POST":
form = MarkQuestionForm(request.POST)
# *******************************
# TODO: convert to JSON request
# *******************************
if form.is_valid():
# If skip button is pressed skip the question without marking
if "skip" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
cd = form.cleaned_data
marked_answers = json.loads(cd.get("marked_answers"))
# This should probably use json (or something else...)
# ajax.....
to_run = (
("correct", Answer.MarkOptions.CORRECT),
("half-correct", Answer.MarkOptions.HALF_MARK),
("incorrect", Answer.MarkOptions.INCORRECT),
)
for status_string, status in to_run:
for ans in marked_answers[status_string]:
ans = ans.strip()
if ans == "":
continue
a = Answer.objects.filter(
answer__iexact=ans, question_id=question.pk
).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = ans
a.status = status
a.save()
if "next" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
elif "previous" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n - 1)
# Reset user answers (relies on the functional javascript)
unmarked_user_answers = set()
else:
form = MarkQuestionForm()
# answers_dict = {}
# for ans in question.answers.all():
# answers_dict[ans.answer_compare] = ans
correct_answers = []
half_mark_answers = []
incorrect_answers = []
review_user_answers = []
unmarked_answers_bool = False
unmarked_user_answers = []
# this could be improved
if question.normal:
incorrect_answers = question.get_user_answers(exam_pk=exam_pk)
if "" in incorrect_answers:
incorrect_answers.remove("")
if "normal" in incorrect_answers:
incorrect_answers.remove("normal")
else:
if review:
unmarked_user_answers = question.get_user_answers(
exam_pk=exam_pk, include_normal=False
)
elif unmarked_exam_answers_only:
unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk)
else:
unmarked_user_answers = question.get_unmarked_user_answers()
if not unmarked_exam_answers_only:
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
half_mark_answers = question.answers.filter(
status=Answer.MarkOptions.HALF_MARK
)
incorrect_answers = question.answers.filter(
status=Answer.MarkOptions.INCORRECT
)
if review:
for ans in unmarked_user_answers:
# duplicated from user answer model....
marked_ans = question.answers.filter(answer_compare__iexact=ans).first()
mark = "unmarked"
if marked_ans is not None:
if marked_ans.status == Answer.MarkOptions.CORRECT:
mark = "correct"
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
mark = "half-correct"
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
mark = "incorrect"
if mark == "unmarked":
unmarked_answers_bool = True
review_user_answers.append((ans, mark))
return render(
request,
"shorts/mark.html",
{
"exam": exam,
"form": form,
"review": review,
"question": question,
"question_number": n,
"question_details": question_details,
"unmarked_answers_bool": unmarked_answers_bool,
"user_answers": unmarked_user_answers,
"review_user_answers": review_user_answers,
"correct_answers": correct_answers,
"half_mark_answers": half_mark_answers,
"incorrect_answers": incorrect_answers,
"unmarked_exam_answers_only": unmarked_exam_answers_only,
},
)
class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView):
model = Question
success_url = reverse_lazy("shorts:question_view")
GenericExamViews = ExamViews(
Exam, Question, None, UserAnswer, "shorts", "shorts", normalise_score=normaliseScore
)
class ExamCreate(ExamCreateBase):
model = Exam
form_class = ExamForm
class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
model = Exam
form_class = ExamForm
class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
model = Exam
class ExamAuthorUpdate(
RevisionMixin,
CheckCanEditMixin,
LoginRequiredMixin,
AuthorRequiredMixin,
UpdateView,
):
model = Exam
form_class = ExamAuthorForm
template_name = "author_form.html"
class ExamMarkersUpdate(
RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
):
model = Exam
form_class = ExamMarkerForm
template_name = "markers_form.html"
class ExamGroupsUpdate(ExamGroupsUpdateBase):
model = Exam
form_class = ExamGroupsForm
class UserAnswerView(LoginRequiredMixin, DetailView):
model = UserAnswer
class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView):
model = UserAnswer
table_class = QuestionUserAnswerTable
template_name = "shorts/user_answer_question_view.html"
filterset_class = QuestionUserAnswerFilter
class UserAnswerDelete(SuperuserRequiredMixin, DeleteView):
model = UserAnswer
template_name = "user_answer_delete.html"
success_url = reverse_lazy("shorts:user_answer_table_view")
GenericViews = GenericViewBase("shorts", Question, UserAnswer, Exam)
@user_is_author_or_shorts_checker
def question_anonymise_dicom(request, pk):
question = get_object_or_404(Question, pk=pk)
question.anonymise_images()
return redirect("shorts:question_detail", pk=pk)
@user_is_author_or_shorts_checker
def question_save_annotation(request, pk):
if request.method == "POST":
question = get_object_or_404(Question, pk=pk)
image_annotations = json.loads(request.POST.get("annotation"))
question_images = question.images.filter(feedback_image=False)
for n, image in enumerate(question_images):
annotation = image_annotations[n]
if annotation != "undefined":
image.image_annotations = annotation
else:
image.image_annotations = ""
image.save()
data = {"status": "success"}
return JsonResponse(data, status=200)
else:
data = {"status": "error"}
return JsonResponse(data, status=400)
class ExamClone(ExamCloneMixin, ExamCreate):
"""Clone exam view"""
@login_required
def get_question_by_hash(request):
if request.method == "POST":
hash = request.POST.get("hash")
try:
question = QuestionImage.objects.get(image_md5_hash=hash)
data = {
"status": "success",
"id": question.pk,
"url": question.question.get_absolute_url(),
}
return JsonResponse(data, status=200)
except QuestionImage.DoesNotExist:
data = {"status": "success", "id": False}
return JsonResponse(data, status=200)
# TODO: better permissions
@login_required
def question_json_unbased(request, pk):
"""
No (file based) caching is enabled for unbased quesitons
"""
question = get_object_or_404(Question, pk=pk)
question_json = question.get_question_json(based=False)
return JsonResponse(question_json)
@login_required
def question_json(request, pk):
question = get_object_or_404(Question, pk=pk)
question_json = question.get_question_json(based=True)
return JsonResponse(question_json)
@login_required
def question_viewer(request):
# question = get_object_or_404(Question, pk=pk)
ids = request.GET.get("ids").split(",")
return render(
request,
"shorts/question_viewer.html",
{
"ids": ids,
},
)
#class AnswerAutocomplete(autocomplete.Select2QuerySetView):
# def get_queryset(self):
# # Don't forget to filter out results depending on the visitor !
# if not self.request.user.is_authenticated:
# return Answer.objects.none()
#
# qs = Answer.objects.all()
#
# if self.q:
# qs = qs.filter(answer__istartswith=self.q)
#
# return qs
#def confirm_answer(request, answer_id: int):
# if request.htmx:
# answer = get_object_or_404(Answer, pk=answer_id)
#
# # Check if the user has permission to confirm the answer
# if not answer.can_edit(request.user):
# return HttpResponse("Invalid permissions", status=403)
#
# answer.clean()
# answer.proposed = False
# answer.save()
# return HttpResponse("Answer accepted")
#
# raise PermissionDenied()
#
#def delete_answer(request, answer_id: int):
# if request.htmx:
# answer = get_object_or_404(Answer, pk=answer_id)
#
# # Check if the user has permission to confirm the answer
# if not answer.can_edit(request.user):
# return HttpResponse("Invalid permissions", status=403)
#
# answer.delete()
# return HttpResponse("Answer deleted")
# raise PermissionDenied()
def question_add_exam(request, question_id: int):
if request.htmx:
question = get_object_or_404(Question, pk=question_id)
if not question.can_edit(request.user):
return HttpResponse("Invalid permissions", status=403)
if request.POST:
exam_id = request.POST.get("exam_id", None)
if exam_id is None:
return HttpResponse("No exam id provided", status=400)
exam = get_object_or_404(Exam, pk=exam_id)
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
return HttpResponse("Question removed from exam.")
else:
question.exams.add(exam)
return HttpResponse("Question added to exam.")
else:
# Return a list of exams that we can add to the question
exams = Exam.objects.filter(author=request.user)
exams = exams.difference(question.exams.all())
if not exams:
return HttpResponse("No exams available to add")
html = "Exams to add to question: <br>"
for exam in exams:
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><button hx-post=\"{request.path}\""
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<br>Exams to remove from question: <br>"
for exam in question.exams.all():
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><input name='remove' value='true' type='hidden'><button hx-post=\"{request.path}\""
f"hx-confirm='Are you sure you want to remove this exam from the question?'"
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<button _='on click remove #htmx-exam-list'>Cancel</button>"
return HttpResponse(mark_safe(html))
raise PermissionDenied()