Repository: comic/grand-challenge.org

Issue:
Add endpoint to retrieve image level annotation ids for a specific image and user
I need to create an endpoint that returns the ids of the most recent image-level annotations for a specific user and image. Also, the existing image level annotation models need new viewsets that are added to the default API instead of the retina API.


Discussion:

Resolving patch:
diff --git a/app/grandchallenge/api/urls.py b/app/grandchallenge/api/urls.py
--- a/app/grandchallenge/api/urls.py
+++ b/app/grandchallenge/api/urls.py
@@ -22,7 +22,14 @@
     QuestionViewSet,
     ReaderStudyViewSet,
 )
-from grandchallenge.retina_api.views import LandmarkAnnotationSetViewSet
+from grandchallenge.retina_api.views import (
+    ImageLevelAnnotationsForImageViewSet,
+    LandmarkAnnotationSetViewSet,
+    PathologyAnnotationViewSet,
+    QualityAnnotationViewSet,
+    RetinaPathologyAnnotationViewSet,
+    TextAnnotationViewSet,
+)
 from grandchallenge.subdomains.utils import reverse_lazy
 from grandchallenge.workstation_configs.views import WorkstationConfigViewSet
 from grandchallenge.workstations.views import SessionViewSet
@@ -72,7 +79,32 @@
 router.register(
     r"retina/landmark-annotation",
     LandmarkAnnotationSetViewSet,
-    basename="landmark-annotation",
+    basename="retina-landmark-annotation",
+)
+router.register(
+    r"retina/image-level-annotation-for-image",
+    ImageLevelAnnotationsForImageViewSet,
+    basename="retina-image-level-annotation-for-image",
+)
+router.register(
+    r"retina/quality-annotation",
+    QualityAnnotationViewSet,
+    basename="retina-quality-annotation",
+)
+router.register(
+    r"retina/pathology-annotation",
+    PathologyAnnotationViewSet,
+    basename="retina-pathology-annotation",
+)
+router.register(
+    r"retina/retina-pathology-annotation",
+    RetinaPathologyAnnotationViewSet,
+    basename="retina-retina-pathology-annotation",
+)
+router.register(
+    r"retina/text-annotation",
+    TextAnnotationViewSet,
+    basename="retina-text-annotation",
 )
 
 # Workstations
@@ -83,7 +115,6 @@
 )
 router.register(r"workstations/sessions", SessionViewSet)
 
-
 schema_view = get_schema_view(
     openapi.Info(
         title=f"{settings.SESSION_COOKIE_DOMAIN.lstrip('.')} API",
diff --git a/app/grandchallenge/retina_api/serializers.py b/app/grandchallenge/retina_api/serializers.py
--- a/app/grandchallenge/retina_api/serializers.py
+++ b/app/grandchallenge/retina_api/serializers.py
@@ -86,3 +86,18 @@ class TreeImageSerializer(TreeObjectSerializer):
     modality = ImagingModalitySerializer()
     study = TreeStudySerializer(required=False)
     archive_set = TreeArchiveSerializer(many=True)
+
+
+class ImageLevelAnnotationsForImageSerializer(serializers.BaseSerializer):
+    quality = serializers.UUIDField(allow_null=True)
+    pathology = serializers.UUIDField(allow_null=True)
+    retina_pathology = serializers.UUIDField(allow_null=True)
+    text = serializers.UUIDField(allow_null=True)
+
+    def to_representation(self, instance):
+        return {
+            "quality": instance.get("quality"),
+            "pathology": instance.get("pathology"),
+            "retina_pathology": instance.get("retina_pathology"),
+            "text": instance.get("text"),
+        }
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
@@ -14,7 +14,7 @@
 from django.utils.decorators import method_decorator
 from django.views import View
 from django.views.decorators.csrf import ensure_csrf_cookie
-from rest_framework import authentication, status, viewsets
+from rest_framework import authentication, mixins, status, viewsets
 from rest_framework.exceptions import NotFound
 from rest_framework.generics import ListAPIView, RetrieveAPIView
 from rest_framework.response import Response
@@ -61,6 +61,7 @@
 from grandchallenge.retina_api.renderers import Base64Renderer
 from grandchallenge.retina_api.serializers import (
     BytesImageSerializer,
+    ImageLevelAnnotationsForImageSerializer,
     TreeImageSerializer,
     TreeObjectSerializer,
 )
@@ -898,3 +899,69 @@ def get_queryset(self):
             )
 
         return queryset
+
+
+class ImageLevelAnnotationsForImageViewSet(
+    mixins.RetrieveModelMixin, viewsets.GenericViewSet
+):
+    permission_classes = (RetinaAPIPermission,)
+    authentication_classes = (authentication.TokenAuthentication,)
+    serializer_class = ImageLevelAnnotationsForImageSerializer
+    filter_backends = (filters.ObjectPermissionsFilter,)
+
+    def get_object(self):
+        keys_to_models_map = {
+            "quality": ImageQualityAnnotation,
+            "pathology": ImagePathologyAnnotation,
+            "retina_pathology": RetinaImagePathologyAnnotation,
+            "text": ImageTextAnnotation,
+        }
+        image_id = self.kwargs.get("pk")
+        if image_id is None:
+            raise NotFound()
+        data = {}
+        for key, model in keys_to_models_map.items():
+            objects = model.objects.filter(
+                image__id=image_id, grader=self.request.user
+            )
+            try:
+                data[key] = objects[0].id
+            except (IndexError, AttributeError):
+                data[key] = None
+        return data
+
+
+class QualityAnnotationViewSet(viewsets.ModelViewSet):
+    permission_classes = (RetinaAPIPermission,)
+    authentication_classes = (authentication.TokenAuthentication,)
+    serializer_class = ImageQualityAnnotationSerializer
+    filter_backends = (filters.ObjectPermissionsFilter, RetinaAnnotationFilter)
+    pagination_class = None
+    queryset = ImageQualityAnnotation.objects.all()
+
+
+class PathologyAnnotationViewSet(viewsets.ModelViewSet):
+    permission_classes = (RetinaAPIPermission,)
+    authentication_classes = (authentication.TokenAuthentication,)
+    serializer_class = ImagePathologyAnnotationSerializer
+    filter_backends = (filters.ObjectPermissionsFilter, RetinaAnnotationFilter)
+    pagination_class = None
+    queryset = ImagePathologyAnnotation.objects.all()
+
+
+class RetinaPathologyAnnotationViewSet(viewsets.ModelViewSet):
+    permission_classes = (RetinaAPIPermission,)
+    authentication_classes = (authentication.TokenAuthentication,)
+    serializer_class = RetinaImagePathologyAnnotationSerializer
+    filter_backends = (filters.ObjectPermissionsFilter, RetinaAnnotationFilter)
+    pagination_class = None
+    queryset = RetinaImagePathologyAnnotation.objects.all()
+
+
+class TextAnnotationViewSet(viewsets.ModelViewSet):
+    permission_classes = (RetinaAPIPermission,)
+    authentication_classes = (authentication.TokenAuthentication,)
+    serializer_class = ImageTextAnnotationSerializer
+    filter_backends = (filters.ObjectPermissionsFilter, RetinaAnnotationFilter)
+    pagination_class = None
+    queryset = ImageTextAnnotation.objects.all()


Test patch:
diff --git a/app/tests/conftest.py b/app/tests/conftest.py
--- a/app/tests/conftest.py
+++ b/app/tests/conftest.py
@@ -17,10 +17,14 @@
     BooleanClassificationAnnotationFactory,
     CoordinateListAnnotationFactory,
     ETDRSGridAnnotationFactory,
+    ImagePathologyAnnotationFactory,
+    ImageQualityAnnotationFactory,
+    ImageTextAnnotationFactory,
     IntegerClassificationAnnotationFactory,
     LandmarkAnnotationSetFactory,
     MeasurementAnnotationFactory,
     PolygonAnnotationSetFactory,
+    RetinaImagePathologyAnnotationFactory,
     SingleLandmarkAnnotationFactory,
     SinglePolygonAnnotationFactory,
 )
@@ -669,3 +673,20 @@ def reader_study_with_gt():
             ans.images.add(im)
 
     return rs
+
+
+@pytest.fixture
+def image_with_image_level_annotations():
+    grader = UserFactory()
+    add_to_graders_group([grader])
+    image = ImageFactory()
+    factory_kwargs = {"image": image, "grader": grader}
+    annotations = {
+        "quality": ImageQualityAnnotationFactory(**factory_kwargs),
+        "pathology": ImagePathologyAnnotationFactory(**factory_kwargs),
+        "retina_pathology": RetinaImagePathologyAnnotationFactory(
+            **factory_kwargs
+        ),
+        "text": ImageTextAnnotationFactory(**factory_kwargs),
+    }
+    return image, grader, annotations
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
@@ -30,16 +30,21 @@
 from grandchallenge.retina_api.views import (
     ETDRSGridAnnotationViewSet,
     GradersWithPolygonAnnotationsListView,
+    ImageLevelAnnotationsForImageViewSet,
     ImagePathologyAnnotationViewSet,
     ImageQualityAnnotationViewSet,
     ImageTextAnnotationViewSet,
     LandmarkAnnotationSetForImageList,
     LandmarkAnnotationSetViewSet,
     OctObsRegistrationRetrieve,
+    PathologyAnnotationViewSet,
     PolygonAnnotationSetViewSet,
     PolygonListView,
+    QualityAnnotationViewSet,
     RetinaImagePathologyAnnotationViewSet,
+    RetinaPathologyAnnotationViewSet,
     SinglePolygonViewSet,
+    TextAnnotationViewSet,
 )
 from grandchallenge.subdomains.utils import reverse
 from tests.annotations_tests.factories import (
@@ -1316,27 +1321,63 @@ def test_destroy_view_wrong_user(
     ],
 )
 @pytest.mark.parametrize(
-    "viewset,factory,serializer",
+    "viewset,factory,serializer,basename,with_user",
     (
         (
             ImageQualityAnnotationViewSet,
             ImageQualityAnnotationFactory,
             ImageQualityAnnotationSerializer,
+            None,
+            True,
         ),
         (
             ImagePathologyAnnotationViewSet,
             ImagePathologyAnnotationFactory,
             ImagePathologyAnnotationSerializer,
+            None,
+            True,
         ),
         (
             RetinaImagePathologyAnnotationViewSet,
             RetinaImagePathologyAnnotationFactory,
             RetinaImagePathologyAnnotationSerializer,
+            None,
+            True,
         ),
         (
             ImageTextAnnotationViewSet,
             ImageTextAnnotationFactory,
             ImageTextAnnotationSerializer,
+            None,
+            True,
+        ),
+        (
+            QualityAnnotationViewSet,
+            ImageQualityAnnotationFactory,
+            ImageQualityAnnotationSerializer,
+            "retina-quality-annotation",
+            False,
+        ),
+        (
+            PathologyAnnotationViewSet,
+            ImagePathologyAnnotationFactory,
+            ImagePathologyAnnotationSerializer,
+            "retina-pathology-annotation",
+            False,
+        ),
+        (
+            RetinaPathologyAnnotationViewSet,
+            RetinaImagePathologyAnnotationFactory,
+            RetinaImagePathologyAnnotationSerializer,
+            "retina-retina-pathology-annotation",
+            False,
+        ),
+        (
+            TextAnnotationViewSet,
+            ImageTextAnnotationFactory,
+            ImageTextAnnotationSerializer,
+            "retina-text-annotation",
+            False,
         ),
     ),
 )
@@ -1357,17 +1398,22 @@ 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, basename, with_user
+    ):
         models = self.create_models(factory)
         response = view_test(
             "list",
             user_type,
-            self.namespace,
-            factory._meta.model._meta.model_name,
+            self.namespace if basename is None else "api",
+            basename
+            if basename is not None
+            else factory._meta.model._meta.model_name,
             models[0].grader,
             None,
             rf,
             viewset,
+            with_user=with_user,
         )
         if user_type == "retina_grader":
             serialized_data = serializer(models[0:2], many=True).data
@@ -1380,7 +1426,9 @@ 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, basename, with_user
+    ):
         models = self.create_models(factory)
         build_kwargs = {"grader": models[0].grader}
         build_kwargs.update({"image": models[0].image})
@@ -1393,13 +1441,16 @@ def test_create_view(self, rf, user_type, viewset, factory, serializer):
         response = view_test(
             "create",
             user_type,
-            self.namespace,
-            factory._meta.model._meta.model_name,
+            self.namespace if basename is None else "api",
+            basename
+            if basename is not None
+            else factory._meta.model._meta.model_name,
             models[0].grader,
             None,
             rf,
             viewset,
             model_json,
+            with_user=with_user,
         )
         if user_type in ("retina_grader", "retina_admin"):
             model_serialized["id"] = response.data["id"]
@@ -1407,7 +1458,7 @@ def test_create_view(self, rf, user_type, viewset, factory, serializer):
             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, basename, with_user
     ):
         models = self.create_models(factory)
         build_kwargs = {"grader": models[0].grader}
@@ -1421,13 +1472,16 @@ def test_create_view_wrong_user_id(
         response = view_test(
             "create",
             user_type,
-            self.namespace,
-            factory._meta.model._meta.model_name,
+            self.namespace if basename is None else "api",
+            basename
+            if basename is not None
+            else factory._meta.model._meta.model_name,
             models[0].grader,
             None,
             rf,
             viewset,
             model_json,
+            with_user=with_user,
             check_response_status_code=False,
         )
         if user_type == "retina_admin":
@@ -1443,23 +1497,30 @@ 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, basename, with_user
+    ):
         models = self.create_models(factory)
         response = view_test(
             "retrieve",
             user_type,
-            self.namespace,
-            factory._meta.model._meta.model_name,
+            self.namespace if basename is None else "api",
+            basename
+            if basename is not None
+            else factory._meta.model._meta.model_name,
             models[0].grader,
             models[0],
             rf,
             viewset,
+            with_user=with_user,
         )
         if user_type == "retina_grader" or user_type == "retina_admin":
             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, basename, with_user
+    ):
         models = self.create_models(factory)
         model_serialized = serializer(models[1]).data
         models[1].delete()
@@ -1470,13 +1531,16 @@ def test_update_view(self, rf, user_type, viewset, factory, serializer):
         response = view_test(
             "update",
             user_type,
-            self.namespace,
-            factory._meta.model._meta.model_name,
+            self.namespace if basename is None else "api",
+            basename
+            if basename is not None
+            else factory._meta.model._meta.model_name,
             models[0].grader,
             models[0],
             rf,
             viewset,
             model_json,
+            with_user=with_user,
         )
 
         if user_type in ("retina_grader", "retina_admin"):
@@ -1484,7 +1548,7 @@ def test_update_view(self, rf, user_type, viewset, factory, serializer):
             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, basename, with_user
     ):
         other_user = UserFactory()
         models = self.create_models(factory)
@@ -1496,13 +1560,16 @@ def test_update_view_wrong_user_id(
         response = view_test(
             "update",
             user_type,
-            self.namespace,
-            factory._meta.model._meta.model_name,
+            self.namespace if basename is None else "api",
+            basename
+            if basename is not None
+            else factory._meta.model._meta.model_name,
             models[0].grader,
             models[0],
             rf,
             viewset,
             model_json,
+            with_user=with_user,
             check_response_status_code=False,
         )
         if user_type == "retina_admin":
@@ -1519,7 +1586,7 @@ 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, basename, with_user
     ):
         models = self.create_models(factory)
         model_serialized = serializer(models[0]).data
@@ -1532,29 +1599,37 @@ def test_partial_update_view(
         response = view_test(
             "partial_update",
             user_type,
-            self.namespace,
-            factory._meta.model._meta.model_name,
+            self.namespace if basename is None else "api",
+            basename
+            if basename is not None
+            else factory._meta.model._meta.model_name,
             models[0].grader,
             models[0],
             rf,
             viewset,
             model_json,
+            with_user=with_user,
         )
 
         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, basename, with_user
+    ):
         models = self.create_models(factory)
         view_test(
             "destroy",
             user_type,
-            self.namespace,
-            factory._meta.model._meta.model_name,
+            self.namespace if basename is None else "api",
+            basename
+            if basename is not None
+            else factory._meta.model._meta.model_name,
             models[0].grader,
             models[0],
             rf,
             viewset,
+            with_user=with_user,
         )
         if user_type in ("retina_grader", "retina_admin"):
             assert not factory._meta.model.objects.filter(
@@ -1562,18 +1637,21 @@ 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, basename, with_user
     ):
         models = self.create_models(factory)
         response = view_test(
             "destroy",
             user_type,
-            self.namespace,
-            factory._meta.model._meta.model_name,
+            self.namespace if basename is None else "api",
+            basename
+            if basename is not None
+            else factory._meta.model._meta.model_name,
             models[2].grader,
             models[0],
             rf,
             viewset,
+            with_user=with_user,
             check_response_status_code=False,
         )
         if user_type == "retina_admin":
@@ -1597,7 +1675,7 @@ def test_destroy_view_wrong_user(
             LandmarkAnnotationSetViewSet,
             LandmarkAnnotationSetFactory,
             LandmarkAnnotationSetSerializer,
-            "landmark-annotation",
+            "retina-landmark-annotation",
         ),
     ),
 )
@@ -1900,7 +1978,7 @@ def perform_request(rf, user_type, querystring, data=None):
 
         user = get_user_from_user_type(user_type, **kwargs)
 
-        url = reverse(f"api:landmark-annotation-list") + querystring
+        url = reverse(f"api:retina-landmark-annotation-list") + querystring
 
         request = rf.get(url)
 
@@ -1984,3 +2062,109 @@ def test_annotation_image(
             serialized_data.sort(key=lambda k: k["created"], reverse=True)
         else:
             assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+    "user_type", [None, "normal_user", "retina_grader", "retina_admin"]
+)
+class TestImageLevelAnnotationsForImageViewSet:
+    @staticmethod
+    def perform_request(rf, user_type, image_id=None, grader=None):
+        kwargs = {"pk": image_id}
+        user = get_user_from_user_type(user_type, grader=grader)
+
+        url = reverse(
+            "api:retina-image-level-annotation-for-image-detail", kwargs=kwargs
+        )
+
+        request = rf.get(url)
+
+        force_authenticate(request, user=user)
+        view = ImageLevelAnnotationsForImageViewSet.as_view(
+            actions={"get": "retrieve"}
+        )
+        return view(request, **kwargs)
+
+    def test_no_image_id(self, rf, user_type):
+        response = self.perform_request(rf, user_type)
+
+        if user_type in (None, "normal_user"):
+            assert response.status_code == status.HTTP_403_FORBIDDEN
+        else:
+            assert response.status_code == status.HTTP_404_NOT_FOUND
+
+    def test_non_existant_image(self, rf, user_type):
+        img = ImageFactory()
+        pk = img.pk
+        img.delete()
+        response = self.perform_request(rf, user_type, image_id=pk)
+
+        if user_type in (None, "normal_user"):
+            assert response.status_code == status.HTTP_403_FORBIDDEN
+        else:
+            assert response.status_code == status.HTTP_200_OK
+            assert response.data == {
+                "quality": None,
+                "pathology": None,
+                "retina_pathology": None,
+                "text": None,
+            }
+
+    def test_non_annotation_image(self, rf, user_type):
+        img = ImageFactory()
+        response = self.perform_request(rf, user_type, image_id=img.pk)
+
+        if user_type in (None, "normal_user"):
+            assert response.status_code == status.HTTP_403_FORBIDDEN
+        else:
+            assert response.status_code == status.HTTP_200_OK
+            assert response.data == {
+                "quality": None,
+                "pathology": None,
+                "retina_pathology": None,
+                "text": None,
+            }
+
+    def test_annotation_image_wrong_user(
+        self, rf, user_type, image_with_image_level_annotations
+    ):
+        image, grader, annotations = image_with_image_level_annotations
+        response = self.perform_request(rf, user_type, image_id=image.pk)
+        if user_type in (None, "normal_user"):
+            assert response.status_code == status.HTTP_403_FORBIDDEN
+        else:
+            assert response.status_code == status.HTTP_200_OK
+            assert response.data == {
+                "quality": None,
+                "pathology": None,
+                "retina_pathology": None,
+                "text": None,
+            }
+
+    def test_annotation_image_correct_user(
+        self, rf, user_type, image_with_image_level_annotations
+    ):
+        image, grader, annotations = image_with_image_level_annotations
+        response = self.perform_request(
+            rf, user_type, image_id=image.pk, grader=grader
+        )
+
+        if user_type == "retina_admin":
+            assert response.status_code == status.HTTP_200_OK
+            assert response.data == {
+                "quality": None,
+                "pathology": None,
+                "retina_pathology": None,
+                "text": None,
+            }
+        elif user_type == "retina_grader":
+            assert response.status_code == status.HTTP_200_OK
+            assert response.data == {
+                "quality": annotations["quality"].id,
+                "pathology": annotations["pathology"].id,
+                "retina_pathology": annotations["retina_pathology"].id,
+                "text": annotations["text"].id,
+            }
+        else:
+            assert response.status_code == status.HTTP_403_FORBIDDEN
diff --git a/app/tests/viewset_helpers.py b/app/tests/viewset_helpers.py
--- a/app/tests/viewset_helpers.py
+++ b/app/tests/viewset_helpers.py
@@ -250,6 +250,9 @@ def view_test(
     check_response_status_code=True,
     with_user=True,
 ):
+    if not with_user and user_type == "retina_grader_non_allowed":
+        user_type = "normal_user"
+
     user = get_user_from_user_type(user_type, grader=grader)
 
     url, kwargs = get_viewset_url_kwargs(