Repository: comic/grand-challenge.org

Issue:
Create a management command to remove a complete archive with all related patients, studies, images, annotations, registrations, etc
The current "Rotterdam Study 1" archive has to be removed and reuploaded. Although this might not happen often, doing this manually in the admin is not time efficient and error-prone. Therefore, I think we should develop a way in which this can be done easier. There are a few difficulties when doing this because of the way the models are set up:

- Removing an `Archive` will leave all other data intact.

- Removing a `Patient` or `Study` is not possible when it has related images since the study foreign key field on an image contains `on_delete=models.PROTECT`.

- Removing an `Image` will also remove all related annotations, but not any related `Study`/`Patient`/`Archive`/`ImageFile`.

I think the best way to achieve this is to make a management command with `Archive.id` as input that removes the Archive and all of the related models.





Discussion:
I'm not so sure about this - perhaps we should change the `PROTECT` statement and update the delete methods on the models so that everything is cleaned up?
Just discussed this IRL. Conclusion:
- Change all the `on_delete` attributes on the relations to cascade. 
- Extend the `delete` method on an archive to find all related patients (through `image_set.study.patient`) and remove those. If all `on_delete` attributes are changed this should fire a cascading delete for all related `Study`/`Patient`/`Archive`/`ImageFile`/`Annotation` models

One thing to keep in mind: When removing an `Archive` we should make sure that all images belonging to it may be deleted, since they could also belong to other Archives which they will then also be removed from.

Resolving patch:
diff --git a/app/grandchallenge/archives/models.py b/app/grandchallenge/archives/models.py
--- a/app/grandchallenge/archives/models.py
+++ b/app/grandchallenge/archives/models.py
@@ -1,6 +1,10 @@
 from django.db import models
+from django.db.models import Count
+
 from grandchallenge.core.models import UUIDModel
 from grandchallenge.cases.models import Image
+from grandchallenge.patients.models import Patient
+from grandchallenge.studies.models import Study
 
 
 class Archive(UUIDModel):
@@ -13,4 +17,57 @@ class Archive(UUIDModel):
     images = models.ManyToManyField(Image)
 
     def __str__(self):
-        return "<{} {}>".format(self.__class__.__name__, self.name)
+        return f"<{self.__class__.__name__} {self.name}>"
+
+    def delete(self, *args, **kwargs):
+        """
+        Removes all patients, studies, images, imagefiles and annotations that belong
+        exclusively to this archive
+        """
+
+        def find_protected_studies_and_patients(images):
+            """
+            Returns a tuple containing a set of Study ids and a set of Patient ids
+            that are "protected". Where "protected" means that these Study and Patient
+            objects contain images that are not in the given list of images. Therefore,
+            when deleting an archive and it's related objects, these Study and Patient
+            objects should not be deleted since that would also delete other images,
+            because of the cascading delete behavior of the many-to-one relation.
+            :param images: list of image objects that are going to be removed
+            :return: tuple containing a set of Study ids and a set of Patient ids
+            that should not be removed
+            """
+            protected_study_ids = set()
+            protected_patient_ids = set()
+            for image in images:
+                if image.study is None:
+                    continue
+                for other_study_image in image.study.image_set.all():
+                    if other_study_image not in images_to_remove:
+                        protected_study_ids.add(image.study.id)
+                        protected_patient_ids.add(image.study.patient.id)
+                        break
+
+            return protected_study_ids, protected_patient_ids
+
+        images_to_remove = (
+            Image.objects.annotate(num_archives=Count("archive"))
+            .filter(archive__id=self.id, num_archives=1)
+            .all()
+        )
+
+        protected_study_ids, protected_patient_ids = find_protected_studies_and_patients(
+            images_to_remove
+        )
+
+        Patient.objects.filter(
+            study__image__in=images_to_remove
+        ).distinct().exclude(pk__in=protected_patient_ids).delete(
+            *args, **kwargs
+        )
+        Study.objects.filter(image__in=images_to_remove).distinct().exclude(
+            pk__in=protected_study_ids
+        ).delete(*args, **kwargs)
+        images_to_remove.delete(*args, **kwargs)
+
+        super().delete(*args, **kwargs)
diff --git a/app/grandchallenge/cases/migrations/0014_auto_20190828_1655.py b/app/grandchallenge/cases/migrations/0014_auto_20190828_1655.py
new file mode 100644
--- /dev/null
+++ b/app/grandchallenge/cases/migrations/0014_auto_20190828_1655.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.2.4 on 2019-08-28 16:55
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("cases", "0013_rawimageuploadsession_reader_study")]
+
+    operations = [
+        migrations.AlterField(
+            model_name="image",
+            name="study",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to="studies.Study",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="imagefile",
+            name="image",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="files",
+                to="cases.Image",
+            ),
+        ),
+    ]
diff --git a/app/grandchallenge/cases/models.py b/app/grandchallenge/cases/models.py
--- a/app/grandchallenge/cases/models.py
+++ b/app/grandchallenge/cases/models.py
@@ -224,7 +224,7 @@ class Image(UUIDModel):
     )
 
     name = models.CharField(max_length=128)
-    study = models.ForeignKey(Study, on_delete=models.PROTECT, null=True)
+    study = models.ForeignKey(Study, on_delete=models.CASCADE, null=True)
     origin = models.ForeignKey(
         to=RawImageUploadSession, null=True, on_delete=models.SET_NULL
     )
@@ -364,7 +364,7 @@ class ImageFile(UUIDModel):
     )
 
     image = models.ForeignKey(
-        to=Image, null=True, on_delete=models.SET_NULL, related_name="files"
+        to=Image, null=True, on_delete=models.CASCADE, related_name="files"
     )
     image_type = models.CharField(
         max_length=4, blank=False, choices=IMAGE_TYPES, default=IMAGE_TYPE_MHD


Test patch:
diff --git a/app/tests/archives_tests/test_models.py b/app/tests/archives_tests/test_models.py
--- a/app/tests/archives_tests/test_models.py
+++ b/app/tests/archives_tests/test_models.py
@@ -1,12 +1,18 @@
 import pytest
+from django.core.exceptions import ObjectDoesNotExist
+
 from tests.archives_tests.factories import ArchiveFactory
+from tests.cases_tests.factories import (
+    ImageFactoryWithImageFile,
+    ImageFactoryWithoutImageFile,
+)
 from tests.model_helpers import do_test_factory
 
 
 @pytest.mark.django_db
 class TestArchivesModels:
     # test functions are added dynamically to this class
-    def test_study_str(self):
+    def test_str(self):
         model = ArchiveFactory()
         assert str(model) == "<{} {}>".format(
             model.__class__.__name__, model.name
@@ -18,3 +24,66 @@ def test_study_str(self):
 class TestFactories:
     def test_factory_creation(self, factory):
         do_test_factory(factory)
+
+
+@pytest.mark.django_db
+class TestCascadeDelete:
+    def test_removes_all_related_models(
+        self, ArchivePatientStudyImageSet, AnnotationSetForImage
+    ):
+        apsi_set = ArchivePatientStudyImageSet
+        annotation_set = AnnotationSetForImage(
+            retina_grader=True, image=apsi_set.images111[0]
+        )
+        image_not_in_archive = ImageFactoryWithoutImageFile(
+            study=apsi_set.study111
+        )
+        image_with_files = ImageFactoryWithImageFile(study=apsi_set.study111)
+        archive_only_images = ImageFactoryWithoutImageFile.create_batch(
+            4, study=None
+        )
+        dual_archive_images = ImageFactoryWithoutImageFile.create_batch(4)
+        apsi_set.archive1.images.add(image_with_files)
+        apsi_set.archive1.images.add(*archive_only_images)
+        apsi_set.archive1.images.add(*dual_archive_images)
+        apsi_set.archive2.images.add(*dual_archive_images)
+        apsi_set.archive1.delete()
+        all_deleted_models = (
+            apsi_set.archive1,
+            apsi_set.patient12,
+            apsi_set.study112,
+            apsi_set.study113,
+            apsi_set.study121,
+            apsi_set.study122,
+            *apsi_set.images111,
+            *apsi_set.images112,
+            *apsi_set.images113,
+            *apsi_set.images122,
+            *apsi_set.images121,
+            annotation_set.measurement,
+            annotation_set.boolean,
+            annotation_set.polygon,
+            annotation_set.coordinatelist,
+            annotation_set.singlelandmarks[0],
+            annotation_set.etdrs,
+            annotation_set.integer,
+            *archive_only_images,
+            image_with_files,
+            *image_with_files.files.all(),
+        )
+        for model in all_deleted_models:
+            with pytest.raises(ObjectDoesNotExist):
+                assert model.refresh_from_db()
+
+        not_deleted_models = (
+            apsi_set.patient11,
+            apsi_set.study111,
+            apsi_set.archive2,
+            *apsi_set.images211,
+            annotation_set.landmark,
+            *annotation_set.singlelandmarks[1:],
+            image_not_in_archive,
+            *dual_archive_images,
+        )
+        for model in not_deleted_models:
+            assert model.refresh_from_db() is None
diff --git a/app/tests/conftest.py b/app/tests/conftest.py
--- a/app/tests/conftest.py
+++ b/app/tests/conftest.py
@@ -292,30 +292,49 @@ class AnnotationSet(NamedTuple):
     polygon: PolygonAnnotationSetFactory
     coordinatelist: CoordinateListAnnotationFactory
     landmark: LandmarkAnnotationSetFactory
+    singlelandmarks: List
     etdrs: ETDRSGridAnnotationFactory
 
 
-def generate_annotation_set(retina_grader=False):
+def generate_annotation_set(retina_grader=False, image=False):
     grader = UserFactory()
 
     if retina_grader:
         add_to_graders_group([grader])
 
-    measurement = MeasurementAnnotationFactory(grader=grader)
-    boolean = BooleanClassificationAnnotationFactory(grader=grader)
-    integer = IntegerClassificationAnnotationFactory(grader=grader)
-    polygon = PolygonAnnotationSetFactory(grader=grader)
-    coordinatelist = CoordinateListAnnotationFactory(grader=grader)
-    landmark = LandmarkAnnotationSetFactory(grader=grader)
-    etdrs = ETDRSGridAnnotationFactory(grader=grader)
+    create_options = {"grader": grader}
+    if image:
+        create_options_with_image = {"image": image, **create_options}
+    else:
+        create_options_with_image = create_options
+
+    measurement = MeasurementAnnotationFactory(**create_options_with_image)
+    boolean = BooleanClassificationAnnotationFactory(
+        **create_options_with_image
+    )
+    integer = IntegerClassificationAnnotationFactory(
+        **create_options_with_image
+    )
+    polygon = PolygonAnnotationSetFactory(**create_options_with_image)
+    coordinatelist = CoordinateListAnnotationFactory(
+        **create_options_with_image
+    )
+    etdrs = ETDRSGridAnnotationFactory(**create_options_with_image)
+    landmark = LandmarkAnnotationSetFactory(**create_options)
 
     # Create child models for polygon annotation set
     SinglePolygonAnnotationFactory.create_batch(10, annotation_set=polygon)
 
     # Create child models for landmark annotation set (3 per image)
+    single_landmarks = []
     for i in range(5):
-        image = ImageFactory()
-        SingleLandmarkAnnotationFactory(annotation_set=landmark, image=image)
+        if i > 0 or not image:
+            image = ImageFactory()
+        single_landmarks.append(
+            SingleLandmarkAnnotationFactory(
+                annotation_set=landmark, image=image
+            )
+        )
 
     return AnnotationSet(
         grader=grader,
@@ -324,6 +343,7 @@ def generate_annotation_set(retina_grader=False):
         polygon=polygon,
         coordinatelist=coordinatelist,
         landmark=landmark,
+        singlelandmarks=single_landmarks,
         etdrs=etdrs,
         integer=integer,
     )
@@ -338,6 +358,15 @@ def annotation_set():
     return generate_annotation_set()
 
 
+@pytest.fixture(name="AnnotationSetForImage")
+def annotation_set_for_image():
+    """ Creates a user with the one of each of the following annotations:
+    Measurement, BooleanClassification, PolygonAnnotationSet (with 10 child
+    annotations), CoordinateList, LandmarkAnnotationSet(with single landmark
+    annotations for 5 images), ETDRSGrid """
+    return generate_annotation_set
+
+
 class TwoPolygonAnnotationSets(NamedTuple):
     grader1: UserFactory
     grader2: UserFactory