furher shorts improvements

This commit is contained in:
Ross
2025-04-07 11:34:10 +01:00
parent afa1379ce4
commit d387e32777
16 changed files with 795 additions and 111 deletions
+2 -12
View File
@@ -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(
+29 -1
View File
@@ -1963,4 +1963,32 @@ class CimarSeriesThumbnail(models.Model):
image = models.ImageField(upload_to="cimar_series_thumbnails")
def __str__(self):
return f"{self.series_id}"
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))
+2
View File
@@ -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):
@@ -66,8 +66,8 @@
{% endif %}>
{{ answer }}
</span>
<td>
<td>{{answer.status}}</td>
</td>
<td>{{answer.status}}</td>
</tr>
{% endfor %}
</table>
+54
View File
@@ -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')
+57 -22
View File
@@ -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("<h4 class='clear-both' id='exams-label'>Exams</h4>"),
@@ -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
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)
@@ -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),
),
]
+29
View File
@@ -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')),
],
),
]
@@ -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',
),
]
+109 -25
View File
@@ -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
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}"
@@ -26,6 +26,7 @@
</span>
{% endfor %}
</div>
<a href='{% url "shorts:question_findings" question_id=question.pk %}'>Click here to view/add findings</a>
<div>
Exam(s): {% for exam in question.exams.all %}
<a href="{% url 'shorts:exam_overview' pk=exam.pk %}">{{ exam }}</a>,
@@ -40,9 +41,28 @@
<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>
Sample Answers:
</summary>
<table>
<tr><th>Answer</th><th>Score</th></tr>
{% for answer in question.sample_answers.all|dictsortreversed:"score" %}
<tr>
<td>
<span {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="shorts"
{% endif %}>
{{ answer }}
</span>
</td>
<td>{{answer.score}}</td>
</tr>
{% endfor %}
</table>
</details>
</p>
{% comment %} <p class="pre-whitespace">
<details>
<summary>
Answers:
@@ -62,49 +82,15 @@
{% endfor %}
</table>
</details>
</p>
</p> {% endcomment %}
</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(() => {
+210
View File
@@ -0,0 +1,210 @@
{% extends 'shorts/base.html' %}
{% load crispy_forms_tags %}
{% block content %}
{% include "shorts/question_link_header.html" %}
<p>The viewer shown below can be used to annotate the images. The viewport can be zoomed and paned to highlight the abnormality which can then be indicated with arrows or bounding boxes.</p>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ question.get_image_url_array }}">
</div>
{% if can_edit %}
{% if editing_finding < 1 %}
<button id="add-finding-button">Add finding</button>
{% endif %}
<button id="reset-viewport-button">Reset viewport</button>
<div id="finding-form">
<div class="hide" id="hidden-form">
<form method="post" id="question_finding_form">
{% csrf_token %}
{{question_finding_form|crispy}}
<input type="submit" value="Submit">
<button id="cancel-add-finding-button">Cancel</button>
</form>
</div>
</div>
{% else %}
<button id="reset-viewport-button">Reset viewport</button>
{% endif %}
<details open>
<summary>Findings</summary>
{% for finding in question.findings.all %}
<div class="finding-box">
<button class="view-finding-button" data-annotationjson={{finding.annotation_json}}
data-viewportjson={{finding.viewport_json}} data-findingid={{finding.id}} data-currentimageid={{finding.current_image_id_index}}>Click to view</button>
<span class="view-finding-details">
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
Description: {{finding.description}}<br />
{% if request.user.is_superuser %}
<span _='on click toggle .hidden on next .extra-details'>+</span>
<div class="hidden extra-details">
<h4>Annotation JSON</h4>
<pre>{{finding.get_pretty_annotation_json}}</pre>
<h4>Viewport JSON</h4>
<pre>{{finding.get_pretty_viewport_json}}</pre>
<h4>Image ID</h4>
<pre>{{finding.current_image_id_index}}</pre>
</div>
{% endif %}
</span>
{% if can_edit %}
<a href="{% url 'shorts:question_edit_finding' question_id=question.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a>
<a href="{% url 'shorts:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
{% endif %}
</div>
{% endfor %}
</details>
{% endblock %}
{% block js %}
<script>
$(document).ready(function () {
$("#add-finding-button").click(() => {
console.log("add finding button clicked")
$("#hidden-form").show()
dicom_element = $(".cornerstone-element").get(0);
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
//cornerstone.reset(dicom_element);
////cornerstone.resize(dicom_element, true);
$("#add-finding-button").hide()
//$("#finding-form").empty().append(
// $("#hidden-form form").clone()
//);
});
$("#cancel-add-finding-button").click((e) => {
$("#hidden-form").hide()
$("#add-finding-button").show()
e.preventDefault();
});
$("#reset-viewport-button").click(() => {
dicom_element = $(".cornerstone-element").get(0);
//annotationjson = JSON.parse(e.currentTarget.dataset.annotationjson);
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
cornerstone.reset(dicom_element);
cornerstone.resize(dicom_element, true);
});
$(".view-finding-button").each((n, el) => {
$(el).click((e) => {
dicom_element = $(".cornerstone-element").get(0);
annotationjson = JSON.parse(e.currentTarget.dataset.annotationjson);
viewport = JSON.parse(e.currentTarget.dataset.viewportjson);
current_image_id_index = e.currentTarget.dataset.currentimageid;
console.log(e.currentTarget, current_image_id_index)
loadAnnotationAndViewportOnElement(annotationjson, viewport, dicom_element, current_image_id_index);
});
});
{% if editing_finding > 0 %}
$("#hidden-form").show()
setTimeout(function() {
$(".view-finding-button[data-findingid={{editing_finding}}]").trigger("click");
}, 1000)
{% endif %}
});
function loadAnnotationAndViewportOnElement(annotation, viewport, dicom_element, current_image_id_index) {
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
cornerstone.getEnabledElement(dicom_element).viewport = viewport
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(
annotation);
console.log("annotation", annotation)
console.log("viewport", viewport)
//cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson)
cornerstone.resize(dicom_element);
console.log("current_image_id_index", current_image_id_index)
//current_image_id_index = parseInt(current_image_id_index);
console.log(current_image_id_index, Number.isInteger(current_image_id_index))
if (Number.isInteger(current_image_id_index)) {
console.log("loading stack index")
dicomViewer.loadStackIndex(current_image_id_index, dicom_element);
} else {
try {
console.log("current_image_id_index", current_image_id_index)
dicomViewer.loadImageById(current_image_id_index, dicom_element);
} catch (e) {
console.log(e)
console.log("loading image by id failed")
console.log("loading next annotation image")
dicomViewer.getNextAnnotationImage(dicom_element);
}
}
}
$(document).on('submit', '#question_finding_form', function (e) {
dicom_element = $(".cornerstone-element").get(0);
c = cornerstone.getEnabledElement(dicom_element);
e.preventDefault();
$.ajax({
type: 'POST',
url: '{% url "shorts:add_finding" %}',
data: {
question_finding_id: {{ editing_finding }},
description: $('#id_description').val(),
question: {{ question.pk }},
//annotation_json: JSON.stringify(c.toolStateManager.saveToolState()),
// This is the stack number
//current_image_id_index: c.toolStateManager.toolState.stack.data[0].currentImageIdIndex,
// but we want to use the current image id (so if the stack is reordered it still attaches to the correct image)
current_image_id_index: c.toolStateManager.toolState.stack.data[0].imageIds[c.toolStateManager.toolState.stack.data[0].currentImageIdIndex],
annotation_json: JSON.stringify(cornerstoneTools.globalImageIdSpecificToolStateManager
.saveToolState()),
viewport_json: JSON.stringify(c.viewport),
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
.map((i, el) => {
return $(el).val()
}).toArray()),
structures: JSON.stringify($('#finding-form select[name="structures"]').find(
":selected")
.map((i, el) => {
return $(el).val()
}).toArray()),
conditions: JSON.stringify($('#finding-form select[name="conditions"]').find(
":selected")
.map((i, el) => {
return $(el).val()
}).toArray()),
csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
action: 'post'
},
success: function (json) {
$("#finding-form").empty();
toastr.info('Finding added.');
location.href='{{question.get_findings_url}}';
},
error: function (xhr, errmsg, err) {
console.log(xhr.status + ": " + xhr
.responseText); // provide a bit more info about the error to the console
}
});
});
</script>
{% endblock js %}
@@ -1,10 +1,11 @@
<div class="floating-header">
<a href="{% url 'shorts:question_detail' pk=question.pk %}" title="View the Question">View</a>
<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="{% url 'shorts:question_sample_answers' pk=question.pk %}" title="Update the question sample answers">Edit Sample Answers</a>
<a href="{% url 'shorts:question_findings' question_id=question.pk %}" title="Editing the question findings">Edit Findings</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>
+78
View File
@@ -0,0 +1,78 @@
{% extends "shorts/base.html" %}
{% load static from static %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript">
let csrf_token = "{{csrf_token}}";
function add_answers_input_form() {
var form_idx = $('#id_sample_answers-TOTAL_FORMS').val();
$('#answer_form_set').append($('#answer_empty_form').html().replace(/__prefix__/g, form_idx));
$(`#id_sample_answers-${form_idx}-status`).val(2);
$('#id_sample_answers-TOTAL_FORMS').val(parseInt(form_idx) + 1);
}
$(document).ready(function () {
$('#add_more_answers').click(() => {
add_answers_input_form()
});
});
</script>
{{ form.media }}
{% endblock %}
{% block content %}
{% if object %}
{% include "shorts/question_link_header.html" %}
{% endif %}
<h2>Update Sample Answers</h2>
<p>This page allows you to update sample answers for the question.</p>
<p>Sample answers are used to aid marking of a question and providing feedback to candidates.</p>
<p>Ideally there should be a selection of answers that cover the available scores</p>
<form action="" method="post" enctype="multipart/form-data" id="shorts-form">
{% csrf_token %}
<h3>Answers:</h3>
<div id="answer_form_set">
{% for form in answer_formset %}
<ul class="no-error answer-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form.as_ul }}
</ul>
{% endfor %}
</div>
{{ answer_formset.management_form }}
<input type="button" value="Add More Answers" id="add_more_answers">
<p>
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit">
</p>
</form>
<div id="answer_empty_form" style="display:none">
<ul class='no_error answer-formset'>
{{ answer_formset.empty_form.as_ul }}
</ul>
</div>
{% endblock %}
+29 -3
View File
@@ -82,10 +82,15 @@ urlpatterns = [
name="question_update",
),
path(
"question/<int:pk>/update_answers",
views.QuestionAnswerUpdate.as_view(),
name="question_answer_update",
"question/<int:pk>/sample_answers",
views.QuestionSampleAnswerUpdate.as_view(),
name="question_sample_answers",
),
#path(
# "question/<int:pk>/update_answers",
# views.QuestionAnswerUpdate.as_view(),
# name="question_answer_update",
#),
path(
"user_answers/",
views.UserAnswerTableView.as_view(),
@@ -99,6 +104,27 @@ urlpatterns = [
views.UserAnswerDelete.as_view(),
name="user_answer_delete",
),
path(
"question/<int:question_id>/findings",
views.question_findings,
name="question_findings",
),
path(
"question/add_finding",
views.create_findings,
name="add_finding",
),
path(
"question/delete_finding/<int:pk>",
views.FindingDelete.as_view(),
name="delete_finding",
),
path(
"question/<int:question_id>/<int:finding_pk>",
views.question_findings,
name="question_edit_finding",
),
#path(
# "answer-autocomplete",
# views.AnswerAutocomplete.as_view(
+128 -7
View File
@@ -37,11 +37,13 @@ from generic.views import (
UpdateQuestionMixin,
)
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from atlas.models import Finding, Structure, Condition
from shorts.decorators import user_is_author_or_shorts_checker
from .models import (
Exam,
Question,
QuestionFinding,
QuestionImage,
UserAnswer,
SampleAnswer,
@@ -53,11 +55,13 @@ from .forms import (
ExamAuthorForm,
ExamGroupsForm,
ExamMarkerForm,
QuestionFindingForm,
QuestionForm,
QuestionAnswerForm,
QuestionSampleAnswerForm,
MarkQuestionForm,
ExamForm,
ImageFormSet,
SampleAnswerUpdateFormSet,
)
@@ -224,18 +228,41 @@ class QuestionCreate(QuestionCreateBase):
# return super().form_valid(form)
class QuestionAnswerUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView):
class QuestionSampleAnswerUpdate(AuthorOrCheckerRequiredMixin, UpdateView):
model = Question
form_class = QuestionAnswerForm
template_name = "shorts/shortsquestionanswer_form.html"
form_class = QuestionSampleAnswerForm
template_name = "shorts/sample_answer_form.html"
def get_context_data(self, **kwargs):
context = super(QuestionAnswerUpdate, self).get_context_data(**kwargs)
context = super(QuestionSampleAnswerUpdate, self).get_context_data(**kwargs)
if self.request.POST:
context["answer_formset"] = SampleAnswerUpdateFormSet(
self.request.POST, self.request.FILES, instance=self.object
)
context["answer_formset"].full_clean()
else:
context["answer_formset"] = SampleAnswerUpdateFormSet(instance=self.object)
context["question"] = context["object"]
return context
def form_valid(self, form):
return super().form_valid(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)
formset = context["answer_formset"]
# logger.debug(formset.is_valid())
if formset.is_valid():
response = super().form_valid(form)
formset.instance = self.object
formset.save()
return response
else:
return super().form_invalid(form)
class QuestionUpdate(
@@ -728,4 +755,98 @@ def question_add_exam(request, question_id: int):
return HttpResponse(mark_safe(html))
raise PermissionDenied()
raise PermissionDenied()
# At the moment only the series owner / author can delete findings but anyone can create
# TODO: duplicated from atlas "create_series_findings"
def create_findings(request):
# posts = Post.objects.all()
response_data = {}
if request.POST.get("action") == "post":
question_finding_id = int(request.POST.get("question_finding_id"))
question_id = request.POST.get("question")
findings_ids = json.loads(request.POST.get("findings"))
structure_ids = json.loads(request.POST.get("structures"))
condition_ids = json.loads(request.POST.get("conditions"))
description = request.POST.get("description")
annotation_json = request.POST.get("annotation_json")
viewport_json = request.POST.get("viewport_json")
current_image_id_index = request.POST.get("current_image_id_index")
question = Question.objects.get(pk=question_id)
findings = Finding.objects.filter(pk__in=findings_ids)
structures = Structure.objects.filter(pk__in=structure_ids)
conditions = Condition.objects.filter(pk__in=condition_ids)
if question_finding_id > 0:
sf = get_object_or_404(QuestionFinding, pk=question_finding_id)
sf.question = question
sf.description = description
sf.annotation_json = annotation_json
sf.viewport_json = viewport_json
else:
sf = QuestionFinding.objects.create(
question=question,
description=description,
annotation_json=annotation_json,
viewport_json=viewport_json,
)
sf.findings.set(findings)
sf.structures.set(structures)
sf.conditions.set(conditions)
sf.current_image_id_index = current_image_id_index
sf.save()
return JsonResponse({"success": True})
return JsonResponse({"success": False})
return render(request, "create_post.html", {"posts": posts})
# TODO fix permissions
class FindingDelete(RevisionMixin, DeleteView): # PermissionRequiredMixin,
#permission_required = "atlas.delete_seriesfinding"
model = QuestionFinding
template_name = "confirm_delete.html"
# success_url = reverse_lazy("atlas:case_view")
def get_object(self):
question_finding = super().get_object()
question = question_finding.question
if question.check_user_can_edit(self.request.user):
return question_finding
else:
raise PermissionDenied
def get_success_url(self):
pk = self.kwargs["pk"]
question_id = get_object_or_404(QuestionFinding, pk=pk).question.id
return reverse("shorts:question_detail", kwargs={"pk": question_id})
def question_findings(request, question_id, finding_pk=None):
question = get_object_or_404(Question, pk=question_id)
#can_edit = series.check_user_can_edit(request.user)
can_edit = True
editing_finding = -1
if finding_pk is not None:
finding = get_object_or_404(QuestionFinding, pk=finding_pk)
question_finding_form = QuestionFindingForm(None, instance=finding)
editing_finding = finding.pk
else:
question_finding_form = QuestionFindingForm(question_id=question.id)
# logging.debug(atlas.subspecialty.first().name.all())
return render(
request,
"shorts/question_findings.html",
{
"question": question,
"question_finding_form": question_finding_form,
"editing_finding": editing_finding,
"can_edit": can_edit,
},
)