577 lines
18 KiB
Python
577 lines
18 KiB
Python
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 django.contrib.contenttypes.fields import GenericRelation
|
|
|
|
from generic.models import Site, Condition, Sign, ExamBase, QuestionNote
|
|
|
|
import reversion
|
|
|
|
image_storage = FileSystemStorage(
|
|
# Physical file location ROOT
|
|
location=u"{0}".format(settings.MEDIA_ROOT),
|
|
# Url for file
|
|
base_url=u"{0}".format(settings.MEDIA_URL),
|
|
)
|
|
|
|
def image_directory_path(instance, filename):
|
|
#return u"{0}".format(filename)
|
|
return u"rapids/picture/{0}".format(filename)
|
|
|
|
|
|
def get_answer_compare(s):
|
|
s = s.strip().lower()
|
|
s = s.translate(str.maketrans('', '', string.punctuation.replace("#", "")))
|
|
return s
|
|
|
|
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
|
|
)
|
|
|
|
proposed = models.BooleanField(default=False)
|
|
|
|
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_compare = get_answer_compare(self.answer)
|
|
|
|
|
|
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
|
|
)
|
|
|
|
anon_notes = GenericRelation(QuestionNote)
|
|
|
|
|
|
def get_absolute_url(self):
|
|
return reverse('rapids:question_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 get_primary_answer(self):
|
|
if self.normal:
|
|
return "Normal"
|
|
elif len(self.answers.filter(proposed=False)) > 0:
|
|
return self.answers.filter(proposed=False).first().answer
|
|
else:
|
|
return "None yet..."
|
|
|
|
def get_exams(self):
|
|
e = self.exams.all().values_list("name", flat=True)
|
|
exams = ", ".join(e)
|
|
return exams
|
|
|
|
def get_unmarked_answer_string(self):
|
|
unmarked_answers = self.get_unmarked_answers()
|
|
|
|
if not unmarked_answers:
|
|
return "No answers to mark"
|
|
return format_html(
|
|
"<span class='warn'>{} answer unmarked:</span> {}".format(
|
|
len(unmarked_answers), ", ".join(unmarked_answers)
|
|
)
|
|
)
|
|
|
|
def get_unmarked_answers(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.get_marked_answers()
|
|
|
|
return unmarked_answers
|
|
|
|
def get_unmarked_answer_count(self):
|
|
return len(self.get_unmarked_answers())
|
|
|
|
def get_compare_answers(self):
|
|
return set(
|
|
[
|
|
i.answer_compare
|
|
for i in self.answers.filter()
|
|
]
|
|
)
|
|
|
|
def get_marked_answers(self):
|
|
return set(
|
|
[
|
|
i.answer_compare
|
|
for i in self.answers.filter(proposed=False)
|
|
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 get_correct_unstripped_answers(self):
|
|
return set(
|
|
[
|
|
str(i)
|
|
for i in self.answers.filter(proposed=False)
|
|
if i.status == i.MarkOptions.CORRECT
|
|
]
|
|
)
|
|
|
|
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(["https://penracourses.org.uk{}".format(i.url) for i in self.get_images()])
|
|
#def GetNonFeedbackQuestionImages(self):
|
|
#return self.get_images()
|
|
|
|
def get_laterality_string(self):
|
|
if self.laterality == self.NONE:
|
|
s = ""
|
|
elif self.laterality == self.BILATERAL:
|
|
s = "bilateral "
|
|
elif self.laterality == self.RIGHT:
|
|
s = "right "
|
|
elif self.laterality == self.LEFT:
|
|
s = "left "
|
|
|
|
return s
|
|
|
|
def get_suggested_answers(self):
|
|
answers = []
|
|
for r in self.region.all():
|
|
laterality = self.get_laterality_string()
|
|
for a in self.abnormality.all():
|
|
answers.append("{}{} {}".format(laterality, r.name, a.name))
|
|
answers.append("{} {}{}".format(a.name, laterality, r.name))
|
|
answers.append("{} {}".format(r.name, a.name))
|
|
answers.append("{} {}".format(a.name, r.name))
|
|
|
|
compare_answers = self.get_compare_answers()
|
|
|
|
new_answers = [a for a in answers if get_answer_compare(a) not in compare_answers]
|
|
|
|
return new_answers
|
|
|
|
|
|
#reversion.register(Rapid, follow=["images"])
|
|
#@reversion.register
|
|
class RapidImage(models.Model):
|
|
rapid = models.ForeignKey(Rapid,
|
|
related_name="images",
|
|
on_delete=models.CASCADE)
|
|
image = models.FileField(upload_to=image_directory_path)
|
|
|
|
feedback_image = 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'
|
|
|
|
|
|
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:question_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')
|
|
|
|
#@reversion.register
|
|
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
|
|
)
|
|
|
|
author = models.ManyToManyField(settings.AUTH_USER_MODEL,
|
|
blank=True,
|
|
help_text='Author of exam',
|
|
related_name="rapid_exam_author")
|
|
|
|
|
|
def get_normal_abnormal_breakdown(self):
|
|
# Inefficient but more extendible
|
|
questions = self.exam_questions.all()
|
|
|
|
normal = []
|
|
abnormal = []
|
|
for q in questions:
|
|
if q.normal:
|
|
normal.append(q)
|
|
else:
|
|
abnormal.append(q)
|
|
|
|
return len(normal)
|
|
|
|
|
|
def get_exam_json(self, based=True):
|
|
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:
|
|
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))
|
|
|
|
|
|
|
|
exam_questions[q.id] = {
|
|
"images": images,
|
|
#"feedback_image": [],
|
|
#"annotations": [str(q.image_annotations)],
|
|
"type": "rapid",
|
|
}
|
|
|
|
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 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": self.exam_mode,
|
|
"exam_order": exam_order,
|
|
"questions": exam_questions,
|
|
}
|
|
|
|
if self.time_limit:
|
|
exam_json["exam_time"] = self.time_limit
|
|
|
|
return exam_json
|
|
|
|
def get_absolute_url(self):
|
|
return reverse('rapids:exam_overview', kwargs={'pk': self.pk})
|
|
|
|
|
|
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.get_primary_answer(), 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.replace("#", "")))
|
|
|
|
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
|