Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENG-5017] Addons V2 API Standardization #10510

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
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
8 changes: 6 additions & 2 deletions api/addons/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def get_root_folder(self, obj):

class AddonSerializer(JSONAPISerializer):
filterable_fields = frozenset([
'categories',
'category',
'name',
])

class Meta:
Expand All @@ -54,7 +55,10 @@ class Meta:
name = ser.CharField(source='full_name', read_only=True)
description = ser.CharField(read_only=True)
url = ser.CharField(read_only=True)
categories = ser.ListField(read_only=True)
category = ser.SerializerMethodField(read_only=True)

def get_category(self, obj):
return obj.categories[0]

def get_absolute_url(self, obj):
return absolute_reverse(
Expand Down
1 change: 1 addition & 0 deletions api/addons/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
# re_path(r'^$', 'api.views.home', name='home'),
# re_path(r'^blog/', include('blog.urls')),
re_path(r'^$', views.AddonList.as_view(), name=views.AddonList.view_name),
re_path(r'^(?P<provider_id>[a-z0-9]+)/$', views.AddonDetail.as_view(), name=views.AddonDetail.view_name),
]
23 changes: 23 additions & 0 deletions api/addons/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,26 @@ def param_queryset(self, query_params, default_queryset):

queryset = sub_query.intersection(queryset)
return list(queryset)


class AddonDetail(JSONAPIBaseView, generics.RetrieveAPIView):
"""

"""
permission_classes = (
drf_permissions.AllowAny,
drf_permissions.IsAuthenticatedOrReadOnly,
TokenHasScope, )

required_read_scopes = [CoreScopes.ALWAYS_PUBLIC]
required_write_scopes = [CoreScopes.NULL]

serializer_class = AddonSerializer
view_category = 'addons'
view_name = 'addon-detail'

def get_object(self):
try:
return osf_settings.ADDONS_AVAILABLE_DICT[self.kwargs['provider_id']]
except KeyError:
raise NotFound(f'`{self.kwargs["provider_id"]}` Not found')
54 changes: 54 additions & 0 deletions api/nodes/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@

""" Payload for creating a addon """
create_addon_payload = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'type': 'object',
'properties': {
'data': {
'type': 'object',
'properties': {
'type': {
'type': 'string',
},
'relationships': {
'type': 'object',
'properties': {
'provider': {
'type': 'object',
'properties': {
'data': {
'type': 'object',
'properties': {
'id': {
'pattern': '[a-z0-9]',
},
'type': {
'pattern': 'addons',
},
},
'required': [
'id',
'type',
],
},
},
'required': [
'data',
],
},
},
'required': [
'provider',
],
},
},
'required': [
'type',
'relationships',
],
},
},
'required': [
'data',
],
}
42 changes: 40 additions & 2 deletions api/nodes/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,17 +902,37 @@ class Meta:
def get_type(request):
return get_kebab_snake_case_field(request.version, 'node-addons')

id = ser.CharField(source='config.short_name', read_only=True)
filterable_fields = frozenset([
'id',
'name',
'node_has_auth',
'configured',
'folder_id',
'category',
])

id = ser.SerializerMethodField(read_only=True)
name = ser.CharField(read_only=True)
node_has_auth = ser.BooleanField(source='has_auth', read_only=True)
configured = ser.BooleanField(read_only=True)
external_account_id = ser.CharField(source='external_account._id', required=False, allow_null=True)
folder_id = ser.CharField(required=False, allow_null=True)
folder_path = ser.CharField(required=False, allow_null=True)
category = ser.SerializerMethodField(read_only=True)

def get_category(self, obj):
return obj.config.categories[0]

# Forward-specific
label = ser.CharField(required=False, allow_blank=True)
url = ser.URLField(required=False, allow_blank=True)

provider = RelationshipField(
read_only=True,
related_view='addons:addon-detail',
related_view_kwargs={'provider_id': '<config.short_name>'},
)

links = LinksField({
'self': 'get_absolute_url',
})
Expand All @@ -927,13 +947,22 @@ def get_absolute_url(self, obj):
kwargs=kwargs,
)

def get_id(self, obj):
return f'{obj.owner._id}-{obj.config.short_name}'

def create(self, validated_data):
auth = Auth(self.context['request'].user)
node = self.context['view'].get_node()
addon = self.context['request'].parser_context['kwargs']['provider']
addon = self.context['request'].parser_context['kwargs'].get('provider')
if not addon:
addon = validated_data.get('provider')

if not addon:
raise exceptions.NotFound('Requested addon not found')

return node.get_or_add_addon(addon, auth=auth)


class ForwardNodeAddonSettingsSerializer(NodeAddonSettingsSerializerBase):

def update(self, instance, validated_data):
Expand Down Expand Up @@ -992,6 +1021,15 @@ def update(self, instance, validated_data):

class NodeAddonSettingsSerializer(NodeAddonSettingsSerializerBase):

filterable_fields = frozenset([
'id',
'name',
'node_has_auth',
'configured',
'folder_id',
'category',
])

def check_for_update_errors(self, node_settings, folder_info, external_account_id):
if (not node_settings.has_auth and folder_info and not external_account_id):
raise Conflict('Cannot set folder without authorization')
Expand Down
39 changes: 34 additions & 5 deletions api/nodes/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
import io

from distutils.version import StrictVersion
from django.apps import apps
Expand All @@ -11,6 +12,9 @@
from rest_framework.status import HTTP_202_ACCEPTED, HTTP_204_NO_CONTENT

from addons.base.exceptions import InvalidAuthError
from api.base.parsers import JSONSchemaParser
from api.nodes.schemas import create_addon_payload

from api.addons.serializers import NodeAddonFolderSerializer
from api.addons.views import AddonSettingsMixin
from api.base import generic_bulk_views as bulk_views
Expand Down Expand Up @@ -1339,7 +1343,7 @@ def get_serializer_context(self):
return context


class NodeAddonList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, NodeMixin, AddonSettingsMixin):
class NodeAddonList(JSONAPIBaseView, generics.ListCreateAPIView, ListFilterMixin, NodeMixin, AddonSettingsMixin):
"""The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/nodes_addons_list).

"""
Expand All @@ -1352,24 +1356,49 @@ class NodeAddonList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, Node
)

required_read_scopes = [CoreScopes.NODE_ADDON_READ]
required_write_scopes = [CoreScopes.NULL]
required_write_scopes = [CoreScopes.NODE_ADDON_WRITE]

serializer_class = NodeAddonSettingsSerializer
view_category = 'nodes'
view_name = 'node-addons'
ordering_field = ('name',)

ordering = ('-id',)
create_payload_schema = create_addon_payload

def get_default_queryset(self):
qs = []
for addon in ADDONS_OAUTH:
obj = self.get_addon_settings(provider=addon, fail_if_absent=False, check_object_permissions=False)
# Since there's no queryset, just a list, we have to map short_name to it's serializer field.
if obj:
obj.name = obj.config.short_name
qs.append(obj)
sorted(qs, key=lambda addon: addon.id, reverse=True)

return qs

get_queryset = get_default_queryset
def get_queryset(self):
return self.get_queryset_from_request()

def perform_create(self, serializer):
request_json = JSONSchemaParser().parse(
io.BytesIO(self.request.body),
parser_context={
'request': self.request,
'json_schema': self.create_payload_schema,
},
)
addon = request_json['data']['relationships']['provider']['data']['id']
serializer.validated_data['provider'] = addon
if addon not in ADDONS_OAUTH:
raise NotFound('Requested addon unavailable')

node = self.get_node()
if node.has_addon(addon):
raise InvalidModelValueError(
detail='Add-on {} already enabled for node {}'.format(addon, node._id),
)

return super().perform_create(serializer)


class NodeAddonDetail(JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView, generics.CreateAPIView, NodeMixin, AddonSettingsMixin):
Expand Down
12 changes: 12 additions & 0 deletions api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,19 @@ class UserAddonSettingsSerializer(JSONAPISerializer):
Overrides UserSerializer to make id required.
"""
id = ser.CharField(source='config.short_name', read_only=True)
name = ser.CharField(read_only=True)
user_has_auth = ser.BooleanField(source='has_auth', read_only=True)
category = ser.SerializerMethodField(read_only=True)

def get_category(self, obj):
return obj.config.categories[0]

filterable_fields = frozenset([
'id',
'name',
'node_has_auth',
'category',
])

links = LinksField({
'self': 'get_absolute_url',
Expand Down
15 changes: 11 additions & 4 deletions api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,20 @@ class UserAddonList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, User
serializer_class = UserAddonSettingsSerializer
view_category = 'users'
view_name = 'user-addons'
ordering_fields = ('name',)

ordering = ('-id',)
def get_default_queryset(self):
addons = []
# Since there's no queryset, just a list, we have to map short_name to it's serializer field.
for addon in self.get_user().get_addons():
if 'accounts' in addon.config.configs:
addon.name = addon.config.short_name
addons.append(addon)

return addons

def get_queryset(self):
qs = [addon for addon in self.get_user().get_addons() if 'accounts' in addon.config.configs]
sorted(qs, key=lambda addon: addon.id, reverse=True)
return qs
return self.get_queryset_from_request()


class UserAddonDetail(JSONAPIBaseView, generics.RetrieveAPIView, UserMixin, AddonSettingsMixin):
Expand Down
17 changes: 17 additions & 0 deletions api_tests/addons_tests/test_addons_detail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import pytest
from api.base.settings.defaults import API_BASE


@pytest.mark.django_db
class TestAddonDetail:
"""
Tests Add Detail endpoint
"""

def test_addon_detail(self, app):
resp = app.get(f'/{API_BASE}addons/s3/')

assert resp.status_code == 200
data = resp.json['data']
assert data['attributes']['name'] == 'Amazon S3'
assert data['attributes']['category'] == 'storage'
8 changes: 4 additions & 4 deletions api_tests/addons_tests/test_addons_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ class TestAddonsList:

def test_filter_by_category(self, app):
url = '/{}addons/'.format(API_BASE)
url_storage = '{}?filter[categories]=storage'.format(url)
url_citations = '{}?filter[categories]=citations'.format(url)
url_storage = '{}?filter[category]=storage'.format(url)
url_citations = '{}?filter[category]=citations'.format(url)

data_storage = app.get(url_storage).json['data']
data_citations = app.get(url_citations).json['data']

for addon in data_storage:
assert 'storage' in addon['attributes']['categories']
assert 'storage' in addon['attributes']['category']

for addon in data_citations:
assert 'citations' in addon['attributes']['categories']
assert 'citations' in addon['attributes']['category']
2 changes: 1 addition & 1 deletion api_tests/nodes/views/test_node_addons.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def set_setting_list_url(self):

def get_response_for_addon(self, response):
for datum in response.json['data']:
if datum['id'] == self.short_name:
if self.short_name in datum['id']: # id == <node_id>-<short_name>
return datum['attributes']
return None

Expand Down
Loading