From 538c8e1b20739a8e66c207c3690f6c997658a1e3 Mon Sep 17 00:00:00 2001 From: Emmy Thamakaison <93239069+emmyxth@users.noreply.github.com> Date: Thu, 16 Oct 2025 23:56:34 -0700 Subject: [PATCH] annotation api updated views with token support, tests, read view --- physionet-django/annotation/permissions.py | 22 ++++++- physionet-django/annotation/serializers.py | 55 +++++++++-------- physionet-django/annotation/tests.py | 70 ++++++++++++++++++++-- physionet-django/annotation/urls.py | 6 ++ physionet-django/annotation/views.py | 26 +++++++- 5 files changed, 144 insertions(+), 35 deletions(-) diff --git a/physionet-django/annotation/permissions.py b/physionet-django/annotation/permissions.py index 12563f268c..57d4e569a0 100644 --- a/physionet-django/annotation/permissions.py +++ b/physionet-django/annotation/permissions.py @@ -5,7 +5,25 @@ class AnnotationsScope(TokenHasScope): def get_scopes(self, request, view): return ( - ["annotations:view"] + ["annotations:annotations:read"] if request.method in SAFE_METHODS - else ["annotations:edit"] + else ["annotations:annotations:write"] + ) + + +class AnnotationsTypesScope(TokenHasScope): + def get_scopes(self, request, view): + return ( + ["annotations:types:read"] + if request.method in SAFE_METHODS + else ["annotations:types:write"] + ) + + +class AnnotationsCollectionsScope(TokenHasScope): + def get_scopes(self, request, view): + return ( + ["annotations:collections:read"] + if request.method in SAFE_METHODS + else ["annotations:collections:write"] ) diff --git a/physionet-django/annotation/serializers.py b/physionet-django/annotation/serializers.py index d731f309bb..483b63ba45 100644 --- a/physionet-django/annotation/serializers.py +++ b/physionet-django/annotation/serializers.py @@ -19,27 +19,6 @@ import uuid -class AnnotationCollectionSerializer(serializers.ModelSerializer): - class Meta: - model = AnnotationCollection - fields = [ - "id", - "slug", - "name", - "description", - "created_by", - "created_datetime", - "updated_datetime", - ] - read_only_fields = ["created_by", "created_datetime", "updated_datetime"] - - def create(self, validated_data): - request = self.context.get("request") - if request and request.user and request.user.is_authenticated: - validated_data["created_by"] = request.user - return super().create(validated_data) - - class AnnotationTypeSerializer(serializers.ModelSerializer): class Meta: model = AnnotationType @@ -112,13 +91,17 @@ def to_representation(self, instance): data = super().to_representation(instance) if instance.location: if instance.location.location_type == "text_span": # TextSpanLocation - data["location"] = TextSpanLocationSerializer(instance.location).data + data["location"] = TextSpanLocationSerializer( + instance.location.textspanlocation + ).data elif instance.location.location_type == "timeseries_interval": data["location"] = TimeseriesIntervalLocationSerializer( - instance.location + instance.location.timeseriesintervallocation ).data elif instance.location.location_type == "image_bbox": # ImageBBoxLocation - data["location"] = ImageBBoxLocationSerializer(instance.location).data + data["location"] = ImageBBoxLocationSerializer( + instance.location.imagebboxlocation + ).data else: raise serializers.ValidationError( f"Unknown location_type: {instance.location.location_type}" @@ -175,3 +158,27 @@ def validate(self, data): '{location_data.get('location_type')}'" ) return data + + +class AnnotationCollectionSerializer(serializers.ModelSerializer): + annotations = AnnotationSerializer(many=True, read_only=True) + + class Meta: + model = AnnotationCollection + fields = [ + "id", + "slug", + "name", + "description", + "annotations", + "created_by", + "created_datetime", + "updated_datetime", + ] + read_only_fields = ["created_by", "created_datetime", "updated_datetime"] + + def create(self, validated_data): + request = self.context.get("request") + if request and request.user and request.user.is_authenticated: + validated_data["created_by"] = request.user + return super().create(validated_data) diff --git a/physionet-django/annotation/tests.py b/physionet-django/annotation/tests.py index e63d2ceaaa..7f16437b95 100644 --- a/physionet-django/annotation/tests.py +++ b/physionet-django/annotation/tests.py @@ -74,6 +74,19 @@ def _create_annotation_collection(self): ) return response + def _read_annotation_collection(self): + """ + Helper function to read annotation collection + """ + response = self.client.get( + reverse( + "annotation:annotation-collection-read", args=[self.collection.slug] + ), + format="json", + HTTP_AUTHORIZATION=self.auth_header, + ) + return response + def _create_annotation_type(self): """ Helper function to create annotation type @@ -123,7 +136,7 @@ class AnnotationAPITests(BaseTest): def test_create_annotation_collection_correct_scope(self): self.access_token = AccessToken.objects.create( user=self.user, - scope="annotations:edit", + scope="annotations:collections:write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=self.application, @@ -140,7 +153,7 @@ def test_create_annotation_collection_correct_scope(self): def test_create_annotation_collection_wrong_scope(self): self.access_token = AccessToken.objects.create( user=self.user, - scope="annotations:view", + scope="annotations:collections:read", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=self.application, @@ -163,10 +176,55 @@ def test_create_annotation_collection_no_scope(self): self.assertEqual(response.status_code, 403) response = response.json() + def test_read_annotation_collection_correct_scope(self): + self.collection = AnnotationCollection.objects.create( + slug="test-collection-text-span", + name="Test Collection Text Span", + description="Test Description", + created_by=self.user, + ) + self.access_token = AccessToken.objects.create( + user=self.user, + scope="annotations:collections:read annotations:annotations:write", + expires=timezone.now() + timedelta(seconds=300), + token="secret-access-token-key", + application=self.application, + ) + self.auth_header = self._create_authorization_header(self.access_token.token) + self.annotation_type = AnnotationType.objects.create( + slug="test-annotation-type-text-span", + name="Test Annotation Type Text Span", + description="Test Description", + label_schema={ + "type": "object", + "properties": { + "label": {"type": "string"}, + "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0}, + }, + "required": ["label"], + }, + allowed_location_type="text_span", + ) + text_span_annotation_data = { + "annotation_type": self.annotation_type.slug, + "project": self.project.slug, + "file_path": "../test-filepath.txt", + "labels": {"label": "Test Label", "confidence": 0.5}, + "location": { + "location_type": "text_span", + "coord_system": "char_offset", + "begin": 100, + "end": 200, + }, + } + self._create_annotation(data=text_span_annotation_data) + response = self._read_annotation_collection() + self.assertEqual(response.status_code, 200) + def test_create_annotation_type_correct_scope(self): self.access_token = AccessToken.objects.create( user=self.user, - scope="annotations:edit", + scope="annotations:types:write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=self.application, @@ -228,7 +286,7 @@ def test_create_annotation_text_span_correct_scope(self): self.access_token = AccessToken.objects.create( user=self.user, - scope="annotations:edit", + scope="annotations:annotations:write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=self.application, @@ -285,7 +343,7 @@ def test_create_annotation_image_bbox(self): } self.access_token = AccessToken.objects.create( user=self.user, - scope="annotations:edit", + scope="annotations:annotations:write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=self.application, @@ -343,7 +401,7 @@ def test_create_annotation_location_type_mismatch(self): } self.access_token = AccessToken.objects.create( user=self.user, - scope="annotations:edit", + scope="annotations:annotations:write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=self.application, diff --git a/physionet-django/annotation/urls.py b/physionet-django/annotation/urls.py index ffe6e74a59..d94bb69f1b 100644 --- a/physionet-django/annotation/urls.py +++ b/physionet-django/annotation/urls.py @@ -1,6 +1,7 @@ from django.urls import path from annotation.views import ( AnnotationCollectionCreateAPIView, + AnnotationCollectionReadAPIView, AnnotationTypeCreateAPIView, AnnotationCreateAPIView, ) @@ -13,6 +14,11 @@ AnnotationCollectionCreateAPIView.as_view(), name="annotation-collection-create", ), + path( + "annotations/collection//", + AnnotationCollectionReadAPIView.as_view(), + name="annotation-collection-read", + ), path( "annotations/type/create/", AnnotationTypeCreateAPIView.as_view(), diff --git a/physionet-django/annotation/views.py b/physionet-django/annotation/views.py index 55656d005f..81aeac7a5f 100644 --- a/physionet-django/annotation/views.py +++ b/physionet-django/annotation/views.py @@ -15,7 +15,11 @@ TokenHasScope, OAuth2Authentication, ) -from annotation.permissions import AnnotationsScope +from annotation.permissions import ( + AnnotationsScope, + AnnotationsTypesScope, + AnnotationsCollectionsScope, +) class AnnotationCollectionCreateAPIView(generics.CreateAPIView): @@ -24,9 +28,25 @@ class AnnotationCollectionCreateAPIView(generics.CreateAPIView): """ authentication_classes = [OAuth2Authentication] - permission_classes = [AnnotationsScope, IsAuthenticated] + permission_classes = [AnnotationsCollectionsScope, IsAuthenticated] + serializer_class = AnnotationCollectionSerializer + queryset = AnnotationCollection.objects.all() + + +class AnnotationCollectionReadAPIView(generics.RetrieveAPIView): + authentication_classes = [OAuth2Authentication] + permission_classes = [AnnotationsCollectionsScope, IsAuthenticated] serializer_class = AnnotationCollectionSerializer queryset = AnnotationCollection.objects.all() + lookup_field = "slug" + + def get_queryset(self): + return AnnotationCollection.objects.prefetch_related( + "collection_slug", + "collection_slug__annotation_type", + "collection_slug__location", + "collection_slug__project", + ) class AnnotationTypeCreateAPIView(generics.CreateAPIView): @@ -35,7 +55,7 @@ class AnnotationTypeCreateAPIView(generics.CreateAPIView): """ authentication_classes = [OAuth2Authentication] - permission_classes = [AnnotationsScope, IsAuthenticated] + permission_classes = [AnnotationsTypesScope, IsAuthenticated] serializer_class = AnnotationTypeSerializer queryset = AnnotationType.objects.all()