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