diff --git a/atlas/migrations/0050_alter_series_description.py b/atlas/migrations/0050_alter_series_description.py new file mode 100644 index 00000000..f9670a8e --- /dev/null +++ b/atlas/migrations/0050_alter_series_description.py @@ -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'), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index 6b6d427d..1a2b9770 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -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('Invalid image url', img), len( - images - ) - return format_html('', thumbnail), len(images) - - def get_thumbnail_link(self): - return format_html( - "{}", 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( - "
{}
{}
Images: {}
", 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 - ) \ No newline at end of file + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) diff --git a/atlas/tables.py b/atlas/tables.py index 1a69ea85..3e1f1575 100755 --- a/atlas/tables.py +++ b/atlas/tables.py @@ -25,7 +25,9 @@ class AtlasImageColumn(tables.Column): for obj in value.all(): blocks.append(obj.get_block()) return format_html( - "{}".format("".join(blocks)) + """ + {} + """.format("".join(blocks)) ) obj = value.first() diff --git a/generic/models.py b/generic/models.py index c723c3a9..a6d9cf06 100644 --- a/generic/models.py +++ b/generic/models.py @@ -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('Invalid image url', img), len( + images + ) + return format_html('', thumbnail), len(images) + + def get_thumbnail_link(self): + return format_html( + "
{}", 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( + """ + {}
+ {}
+ Images: {} +
""", + 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() - diff --git a/longs/migrations/0070_remove_long_condition_remove_long_sign_and_more.py b/longs/migrations/0070_remove_long_condition_remove_long_sign_and_more.py new file mode 100644 index 00000000..e8a7cdd5 --- /dev/null +++ b/longs/migrations/0070_remove_long_condition_remove_long_sign_and_more.py @@ -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'), + ), + ] diff --git a/longs/models.py b/longs/models.py index f7daac8f..666a854d 100644 --- a/longs/models.py +++ b/longs/models.py @@ -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('Invalid image url', img), len( - images - ) - return format_html('', thumbnail), len(images) - - def get_block(self): - examination = self.get_examination_full() - thumb, image_number = self.get_thumbnail() - return format_html( - "
{}
{}
Images: {}
", 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( -# 'Click and hold to zoom' -# .format(self.image.url)) -# else: -# return "" -# -# image_tag.short_description = 'Image' + return reverse("longs:series_detail", kwargs={"pk": self.pk}) class LongCreationDefault(models.Model):