Files
penracourses/shorts/models.py
T

726 lines
22 KiB
Python

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,
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
from rapids.models import Abnormality, Examination, Region
from helpers.images import get_image_hash, image_as_base64
from django.utils.html import format_html
from helpers.images import combine_dicom_images_side_by_side
from django.core.files import File
import tempfile
from typing import List, TypedDict, Required, NotRequired
class QuestionData(TypedDict):
images: Required[list[str]]
annotations: Required[List[str]]
type: Required[str]
history: NotRequired[str]
answers: NotRequired[List[str]]
feedback: NotRequired[str]
feedback_images: NotRequired[List[str]]
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.",
)
examination = models.ManyToManyField(
Examination, help_text="Name of the (primary) 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,
help_text="Author of question",
related_name="shorts_authored_questions",
)
def __str__(self):
return "{}: {}".format(
self.id,
self.history,
)
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})
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 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
def combine_images_side_by_side(self, image1_id, image2_id):
"""
Combines two DICOM images side by side and saves the new image.
Marks the original images as feedback images.
"""
img1 = self.images.get(id=image1_id)
img2 = self.images.get(id=image2_id)
dicom_path1 = os.path.join(settings.MEDIA_ROOT, img1.image.name)
dicom_path2 = os.path.join(settings.MEDIA_ROOT, img2.image.name)
# Combine images (returns a FileDataset)
combined_dataset = combine_dicom_images_side_by_side(dicom_path1, dicom_path2)
# Save new image directly to storage
combined_image_name = f"combined_{img1.filename}_{img2.filename}.dcm"
combined_image_path = os.path.join("shorts/picture", combined_image_name)
full_path = os.path.join(settings.MEDIA_ROOT, combined_image_path)
combined_dataset.save_as(full_path)
with open(full_path, "rb") as f:
new_image = QuestionImage(
question=self,
image=File(f, name=combined_image_path),
feedback_image=False,
is_dicom=True,
)
new_image.save()
# Mark originals as feedback images
img1.feedback_image = True
img1.save()
img2.feedback_image = True
img2.save()
return new_image
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
def get_best_sample_answer(self):
return self.sample_answers.filter(score__gt=0).order_by("-score").first()
def get_exams(self):
e = self.exams.all().values_list("name", flat=True)
exams = ", ".join(e)
return exams
def get_unmarked_user_answer_string(self, exam_pk: int = None):
unmarked_answers = self.get_unmarked_user_answers(exam_pk)
if not unmarked_answers:
return "No answers to mark"
return format_html(
"<span class='warn'>{} answer unmarked:</span> {}",
len(unmarked_answers),
", ".join(unmarked_answers),
)
def get_unmarked_user_answers(self, exam_pk: int | None = None, marker=None):
if exam_pk is None:
user_answer_queryset = self.cid_user_answers.all()
else:
user_answer_queryset = self.cid_user_answers.filter(exam__id=exam_pk)
unmarked_answers = user_answer_queryset.filter(score__isnull=True)
return unmarked_answers
def get_unmarked_user_answer_count(self, exam_pk=None, marker=None):
if exam_pk is None:
return self.cid_user_answers.all().count()
return len(self.get_unmarked_user_answers(exam_pk, marker=marker))
def get_user_answers(self, exam_pk=None, include_normal=True):
if exam_pk is None:
queryset = self.cid_user_answers.all()
else:
queryset = self.cid_user_answers.filter(exam__id=exam_pk)
if not include_normal:
queryset = queryset.filter(normal=False)
user_answers = set([i.answer for i in queryset])
return user_answers
def get_examination_string(self):
"""Returns a string of the examinations associated with the question."""
examinations = self.examination.all().values_list("examination", flat=True)
return ", ".join(examinations)
def get_sample_answers(self):
return self.sample_answers.all().order_by("-score")
def get_question_json(
self,
based: bool = True,
feedback: bool = True,
answers: bool = True,
history: bool = True,
annotations: bool = False,
) -> QuestionData:
"""Returns a json representation of the question"""
# Loop through rapidimage associations
images = []
annotations_list = []
feedback_images = []
i: QuestionImage
for i in self.images.all():
# TODO: add anotations
if i.feedback_image:
if feedback:
#annotations_list.append(i.image_annotations)
if based:
feedback_images.append(image_as_base64(i.image))
else:
feedback_images.append(
"{}/{}".format(settings.REMOTE_URL, i.image.url)
)
# feedback_images.append("{}/{}".format(settings.REMOTE_URL, i.image.url))
else:
#annotations_list.append(i.image_annotations)
if based:
images.append(image_as_base64(i.image))
else:
images.append("{}/{}".format(settings.REMOTE_URL, i.image.url))
json = {
"images": images,
# "feedback_image": [],
"type": "shorts",
}
#if annotations:
# json["annotations"] = annotations_list
if history:
json["history"] = self.history
if answers:
sample_answers = list(self.get_sample_answers().values_list("answer", "score"))
json["answers"] = sample_answers
if feedback:
json["feedback_images"] = feedback_images
json["feedback"] = self.feedback
return json
@classmethod
def from_rapid(cls, rapid):
"""Create a `shorts.Question` from a `rapids.Rapid` instance.
Fields mapped:
- `history` -> `history`
- `examination` m2m copied
- `author` m2m copied
- images copied to `QuestionImage`
- `answers` (from `rapids.Answer`) -> `SampleAnswer` with simple score mapping
All other fields are concatenated into `marking_guidance`.
"""
from rapids.models import Answer as RapidAnswer
# Skip migration for normal rapids (nothing to migrate)
if getattr(rapid, "normal", False):
return None
guidance_parts = []
guidance_parts.append(f"Migrated from rapid id: {rapid.id}")
if rapid.laterality:
guidance_parts.append(f"Laterality: {rapid.laterality}")
abnormalities = ", ".join([a.name for a in rapid.abnormality.all()])
if abnormalities:
guidance_parts.append(f"Abnormalities: {abnormalities}")
regions = ", ".join([r.name for r in rapid.region.all()])
if regions:
guidance_parts.append(f"Regions: {regions}")
if rapid.question:
guidance_parts.append(f"Original question text: {rapid.question}")
marking_guidance = "\n".join(guidance_parts)
# Create the short question
q = cls.objects.create(
history=rapid.history,
marking_guidance=marking_guidance,
)
# copy m2m fields
try:
q.examination.set(rapid.examination.all())
except Exception:
pass
try:
q.author.set(rapid.author.all())
except Exception:
pass
# copy images
for img in rapid.images.all():
new_img = QuestionImage(
question=q,
image=img.image,
feedback_image=img.feedback_image,
filename=img.filename,
description=img.description,
image_md5_hash=img.image_md5_hash,
is_dicom=img.is_dicom,
)
new_img.save()
# Do not migrate rapid answers into shorts.SampleAnswer per request.
return q
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 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)
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):
"""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)]
)
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",
)
double_mark = models.BooleanField(
default=False, help_text="Defines if an exam is expected to be double marked"
)
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):
questions = self.get_questions()
exam_questions = defaultdict(dict)
exam_order = []
q: Question
for q in questions:
exam_order.append(q.id)
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(q.get_examination_string())
exam_questions[q.id] = {
"images": images,
# "feedback_image": [],
# "annotations": [str(q.image_annotations)],
"type": "short",
}
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": "short/{}".format(self.id),
"cached": False,
"exam_type": "short",
"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
print(exam_json)
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
)
# If score is null then the answer is unmarked
score = models.IntegerField(
default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)], null=True,
help_text="Score for the answer. If null then the answer is unmarked. This should be number 0-5.",
)
candidate_feedback = models.TextField(
null=True, blank=True, help_text="Feedback for the candidate, this is optional but WILL be shown to the candidate."
)
marker_feedback = models.TextField(
null=True, blank=True, help_text="Feedback for the marker, this is optional and will NOT be shown to the candidate."
)
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
def get_answer_string(self):
return self.answer
def is_marked(self):
if self.score is None:
return False
else:
return True
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}"
class AnswerMarks(models.Model):
# Same as in UserAnswer except null is not allowed
score = models.IntegerField(
default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)]
)
mark_reason = models.TextField(
null=True,
blank=True,
help_text="Reason for the given mark - not visible to candidates",
)
marker = models.ForeignKey(
settings.AUTH_USER_MODEL, related_name="shorts_answers", on_delete=models.CASCADE
)
user_answer = models.ForeignKey(
UserAnswer,
related_name="mark",
on_delete=models.SET_NULL,
null=True,
blank=True,
)