Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions physionet-django/annotation/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
)
55 changes: 31 additions & 24 deletions physionet-django/annotation/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the annotations field refers to the related_name relationship to Annotation, which is currently collection_slug? This should be fixed by: #2527

"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)
70 changes: 64 additions & 6 deletions physionet-django/annotation/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions physionet-django/annotation/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import path
from annotation.views import (
AnnotationCollectionCreateAPIView,
AnnotationCollectionReadAPIView,
AnnotationTypeCreateAPIView,
AnnotationCreateAPIView,
)
Expand All @@ -13,6 +14,11 @@
AnnotationCollectionCreateAPIView.as_view(),
name="annotation-collection-create",
),
path(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not directly related to this PR, but would it be clearer to go with one of collections or collection for the endpoint? Currently we use both.

"annotations/collection/<slug:slug>/",
AnnotationCollectionReadAPIView.as_view(),
name="annotation-collection-read",
),
path(
"annotations/type/create/",
AnnotationTypeCreateAPIView.as_view(),
Expand Down
26 changes: 23 additions & 3 deletions physionet-django/annotation/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
TokenHasScope,
OAuth2Authentication,
)
from annotation.permissions import AnnotationsScope
from annotation.permissions import (
AnnotationsScope,
AnnotationsTypesScope,
AnnotationsCollectionsScope,
)


class AnnotationCollectionCreateAPIView(generics.CreateAPIView):
Expand All @@ -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):
Expand All @@ -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()

Expand Down
8 changes: 6 additions & 2 deletions physionet-django/physionet/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,8 +803,12 @@ class StorageTypes:
"orcid:read": "Read access to user's ORCID iD",
"public_id:read": "Read access to the user's persistent public ID",
"data:download": "Download project data if token-holder is approved for access (training, DUA, etc).",
"annotations:view": "Read Annotation resources",
"annotations:edit": "Create/Update/Delete Annotation resources",
"annotations:collections:read": "Read access to annotation collections",
"annotations:collections:write": "Create/Update/Delete annotation collections",
"annotations:types:read": "Read access to annotation types",
"annotations:types:write": "Create/Update/Delete annotation types",
"annotations:annotations:read": "Read access to annotations",
"annotations:annotations:write": "Create/Update/Delete annotations",
}
}

Expand Down
Loading