diff --git a/mayan/apps/rest_api/api_views.py b/mayan/apps/rest_api/api_views.py index ee1dd41c94..bad68a2f5f 100644 --- a/mayan/apps/rest_api/api_views.py +++ b/mayan/apps/rest_api/api_views.py @@ -1,31 +1,11 @@ from __future__ import unicode_literals -from rest_framework import renderers +from drf_yasg.views import get_schema_view + +from rest_framework import permissions, renderers from rest_framework.authtoken.views import ObtainAuthToken -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.schemas.generators import EndpointEnumerator -from .classes import Endpoint -from .serializers import EndpointSerializer - - -class APIRoot(APIView): - swagger_schema = None - - def get(self, request, format=None): - """ - get: Return a list of all endpoints. - """ - endpoint_enumerator = EndpointEnumerator() - - endpoints = [] - for url in sorted(set([entry[0].split('/')[2] for entry in endpoint_enumerator.get_api_endpoints()])): - if url: - endpoints.append(Endpoint(label=url)) - - serializer = EndpointSerializer(endpoints, many=True) - return Response(serializer.data) +from .schemas import openapi_info class BrowseableObtainAuthToken(ObtainAuthToken): @@ -33,3 +13,11 @@ class BrowseableObtainAuthToken(ObtainAuthToken): Obtain an API authentication token. """ renderer_classes = (renderers.BrowsableAPIRenderer, renderers.JSONRenderer) + + +schema_view = get_schema_view( + openapi_info, + validators=['flex', 'ssv'], + public=True, + permission_classes=(permissions.AllowAny,), +) diff --git a/mayan/apps/rest_api/apps.py b/mayan/apps/rest_api/apps.py index 88d0b2c61a..d913ccd02c 100644 --- a/mayan/apps/rest_api/apps.py +++ b/mayan/apps/rest_api/apps.py @@ -5,6 +5,8 @@ from django.conf import settings from django.utils.module_loading import import_string from django.utils.translation import ugettext_lazy as _ +from rest_framework import routers + from mayan.apps.common import MayanAppConfig, menu_tools from .links import ( @@ -21,7 +23,7 @@ class RESTAPIApp(MayanAppConfig): def ready(self): super(RESTAPIApp, self).ready() - from .urls import api_urls + from .urls import urlpatterns settings.STRONGHOLD_PUBLIC_URLS += (r'^/%s/.+$' % self.app_url,) menu_tools.bind_links( @@ -29,8 +31,14 @@ class RESTAPIApp(MayanAppConfig): link_api, link_api_documentation, link_api_documentation_redoc ) ) + router = routers.DefaultRouter() for app in apps.get_app_configs(): if getattr(app, 'has_rest_api', False): - app_api_urls = import_string('{}.urls.api_urls'.format(app.name)) - api_urls.extend(app_api_urls) + try: + for entry in import_string('{}.urls.api_router_entries'.format(app.name)): + router.register(**entry) + except ImportError: + pass + + urlpatterns.extend(router.urls) diff --git a/mayan/apps/rest_api/classes.py b/mayan/apps/rest_api/classes.py deleted file mode 100644 index 4cb84795a8..0000000000 --- a/mayan/apps/rest_api/classes.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import unicode_literals - - -class Endpoint(object): - def __init__(self, label): - self.label = label - - @property - def url(self): - return '/api/{}/'.format(self.label) diff --git a/mayan/apps/rest_api/filters.py b/mayan/apps/rest_api/filters.py index 1b561c598e..928c997bc8 100644 --- a/mayan/apps/rest_api/filters.py +++ b/mayan/apps/rest_api/filters.py @@ -5,18 +5,27 @@ from rest_framework.filters import BaseFilterBackend from mayan.apps.acls.models import AccessControlList -class MayanObjectPermissionsFilter(BaseFilterBackend): +class MayanViewSetObjectPermissionsFilter(BaseFilterBackend): def filter_queryset(self, request, queryset, view): - # TODO: fix variable name to make it clear it should be a single - # permission + """ + Filter the API view queryset by access using a permission. + Requires the object_permission_map class attribute which is a dictionary + that matches a view action ('update', 'list', etc) to a single + permission instance. + Example: object_permission_map = { + 'update': permission_..._edit + 'list': permission_..._view + } + """ + object_permission_dictionary = getattr(view, 'object_permission_map', {}) + object_permission = object_permission_dictionary.get( + view.action, None + ) - required_permissions = getattr( - view, 'mayan_object_permissions', {} - ).get(request.method, None) - - if required_permissions: - return AccessControlList.objects.filter_by_access( - required_permissions[0], request.user, queryset=queryset + if object_permission: + return AccessControlList.objects.restrict_queryset( + permission=object_permission, queryset=queryset, + user=request.user ) else: return queryset diff --git a/mayan/apps/rest_api/generics.py b/mayan/apps/rest_api/generics.py new file mode 100644 index 0000000000..ec3cf267d3 --- /dev/null +++ b/mayan/apps/rest_api/generics.py @@ -0,0 +1,48 @@ +from __future__ import absolute_import, unicode_literals + +from rest_framework import generics + +from mayan.apps.rest_api.filters import MayanObjectPermissionsFilter +from mayan.apps.rest_api.permissions import MayanPermission + + +class ListAPIView(generics.ListAPIView): + """ + requires: + object_permission = {'GET': ...} + """ + filter_backends = (MayanObjectPermissionsFilter,) + + +class ListCreateAPIView(generics.ListCreateAPIView): + """ + requires: + object_permission = {'GET': ...} + view_permission = {'POST': ...} + """ + filter_backends = (MayanObjectPermissionsFilter,) + permission_classes = (MayanPermission,) + + +class RetrieveDestroyAPIView(generics.RetrieveDestroyAPIView): + """ + requires: + object_permission = { + 'DELETE': ..., + 'GET': ..., + } + """ + filter_backends = (MayanObjectPermissionsFilter,) + + +class RetrieveUpdateDestroyAPIView(generics.RetrieveUpdateDestroyAPIView): + """ + requires: + object_permission = { + 'DELETE': ..., + 'GET': ..., + 'PATCH': ..., + 'PUT': ... + } + """ + filter_backends = (MayanObjectPermissionsFilter,) diff --git a/mayan/apps/rest_api/links.py b/mayan/apps/rest_api/links.py index efd5443ab3..d181599268 100644 --- a/mayan/apps/rest_api/links.py +++ b/mayan/apps/rest_api/links.py @@ -11,14 +11,14 @@ from .icons import ( link_api = Link( icon_class=icon_api, tags='new_window', text=_('REST API'), - view='rest_api:api_root' + view='rest_api:api-root' ) link_api_documentation = Link( icon_class=icon_api_documentation, tags='new_window', - text=_('API Documentation (Swagger)'), view='schema-swagger-ui' + text=_('API Documentation (Swagger)'), view='rest_api:schema-swagger-ui' ) link_api_documentation_redoc = Link( icon_class=icon_api_documentation_redoc, tags='new_window', - text=_('API Documentation (ReDoc)'), view='schema-redoc' + text=_('API Documentation (ReDoc)'), view='rest_api:schema-redoc' ) diff --git a/mayan/apps/rest_api/mixins.py b/mayan/apps/rest_api/mixins.py new file mode 100644 index 0000000000..efacb23ac1 --- /dev/null +++ b/mayan/apps/rest_api/mixins.py @@ -0,0 +1,54 @@ +from __future__ import absolute_import, unicode_literals + +from django.core.exceptions import ImproperlyConfigured + +from mayan.apps.acls.models import AccessControlList + + +class ExternalObjectListSerializerMixin(object): + class Meta: + external_object_list_model = None + external_object_list_permission = None + external_object_list_queryset = None + external_object_list_pk_field = None + external_object_list_pk_list_field = None + + def get_external_object_list(self): + queryset = self.get_external_object_list_queryset() + + if self.Meta.external_object_list_permission: + queryset = AccessControlList.objects.restrict_queryset( + permission=self.Meta.external_object_list_permission, + queryset=queryset, + user=self.context['request'].user + ) + + if self.Meta.external_object_list_pk_field: + id_list = ( + self.validated_data.get(self.Meta.external_object_list_pk_field), + ) + elif self.Meta.external_object_list_pk_list_field: + id_list = self.validated_data.get( + self.Meta.external_object_list_pk_list_field, '' + ).split(',') + else: + raise ImproperlyConfigured( + 'ExternalObjectListSerializerMixin requires a ' + 'external_object_list__pk_field a ' + 'external_object_list_pk_list_field.' + ) + + return queryset.filter(pk__in=id_list) + + def get_external_object_list_queryset(self): + if self.Meta.external_object_list_model: + queryset = self.Meta.external_object_list_model._meta.default_manager.all() + elif self.Meta.external_object_list_queryset: + return self.Meta.external_object_list_queryset + else: + raise ImproperlyConfigured( + 'ExternalObjectListSerializerMixin requires a ' + 'external_object_list_model or a external_object_list_queryset.' + ) + + return queryset diff --git a/mayan/apps/rest_api/permissions.py b/mayan/apps/rest_api/permissions.py index 476b551a21..830c4e5a07 100644 --- a/mayan/apps/rest_api/permissions.py +++ b/mayan/apps/rest_api/permissions.py @@ -1,26 +1,31 @@ -from __future__ import absolute_import - -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from django.core.exceptions import PermissionDenied -from django.http import Http404 from rest_framework.permissions import BasePermission -from mayan.apps.acls.models import AccessControlList from mayan.apps.permissions import Permission -class MayanPermission(BasePermission): +class MayanViewSetPermission(BasePermission): def has_permission(self, request, view): - required_permission = getattr( - view, 'mayan_view_permissions', {} - ).get(request.method, None) + """ + Block the API view by access using a permission. + Required the view_permission_map class attribute which is a dictionary + that matches a view actions ('create', 'destroy', etc) to a single + permission instance. + Example: view_permission_map = { + 'update': permission_..._edit + 'list': permission_..._view + } + """ + view_permission_dictionary = getattr(view, 'view_permission_map', {}) + view_permission = view_permission_dictionary.get(view.action, None) - if required_permission: + if view_permission: try: - Permission.check_permissions( - requester=request.user, permissions=required_permission + Permission.check_user_permission( + permission=view_permission, user=request.user ) except PermissionDenied: return False @@ -28,35 +33,3 @@ class MayanPermission(BasePermission): return True else: return True - - def has_object_permission(self, request, view, obj): - required_permission = getattr( - view, 'mayan_object_permissions', {} - ).get(request.method, None) - - object_permissions_raise_404 = getattr( - view, 'mayan_object_permissions_raise_404', () - ) - - if required_permission: - try: - if hasattr(view, 'mayan_permission_attribute_check'): - AccessControlList.objects.check_access( - permissions=required_permission, - user=request.user, obj=obj, - related=view.mayan_permission_attribute_check - ) - else: - AccessControlList.objects.check_access( - permissions=required_permission, user=request.user, - obj=obj - ) - except PermissionDenied: - if request.method in object_permissions_raise_404: - raise Http404 - else: - return False - else: - return True - else: - return True diff --git a/mayan/apps/rest_api/relations.py b/mayan/apps/rest_api/relations.py new file mode 100644 index 0000000000..5ff9f24f75 --- /dev/null +++ b/mayan/apps/rest_api/relations.py @@ -0,0 +1,35 @@ +from __future__ import unicode_literals + +from rest_framework.relations import HyperlinkedIdentityField + +from mayan.apps.common.utils import resolve_attribute + + +class MultiKwargHyperlinkedIdentityField(HyperlinkedIdentityField): + def __init__(self, *args, **kwargs): + self.view_kwargs = kwargs.pop('view_kwargs', []) + super(MultiKwargHyperlinkedIdentityField, self).__init__(*args, **kwargs) + + def get_url(self, obj, view_name, request, format): + """ + Extends HyperlinkedRelatedField to allow passing more than one view + keyword argument. + ---- + Given an object, return the URL that hyperlinks to the object. + + May raise a `NoReverseMatch` if the `view_name` and `lookup_field` + attributes are not configured to correctly match the URL conf. + """ + # Unsaved objects will not yet have a valid URL. + if hasattr(obj, 'pk') and obj.pk in (None, ''): + return None + + kwargs = {} + for entry in self.view_kwargs: + kwargs[entry['lookup_url_kwarg']] = resolve_attribute( + obj=obj, attribute=entry['lookup_field'] + ) + + return self.reverse( + viewname=view_name, kwargs=kwargs, request=request, format=format + ) diff --git a/mayan/apps/rest_api/serializers.py b/mayan/apps/rest_api/serializers.py deleted file mode 100644 index 041fc524a4..0000000000 --- a/mayan/apps/rest_api/serializers.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import unicode_literals - -from rest_framework import serializers - - -class EndpointSerializer(serializers.Serializer): - label = serializers.CharField(read_only=True) - url = serializers.URLField(read_only=True) diff --git a/mayan/apps/rest_api/urls.py b/mayan/apps/rest_api/urls.py index 33a3a7bed8..a9101bb273 100644 --- a/mayan/apps/rest_api/urls.py +++ b/mayan/apps/rest_api/urls.py @@ -1,18 +1,24 @@ from __future__ import unicode_literals -from django.conf.urls import include, url +from django.conf.urls import url -from .api_views import APIRoot, BrowseableObtainAuthToken - - -api_urls = [ - url(r'^$', APIRoot.as_view(), name='api_root'), - url( - r'^auth/token/obtain/$', BrowseableObtainAuthToken.as_view(), - name='auth_token_obtain' - ), -] +from .api_views import BrowseableObtainAuthToken, schema_view urlpatterns = [ - url(r'^', include(api_urls)), + url( + regex=r'^auth/token/obtain/$', name='auth_token_obtain', + view=BrowseableObtainAuthToken.as_view() + ), + url( + regex=r'^swagger(?P.json|.yaml)$', name='schema-json', + view=schema_view.without_ui(cache_timeout=None), + ), + url( + regex=r'^swagger/$', name='schema-swagger-ui', + view=schema_view.with_ui('swagger', cache_timeout=None) + ), + url( + regex=r'^redoc/$', name='schema-redoc', + view=schema_view.with_ui('redoc', cache_timeout=None) + ), ] diff --git a/mayan/apps/rest_api/viewsets.py b/mayan/apps/rest_api/viewsets.py new file mode 100644 index 0000000000..3369f1350d --- /dev/null +++ b/mayan/apps/rest_api/viewsets.py @@ -0,0 +1,11 @@ +from __future__ import absolute_import, unicode_literals + +from rest_framework import viewsets + +from .filters import MayanViewSetObjectPermissionsFilter +from .permissions import MayanViewSetPermission + + +class MayanAPIModelViewSet(viewsets.ModelViewSet): + filter_backends = (MayanViewSetObjectPermissionsFilter,) + permission_classes = (MayanViewSetPermission,) diff --git a/mayan/urls/base.py b/mayan/urls/base.py index 7e3a9a143d..6681443287 100644 --- a/mayan/urls/base.py +++ b/mayan/urls/base.py @@ -3,22 +3,8 @@ from __future__ import unicode_literals from django.conf.urls import url from django.contrib import admin -from drf_yasg.views import get_schema_view -from rest_framework import permissions - -from mayan.apps.rest_api.schemas import openapi_info - admin.autodiscover() -schema_view = get_schema_view( - openapi_info, - validators=['flex', 'ssv'], - public=True, - permission_classes=(permissions.AllowAny,), -) urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^swagger(?P.json|.yaml)$', schema_view.without_ui(cache_timeout=None), name='schema-json'), - url(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=None), name='schema-swagger-ui'), - url(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=None), name='schema-redoc'), ]