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 dicognito.anonymizer
from django.core.files.storage import FileSystemStorage
from django.conf import settings
@@ -43,13 +41,11 @@ from generic.models import (
Plane,
Contrast,
QuestionNote,
SeriesBase,
)
# 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 datetime
from django.utils import timezone
@@ -343,7 +339,6 @@ class SeriesImage(models.Model):
class SeriesFinding(models.Model):
series = models.ForeignKey(
"Series", related_name="findings", on_delete=models.SET_NULL, null=True
)
@@ -364,7 +359,7 @@ class SeriesFinding(models.Model):
@reversion.register
class Series(models.Model):
class Series(SeriesBase):
modality = models.ForeignKey(
Modality,
related_name="atlas_series_modality",
@@ -394,10 +389,6 @@ class Series(models.Model):
null=True,
blank=True,
)
description = models.TextField(
blank=True,
help_text="Description of stack",
)
author = models.ManyToManyField(
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")
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):
if self.case:
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
)
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):
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):
@@ -647,8 +474,11 @@ class CaseCollection(models.Model):
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)
exam_mode = models.BooleanField(default=False)
@@ -693,7 +523,6 @@ class CaseCollection(models.Model):
return True
if self.valid_cid_users.exists():
user = self.valid_cid_users.filter(cid=cid).first()
if not user or user.passcode != passcode:
@@ -771,13 +600,13 @@ class BaseReportAnswer(models.Model):
class Meta:
abstract = True
class CidReportAnswer(BaseReportAnswer):
cid = models.BigIntegerField(
blank=False,
help_text="Candidate ID",
)
class UserReportAnswer(BaseReportAnswer):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE
)
user = models.ForeignKey(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():
blocks.append(obj.get_block())
return format_html(
"<span class='multi-image-block'>{}</span>".format("".join(blocks))
"""<span class='multi-image-block'>
{}
</span>""".format("".join(blocks))
)
obj = value.first()
+329 -70
View File
@@ -1,4 +1,5 @@
from collections import defaultdict
import json
from typing import Tuple
from django.contrib.auth.mixins import LoginRequiredMixin
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.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):
@@ -57,16 +74,19 @@ class Examination(models.Model):
class Site(models.Model):
"""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):
return self.short_code
class QuestionBase(models.Model):
authors_only = models.BooleanField(
help_text="If true only question authors will be able to view.",
default=False,
@@ -85,7 +105,7 @@ class QuestionBase(models.Model):
class Meta:
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
"""
@@ -96,6 +116,181 @@ class QuestionBase(models.Model):
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):
name = models.CharField(max_length=200, help_text="Name of the exam")
# 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_graph = models.TextField(default=0)
# stats_graph = models.TextField(default=0)
user_scores = models.JSONField(default=dict)
@@ -162,7 +357,6 @@ class ExamBase(models.Model):
exam_results_emailed = models.DateTimeField(default=None, null=True)
class Meta:
abstract = True
@@ -191,10 +385,14 @@ class ExamBase(models.Model):
return reverse("{}:exam_overview".format(self.app_name), kwargs={"pk": self.pk})
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):
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):
return f"{settings.REMOTE_URL}/rts"
@@ -206,7 +404,9 @@ class ExamBase(models.Model):
if cid is None:
return reverse("{}:exam_json".format(self.app_name), args=(self.pk,))
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):
return list(self.exam_questions.all()).index(question)
@@ -223,7 +423,14 @@ class ExamBase(models.Model):
authors = [i for i in self.author.all()]
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}")
if request is not None and request.user.is_superuser:
return True
@@ -243,7 +450,6 @@ class ExamBase(models.Model):
# Then test CID data
if self.valid_cid_users.exists():
cid_user = self.valid_cid_users.filter(cid=cid).first()
if cid_user and cid_user.passcode == passcode:
@@ -253,7 +459,9 @@ class ExamBase(models.Model):
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)
if cid_user is not None:
@@ -270,11 +478,9 @@ class ExamBase(models.Model):
if c:
return c
if start_time is None:
start_time = timezone.now()
if cid_user is not None:
new = CidUserExam(
content_type=content_type,
@@ -312,7 +518,7 @@ class ExamBase(models.Model):
)
def get_cid_user_score(self, cid_user):
c = "c/"+str(cid_user)
c = "c/" + str(cid_user)
if c in self.user_scores:
return self.user_scores[c]
else:
@@ -326,41 +532,35 @@ class ExamBase(models.Model):
exam_text.append(f"{'='*len(exam_name)}\n{exam_name}\n{'='*len(exam_name)}")
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":
callstate = self.user_scores["u/"+str(user.pk)]["callstates"]
callstate = self.user_scores["u/" + str(user.pk)]["callstates"]
except KeyError:
user_score = "Score not generated"
try:
percentage = (
f" ({user_score / int(self.stats_max_possible) * 100:.2f}%)"
)
percentage = f" ({user_score / int(self.stats_max_possible) * 100:.2f}%)"
except TypeError:
percentage = ""
exam_text.append(
f"""Results\n{'-'*len('Results')}
f"""Results\n{'-'*len('Results')}
# Score: {user_score} / {int(self.stats_max_possible)}{percentage}
"""
)
)
stats = self.get_exam_stats(name=False)
exam_text.append(f"Stats\n{'-'*len('Stats')}\n{stats}")
if self.app_name == "rapids":
exam_text.append(
f"Answers breakdown: {callstate}"
)
exam_text.append(f"Answers breakdown: {callstate}")
msg = "\n\n".join(exam_text)
return msg
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."
# Get a list of taken exams
@@ -376,7 +576,6 @@ class ExamBase(models.Model):
if supervisor_email:
emails.append(supervisor_email)
if additional_emails is not None:
emails.extend(additional_emails)
@@ -392,14 +591,19 @@ class ExamBase(models.Model):
except SMTPException as e:
return [False, e]
#self.results_email_sent = True
#self.save()
# self.results_email_sent = True
# self.save()
return [True, ""]
class ExamUserStatus(models.Model):
datetime = models.DateTimeField(auto_now_add=True)
cid_user = models.ForeignKey("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)
cid_user = models.ForeignKey(
"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)
extra = models.CharField(max_length=255)
@@ -413,10 +617,9 @@ class ExamUserStatus(models.Model):
else:
user = self.user_user.username
return f"{self.datetime}: {user} - {self.status} ({self.extra})"
class UserAnswerBase(models.Model):
cid = models.BigIntegerField(
blank=True,
null=True,
@@ -426,16 +629,13 @@ class UserAnswerBase(models.Model):
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
ordering = ["cid"]
def __str__(self) -> str:
return "{}/{}/{}".format(self.get_candidate_name(), self.exam, self.question.pk)
def get_candidate_name(self) -> str:
if self.cid is not None:
return str(self.cid)
@@ -454,7 +654,7 @@ class UserAnswerBase(models.Model):
def get_answer(self):
"""might need overriding"""
return self.answer
class NoteType(models.Model):
note_type = models.CharField(max_length=200)
@@ -512,6 +712,7 @@ class QuestionNote(models.Model):
def get_short_str(self):
return f"{self.note_type} - {self.note}"
EXAM_TYPES = (
("physics", "physics_exams"),
("rapids", "rapid_exams"),
@@ -521,25 +722,31 @@ EXAM_TYPES = (
("casecollection", "casecollection_exams"),
)
class CidUser(models.Model):
class CidUser(models.Model):
cid = models.IntegerField(blank=True, null=True, unique=True)
passcode = models.CharField(blank=True, max_length=50)
active = models.BooleanField(default=True)
internal_candidate = models.BooleanField(default=False)
name = models.CharField(blank=True, max_length=255)
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)
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:
indexes = [
models.Index(fields=["cid"])
]
indexes = [models.Index(fields=["cid"])]
def __str__(self) -> str:
return str(self.cid)
@@ -575,7 +782,7 @@ class CidUser(models.Model):
for exam in exams:
try:
user_score = exam.user_scores["c/"+str(self.cid)]
user_score = exam.user_scores["c/" + str(self.cid)]
except KeyError:
user_score = "Score not generated"
t = exam.get_exam_stats()
@@ -634,7 +841,7 @@ class CidUser(models.Model):
self.save()
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
Args:
@@ -648,7 +855,6 @@ class CidUser(models.Model):
login_url = reverse("cid_selector")
msg = f"""
See below for your details for: {self.group}
@@ -682,7 +888,7 @@ class CidUser(models.Model):
msg,
"no-reply@penracourses.org.uk",
[self.email],
#["ross.kruger@nhs.net"],
# ["ross.kruger@nhs.net"],
)
email.send(fail_silently=False)
except SMTPException as e:
@@ -699,14 +905,16 @@ class CidUser(models.Model):
return reverse("generic:candidate_email_results", kwargs={"cid": self.cid})
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():
"""Returns the next available CID"""
return CidUser.objects.order_by("-cid").first().cid + 1
class CidUserExam(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
@@ -715,8 +923,20 @@ class CidUserExam(models.Model):
start_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")
user_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name="user_userexam")
cid_user = models.ForeignKey(
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)
@@ -728,7 +948,10 @@ class CidUserExam(models.Model):
user = self.user_user.username
else:
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 = (
("SBAs", "sba_cid_user_groups"),
@@ -738,6 +961,7 @@ CID_GROUP_EXAMS = (
("Longs", "longs_cid_user_groups"),
)
class CidUserGroup(models.Model):
name = models.CharField(blank=True, max_length=50)
archive = models.BooleanField(default=False)
@@ -760,11 +984,10 @@ class CidUserGroup(models.Model):
return dict(exams)
def get_absolute_url(self):
return reverse("generic:cid_group_update", kwargs={"pk": self.pk})
USER_GROUP_EXAMS = (
("SBAs", "sba_user_user_groups"),
("Physics", "physics_user_user_groups"),
@@ -773,9 +996,15 @@ USER_GROUP_EXAMS = (
("Longs", "longs_user_user_groups"),
)
class UserUserGroup(models.Model):
name = models.CharField(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")
name = models.CharField(
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(
settings.AUTH_USER_MODEL,
@@ -803,30 +1032,44 @@ class UserUserGroup(models.Model):
def get_absolute_url(self):
return reverse("generic:user_group_update", kwargs={"pk": self.pk})
class UserGrades(models.Model):
name = models.CharField(max_length=100)
def __str__(self):
return self.name
USER_EXAM_TYPES = (
("Physics", "user_physics_exams"),
("Rapids", "user_rapid_exams"),
("SBAs", "user_sba_exams"),
("Anatomy", "user_anatomy_exams"),
("Longs", "user_longs_exams"),
#("CaseCollection", "user_casecollection_exams"),
# ("CaseCollection", "user_casecollection_exams"),
)
class UserProfile(models.Model):
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)
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)
def getusername(self):
return self.user.username
@@ -846,34 +1089,50 @@ class UserProfile(models.Model):
return dict(available_exams)
class Supervisor(models.Model):
"""Model to hold individual supervisor details (email and name)
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")
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")
site = models.ForeignKey("Site", on_delete=models.SET_NULL, null=True, blank=True, help_text="Hospital site at which the supervisor is based")
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",
)
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)
def __str__(self) -> str:
return f"{self.name} ({self.email})"
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):
self.email = self.email.strip()
self.name = self.name.strip()
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
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,
ExamUserStatus,
Examination,
# Condition,
# Sign,
ExamBase,
Plane,
Contrast,
QuestionBase,
QuestionNote,
SeriesBase,
UserAnswerBase,
UserUserGroup,
)
@@ -70,14 +69,6 @@ def image_directory_path(instance, 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
class Long(QuestionBase):
@@ -380,7 +371,7 @@ class LongSeriesImage(models.Model):
@reversion.register
class LongSeries(models.Model):
class LongSeries(SeriesBase):
modality = models.ForeignKey(
Modality, related_name="series_modality", on_delete=models.SET_NULL, null=True
)
@@ -407,16 +398,6 @@ class LongSeries(models.Model):
null=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(
settings.AUTH_USER_MODEL,
@@ -424,169 +405,11 @@ class LongSeries(models.Model):
related_name="long_series",
)
open_access = models.BooleanField(
help_text="If a question should be freely available to browse", default=True
)
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)
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):
return reverse("longs:long_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'
return reverse("longs:series_detail", kwargs={"pk": self.pk})
class LongCreationDefault(models.Model):