diff --git a/anatomy/migrations/0013_rename_exam_collection_exam_examcollection.py b/anatomy/migrations/0013_rename_exam_collection_exam_examcollection.py new file mode 100644 index 00000000..89110e86 --- /dev/null +++ b/anatomy/migrations/0013_rename_exam_collection_exam_examcollection.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.4 on 2024-02-05 10:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('anatomy', '0012_alter_exam_exam_collection'), + ] + + operations = [ + migrations.RenameField( + model_name='exam', + old_name='exam_collection', + new_name='examcollection', + ), + ] diff --git a/anatomy/models.py b/anatomy/models.py index 6ae5699f..4f1b6f5a 100644 --- a/anatomy/models.py +++ b/anatomy/models.py @@ -404,7 +404,7 @@ class Exam(ExamBase): exam_user_status = GenericRelation(ExamUserStatus) - exam_collection = models.ForeignKey(ExamCollection, blank=True, null=True, on_delete=models.SET_NULL, related_name="anatomy_exams") + examcollection = models.ForeignKey(ExamCollection, blank=True, null=True, on_delete=models.SET_NULL, related_name="anatomy_exams") def get_exam_json(self, based=True): questions = self.exam_questions.all() diff --git a/generic/forms.py b/generic/forms.py index 2457c733..69cdb7d7 100755 --- a/generic/forms.py +++ b/generic/forms.py @@ -17,6 +17,7 @@ from generic.models import ( CidUser, CidUserGroup, Examination, + ExamCollection, QuestionNote, Supervisor, UserGrades, @@ -333,6 +334,7 @@ class UserGroupExamForm(ModelForm): "physics_user_user_groups", "sba_user_user_groups", ] + # users = UserUserGroupModelChoiceField( # required=False, # queryset=User.objects.all(), @@ -346,10 +348,10 @@ class UserGroupExamForm(ModelForm): # The widget for a ModelMultipleChoiceField expects # a list of primary key for the selected data. for groups in self.GROUP_TYPES: - #instance.anatomy_cid_user_groups.clear() - #obj = getattr(instance,groups) + # instance.anatomy_cid_user_groups.clear() + # obj = getattr(instance,groups) initial[groups] = [ - t.pk for t in getattr(kwargs["instance"],groups).all() + t.pk for t in getattr(kwargs["instance"], groups).all() ] ModelForm.__init__(self, *args, **kwargs) @@ -401,8 +403,8 @@ class UserGroupExamForm(ModelForm): old_save_m2m() for groups in self.GROUP_TYPES: - #instance.anatomy_cid_user_groups.clear() - obj = getattr(instance,groups) + # instance.anatomy_cid_user_groups.clear() + obj = getattr(instance, groups) obj.clear() for exam in self.cleaned_data[groups]: obj.add(exam.pk) @@ -438,10 +440,10 @@ class CidGroupExamForm(ModelForm): # The widget for a ModelMultipleChoiceField expects # a list of primary key for the selected data. for groups in self.GROUP_TYPES: - #instance.anatomy_cid_user_groups.clear() - #obj = getattr(instance,groups) + # instance.anatomy_cid_user_groups.clear() + # obj = getattr(instance,groups) initial[groups] = [ - t.pk for t in getattr(kwargs["instance"],groups).all() + t.pk for t in getattr(kwargs["instance"], groups).all() ] ModelForm.__init__(self, *args, **kwargs) @@ -493,8 +495,8 @@ class CidGroupExamForm(ModelForm): old_save_m2m() for groups in self.GROUP_TYPES: - #instance.anatomy_cid_user_groups.clear() - obj = getattr(instance,groups) + # instance.anatomy_cid_user_groups.clear() + obj = getattr(instance, groups) obj.clear() for exam in self.cleaned_data[groups]: obj.add(exam.pk) @@ -549,3 +551,75 @@ class SupervisorForm(ModelForm): class Meta: model = Supervisor exclude = ("",) + + +class ExamCollectionForm(ModelForm): + GROUP_TYPES = ( + ("Rapid Exams", "rapids_exams", RapidsExam), + ("Long Exams", "longs_exams", LongsExam), + ("SBA Exams", "sbas_exams", SbasExam), + ("Physic Exams", "physics_exams", PhysicsExam), + ("Anatomy Exams", "anatomy_exams", AnatomyExam), + ) + + class Meta: + model = ExamCollection + exclude = ("",) + + def __init__(self, *args, **kwargs): + if kwargs.get("instance"): + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + initial = kwargs.setdefault("initial", {}) + # The widget for a ModelMultipleChoiceField expects + # a list of primary key for the selected data. + for name, reverse_prop, exam_obj in self.GROUP_TYPES: + # instance.anatomy_cid_user_groups.clear() + # obj = getattr(instance,groups) + initial[reverse_prop] = [ + t.pk for t in getattr(kwargs["instance"], reverse_prop).all() + ] + + ModelForm.__init__(self, *args, **kwargs) + + super(ExamCollectionForm, self).__init__(*args, **kwargs) + + for name, reverse_prop, exam_obj in self.GROUP_TYPES: + self.fields[reverse_prop] = ModelMultipleChoiceField( + required=False, + queryset=exam_obj.objects.filter(archive=False), + widget=FilteredSelectMultiple( + verbose_name=name, is_stacked=False + ), + ) + + def save(self, commit=True): + # Get the unsaved Long instance + instance = ModelForm.save(self, False) + + # Prepare a 'save_m2m' method for the form, + old_save_m2m = self.save_m2m + + def save_m2m(): + old_save_m2m() + + for name, reverse_prop, exam_obj in self.GROUP_TYPES: + # instance.anatomy_cid_user_groups.clear() + obj = getattr(instance, reverse_prop) + obj.clear() + for exam in self.cleaned_data[reverse_prop]: + obj.add(exam) + + self.save_m2m = save_m2m + + # Do we need to save all changes now? + # if commit: + instance.save() + self.save_m2m() + + return instance + +class ExamCollectionCloneForm(ModelForm): + class Meta: + model = ExamCollection + fields = ("name", "date") diff --git a/generic/migrations/0013_examcollection_archive.py b/generic/migrations/0013_examcollection_archive.py new file mode 100644 index 00000000..c83acb1b --- /dev/null +++ b/generic/migrations/0013_examcollection_archive.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.4 on 2024-02-05 09:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generic', '0012_examcollection_author'), + ] + + operations = [ + migrations.AddField( + model_name='examcollection', + name='archive', + field=models.BooleanField(default=False), + ), + ] diff --git a/generic/models.py b/generic/models.py index 45ac6d75..b2a26a16 100644 --- a/generic/models.py +++ b/generic/models.py @@ -39,6 +39,7 @@ from easy_thumbnails.exceptions import InvalidImageFormatError import pydicom import dicognito.anonymizer from django.contrib.auth.models import User +from django.forms.models import model_to_dict def findMiddle(input_list): @@ -49,8 +50,6 @@ def findMiddle(input_list): return input_list[int(middle)] - - class Modality(models.Model): modality = models.CharField(max_length=200, unique=True) short_code = models.CharField(max_length=5, unique=True, null=True) @@ -223,7 +222,6 @@ class SeriesImageBase(models.Model): help_text="Set to true if the file this object refers to has been removed (either from series truncation or merging)", ) - class Meta: ordering = ["position"] abstract = True @@ -244,7 +242,9 @@ class SeriesImageBase(models.Model): def generate_md5_hash(self): if not self.removed: - image_hash, is_dicom = get_image_hash(self.image, hash_type="md5", direct_pixel_data=False) + image_hash, is_dicom = get_image_hash( + self.image, hash_type="md5", direct_pixel_data=False + ) self.is_dicom = is_dicom self.image_md5_hash = image_hash @@ -253,7 +253,9 @@ class SeriesImageBase(models.Model): def generate_blake3_hash(self): if not self.removed: - image_blake3_hash, is_dicom = get_image_hash(self.image, hash_type="blake3", direct_pixel_data=True) + image_blake3_hash, is_dicom = get_image_hash( + self.image, hash_type="blake3", direct_pixel_data=True + ) self.is_dicom = is_dicom self.image_blake3_hash = image_blake3_hash @@ -262,19 +264,21 @@ class SeriesImageBase(models.Model): def save(self, *args, **kwargs): """Override save method to add image hash""" if self.image: - #if not self.image_md5_hash: + # if not self.image_md5_hash: # image_hash, is_dicom = get_image_hash(self.image, hash_type="md5", direct_pixel_data=False) # self.is_dicom = is_dicom # self.image_md5_hash = image_hash if not self.image_blake3_hash: - image_blake3_hash, is_dicom = get_image_hash(self.image, hash_type="blake3", direct_pixel_data=True) + image_blake3_hash, is_dicom = get_image_hash( + self.image, hash_type="blake3", direct_pixel_data=True + ) self.is_dicom = is_dicom self.image_blake3_hash = image_blake3_hash ## Hack for tests - #if image_hash != "12345ABCD": + # if image_hash != "12345ABCD": # super().save(*args, **kwargs) # Call the "real" save() method. super().save(*args, **kwargs) # Call the "real" save() method. @@ -301,12 +305,24 @@ class SeriesBase(models.Model): modified_date = models.DateTimeField(auto_now=True) class SeriesModifiedChocies(models.TextChoices): - NO = "NO", "Not modified", - TR = "TR", "Truncated", - RE = "RE", "Resliced/resampled", + NO = ( + "NO", + "Not modified", + ) + TR = ( + "TR", + "Truncated", + ) + RE = ( + "RE", + "Resliced/resampled", + ) - - modified = models.CharField(max_length=2, default=SeriesModifiedChocies.NO, choices=SeriesModifiedChocies.choices) + modified = models.CharField( + max_length=2, + default=SeriesModifiedChocies.NO, + choices=SeriesModifiedChocies.choices, + ) class Meta: abstract = True @@ -317,7 +333,7 @@ class SeriesBase(models.Model): def check_user_can_edit(self, user: User): if user.is_superuser: return True - + if user in self.get_author_objects(): return True @@ -558,7 +574,7 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin): def check_user_can_edit(self, user: User): if user.is_superuser: return True - + if user in self.get_author_objects(): return True @@ -720,6 +736,48 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin): content_type=content_type, object_id=self.pk, user_user=user_user ) + def clone_model(self): + M2M_fields = ( + "exam_questions", + "author", + "valid_cid_users", + "valid_user_users", + "cid_user_groups", + "user_user_groups", + ) + FK_fields = ( + "examcollection", + ) + + model_dict = model_to_dict(self) + + m2m_to_update = {} + for field in M2M_fields: + m2m_to_update[field] = model_dict[field] + del model_dict[field] + fk_to_update = {} + for field in FK_fields: + fk_to_update[field] = model_dict[field] + del model_dict[field] + + cloned_model = self.__class__(**model_dict) + cloned_model.pk = None + + for field in fk_to_update: + setattr(cloned_model, f"{field}_id", fk_to_update[field]) + + cloned_model.save() + + for field in m2m_to_update: + rel = getattr(cloned_model, field) + rel.clear() + for item in m2m_to_update[field]: + rel.add(item) + + + + return cloned_model + class ExamBase(ExamOrCollectionGenericBase): exam_mode = models.BooleanField( @@ -831,7 +889,7 @@ class ExamBase(ExamOrCollectionGenericBase): def get_question_index(self, question): return list(self.exam_questions.all()).index(question) - def get_question_by_index(self, index:int): + def get_question_by_index(self, index: int): # There must be a better way to do this return list(self.exam_questions.all())[index] @@ -890,7 +948,7 @@ class ExamBase(ExamOrCollectionGenericBase): """ if self.app_name == "rapids": - html_msg =f"{html_msg}
{rapids_extra}" + html_msg = f"{html_msg}
{rapids_extra}" return msg, html_msg @@ -914,7 +972,6 @@ class ExamBase(ExamOrCollectionGenericBase): else: extra = "No supervisor" - if additional_emails is not None: emails.extend(additional_emails) @@ -1505,10 +1562,13 @@ def create_user_profile(sender, instance, created, **kwargs): def save_user_profile(sender, instance, **kwargs): instance.userprofile.save() + class ExamCollection(models.Model, AuthorMixin): name = models.CharField(max_length=255) - date = models.DateField(blank=True,null=True) + date = models.DateField(blank=True, null=True) + + archive = models.BooleanField(default=False) author = models.ManyToManyField( settings.AUTH_USER_MODEL, @@ -1518,4 +1578,6 @@ class ExamCollection(models.Model, AuthorMixin): def __str__(self): return f"{self.name} [{self.date}]" - \ No newline at end of file + + def get_absolute_url(self): + return reverse("generic:examcollection_detail", kwargs={"pk": self.pk}) diff --git a/generic/templates/generic/base.html b/generic/templates/generic/base.html index 559b5139..bde61e8c 100755 --- a/generic/templates/generic/base.html +++ b/generic/templates/generic/base.html @@ -23,7 +23,7 @@ User Groups / Manage Users / Manage Supervisors / - Collections + Collections {% endif %} {% endblock %} diff --git a/generic/templates/generic/exam_overview_headers.html b/generic/templates/generic/exam_overview_headers.html index eeac7ca4..dbe291cf 100644 --- a/generic/templates/generic/exam_overview_headers.html +++ b/generic/templates/generic/exam_overview_headers.html @@ -49,9 +49,17 @@ Open access: {{ exam.open_access }}
{% if exam.exam_mode %}
Publish results: [When checked the exam results will be available to users on this site] + {% if exam.publish_results %}checked{% endif %}> [When checked the exam results will be available to users on this site]
{% if app_name == "anatomy" or app_name == "rapids" or app_name == "longs" %}

{% endif %} {% endif %} + + +{% if exam.examcollection %} + Exam Collection: {{ exam.examcollection }}
+{% endif %} +Author(s): {% for author in exam.author.all %} + {{ author }}{% if not forloop.last %}, {% endif %} +{% endfor %}
diff --git a/generic/templates/generic/examcollection_base.html b/generic/templates/generic/examcollection_base.html index ee20e988..35cbc6a5 100644 --- a/generic/templates/generic/examcollection_base.html +++ b/generic/templates/generic/examcollection_base.html @@ -3,7 +3,8 @@ {% block navigation %} {{block.super}} -
Test +
{% comment %} Collections / {% endcomment %} + Create Exam Collection {% endblock %} \ No newline at end of file diff --git a/generic/templates/generic/examcollection_clone_form.html b/generic/templates/generic/examcollection_clone_form.html new file mode 100755 index 00000000..fede68c5 --- /dev/null +++ b/generic/templates/generic/examcollection_clone_form.html @@ -0,0 +1,65 @@ +{% extends "generic/base.html" %} + + +{% load crispy_forms_tags %} + +{% block css %} +{% endblock %} +{% block js %} + +{{form.media}} + + + + +{% endblock %} +{% block content %} + + +

Clone Exam Collection {{object.name}}

+Clones the collection. This will maintain the collection name but will create clones of all of the associated exams. +
+ {% csrf_token %} + {{ form|crispy }} + + +

The following exams will be cloned
+

Anatomy Exams:

+ + +

Longs Exams:

+ + +

SBAs Exams:

+ + +

Physics Exams:

+ + +

Rapids Exams:

+ +

+ +
+{% endblock %} \ No newline at end of file diff --git a/generic/templates/generic/examcollection_detail.html b/generic/templates/generic/examcollection_detail.html index 512ea833..3b44338e 100755 --- a/generic/templates/generic/examcollection_detail.html +++ b/generic/templates/generic/examcollection_detail.html @@ -4,7 +4,49 @@

{{ object.name }} [{{object.date}}]

-{{object.anatomy_exams}} +

+ Edit + Clone + Delete +

+ +This collection contains the following exams + + +

Anatomy Exams:

+ + +

Longs Exams:

+ + +

Rapids Exams:

+ + +

Physics Exams:

+ + +

SBAs Exams:

+ diff --git a/generic/templates/generic/examcollection_form.html b/generic/templates/generic/examcollection_form.html new file mode 100755 index 00000000..5ff99020 --- /dev/null +++ b/generic/templates/generic/examcollection_form.html @@ -0,0 +1,26 @@ +{% extends "generic/base.html" %} + + +{% load crispy_forms_tags %} + +{% block css %} +{% endblock %} +{% block js %} + +{{form.media}} + + + + +{% endblock %} +{% block content %} + + +

Edit Exam Collection {{object.name}}

+
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} \ No newline at end of file diff --git a/generic/templates/generic/examcollection_list.html b/generic/templates/generic/examcollection_list.html index 7c6553d8..b2a7487b 100755 --- a/generic/templates/generic/examcollection_list.html +++ b/generic/templates/generic/examcollection_list.html @@ -5,7 +5,7 @@

Exam Collections