diff --git a/atlas/models.py b/atlas/models.py
index 00d704d5..78063dac 100644
--- a/atlas/models.py
+++ b/atlas/models.py
@@ -52,6 +52,7 @@ from generic.models import (
Examination,
# Condition,
ExamBase,
+ FindingBase,
Plane,
Contrast,
QuestionNote,
@@ -600,29 +601,18 @@ class SeriesImage(SeriesImageBase):
# }}
-class SeriesFinding(models.Model):
+class SeriesFinding(FindingBase):
series = models.ForeignKey(
"Series", related_name="findings", on_delete=models.SET_NULL, null=True
)
- description = models.TextField(
- null=True, blank=True, help_text="Findings on the series / stack"
- )
findings = models.ManyToManyField(Finding, blank=True)
structures = models.ManyToManyField(Structure, blank=True)
conditions = models.ManyToManyField(Condition, blank=True)
- annotation_json = models.TextField(null=True, blank=True)
- viewport_json = models.TextField(null=True, blank=True)
- # This has been migrated to install the image id (not index)
- current_image_id_index = models.TextField(null=True, blank=True)
def __str__(self) -> str:
findings = self.findings.all().values_list("name")
return f"SeriesFinding: {self.series.id}/{findings}/{self.description}"
- def annotation_as_string(self) -> str:
- return json.dumps(self.annotation_json)
-
-
@reversion.register
class Series(SeriesBase):
modality = models.ForeignKey(
diff --git a/generic/models.py b/generic/models.py
index 41a09206..c4ab7401 100644
--- a/generic/models.py
+++ b/generic/models.py
@@ -1963,4 +1963,32 @@ class CimarSeriesThumbnail(models.Model):
image = models.ImageField(upload_to="cimar_series_thumbnails")
def __str__(self):
- return f"{self.series_id}"
\ No newline at end of file
+ return f"{self.series_id}"
+
+
+class FindingBase(models.Model):
+ description = models.TextField(
+ null=True, blank=True, help_text="Findings on the question"
+ )
+ annotation_json = models.TextField(null=True, blank=True)
+ viewport_json = models.TextField(null=True, blank=True)
+ # This has been migrated to install the image id (not index)
+ current_image_id_index = models.TextField(null=True, blank=True)
+
+ # TODO add tags
+ # tags = models.ManyToManyField(Tag, blank=True)
+
+ class Meta:
+ abstract = True
+
+ def __str__(self):
+ return self.description
+
+ def annotation_as_string(self) -> str:
+ return json.dumps(self.annotation_json)
+
+ def get_pretty_annotation_json(self):
+ return get_pretty_json(json.loads(self.annotation_json))
+
+ def get_pretty_viewport_json(self):
+ return get_pretty_json(json.loads(self.viewport_json))
\ No newline at end of file
diff --git a/rad/api.py b/rad/api.py
index 588f5486..d31ca66f 100644
--- a/rad/api.py
+++ b/rad/api.py
@@ -6,6 +6,7 @@ from anatomy.api import router as anatomy_router
from sbas.api import router as sbas_router
from physics.api import router as physics_router
from atlas.api import router as atlas_router
+from shorts.api import router as shorts_router
api = NinjaAPI(version="1")
@@ -17,6 +18,7 @@ api.add_router("longs/", longs_router)
api.add_router("sbas/", sbas_router)
api.add_router("physics/", physics_router)
api.add_router("atlas/", atlas_router)
+api.add_router("shorts/", shorts_router)
@api.get("/hello")
def hello(request):
diff --git a/rapids/templates/rapids/question_display_block.html b/rapids/templates/rapids/question_display_block.html
index d7f35d98..b9d5b272 100755
--- a/rapids/templates/rapids/question_display_block.html
+++ b/rapids/templates/rapids/question_display_block.html
@@ -66,8 +66,8 @@
{% endif %}>
{{ answer }}
-
- | {{answer.status}} |
+
+ {{answer.status}} |
{% endfor %}
diff --git a/shorts/api.py b/shorts/api.py
new file mode 100644
index 00000000..49736127
--- /dev/null
+++ b/shorts/api.py
@@ -0,0 +1,54 @@
+from typing import List
+from django.shortcuts import get_object_or_404
+from ninja import ModelSchema, Router
+from .models import Question, Exam
+
+from .decorators import user_is_author_or_shorts_checker
+from django.core.exceptions import PermissionDenied
+
+from generic.decorators import check_user_in_group
+from generic.constants import Group
+
+from ninja.security import django_auth
+
+router = Router()
+
+
+class QuestionSchema(ModelSchema):
+ class Config:
+ model = Question
+
+ #model_fields = ["question", "history", "feedback", "normal", "laterality"]
+ model_fields = "__all__"
+
+ #model_exclude = ["answers"]
+
+class ExamSchema(ModelSchema):
+ class Config:
+ model = Exam
+
+ model_fields = ["id", "name", "active", "publish_results"]
+
+@router.get('/')
+def list_questions(request):
+ return [
+ {"id": e.id, "normal": e.normal}
+ for e in Question.objects.all()
+ ]
+
+@router.get('/question/{question_id}', response=QuestionSchema)
+@check_user_in_group(Group.cid_user_manager)
+def get_rapid_details(request, question_id: int):
+
+ rapid = get_object_or_404(Question, id=question_id)
+ return rapid
+
+
+@router.get('/user_exams', response=List[ExamSchema], url_name="shorts_user_exams")
+def user_exams(request):
+ """Returns a list of exams that the user has access to"""
+ user = request.user
+ if user.groups.filter(name="shorts_checker").exists():
+ return Exam.objects.filter(archive=False).order_by('name')
+
+ return Exam.objects.filter(author__id=user.id, archive=False).order_by('name')
\ No newline at end of file
diff --git a/shorts/forms.py b/shorts/forms.py
index bc76ace3..bb6d3836 100644
--- a/shorts/forms.py
+++ b/shorts/forms.py
@@ -13,6 +13,7 @@ from shorts.models import (
Abnormality,
Examination,
Question,
+ QuestionFinding,
QuestionImage,
Region,
SampleAnswer,
@@ -29,10 +30,15 @@ from crispy_forms.layout import Submit, Layout, Div, Field, HTML
from crispy_forms.bootstrap import InlineRadios
-class QuestionAnswerForm(ModelForm):
+from dal import autocomplete
+
+from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
+
+
+class QuestionSampleAnswerForm(ModelForm):
class Meta:
- model = UserAnswer
- fields = ("answer",)
+ model = Question
+ fields = []
class MarkQuestionForm(Form):
@@ -42,10 +48,10 @@ class MarkQuestionForm(Form):
marked_answers = CharField(required=False)
-class QuestionAnswerForm(ModelForm):
- class Meta:
- model = Question
- fields = []
+#class QuestionAnswerForm(ModelForm):
+# class Meta:
+# model = Question
+# fields = []
class QuestionForm(ModelForm):
@@ -93,6 +99,7 @@ class QuestionForm(ModelForm):
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('marking_guidance', css_class='form-control'),
Field('feedback', css_class='form-control'),
Field('open_access', css_class='form-control'),
HTML("Exams
"),
@@ -188,6 +195,7 @@ class QuestionForm(ModelForm):
# "site",
"feedback",
"history",
+ "marking_guidance",
"open_access",
]
# fields = ['question', 'feedback', 'subspecialty', 'references']
@@ -217,20 +225,20 @@ ImageFormSet = inlineformset_factory(
# 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,
-#)
+SampleAnswerUpdateFormSet = inlineformset_factory(
+ Question,
+ SampleAnswer,
+ fields=["answer", "score", "proposed"],
+ 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?
@@ -248,4 +256,31 @@ class ExamMarkerForm(ExamMarkerFormMixin):
class ExamGroupsForm(ExamGroupsFormMixin):
class Meta(ExamGroupsFormMixin.Meta):
- model = Exam
\ No newline at end of file
+ model = Exam
+
+class QuestionFindingForm(ModelForm):
+ class Meta:
+ model = QuestionFinding
+ exclude = ["question", "annotation_json", "viewport_json", "current_image_id_index"]
+
+ widgets = {
+ "findings": autocomplete.ModelSelect2Multiple(
+ url="atlas:finding-autocomplete"
+ ),
+ "structures": autocomplete.ModelSelect2Multiple(
+ url="atlas:structure-autocomplete"
+ ),
+ "conditions": autocomplete.ModelSelect2Multiple(
+ url="atlas:condition-autocomplete"
+ ),
+
+ }
+
+ def __init__(self, *args, **kwargs):
+ if kwargs.get("question_id"):
+ initial = kwargs.setdefault("initial", {})
+ initial["question"] = kwargs.pop("question_id")
+
+ super(QuestionFindingForm, self).__init__(*args, **kwargs)
+
+ ModelForm.__init__(self, *args, **kwargs)
\ No newline at end of file
diff --git a/shorts/migrations/0002_question_marking_guidance_alter_question_history.py b/shorts/migrations/0002_question_marking_guidance_alter_question_history.py
new file mode 100644
index 00000000..e72933e4
--- /dev/null
+++ b/shorts/migrations/0002_question_marking_guidance_alter_question_history.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.1.4 on 2025-04-07 08:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('shorts', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='question',
+ name='marking_guidance',
+ field=models.TextField(blank=True, help_text='Marking guidance for the question. This is not shown to the candidate but is used by the marker to help them mark the question.', null=True),
+ ),
+ migrations.AlterField(
+ model_name='question',
+ name='history',
+ field=models.TextField(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.', null=True),
+ ),
+ ]
diff --git a/shorts/migrations/0003_questionfinding.py b/shorts/migrations/0003_questionfinding.py
new file mode 100644
index 00000000..cdb9cb3d
--- /dev/null
+++ b/shorts/migrations/0003_questionfinding.py
@@ -0,0 +1,29 @@
+# Generated by Django 5.1.4 on 2025-04-07 09:50
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('atlas', '0067_case_cimar_uuid'),
+ ('shorts', '0002_question_marking_guidance_alter_question_history'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='QuestionFinding',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('description', models.TextField(blank=True, help_text='Findings on the question', null=True)),
+ ('annotation_json', models.TextField(blank=True, null=True)),
+ ('viewport_json', models.TextField(blank=True, null=True)),
+ ('current_image_id_index', models.TextField(blank=True, null=True)),
+ ('conditions', models.ManyToManyField(blank=True, to='atlas.condition')),
+ ('findings', models.ManyToManyField(blank=True, to='atlas.finding')),
+ ('question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='findings', to='shorts.question')),
+ ('structures', models.ManyToManyField(blank=True, to='atlas.structure')),
+ ],
+ ),
+ ]
diff --git a/shorts/migrations/0004_remove_questionimage_image_annotations.py b/shorts/migrations/0004_remove_questionimage_image_annotations.py
new file mode 100644
index 00000000..24f67525
--- /dev/null
+++ b/shorts/migrations/0004_remove_questionimage_image_annotations.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1.4 on 2025-04-07 10:10
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('shorts', '0003_questionfinding'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='questionimage',
+ name='image_annotations',
+ ),
+ ]
diff --git a/shorts/models.py b/shorts/models.py
index ac229044..768aa324 100644
--- a/shorts/models.py
+++ b/shorts/models.py
@@ -1,14 +1,28 @@
from collections import defaultdict
+import os
from django.db import models
from django.urls import reverse
from django.conf import settings
+import pydicom
+from atlas.models import Finding, Structure, Condition
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 generic.models import (
+ CidUser,
+ CidUserGroup,
+ ExamBase,
+ ExamCollection,
+ ExamUserStatus,
+ FindingBase,
+ QuestionBase,
+ UserAnswerBase,
+ UserUserGroup,
+ get_pretty_json,
+)
from django.contrib.contenttypes.fields import GenericRelation
from django.core.validators import MaxValueValidator, MinValueValidator
from django.utils.html import mark_safe
@@ -17,16 +31,20 @@ 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.")
+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"
@@ -60,6 +78,12 @@ class Question(QuestionBase):
help_text="Applies to the answer, not the examination",
)
+ marking_guidance = models.TextField(
+ null=True,
+ blank=True,
+ help_text="Marking guidance for the question. This is not shown to the candidate but is used by the marker to help them mark the question.",
+ )
+
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
@@ -70,6 +94,9 @@ class Question(QuestionBase):
def get_app_name(self):
return "shorts"
+ def get_findings_url(self):
+ return reverse("shorts:question_findings", kwargs={"question_id": self.pk})
+
def get_absolute_url(self):
return reverse("shorts:question_detail", kwargs={"pk": self.pk})
@@ -92,10 +119,10 @@ class Question(QuestionBase):
# 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_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:
@@ -122,15 +149,31 @@ class Question(QuestionBase):
except pydicom.errors.InvalidDicomError:
pass
+ def check_user_can_edit(self, user):
+ if user.is_superuser:
+ return True
+
+ if self.author.filter(id=user.id).exists():
+ return True
+
+ if user.groups.filter(name="shorts_checker").exists():
+ return True
+
+ return False
+
+
class QuestionImage(models.Model):
- question = models.ForeignKey(Question, related_name="images", on_delete=models.CASCADE)
+ 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",
- )
+ # Image annotations are stored as findings in shorts cases
+ #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)
@@ -162,7 +205,9 @@ class QuestionImage(models.Model):
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)
+ 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
@@ -170,19 +215,29 @@ class QuestionImage(models.Model):
if image_hash != "12345ABCD":
super().save(*args, **kwargs) # Call the "real" save() method.
+
class SampleAnswer(models.Model):
+ """Model that defines sample answers for the question.
+
+ These are used to aid marking and for feedback purposes
+
+ """
+
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)])
+ 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)
@@ -192,10 +247,13 @@ class ExamQuestionDetail(models.Model):
class Meta:
ordering = ("sort_order",)
+
class Exam(ExamBase):
app_name = "shorts"
- exam_questions = models.ManyToManyField(Question, through=ExamQuestionDetail, related_name="exams")
+ 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",
@@ -238,11 +296,20 @@ class Exam(ExamBase):
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")
+ 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"
@@ -325,6 +392,7 @@ class Exam(ExamBase):
def get_absolute_url(self):
return reverse("shorts:exam_overview", kwargs={"pk": self.pk})
+
class UserAnswer(UserAnswerBase):
"""User answers by candidate"""
@@ -349,7 +417,9 @@ class UserAnswer(UserAnswerBase):
Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True
)
- score = models.IntegerField(default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)])
+ score = models.IntegerField(
+ default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)]
+ )
class CallStateOptions(models.TextChoices):
CORRECT = "C", _("Correct Call")
@@ -398,4 +468,18 @@ class UserAnswer(UserAnswerBase):
return self.callstate.label
def get_answer_score(self, cached=True):
- return self.score
\ No newline at end of file
+ return self.score
+
+
+class QuestionFinding(FindingBase):
+ question = models.ForeignKey(
+ Question, related_name="findings", on_delete=models.SET_NULL, null=True
+ )
+ findings = models.ManyToManyField(Finding, blank=True)
+ structures = models.ManyToManyField(Structure, blank=True)
+ conditions = models.ManyToManyField(Condition, blank=True)
+
+
+ def __str__(self) -> str:
+ findings = self.findings.all().values_list("name")
+ return f"Findings: {self.question.id}/{findings}/{self.description}"
diff --git a/shorts/templates/shorts/question_display_block.html b/shorts/templates/shorts/question_display_block.html
index 3fc87db2..fc3eb33a 100755
--- a/shorts/templates/shorts/question_display_block.html
+++ b/shorts/templates/shorts/question_display_block.html
@@ -26,6 +26,7 @@
{% endfor %}
+ Click here to view/add findings
Exam(s): {% for exam in question.exams.all %}
{{ exam }},
@@ -40,9 +41,28 @@
Feedback: {{ question.feedback }}
Author(s): {% for author in question.author.all %} {{author}}, {% endfor %}
-
Checked by: {% for verified in question.verified.all %} {{verified}}, {% endfor %}
+
+
+ Sample Answers:
+
+
+ | Answer | Score |
+ {% for answer in question.sample_answers.all|dictsortreversed:"score" %}
+
+ |
+
+ {{ answer }}
+
+ |
+ {{answer.score}} |
+
+ {% endfor %}
+
+
+
+{% comment %}
Answers:
@@ -62,49 +82,15 @@
{% endfor %}
-
+ {% endcomment %}
{% 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
+
+{% endblock js %}
\ No newline at end of file
diff --git a/shorts/templates/shorts/question_link_header.html b/shorts/templates/shorts/question_link_header.html
index b3081d93..9212d353 100644
--- a/shorts/templates/shorts/question_link_header.html
+++ b/shorts/templates/shorts/question_link_header.html
@@ -1,10 +1,11 @@