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( "{} answer unmarked: {}", 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( '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): """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, )