This commit is contained in:
Ross
2023-06-12 10:59:36 +01:00
parent 43c984d5aa
commit 796ec1a072
6 changed files with 381 additions and 432 deletions
@@ -0,0 +1,18 @@
# Generated by Django 4.1.4 on 2023-06-12 09:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0049_alter_condition_parent_alter_condition_synonym_and_more'),
]
operations = [
migrations.AlterField(
model_name='series',
name='description',
field=models.TextField(blank=True, help_text='Description of stack, for admin organisation, will not be visible when taking'),
),
]
+10 -181
View File
@@ -10,8 +10,6 @@ from django.utils import timezone
import pydicom import pydicom
import dicognito.anonymizer
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.conf import settings from django.conf import settings
@@ -43,13 +41,11 @@ from generic.models import (
Plane, Plane,
Contrast, Contrast,
QuestionNote, QuestionNote,
SeriesBase,
) )
# from generic.models import Examination, Site, Condition, Sign # from generic.models import Examination, Site, Condition, Sign
from easy_thumbnails.files import get_thumbnailer
from easy_thumbnails.exceptions import InvalidImageFormatError
import pydicom.errors import pydicom.errors
import datetime import datetime
from django.utils import timezone from django.utils import timezone
@@ -343,7 +339,6 @@ class SeriesImage(models.Model):
class SeriesFinding(models.Model): class SeriesFinding(models.Model):
series = models.ForeignKey( series = models.ForeignKey(
"Series", related_name="findings", on_delete=models.SET_NULL, null=True "Series", related_name="findings", on_delete=models.SET_NULL, null=True
) )
@@ -364,7 +359,7 @@ class SeriesFinding(models.Model):
@reversion.register @reversion.register
class Series(models.Model): class Series(SeriesBase):
modality = models.ForeignKey( modality = models.ForeignKey(
Modality, Modality,
related_name="atlas_series_modality", related_name="atlas_series_modality",
@@ -394,10 +389,6 @@ class Series(models.Model):
null=True, null=True,
blank=True, blank=True,
) )
description = models.TextField(
blank=True,
help_text="Description of stack",
)
author = models.ManyToManyField( author = models.ManyToManyField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@@ -407,13 +398,6 @@ class Series(models.Model):
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack") # findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
open_access = models.BooleanField(
help_text="If a series should be freely available to browse", default=True
)
def __str__(self):
return f"{self.pk}:{self.description}"
def get_full_str(self): def get_full_str(self):
if self.case: if self.case:
case_id = ", ".format([case.pk for case in self.case.all()]) case_id = ", ".format([case.pk for case in self.case.all()])
@@ -424,167 +408,10 @@ class Series(models.Model):
self.pk, self.get_examination_full(), self.description, case_id self.pk, self.get_examination_full(), self.description, case_id
) )
def get_absolute_url(self):
return reverse("series:question_detail", kwargs={"pk": self.pk})
# def get_string(self):
# examination = self.get_examination_full()
# return
def get_author_objects(self):
"""Returns a comma seperated text list of authors"""
if self.author:
return self.author.all()
else:
return ["None"]
def get_author_display(self):
return ", ".join([i.username for i in self.get_author_objects()])
def get_examination(self):
"""Returns a comma seperated text list of regions"""
return str(self.examination)
def get_examination_full(self):
examination = ""
plane = ""
contrast = ""
if self.examination:
examination = self.examination
if self.plane:
plane = " {}".format(self.plane)
if self.contrast:
contrast = " {}".format(self.contrast)
return "{}{}{}".format(examination, plane, contrast)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("atlas:series_detail", kwargs={"pk": self.pk}) return reverse("atlas:series_detail", kwargs={"pk": self.pk})
def get_image_urls(self, findings=False):
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
return ",".join(images)
def get_image_url_array_not_json(self):
return self.get_image_url_array(json_output=False)
def get_image_url_array(self, findings=False, json_output=True):
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
if json_output:
return json.dumps(images)
else:
# This is a mess...
return format_html('", "'.join(images))
def get_thumbnail(self):
images = self.images.all()
if len(images) < 1:
return "No images", 0
img = findMiddle(images).image
try:
thumbnailer = get_thumbnailer(img)
thumbnail = thumbnailer["exam-list"]
except InvalidImageFormatError:
return format_html('<span title="{}">Invalid image url<span>', img), len(
images
)
return format_html('<img src="/media/{}" />', thumbnail), len(images)
def get_thumbnail_link(self):
return format_html(
"<a href='{}'>{}<a/>", self.get_absolute_url(), self.get_thumbnail()[0]
)
def get_block(self):
examination = self.get_examination_full()
thumb, image_number = self.get_thumbnail()
return format_html(
"<div>{}<br/>{}<br/>Images: {}</div>", examination, thumb, image_number
)
def order_by_upload_filename(self):
images = self.images.all()
filenames = []
map = {}
for i in images:
filenames.append(i.upload_filename)
map[i.upload_filename] = i
filenames = sorted(filenames)
n = 1
for f in filenames:
i = map[f]
i.position = n
i.save()
n = n + 1
def order_by_dicom(self, field="SliceLocation"):
images = self.images.all()
files = []
for i in images:
files.append((i, pydicom.dcmread(i.image.path)))
# print("file count: {}".format(len(files)))
# skip files with no SliceLocation (eg scout views)
slices = []
map = {}
skipcount = 0
for i, f in files:
if hasattr(f, field):
slices.append(f)
# map[f.SliceLocation] = i
map[f[field].value] = i
else:
skipcount = skipcount + 1
print("skipped, no {}: {}".format(field, skipcount))
# ensure they are in the correct order
slices = sorted(slices, key=lambda s: s[field].value)
# print(slices)
n = 1
for f in slices:
i = map[f[field].value]
i.position = n
i.save()
n = n + 1
def get_total_image_size(self):
images = self.images.all()
size = 0
for i in images:
size += i.image.size
return size
def anonymise_images(self):
# NOTE: this will not maintain the correct hashed filename
# but that doesn't matter as we will never get the same anonymisation
# even with the same dicom...
anonymizer = dicognito.anonymizer.Anonymizer()
for series_image in self.images.all():
file_path = os.path.join(settings.MEDIA_ROOT, series_image.image.name)
try:
with pydicom.dcmread(file_path) as dataset:
anonymizer.anonymize(dataset)
dataset.save_as(file_path)
except pydicom.errors.InvalidDicomError:
pass
class CaseCollection(models.Model): class CaseCollection(models.Model):
@@ -647,8 +474,11 @@ class CaseCollection(models.Model):
CidUser, blank=True, related_name="casecollection_exams" CidUser, blank=True, related_name="casecollection_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.") cid_user_groups = models.ManyToManyField(
CidUserGroup,
blank=True,
help_text="These groups define which candidates are able to be added to the exams/collection.",
)
archive = models.BooleanField(default=False) archive = models.BooleanField(default=False)
exam_mode = models.BooleanField(default=False) exam_mode = models.BooleanField(default=False)
@@ -693,7 +523,6 @@ class CaseCollection(models.Model):
return True return True
if self.valid_cid_users.exists(): if self.valid_cid_users.exists():
user = self.valid_cid_users.filter(cid=cid).first() user = self.valid_cid_users.filter(cid=cid).first()
if not user or user.passcode != passcode: if not user or user.passcode != passcode:
@@ -771,13 +600,13 @@ class BaseReportAnswer(models.Model):
class Meta: class Meta:
abstract = True abstract = True
class CidReportAnswer(BaseReportAnswer): class CidReportAnswer(BaseReportAnswer):
cid = models.BigIntegerField( cid = models.BigIntegerField(
blank=False, blank=False,
help_text="Candidate ID", help_text="Candidate ID",
) )
class UserReportAnswer(BaseReportAnswer): class UserReportAnswer(BaseReportAnswer):
user = models.ForeignKey( user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
settings.AUTH_USER_MODEL, on_delete=models.CASCADE
)
+3 -1
View File
@@ -25,7 +25,9 @@ class AtlasImageColumn(tables.Column):
for obj in value.all(): for obj in value.all():
blocks.append(obj.get_block()) blocks.append(obj.get_block())
return format_html( return format_html(
"<span class='multi-image-block'>{}</span>".format("".join(blocks)) """<span class='multi-image-block'>
{}
</span>""".format("".join(blocks))
) )
obj = value.first() obj = value.first()
+323 -64
View File
@@ -1,4 +1,5 @@
from collections import defaultdict from collections import defaultdict
import json
from typing import Tuple from typing import Tuple
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import models from django.db import models
@@ -23,6 +24,22 @@ from django.contrib.auth.models import User
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from rad.settings import REMOTE_URL
from django.utils.html import format_html
from easy_thumbnails.files import get_thumbnailer
from easy_thumbnails.exceptions import InvalidImageFormatError
import pydicom
import dicognito.anonymizer
def findMiddle(input_list):
middle = float(len(input_list)) / 2
if middle % 2 != 0:
return input_list[int(middle - 0.5)]
else:
return input_list[int(middle)]
class Plane(models.Model): class Plane(models.Model):
@@ -57,16 +74,19 @@ class Examination(models.Model):
class Site(models.Model): class Site(models.Model):
"""Model to hold site details""" """Model to hold site details"""
full_name = models.CharField(max_length=255, blank=True, help_text="Name of the site")
short_code = models.CharField(max_length=255, blank=True, help_text="Shortcode/name of the site")
full_name = models.CharField(
max_length=255, blank=True, help_text="Name of the site"
)
short_code = models.CharField(
max_length=255, blank=True, help_text="Shortcode/name of the site"
)
def __str__(self): def __str__(self):
return self.short_code return self.short_code
class QuestionBase(models.Model): class QuestionBase(models.Model):
authors_only = models.BooleanField( authors_only = models.BooleanField(
help_text="If true only question authors will be able to view.", help_text="If true only question authors will be able to view.",
default=False, default=False,
@@ -85,7 +105,7 @@ class QuestionBase(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def get_unanswered_mark_and_text(self) -> tuple[ int, str ]: def get_unanswered_mark_and_text(self) -> tuple[int, str]:
""" """
Override in models if needed Override in models if needed
""" """
@@ -96,6 +116,181 @@ class QuestionBase(models.Model):
return None return None
class SeriesBase(models.Model):
description = models.TextField(
blank=True,
help_text="Description of stack, for admin organisation, will not be visible when taking",
)
open_access = models.BooleanField(
help_text="If a series should be freely available to browse", default=True
)
class Meta:
abstract = True
def __str__(self):
return f"{self.pk}:{self.description}"
def get_author_objects(self):
"""Returns a comma seperated text list of authors"""
if self.author:
return self.author.all()
else:
return ["None"]
def get_author_display(self):
return ", ".join([i.username for i in self.get_author_objects()])
def get_examination(self):
"""Returns a comma seperated text list of regions"""
return str(self.examination)
def get_examination_full(self):
examination = ""
plane = ""
contrast = ""
if self.examination:
examination = self.examination
if self.plane:
plane = " {}".format(self.plane)
if self.contrast:
contrast = " {}".format(self.contrast)
return "{}{}{}".format(examination, plane, contrast)
def get_image_urls(self):
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
return ",".join(images)
def get_image_url_array_not_json(self):
return self.get_image_url_array(json_output=False)
def get_image_url_array(self, json_output=True):
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
if json_output:
return json.dumps(images)
else:
# This is a mess...
return format_html('", "'.join(images))
def get_thumbnail(self):
images = self.images.all()
if len(images) < 1:
return "No images", 0
img = findMiddle(images).image
try:
thumbnailer = get_thumbnailer(img)
thumbnail = thumbnailer["exam-list"]
except InvalidImageFormatError:
return format_html('<span title="{}">Invalid image url<span>', img), len(
images
)
return format_html('<img src="/media/{}" />', thumbnail), len(images)
def get_thumbnail_link(self):
return format_html(
"<a href='{}'>{}<a/>", self.get_absolute_url(), self.get_thumbnail()[0]
)
def get_block(self):
examination = self.get_examination_full()
thumb, image_number = self.get_thumbnail()
return format_html(
"""<span>
<span class='series-block-examination'>{}</span><br/>
<span class='series-block-thumbnail'>{}</span><br/>
<span class='series-block-image-number'>Images: <span class='series-block-image-number-count'>{}</span></span>
</span>""",
examination,
thumb,
image_number,
)
def order_by_upload_filename(self):
images = self.images.all()
filenames = []
map = {}
for i in images:
filenames.append(i.upload_filename)
map[i.upload_filename] = i
filenames = sorted(filenames)
n = 1
for f in filenames:
i = map[f]
i.position = n
i.save()
n = n + 1
def order_by_dicom(self, field="SliceLocation"):
images = self.images.all()
files = []
for i in images:
files.append((i, pydicom.dcmread(i.image.path)))
# print("file count: {}".format(len(files)))
# skip files with no SliceLocation (eg scout views)
slices = []
map = {}
skipcount = 0
for i, f in files:
if hasattr(f, field):
slices.append(f)
# map[f.SliceLocation] = i
map[f[field].value] = i
else:
skipcount = skipcount + 1
print("skipped, no {}: {}".format(field, skipcount))
# ensure they are in the correct order
slices = sorted(slices, key=lambda s: s[field].value)
# print(slices)
n = 1
for f in slices:
i = map[f[field].value]
i.position = n
i.save()
n = n + 1
def get_total_image_size(self):
images = self.images.all()
size = 0
for i in images:
size += i.image.size
return size
def anonymise_images(self):
# NOTE: this will not maintain the correct hashed filename
# but that doesn't matter as we will never get the same anonymisation
# even with the same dicom...
anonymizer = dicognito.anonymizer.Anonymizer()
for series_image in self.images.all():
file_path = os.path.join(settings.MEDIA_ROOT, series_image.image.name)
try:
with pydicom.dcmread(file_path) as dataset:
anonymizer.anonymize(dataset)
dataset.save_as(file_path)
except pydicom.errors.InvalidDicomError:
pass
class ExamBase(models.Model): class ExamBase(models.Model):
name = models.CharField(max_length=200, help_text="Name of the exam") name = models.CharField(max_length=200, help_text="Name of the exam")
# exam_questions = SortedManyToManyField(Long, related_name="exams", blank="true") # exam_questions = SortedManyToManyField(Long, related_name="exams", blank="true")
@@ -153,7 +348,7 @@ class ExamBase(models.Model):
stats_max_possible = models.FloatField(default=0) stats_max_possible = models.FloatField(default=0)
#stats_graph = models.TextField(default=0) # stats_graph = models.TextField(default=0)
user_scores = models.JSONField(default=dict) user_scores = models.JSONField(default=dict)
@@ -162,7 +357,6 @@ class ExamBase(models.Model):
exam_results_emailed = models.DateTimeField(default=None, null=True) exam_results_emailed = models.DateTimeField(default=None, null=True)
class Meta: class Meta:
abstract = True abstract = True
@@ -191,10 +385,14 @@ class ExamBase(models.Model):
return reverse("{}:exam_overview".format(self.app_name), kwargs={"pk": self.pk}) return reverse("{}:exam_overview".format(self.app_name), kwargs={"pk": self.pk})
def get_cid_edit_url(self): def get_cid_edit_url(self):
return reverse("{}:exam_cids_edit".format(self.app_name), kwargs={"exam_id": self.pk}) return reverse(
"{}:exam_cids_edit".format(self.app_name), kwargs={"exam_id": self.pk}
)
def get_user_edit_url(self): def get_user_edit_url(self):
return reverse("{}:exam_users_edit".format(self.app_name), kwargs={"exam_id": self.pk}) return reverse(
"{}:exam_users_edit".format(self.app_name), kwargs={"exam_id": self.pk}
)
def get_take_url(self): def get_take_url(self):
return f"{settings.REMOTE_URL}/rts" return f"{settings.REMOTE_URL}/rts"
@@ -206,7 +404,9 @@ class ExamBase(models.Model):
if cid is None: if cid is None:
return reverse("{}:exam_json".format(self.app_name), args=(self.pk,)) return reverse("{}:exam_json".format(self.app_name), args=(self.pk,))
else: else:
return reverse("{}:exam_json_cid".format(self.app_name), args=(self.pk,cid, passcode)) return reverse(
"{}:exam_json_cid".format(self.app_name), args=(self.pk, cid, passcode)
)
def get_question_index(self, question): def get_question_index(self, question):
return list(self.exam_questions.all()).index(question) return list(self.exam_questions.all()).index(question)
@@ -223,7 +423,14 @@ class ExamBase(models.Model):
authors = [i for i in self.author.all()] authors = [i for i in self.author.all()]
return authors return authors
def check_cid_user(self, cid: int|None, passcode: str|None, request: HttpRequest|None=None, user_id: int|None=None, allow_authors: bool=True): def check_cid_user(
self,
cid: int | None,
passcode: str | None,
request: HttpRequest | None = None,
user_id: int | None = None,
allow_authors: bool = True,
):
print(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}") print(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}")
if request is not None and request.user.is_superuser: if request is not None and request.user.is_superuser:
return True return True
@@ -243,7 +450,6 @@ class ExamBase(models.Model):
# Then test CID data # Then test CID data
if self.valid_cid_users.exists(): if self.valid_cid_users.exists():
cid_user = self.valid_cid_users.filter(cid=cid).first() cid_user = self.valid_cid_users.filter(cid=cid).first()
if cid_user and cid_user.passcode == passcode: if cid_user and cid_user.passcode == passcode:
@@ -253,7 +459,9 @@ class ExamBase(models.Model):
return False return False
def get_or_create_cid_user_exam(self, cid_user=None, user_user=None, start_time=None): def get_or_create_cid_user_exam(
self, cid_user=None, user_user=None, start_time=None
):
content_type = ContentType.objects.get_for_model(self) content_type = ContentType.objects.get_for_model(self)
if cid_user is not None: if cid_user is not None:
@@ -270,11 +478,9 @@ class ExamBase(models.Model):
if c: if c:
return c return c
if start_time is None: if start_time is None:
start_time = timezone.now() start_time = timezone.now()
if cid_user is not None: if cid_user is not None:
new = CidUserExam( new = CidUserExam(
content_type=content_type, content_type=content_type,
@@ -312,7 +518,7 @@ class ExamBase(models.Model):
) )
def get_cid_user_score(self, cid_user): def get_cid_user_score(self, cid_user):
c = "c/"+str(cid_user) c = "c/" + str(cid_user)
if c in self.user_scores: if c in self.user_scores:
return self.user_scores[c] return self.user_scores[c]
else: else:
@@ -326,41 +532,35 @@ class ExamBase(models.Model):
exam_text.append(f"{'='*len(exam_name)}\n{exam_name}\n{'='*len(exam_name)}") exam_text.append(f"{'='*len(exam_name)}\n{exam_name}\n{'='*len(exam_name)}")
try: try:
user_score = self.user_scores["u/"+str(user.pk)]["score"] user_score = self.user_scores["u/" + str(user.pk)]["score"]
if self.app_name == "rapids": if self.app_name == "rapids":
callstate = self.user_scores["u/"+str(user.pk)]["callstates"] callstate = self.user_scores["u/" + str(user.pk)]["callstates"]
except KeyError: except KeyError:
user_score = "Score not generated" user_score = "Score not generated"
try: try:
percentage = ( percentage = f" ({user_score / int(self.stats_max_possible) * 100:.2f}%)"
f" ({user_score / int(self.stats_max_possible) * 100:.2f}%)"
)
except TypeError: except TypeError:
percentage = "" percentage = ""
exam_text.append( exam_text.append(
f"""Results\n{'-'*len('Results')} f"""Results\n{'-'*len('Results')}
# Score: {user_score} / {int(self.stats_max_possible)}{percentage} # Score: {user_score} / {int(self.stats_max_possible)}{percentage}
""" """
) )
stats = self.get_exam_stats(name=False) stats = self.get_exam_stats(name=False)
exam_text.append(f"Stats\n{'-'*len('Stats')}\n{stats}") exam_text.append(f"Stats\n{'-'*len('Stats')}\n{stats}")
if self.app_name == "rapids": if self.app_name == "rapids":
exam_text.append( exam_text.append(f"Answers breakdown: {callstate}")
f"Answers breakdown: {callstate}"
)
msg = "\n\n".join(exam_text) msg = "\n\n".join(exam_text)
return msg return msg
def email_user_results(self, user, resend=False, additional_emails=None): def email_user_results(self, user, resend=False, additional_emails=None):
#if self.results_email_sent and not resend: # if self.results_email_sent and not resend:
# return False, "Already sent." # return False, "Already sent."
# Get a list of taken exams # Get a list of taken exams
@@ -376,7 +576,6 @@ class ExamBase(models.Model):
if supervisor_email: if supervisor_email:
emails.append(supervisor_email) emails.append(supervisor_email)
if additional_emails is not None: if additional_emails is not None:
emails.extend(additional_emails) emails.extend(additional_emails)
@@ -392,14 +591,19 @@ class ExamBase(models.Model):
except SMTPException as e: except SMTPException as e:
return [False, e] return [False, e]
#self.results_email_sent = True # self.results_email_sent = True
#self.save() # self.save()
return [True, ""] return [True, ""]
class ExamUserStatus(models.Model): class ExamUserStatus(models.Model):
datetime = models.DateTimeField(auto_now_add=True) datetime = models.DateTimeField(auto_now_add=True)
cid_user = models.ForeignKey("CidUser", blank=True, null=True, on_delete=models.SET_NULL) cid_user = models.ForeignKey(
user_user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL) "CidUser", blank=True, null=True, on_delete=models.SET_NULL
)
user_user = models.ForeignKey(
settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
)
status = models.CharField(max_length=255) status = models.CharField(max_length=255)
extra = models.CharField(max_length=255) extra = models.CharField(max_length=255)
@@ -416,7 +620,6 @@ class ExamUserStatus(models.Model):
class UserAnswerBase(models.Model): class UserAnswerBase(models.Model):
cid = models.BigIntegerField( cid = models.BigIntegerField(
blank=True, blank=True,
null=True, null=True,
@@ -426,16 +629,13 @@ class UserAnswerBase(models.Model):
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
class Meta: class Meta:
abstract = True abstract = True
ordering = ["cid"] ordering = ["cid"]
def __str__(self) -> str: def __str__(self) -> str:
return "{}/{}/{}".format(self.get_candidate_name(), self.exam, self.question.pk) return "{}/{}/{}".format(self.get_candidate_name(), self.exam, self.question.pk)
def get_candidate_name(self) -> str: def get_candidate_name(self) -> str:
if self.cid is not None: if self.cid is not None:
return str(self.cid) return str(self.cid)
@@ -512,6 +712,7 @@ class QuestionNote(models.Model):
def get_short_str(self): def get_short_str(self):
return f"{self.note_type} - {self.note}" return f"{self.note_type} - {self.note}"
EXAM_TYPES = ( EXAM_TYPES = (
("physics", "physics_exams"), ("physics", "physics_exams"),
("rapids", "rapid_exams"), ("rapids", "rapid_exams"),
@@ -521,25 +722,31 @@ EXAM_TYPES = (
("casecollection", "casecollection_exams"), ("casecollection", "casecollection_exams"),
) )
class CidUser(models.Model):
class CidUser(models.Model):
cid = models.IntegerField(blank=True, null=True, unique=True) cid = models.IntegerField(blank=True, null=True, unique=True)
passcode = models.CharField(blank=True, max_length=50) passcode = models.CharField(blank=True, max_length=50)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
internal_candidate = models.BooleanField(default=False) internal_candidate = models.BooleanField(default=False)
name = models.CharField(blank=True, max_length=255) name = models.CharField(blank=True, max_length=255)
email = models.EmailField(blank=True) email = models.EmailField(blank=True)
supervisor = models.ForeignKey("Supervisor", on_delete=models.SET_NULL, blank=True, null=True, related_name="cid_user") supervisor = models.ForeignKey(
"Supervisor",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="cid_user",
)
login_email_sent = models.BooleanField(default=False) login_email_sent = models.BooleanField(default=False)
results_email_sent = models.BooleanField(default=False) results_email_sent = models.BooleanField(default=False)
group = models.ForeignKey("CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True) group = models.ForeignKey(
"CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True
)
class Meta: class Meta:
indexes = [ indexes = [models.Index(fields=["cid"])]
models.Index(fields=["cid"])
]
def __str__(self) -> str: def __str__(self) -> str:
return str(self.cid) return str(self.cid)
@@ -575,7 +782,7 @@ class CidUser(models.Model):
for exam in exams: for exam in exams:
try: try:
user_score = exam.user_scores["c/"+str(self.cid)] user_score = exam.user_scores["c/" + str(self.cid)]
except KeyError: except KeyError:
user_score = "Score not generated" user_score = "Score not generated"
t = exam.get_exam_stats() t = exam.get_exam_stats()
@@ -634,7 +841,7 @@ class CidUser(models.Model):
self.save() self.save()
return True, "" return True, ""
def email_details(self, resend: bool=False) -> Tuple[bool, str]: def email_details(self, resend: bool = False) -> Tuple[bool, str]:
"""Email candidate details to the CID user """Email candidate details to the CID user
Args: Args:
@@ -648,7 +855,6 @@ class CidUser(models.Model):
login_url = reverse("cid_selector") login_url = reverse("cid_selector")
msg = f""" msg = f"""
See below for your details for: {self.group} See below for your details for: {self.group}
@@ -682,7 +888,7 @@ class CidUser(models.Model):
msg, msg,
"no-reply@penracourses.org.uk", "no-reply@penracourses.org.uk",
[self.email], [self.email],
#["ross.kruger@nhs.net"], # ["ross.kruger@nhs.net"],
) )
email.send(fail_silently=False) email.send(fail_silently=False)
except SMTPException as e: except SMTPException as e:
@@ -699,14 +905,16 @@ class CidUser(models.Model):
return reverse("generic:candidate_email_results", kwargs={"cid": self.cid}) return reverse("generic:candidate_email_results", kwargs={"cid": self.cid})
def get_email_results_resend_url(self): def get_email_results_resend_url(self):
return reverse("generic:candidate_email_results_resend", kwargs={"cid": self.cid}) return reverse(
"generic:candidate_email_results_resend", kwargs={"cid": self.cid}
)
def get_next_cid(): def get_next_cid():
"""Returns the next available CID""" """Returns the next available CID"""
return CidUser.objects.order_by("-cid").first().cid + 1 return CidUser.objects.order_by("-cid").first().cid + 1
class CidUserExam(models.Model): class CidUserExam(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()
@@ -715,8 +923,20 @@ class CidUserExam(models.Model):
start_time = models.DateTimeField(blank=True, null=True) start_time = models.DateTimeField(blank=True, null=True)
end_time = models.DateTimeField(blank=True, null=True) end_time = models.DateTimeField(blank=True, null=True)
cid_user = models.ForeignKey(CidUser, on_delete=models.CASCADE, blank=True, null=True, related_name="cid_userexam") cid_user = models.ForeignKey(
user_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name="user_userexam") CidUser,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="cid_userexam",
)
user_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="user_userexam",
)
manual_submission = models.BooleanField(default=False) manual_submission = models.BooleanField(default=False)
@@ -728,7 +948,10 @@ class CidUserExam(models.Model):
user = self.user_user.username user = self.user_user.username
else: else:
user = self.cid_user.cid user = self.cid_user.cid
return f"{user}: {self.start_time:%Y-%m-%d %H:%M} {self.end_time:%Y-%m-%d %H:%M}" return (
f"{user}: {self.start_time:%Y-%m-%d %H:%M} {self.end_time:%Y-%m-%d %H:%M}"
)
CID_GROUP_EXAMS = ( CID_GROUP_EXAMS = (
("SBAs", "sba_cid_user_groups"), ("SBAs", "sba_cid_user_groups"),
@@ -738,6 +961,7 @@ CID_GROUP_EXAMS = (
("Longs", "longs_cid_user_groups"), ("Longs", "longs_cid_user_groups"),
) )
class CidUserGroup(models.Model): class CidUserGroup(models.Model):
name = models.CharField(blank=True, max_length=50) name = models.CharField(blank=True, max_length=50)
archive = models.BooleanField(default=False) archive = models.BooleanField(default=False)
@@ -760,7 +984,6 @@ class CidUserGroup(models.Model):
return dict(exams) return dict(exams)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("generic:cid_group_update", kwargs={"pk": self.pk}) return reverse("generic:cid_group_update", kwargs={"pk": self.pk})
@@ -773,9 +996,15 @@ USER_GROUP_EXAMS = (
("Longs", "longs_user_user_groups"), ("Longs", "longs_user_user_groups"),
) )
class UserUserGroup(models.Model): class UserUserGroup(models.Model):
name = models.CharField(blank=True, max_length=50, help_text="Name of the User Group") name = models.CharField(
archive = models.BooleanField(default=False, help_text="Archived groups remain on the test system but are not displayed by default") blank=True, max_length=50, help_text="Name of the User Group"
)
archive = models.BooleanField(
default=False,
help_text="Archived groups remain on the test system but are not displayed by default",
)
users = models.ManyToManyField( users = models.ManyToManyField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@@ -804,29 +1033,43 @@ class UserUserGroup(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("generic:user_group_update", kwargs={"pk": self.pk}) return reverse("generic:user_group_update", kwargs={"pk": self.pk})
class UserGrades(models.Model): class UserGrades(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
def __str__(self): def __str__(self):
return self.name return self.name
USER_EXAM_TYPES = ( USER_EXAM_TYPES = (
("Physics", "user_physics_exams"), ("Physics", "user_physics_exams"),
("Rapids", "user_rapid_exams"), ("Rapids", "user_rapid_exams"),
("SBAs", "user_sba_exams"), ("SBAs", "user_sba_exams"),
("Anatomy", "user_anatomy_exams"), ("Anatomy", "user_anatomy_exams"),
("Longs", "user_longs_exams"), ("Longs", "user_longs_exams"),
#("CaseCollection", "user_casecollection_exams"), # ("CaseCollection", "user_casecollection_exams"),
) )
class UserProfile(models.Model): class UserProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
supervisor = models.ForeignKey("Supervisor", on_delete=models.SET_NULL, blank=True, null=True, related_name="trainee") supervisor = models.ForeignKey(
"Supervisor",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="trainee",
)
registration_number = models.CharField(max_length=25, blank=True) registration_number = models.CharField(max_length=25, blank=True)
grade = models.ForeignKey(UserGrades, null=True, blank=True, help_text="User grade", on_delete=models.CASCADE) grade = models.ForeignKey(
UserGrades,
null=True,
blank=True,
help_text="User grade",
on_delete=models.CASCADE,
)
peninsula_trainee = models.BooleanField(default=False) peninsula_trainee = models.BooleanField(default=False)
def getusername(self): def getusername(self):
return self.user.username return self.user.username
@@ -846,22 +1089,38 @@ class UserProfile(models.Model):
return dict(available_exams) return dict(available_exams)
class Supervisor(models.Model): class Supervisor(models.Model):
"""Model to hold individual supervisor details (email and name) """Model to hold individual supervisor details (email and name)
This can be linked with a user account. This can be linked with a user account.
""" """
email = models.EmailField(unique=True, help_text="The (nhs.net) email address of the supervisor")
email = models.EmailField(
unique=True, help_text="The (nhs.net) email address of the supervisor"
)
name = models.CharField(max_length=255, help_text="Name of the supervisor") name = models.CharField(max_length=255, help_text="Name of the supervisor")
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, help_text="If the supervisor has an account on the test system that can be associated here") user = models.OneToOneField(
site = models.ForeignKey("Site", on_delete=models.SET_NULL, null=True, blank=True, help_text="Hospital site at which the supervisor is based") settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
help_text="If the supervisor has an account on the test system that can be associated here",
)
site = models.ForeignKey(
"Site",
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Hospital site at which the supervisor is based",
)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.name} ({self.email})" return f"{self.name} ({self.email})"
def get_absolute_url(self): def get_absolute_url(self):
return reverse('generic:supervisor_detail', kwargs={'pk': self.pk}) return reverse("generic:supervisor_detail", kwargs={"pk": self.pk})
def clean(self): def clean(self):
self.email = self.email.strip() self.email = self.email.strip()
@@ -873,7 +1132,7 @@ def create_user_profile(sender, instance, created, **kwargs):
if created: if created:
UserProfile.objects.create(user=instance) UserProfile.objects.create(user=instance)
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs): def save_user_profile(sender, instance, **kwargs):
instance.userprofile.save() instance.userprofile.save()
@@ -0,0 +1,18 @@
# Generated by Django 4.1.4 on 2023-06-12 09:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('longs', '0069_alter_long_feedback'),
]
operations = [
migrations.AlterField(
model_name='longseries',
name='open_access',
field=models.BooleanField(default=True, help_text='If a series should be freely available to browse'),
),
]
+3 -180
View File
@@ -32,13 +32,12 @@ from generic.models import (
CidUserGroup, CidUserGroup,
ExamUserStatus, ExamUserStatus,
Examination, Examination,
# Condition,
# Sign,
ExamBase, ExamBase,
Plane, Plane,
Contrast, Contrast,
QuestionBase, QuestionBase,
QuestionNote, QuestionNote,
SeriesBase,
UserAnswerBase, UserAnswerBase,
UserUserGroup, UserUserGroup,
) )
@@ -70,14 +69,6 @@ def image_directory_path(instance, filename):
return "longs/picture/{0}".format(filename) return "longs/picture/{0}".format(filename)
def findMiddle(input_list):
middle = float(len(input_list)) / 2
if middle % 2 != 0:
return input_list[int(middle - 0.5)]
else:
return input_list[int(middle)]
return (input_list[int(middle)], input_list[int(middle - 1)])
@reversion.register @reversion.register
class Long(QuestionBase): class Long(QuestionBase):
@@ -380,7 +371,7 @@ class LongSeriesImage(models.Model):
@reversion.register @reversion.register
class LongSeries(models.Model): class LongSeries(SeriesBase):
modality = models.ForeignKey( modality = models.ForeignKey(
Modality, related_name="series_modality", on_delete=models.SET_NULL, null=True Modality, related_name="series_modality", on_delete=models.SET_NULL, null=True
) )
@@ -407,16 +398,6 @@ class LongSeries(models.Model):
null=True, null=True,
blank=True, blank=True,
) )
# long = models.ManyToManyField(
# "Long",
# help_text="The question(s) this series should be associated with",
# related_name="series",
# blank=True,
# )
description = models.TextField(
blank=True,
help_text="Description of stack, for admin organisation, will not be visible when taking",
)
author = models.ManyToManyField( author = models.ManyToManyField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@@ -424,169 +405,11 @@ class LongSeries(models.Model):
related_name="long_series", related_name="long_series",
) )
open_access = models.BooleanField(
help_text="If a question should be freely available to browse", default=True
)
def __str__(self): def __str__(self):
# if self.long:
# long_id = ", ".format([long.pk for long in self.long.all()])
# # long_id = self.long.pk
# else:
# long_id = "None"
return "{}/{} : {}".format(self.pk, self.get_examination(), self.description) return "{}/{} : {}".format(self.pk, self.get_examination(), self.description)
def get_author_objects(self):
"""Returns a comma seperated text list of authors"""
if self.author:
return self.author.all()
else:
return ["None"]
if self.long:
authors = [i for i in self.long.author.all()]
else:
authors = []
return authors
def get_author_display(self):
return ", ".join([i.username for i in self.get_author_objects()])
def get_examination(self):
"""Returns a comma seperated text list of regions"""
return str(self.examination)
def get_examination_full(self):
examination = ""
plane = ""
contrast = ""
if self.examination:
examination = self.examination
if self.plane:
plane = " {}".format(self.plane)
if self.contrast:
contrast = " {}".format(self.contrast)
return "{}{}{}".format(examination, plane, contrast)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("longs:long_series_detail", kwargs={"pk": self.pk}) return reverse("longs:series_detail", kwargs={"pk": self.pk})
def get_image_urls(self, feedback=False):
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
return ",".join(images)
def get_image_url_array(self, feedback=False):
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
return json.dumps(images)
def get_thumbnail(self):
images = self.images.all()
if len(images) < 1:
return "No images", 0
img = findMiddle(images).image
try:
thumbnailer = get_thumbnailer(img)
thumbnail = thumbnailer["exam-list"]
except InvalidImageFormatError:
return format_html('<span title="{}">Invalid image url<span>', img), len(
images
)
return format_html('<img src="/media/{}" />', thumbnail), len(images)
def get_block(self):
examination = self.get_examination_full()
thumb, image_number = self.get_thumbnail()
return format_html(
"<div>{}<br/>{}<br/>Images: {}</div>", examination, thumb, image_number
)
def order_by_upload_filename(self):
images = self.images.all()
filenames = []
map = {}
for i in images:
filenames.append(i.upload_filename)
map[i.upload_filename] = i
filenames = sorted(filenames)
n = 1
for f in filenames:
i = map[f]
i.position = n
i.save()
n = n + 1
def order_by_dicom(self, field="SliceLocation"):
images = self.images.all()
files = []
for i in images:
files.append((i, pydicom.dcmread(i.image.path)))
# print("file count: {}".format(len(files)))
# skip files with no SliceLocation (eg scout views)
slices = []
map = {}
skipcount = 0
for i, f in files:
if hasattr(f, field):
slices.append(f)
# map[f.SliceLocation] = i
map[f[field].value] = i
else:
skipcount = skipcount + 1
print("skipped, no {}: {}".format(field, skipcount))
# ensure they are in the correct order
slices = sorted(slices, key=lambda s: s[field].value)
# print(slices)
n = 1
for f in slices:
i = map[f[field].value]
i.position = n
i.save()
n = n + 1
def get_total_image_size(self):
images = self.images.all()
size = 0
for i in images:
size += i.image.size
return size
# class LongImage(models.Model):
# long = models.ForeignKey(Long,
# 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(
# '<img src="{}" class="admin-long-image" /><span class="admin-long-image-info">Click and hold to zoom<span>'
# .format(self.image.url))
# else:
# return ""
#
# image_tag.short_description = 'Image'
class LongCreationDefault(models.Model): class LongCreationDefault(models.Model):