Repository: comic/grand-challenge.org

Issue:
Add api viewset for LandmarkAnnotationSet and SingleLandmarkAnnotation models



Discussion:

Resolving patch:
diff --git a/app/grandchallenge/annotations/serializers.py b/app/grandchallenge/annotations/serializers.py
--- a/app/grandchallenge/annotations/serializers.py
+++ b/app/grandchallenge/annotations/serializers.py
@@ -104,18 +104,59 @@ class Meta:
 class SingleLandmarkAnnotationSerializer(AbstractSingleAnnotationSerializer):
     class Meta:
         model = SingleLandmarkAnnotation
-        fields = ("image", "annotation_set", "landmarks")
+        fields = ("id", "image", "annotation_set", "landmarks")
+
+
+class SingleLandmarkAnnotationSerializerNoParent(
+    AbstractSingleAnnotationSerializer
+):
+    class Meta:
+        model = SingleLandmarkAnnotation
+        fields = ("id", "image", "landmarks")
+        extra_kwargs = {"landmarks": {"allow_empty": True}}
 
 
 class LandmarkAnnotationSetSerializer(AbstractAnnotationSerializer):
-    singlelandmarkannotation_set = SingleLandmarkAnnotationSerializer(
-        many=True, read_only=True
+    singlelandmarkannotation_set = SingleLandmarkAnnotationSerializerNoParent(
+        many=True
     )
 
     class Meta:
         model = LandmarkAnnotationSet
         fields = ("id", "grader", "created", "singlelandmarkannotation_set")
 
+    def create(self, validated_data):
+        sla_data = validated_data.pop("singlelandmarkannotation_set")
+        la_set = LandmarkAnnotationSet.objects.create(**validated_data)
+        for sla in sla_data:
+            SingleLandmarkAnnotation.objects.create(
+                annotation_set=la_set, **sla
+            )
+        return la_set
+
+    def update(self, instance, validated_data):
+        def update_delete_or_create_sla(singe_landmark_annotation):
+            try:
+                sla_image = singe_landmark_annotation.get("image")
+                item = SingleLandmarkAnnotation.objects.get(
+                    image=sla_image, annotation_set=instance
+                )
+                new_landmarks = singe_landmark_annotation.get("landmarks")
+                if len(new_landmarks) == 0:
+                    item.delete()
+                else:
+                    item.landmarks = new_landmarks
+                    item.save()
+            except SingleLandmarkAnnotation.DoesNotExist:
+                SingleLandmarkAnnotation.objects.create(
+                    annotation_set=instance, **singe_landmark_annotation
+                )
+
+        sla_data = validated_data.pop("singlelandmarkannotation_set")
+        for singe_landmark_annotation in sla_data:
+            update_delete_or_create_sla(singe_landmark_annotation)
+        return instance
+
 
 class ImageQualityAnnotationSerializer(AbstractAnnotationSerializer):
     class Meta:
diff --git a/app/grandchallenge/retina_api/urls.py b/app/grandchallenge/retina_api/urls.py
--- a/app/grandchallenge/retina_api/urls.py
+++ b/app/grandchallenge/retina_api/urls.py
@@ -18,6 +18,11 @@
     views.PolygonAnnotationSetViewSet,
     basename="polygonannotationset",
 )
+annotation_router.register(
+    "landmarkannotationset",
+    views.LandmarkAnnotationSetViewSet,
+    basename="landmarkannotationset",
+)
 annotation_router.register(
     "etdrsgridannotation",
     views.ETDRSGridAnnotationViewSet,
diff --git a/app/grandchallenge/retina_api/views.py b/app/grandchallenge/retina_api/views.py
--- a/app/grandchallenge/retina_api/views.py
+++ b/app/grandchallenge/retina_api/views.py
@@ -973,3 +973,12 @@ class ImageTextAnnotationViewSet(viewsets.ModelViewSet):
     filter_backends = (filters.ObjectPermissionsFilter,)
     pagination_class = None
     queryset = ImageTextAnnotation.objects.all()
+
+
+class LandmarkAnnotationSetViewSet(viewsets.ModelViewSet):
+    permission_classes = (RetinaOwnerAPIPermission,)
+    authentication_classes = (authentication.TokenAuthentication,)
+    serializer_class = LandmarkAnnotationSetSerializer
+    filter_backends = (filters.ObjectPermissionsFilter,)
+    pagination_class = None
+    queryset = LandmarkAnnotationSet.objects.all()


Test patch:
diff --git a/app/tests/annotations_tests/test_serializers.py b/app/tests/annotations_tests/test_serializers.py
--- a/app/tests/annotations_tests/test_serializers.py
+++ b/app/tests/annotations_tests/test_serializers.py
@@ -11,6 +11,7 @@
     PolygonAnnotationSetSerializer,
     RetinaImagePathologyAnnotationSerializer,
     SingleLandmarkAnnotationSerializer,
+    SingleLandmarkAnnotationSerializerNoParent,
     SinglePolygonAnnotationSerializer,
 )
 from tests.annotations_tests.factories import (
@@ -26,6 +27,7 @@
     SingleLandmarkAnnotationFactory,
     SinglePolygonAnnotationFactory,
 )
+from tests.factories import ImageFactory, UserFactory
 from tests.serializer_helpers import (
     do_test_serializer_fields,
     do_test_serializer_valid,
@@ -110,7 +112,13 @@
                 "unique": True,
                 "factory": SingleLandmarkAnnotationFactory,
                 "serializer": SingleLandmarkAnnotationSerializer,
-                "fields": ("image", "annotation_set", "landmarks"),
+                "fields": ("id", "image", "annotation_set", "landmarks"),
+            },
+            {
+                "unique": True,
+                "factory": SingleLandmarkAnnotationFactory,
+                "serializer": SingleLandmarkAnnotationSerializerNoParent,
+                "fields": ("id", "image", "landmarks"),
             },
             {
                 "unique": True,
@@ -163,3 +171,156 @@ def test_serializer_valid(self, serializer_data):
 
     def test_serializer_fields(self, serializer_data):
         do_test_serializer_fields(serializer_data)
+
+
+@pytest.mark.django_db
+class TestNestedLandmarkSerializer:
+    def test_serialization(self, multiple_landmark_annotation_sets):
+        serialized_model = LandmarkAnnotationSetSerializer(
+            instance=multiple_landmark_annotation_sets.landmarkset1
+        )
+        assert serialized_model.data.get("id") is not None
+        assert serialized_model.data.get("created") is not None
+        assert (
+            serialized_model.data.get("grader")
+            == multiple_landmark_annotation_sets.grader1.id
+        )
+        assert (
+            len(serialized_model.data.get("singlelandmarkannotation_set")) == 2
+        )
+        assert (
+            serialized_model.data["singlelandmarkannotation_set"][0].get("id")
+            is not None
+        )
+        assert (
+            serialized_model.data["singlelandmarkannotation_set"][0].get(
+                "image"
+            )
+            is not None
+        )
+        assert (
+            serialized_model.data["singlelandmarkannotation_set"][0].get(
+                "landmarks"
+            )
+            is not None
+        )
+        assert (
+            len(
+                serialized_model.data["singlelandmarkannotation_set"][0][
+                    "landmarks"
+                ]
+            )
+            > 0
+        )
+        assert (
+            len(
+                serialized_model.data["singlelandmarkannotation_set"][0][
+                    "landmarks"
+                ][0]
+            )
+            == 2
+        )
+
+    @staticmethod
+    def create_annotation_set():
+        user = UserFactory()
+        image1 = ImageFactory()
+        image2 = ImageFactory()
+        return {
+            "grader": user.id,
+            "singlelandmarkannotation_set": [
+                {"image": image1.id, "landmarks": [[0, 0], [1, 1], [2, 2]]},
+                {"image": image2.id, "landmarks": [[1, 1], [2, 2], [3, 3]]},
+            ],
+        }
+
+    def save_annotation_set(self):
+        annotation_set_dict = self.create_annotation_set()
+        serializer = LandmarkAnnotationSetSerializer(data=annotation_set_dict)
+        serializer.is_valid()
+        annotation_set_obj = serializer.save()
+        return annotation_set_dict, annotation_set_obj
+
+    def test_create_method(self):
+        annotation_set = self.create_annotation_set()
+        serializer = LandmarkAnnotationSetSerializer(data=annotation_set)
+        serializer.is_valid()
+        assert serializer.errors == {}
+        try:
+            obj = serializer.save()
+            assert obj.singlelandmarkannotation_set.count() == 2
+        except Exception as e:
+            pytest.fail(f"Saving serializer failed with error: {str(e)}")
+
+    def test_update_method_instance_fields_do_not_change(self):
+        annotation_set_dict, annotation_set_obj = self.save_annotation_set()
+        other_user = UserFactory()
+        updated_set = {
+            **annotation_set_dict,
+            "id": annotation_set_obj.id,
+            "grader": other_user.id,
+        }
+        serializer = LandmarkAnnotationSetSerializer(
+            annotation_set_obj, data=updated_set
+        )
+        serializer.is_valid()
+        saved_set = None
+        try:
+            saved_set = serializer.save()
+        except Exception as e:
+            pytest.fail(f"Saving serializer failed with error: {str(e)}")
+        assert saved_set.grader == annotation_set_obj.grader
+
+    def test_update_method_new_slas_get_added_existing_get_updated(self):
+        annotation_set_dict, annotation_set_obj = self.save_annotation_set()
+        old_slas = list(annotation_set_obj.singlelandmarkannotation_set.all())
+        image3 = ImageFactory()
+        new_slas = [
+            {"image": image3.id, "landmarks": [[2, 2], [3, 3], [4, 4]]},
+            {
+                "image": old_slas[0].image.id,
+                "landmarks": [[4, 4], [5, 5], [6, 6]],
+            },
+        ]
+        updated_set = {
+            **annotation_set_dict,
+            "singlelandmarkannotation_set": new_slas,
+        }
+        serializer = LandmarkAnnotationSetSerializer(
+            annotation_set_obj, data=updated_set
+        )
+        serializer.is_valid()
+        saved_set = None
+        try:
+            saved_set = serializer.save()
+        except Exception as e:
+            pytest.fail(f"Saving serializer failed with error: {str(e)}")
+        assert saved_set.singlelandmarkannotation_set.count() == 3
+        assert saved_set.singlelandmarkannotation_set.filter(
+            image=image3
+        ).exists()
+        assert saved_set.singlelandmarkannotation_set.get(
+            image__id=old_slas[0].image.id
+        ).landmarks == [[4.0, 4.0], [5.0, 5.0], [6.0, 6.0]]
+
+    def test_update_method_removes_empty_sla(self):
+        annotation_set_dict, annotation_set_obj = self.save_annotation_set()
+        old_slas = list(annotation_set_obj.singlelandmarkannotation_set.all())
+        new_slas = [{"image": old_slas[0].image.id, "landmarks": []}]
+        updated_set = {
+            **annotation_set_dict,
+            "singlelandmarkannotation_set": new_slas,
+        }
+        serializer = LandmarkAnnotationSetSerializer(
+            annotation_set_obj, data=updated_set
+        )
+        serializer.is_valid()
+        saved_set = None
+        try:
+            saved_set = serializer.save()
+        except Exception as e:
+            pytest.fail(f"Saving serializer failed with error: {str(e)}")
+        assert saved_set.singlelandmarkannotation_set.count() == 1
+        assert not saved_set.singlelandmarkannotation_set.filter(
+            image=old_slas[0].image.id
+        ).exists()
diff --git a/app/tests/conftest.py b/app/tests/conftest.py
--- a/app/tests/conftest.py
+++ b/app/tests/conftest.py
@@ -488,6 +488,16 @@ def generate_multiple_landmark_annotation_sets(retina_grader=False):
     )
 
 
+@pytest.fixture(name="multiple_landmark_retina_annotation_sets")
+def multiple_landmark_retina_annotation_sets():
+    return generate_multiple_landmark_annotation_sets(retina_grader=True)
+
+
+@pytest.fixture(name="multiple_landmark_annotation_sets")
+def multiple_landmark_annotation_sets():
+    return generate_multiple_landmark_annotation_sets(retina_grader=False)
+
+
 class MultipleETDRSAnnotations(NamedTuple):
     grader1: UserFactory
     grader2: UserFactory
diff --git a/app/tests/retina_api_tests/test_viewsets.py b/app/tests/retina_api_tests/test_viewsets.py
--- a/app/tests/retina_api_tests/test_viewsets.py
+++ b/app/tests/retina_api_tests/test_viewsets.py
@@ -34,6 +34,7 @@
     ImageQualityAnnotationViewSet,
     ImageTextAnnotationViewSet,
     LandmarkAnnotationSetForImageList,
+    LandmarkAnnotationSetViewSet,
     OctObsRegistrationRetrieve,
     PolygonAnnotationSetViewSet,
     PolygonListView,
@@ -46,6 +47,7 @@
     ImagePathologyAnnotationFactory,
     ImageQualityAnnotationFactory,
     ImageTextAnnotationFactory,
+    LandmarkAnnotationSetFactory,
     PolygonAnnotationSetFactory,
     RetinaImagePathologyAnnotationFactory,
     SinglePolygonAnnotationFactory,
@@ -1314,27 +1316,42 @@ def test_destroy_view_wrong_user(
     ],
 )
 @pytest.mark.parametrize(
-    "viewset,factory,serializer",
+    "viewset,factory,serializer,with_image,non_update",
     (
         (
             ImageQualityAnnotationViewSet,
             ImageQualityAnnotationFactory,
             ImageQualityAnnotationSerializer,
+            True,
+            False,
         ),
         (
             ImagePathologyAnnotationViewSet,
             ImagePathologyAnnotationFactory,
             ImagePathologyAnnotationSerializer,
+            True,
+            False,
         ),
         (
             RetinaImagePathologyAnnotationViewSet,
             RetinaImagePathologyAnnotationFactory,
             RetinaImagePathologyAnnotationSerializer,
+            True,
+            False,
         ),
         (
             ImageTextAnnotationViewSet,
             ImageTextAnnotationFactory,
             ImageTextAnnotationSerializer,
+            True,
+            False,
+        ),
+        (
+            LandmarkAnnotationSetViewSet,
+            LandmarkAnnotationSetFactory,
+            LandmarkAnnotationSetSerializer,
+            False,
+            True,
         ),
     ),
 )
@@ -1355,7 +1372,16 @@ def create_models(factory):
 
         return models
 
-    def test_list_view(self, rf, user_type, viewset, factory, serializer):
+    def test_list_view(
+        self,
+        rf,
+        user_type,
+        viewset,
+        factory,
+        serializer,
+        with_image,
+        non_update,
+    ):
         models = self.create_models(factory)
         response = view_test(
             "list",
@@ -1378,14 +1404,25 @@ def test_list_view(self, rf, user_type, viewset, factory, serializer):
             assert len(response.data) == len(serialized_data)
             assert response.data == serialized_data
 
-    def test_create_view(self, rf, user_type, viewset, factory, serializer):
+    def test_create_view(
+        self,
+        rf,
+        user_type,
+        viewset,
+        factory,
+        serializer,
+        with_image,
+        non_update,
+    ):
         models = self.create_models(factory)
-        model_build = factory.build(
-            grader=models[0].grader, image=models[0].image
-        )
+        build_kwargs = {"grader": models[0].grader}
+        if with_image:
+            build_kwargs.update({"image": models[0].image})
+        model_build = factory.build(**build_kwargs)
         model_serialized = serializer(model_build).data
         model_serialized["grader"] = models[0].grader.id
-        model_serialized["image"] = str(model_serialized["image"])
+        if with_image:
+            model_serialized["image"] = str(model_serialized["image"])
         model_json = json.dumps(model_serialized)
 
         response = view_test(
@@ -1401,19 +1438,29 @@ def test_create_view(self, rf, user_type, viewset, factory, serializer):
         )
         if user_type in ("retina_grader", "retina_admin"):
             model_serialized["id"] = response.data["id"]
-            response.data["image"] = str(response.data["image"])
+            if with_image:
+                response.data["image"] = str(response.data["image"])
             assert response.data == model_serialized
 
     def test_create_view_wrong_user_id(
-        self, rf, user_type, viewset, factory, serializer
+        self,
+        rf,
+        user_type,
+        viewset,
+        factory,
+        serializer,
+        with_image,
+        non_update,
     ):
         models = self.create_models(factory)
-        model_build = factory.build(
-            grader=models[0].grader, image=ImageFactory()
-        )
+        build_kwargs = {"grader": models[0].grader}
+        if with_image:
+            build_kwargs.update({"image": ImageFactory()})
+        model_build = factory.build(**build_kwargs)
         model_serialized = serializer(model_build).data
         model_serialized["grader"] = models[2].grader.id
-        model_serialized["image"] = str(model_serialized["image"])
+        if with_image:
+            model_serialized["image"] = str(model_serialized["image"])
         model_json = json.dumps(model_serialized)
 
         response = view_test(
@@ -1430,7 +1477,8 @@ def test_create_view_wrong_user_id(
         )
         if user_type == "retina_admin":
             model_serialized["id"] = response.data["id"]
-            response.data["image"] = str(response.data["image"])
+            if with_image:
+                response.data["image"] = str(response.data["image"])
             assert response.data == model_serialized
         elif user_type == "retina_grader":
             assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -1441,7 +1489,16 @@ def test_create_view_wrong_user_id(
         else:
             assert response.status_code == status.HTTP_403_FORBIDDEN
 
-    def test_retrieve_view(self, rf, user_type, viewset, factory, serializer):
+    def test_retrieve_view(
+        self,
+        rf,
+        user_type,
+        viewset,
+        factory,
+        serializer,
+        with_image,
+        non_update,
+    ):
         models = self.create_models(factory)
         response = view_test(
             "retrieve",
@@ -1457,10 +1514,21 @@ def test_retrieve_view(self, rf, user_type, viewset, factory, serializer):
             model_serialized = serializer(models[0]).data
             assert response.data == model_serialized
 
-    def test_update_view(self, rf, user_type, viewset, factory, serializer):
+    def test_update_view(
+        self,
+        rf,
+        user_type,
+        viewset,
+        factory,
+        serializer,
+        with_image,
+        non_update,
+    ):
         models = self.create_models(factory)
         model_serialized = serializer(models[1]).data
-        model_serialized["image"] = str(model_serialized["image"])
+        models[1].delete()
+        if with_image:
+            model_serialized["image"] = str(model_serialized["image"])
         model_serialized["id"] = str(models[0].id)
         model_json = json.dumps(model_serialized)
 
@@ -1477,16 +1545,28 @@ def test_update_view(self, rf, user_type, viewset, factory, serializer):
         )
 
         if user_type in ("retina_grader", "retina_admin"):
-            response.data["image"] = str(response.data["image"])
-            assert response.data == model_serialized
+            if with_image:
+                response.data["image"] = str(response.data["image"])
+            if non_update:
+                assert response.data != model_serialized
+            else:
+                assert response.data == model_serialized
 
     def test_update_view_wrong_user_id(
-        self, rf, user_type, viewset, factory, serializer
+        self,
+        rf,
+        user_type,
+        viewset,
+        factory,
+        serializer,
+        with_image,
+        non_update,
     ):
         other_user = UserFactory()
         models = self.create_models(factory)
         model_serialized = serializer(models[0]).data
-        model_serialized["image"] = str(model_serialized["image"])
+        if with_image:
+            model_serialized["image"] = str(model_serialized["image"])
         model_serialized["grader"] = other_user.id
         model_json = json.dumps(model_serialized)
 
@@ -1504,8 +1584,12 @@ def test_update_view_wrong_user_id(
         )
         if user_type == "retina_admin":
             model_serialized["id"] = response.data["id"]
-            response.data["image"] = str(response.data["image"])
-            assert response.data == model_serialized
+            if with_image:
+                response.data["image"] = str(response.data["image"])
+            if non_update:
+                assert response.data != model_serialized
+            else:
+                assert response.data == model_serialized
         elif user_type == "retina_grader":
             assert response.status_code == status.HTTP_400_BAD_REQUEST
             assert (
@@ -1516,12 +1600,20 @@ def test_update_view_wrong_user_id(
             assert response.status_code == status.HTTP_403_FORBIDDEN
 
     def test_partial_update_view(
-        self, rf, user_type, viewset, factory, serializer
+        self,
+        rf,
+        user_type,
+        viewset,
+        factory,
+        serializer,
+        with_image,
+        non_update,
     ):
         models = self.create_models(factory)
         model_serialized = serializer(models[0]).data
         partial_model = copy.deepcopy(model_serialized)
-        del partial_model["image"]
+        if with_image:
+            del partial_model["image"]
         del partial_model["id"]
         del partial_model["grader"]
         model_json = json.dumps(partial_model)
@@ -1541,7 +1633,16 @@ def test_partial_update_view(
         if user_type in ("retina_grader", "retina_admin"):
             assert response.data == model_serialized
 
-    def test_destroy_view(self, rf, user_type, viewset, factory, serializer):
+    def test_destroy_view(
+        self,
+        rf,
+        user_type,
+        viewset,
+        factory,
+        serializer,
+        with_image,
+        non_update,
+    ):
         models = self.create_models(factory)
         view_test(
             "destroy",
@@ -1559,7 +1660,14 @@ def test_destroy_view(self, rf, user_type, viewset, factory, serializer):
             ).exists()
 
     def test_destroy_view_wrong_user(
-        self, rf, user_type, viewset, factory, serializer
+        self,
+        rf,
+        user_type,
+        viewset,
+        factory,
+        serializer,
+        with_image,
+        non_update,
     ):
         models = self.create_models(factory)
         response = view_test(