from django.db import models from django.utils import timezone import tagulous import tagulous.models from django.core.files.storage import FileSystemStorage from django.conf import settings from django.utils.html import format_html from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from django.utils.html import mark_safe from sortedm2m.fields import SortedManyToManyField import string from collections import defaultdict from helpers.images import image_as_base64 from generic.models import Site, Condition, Sign, ExamBase image_storage = FileSystemStorage( # Physical file location ROOT location=u"{0}/rapids/".format(settings.MEDIA_ROOT), # Url for file base_url=u"{0}rapids/".format(settings.MEDIA_URL), ) def image_directory_path(instance, filename): return u"{0}".format(filename) return u"picture/{0}".format(filename) class Abnormality(models.Model): name = models.CharField(max_length=200, unique=True, help_text="Primary abnormality on the film(s)") def __str__(self): return self.name class Meta: ordering = ('name', ) class Region(models.Model): name = models.CharField( max_length=200, unique=True, help_text="Region of the abnormality (not including lateralitiy)", blank=True) def __str__(self): return self.name class Meta: ordering = ('name', ) class Examination(models.Model): examination = models.CharField(max_length=200) def __str__(self): return self.examination class Meta: ordering = ('examination', ) class Answer(models.Model): question = models.ForeignKey( "Rapid", related_name="answers", on_delete=models.CASCADE ) answer = models.TextField(max_length=500) answer_compare = models.TextField(max_length=500) class MarkOptions(models.TextChoices): UNMARKED = "", _("Unmarked") INCORRECT = "0", _("Incorrect") HALF_MARK = "1", _("Half mark") CORRECT = "2", _("Correct") status = models.CharField( max_length=1, choices=MarkOptions.choices, default=MarkOptions.UNMARKED ) def __str__(self): return self.answer def save(self, *args, **kwargs): self.clean() return super(Answer, self).save(*args, **kwargs) def clean(self): if self.answer: self.answer = self.answer.strip() s = self.answer.lower() s = s.translate(str.maketrans('', '', string.punctuation)) self.answer_compare = s class Rapid(models.Model): #author = models.ForeignKey('auth.User', on_delete=models.CASCADE) #image = models.ImageField() question = models.TextField(null=True, blank=True) feedback = models.TextField(null=True, blank=True) normal = models.BooleanField(default=False, help_text="Tick if true") 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") condition = tagulous.models.TagField( to=Condition, blank=True, help_text= "Associated condition. Will allow searching / filtering and tips / hints to be displayed. Conditions with spaces must be enclosed in quotes \"...\"" ) sign = tagulous.models.TagField( to=Sign, blank=True, help_text='Radiological signs in the question') DEFAULT_SITE_ID = 1 #site = models.ManyToManyField( # Site, # related_name="site_rapid", # blank=True, # default=DEFAULT_SITE_ID, # help_text= # "If we know the source of the image") verified = models.BooleanField(default=False) created_date = models.DateTimeField(default=timezone.now) published_date = models.DateTimeField(blank=True, null=True) author = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, help_text='Author of question', related_name="rapid_authored_questions") scrapped = models.BooleanField( default=False, help_text='Question has been scrapped and will not be shown') open_access = models.BooleanField( help_text="If a question should be freely available to browse", default=True ) def get_absolute_url(self): return reverse('rapids:rapid_detail', kwargs={'pk': self.pk}) def get_authors(self): """Returns a comma seperated text list of authors""" authors = ", ".join([i.username for i in self.author.all()]) return authors def get_regions(self): """Returns a comma seperated text list of regions""" regions = ", ".join([i.name for i in self.region.all()]) return regions def get_abnormalities(self): """Returns a comma seperated text list of regions""" abnormalities = ", ".join([i.name for i in self.abnormality.all()]) return abnormalities def get_examinations(self): """Returns a comma seperated text list of regions""" examinations = ", ".join([i.examination for i in self.examination.all()]) return examinations def __str__(self): n = "Normal" if not self.normal: #n = self.answers.first() n = "{} / {}".format(self.abnormality.first(), self.region.first()) exams = self.get_examinations() lat = "" if self.laterality != "NONE": lat = self.laterality return "{} / {} {}".format(exams, lat, n) def GetPrimaryAnswer(self): if len(self.answers.all()) > 0: return self.answers.all().first().answer elif self.normal: return "Normal" else: return "None yet..." def GetExams(self): e = self.exams.all().values_list("name", flat=True) exams = ", ".join(e) return exams def GetUnmarkedAnswersString(self): unmarked_answers = self.GetUnmarkedAnswers() if not unmarked_answers: return "No answers to mark" return format_html( "{} answer unmarked: {}".format( len(unmarked_answers), ", ".join(unmarked_answers) ) ) def GetUnmarkedAnswers(self): # If normal no answers to mark if self.normal: return [] user_answers = set( [i.answer_compare for i in self.cid_user_answers.all() if i.normal == False] ) unmarked_answers = user_answers - self.GetMarkedAnswers() return unmarked_answers def GetUnmarkedAnswerCount(self): return len(self.GetUnmarkedAnswers()) def GetMarkedAnswers(self): return set( [ i.answer_compare for i in self.answers.all() if i.status != i.MarkOptions.UNMARKED ] ) correct_answers = set( [i.answer.get_compare_string() for i in self.answers.all()] ) half_mark_answers = set( [i.answer.get_compare_string() for i in self.half_mark_answers.all()] ) incorrect_answers = set( [i.answer.get_compare_string() for i in self.incorrect_answers.all()] ) marked_answers = correct_answers | half_mark_answers | incorrect_answers return marked_answers def GetImages(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 GetImageUrls(self): return ",".join(["http://penracourses.org.uk{}".format(i.url) for i in self.GetImages()]) #def GetNonFeedbackQuestionImages(self): #return self.GetImages() class RapidImage(models.Model): rapid = models.ForeignKey(Rapid, related_name="images", on_delete=models.CASCADE) image = models.ImageField(upload_to=image_directory_path, storage=image_storage) feedback_image = 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' class Note(models.Model): rapid = models.ForeignKey(Rapid, related_name="rapid_notes", on_delete=models.CASCADE, null=True) #author = models.ForeignKey(User, author = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="rapid_notes", on_delete=models.CASCADE) note = models.TextField() created_on = models.DateTimeField(auto_now_add=True) def get_absolute_url(self): self.pk = self.rapid_id return reverse('rapids:rapid_detail', kwargs={'pk': self.pk}) def __str__(self): return "{} [{}] {}".format(self.rapid, self.author, self.created_on) class RapidCreationDefault(models.Model): #author = models.OneToOneField(User, author = models.OneToOneField( settings.AUTH_USER_MODEL, related_name="rapid_default", on_delete=models.CASCADE) #site = models.ManyToManyField( # Site, # related_name="site_rapid_creation_default", # blank=True, # default=1, # help_text="Default site to use when creating a new rapid") def get_absolute_url(self): return reverse('rapids:rapid_create') class Exam(ExamBase): app_name = "rapids" exam_questions = SortedManyToManyField( Rapid, related_name="exams", blank="true" ) time_limit = models.IntegerField( help_text="Exam time limit (in seconds). Default is 2100 secondse (35 minutes)", default=2100 ) def get_exam_json(self): questions = self.exam_questions.all() exam_questions = defaultdict(dict) exam_order = [] for q in questions: exam_order.append(q.id) # Loop through rapidimage associations images = [] feedback_images = [] for i in q.images.all(): if i.feedback_image == True: feedback_images.append(image_as_base64(i.image)) # feedback_images.append(i.image.url) else: images.append(image_as_base64(i.image)) #images.append(i.image.url) exam_questions[q.id] = { "images": images, #"feedback_image": [], #"annotations": [str(q.image_annotations)], "type": "rapid", } #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": True, "exam_order": exam_order, "questions": exam_questions, } if self.time_limit: exam_json["exam_time"] = self.time_limit return exam_json class CidUserAnswer(models.Model): """User answers by candidate""" question = models.ForeignKey( Rapid, related_name="cid_user_answers", on_delete=models.CASCADE ) # For rapids the answer can be normal in which case the below field is true normal = models.BooleanField(default=False) answer = models.TextField(max_length=500, blank=True) answer_compare = models.TextField(max_length=500, blank=True) cid = models.BigIntegerField(blank=True, null=True, help_text="Candidate ID (limitied by BigIntegerField size)") # Each user answer is associated with a particular exam exam = models.ForeignKey( Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True ) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) def __str__(self): try: exam = self.exam except (Exam.DoesNotExist, KeyError) as e: exam = "None" return "{}/{}/{}: {}".format( exam, self.cid, self.question.GetPrimaryAnswer(), self.answer ) def save(self, *args, **kwargs): self.clean() return super(CidUserAnswer, self).save(*args, **kwargs) def clean(self): if self.answer: self.answer = self.answer.strip() s = self.answer.lower() s = s.translate(str.maketrans('', '', string.punctuation)) self.answer_compare = s # 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_score(self): q = self.question # First step we check that the normal/abnormal states match if q.normal != self.normal: # If they don't match the answer is wrong (score is 0) return 0 # If both are normal full marks elif q.normal and self.normal: return 2 # Then compare answer strings (as per anatomy questions) ans = self.answer_compare if ( q.answers.filter( answer_compare__iexact=ans, status=Answer.MarkOptions.CORRECT ).first() is not None ): mark = 2 elif ( q.answers.filter( answer_compare__iexact=ans, status=Answer.MarkOptions.HALF_MARK ).first() is not None ): mark = 1 elif q.answers.filter( answer_compare__iexact=ans, status=Answer.MarkOptions.INCORRECT ).first(): mark = 0 else: mark = "unmarked" return mark