Convert the API URL system from an App based one

to a resource based one.

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
This commit is contained in:
Roberto Rosario
2017-05-12 17:53:44 -04:00
parent 105eab0740
commit acdc7dca48
30 changed files with 310 additions and 108 deletions

View File

@@ -115,8 +115,10 @@ class AccessControlListManager(models.Manager):
def filter_by_access(self, permission, user, queryset):
if user.is_superuser or user.is_staff:
logger.debug('Unfiltered queryset returned to user "%s" as superuser or staff',
user)
logger.debug(
'Unfiltered queryset returned to user "%s" as superuser '
'or staff', user
)
return queryset
try:

View File

@@ -28,19 +28,19 @@ urlpatterns = [
api_urls = [
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/$',
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/$',
APIObjectACLListView.as_view(), name='accesscontrollist-list'
),
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/$',
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/$',
APIObjectACLView.as_view(), name='accesscontrollist-detail'
),
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/permissions/$',
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/permissions/$',
APIObjectACLPermissionListView.as_view(), name='accesscontrollist-permission-list'
),
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/permissions/(?P<permission_pk>\d+)/$',
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/permissions/(?P<permission_pk>\d+)/$',
APIObjectACLPermissionView.as_view(), name='accesscontrollist-permission-detail'
),
]

View File

@@ -1,20 +1,13 @@
from __future__ import absolute_import, unicode_literals
import pytz
from django.shortcuts import get_object_or_404
from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework import generics
from acls.models import AccessControlList
from documents.models import Document
from documents.permissions import permission_document_view
from .models import DocumentCheckout
from .permissions import (
permission_document_checkout, permission_document_checkin,
permission_document_checkin_override
permission_document_checkin, permission_document_checkin_override
)
from .serializers import (
DocumentCheckoutSerializer, NewDocumentCheckoutSerializer
@@ -47,12 +40,23 @@ class APICheckedoutDocumentListView(generics.ListCreateAPIView):
APICheckedoutDocumentListView, self
).get(request, *args, **kwargs)
def get_serializer_context(self):
"""
Extra context provided to the serializer class.
"""
return {
'format': self.format_kwarg,
'request': self.request,
'view': self
}
'''
def post(self, request, *args, **kwargs):
"""
Checkout a document.
"""
serializer = self.get_serializer(data=request.DATA, files=request.FILES)
serializer = self.get_serializer(data=request.data, files=request.file)
if serializer.is_valid():
document = get_object_or_404(
@@ -83,6 +87,7 @@ class APICheckedoutDocumentListView(generics.ListCreateAPIView):
return Response(status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
'''
class APICheckedoutDocumentView(generics.RetrieveDestroyAPIView):

View File

@@ -57,7 +57,10 @@ class CheckoutsApp(MayanAppConfig):
Document.add_to_class(
'check_in',
lambda document, user=None: DocumentCheckout.objects.check_in_document(document, user)
lambda document,
user=None: DocumentCheckout.objects.check_in_document(
document, user
)
)
Document.add_to_class(
'checkout_info',

View File

@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
class DocumentCheckoutManager(models.Manager):
def checkout_document(self, document, expiration_datetime, user, block_new_version=True):
self.create(
return self.create(
document=document, expiration_datetime=expiration_datetime,
user=user, block_new_version=block_new_version
)

View File

@@ -1,15 +1,19 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from acls.models import AccessControlList
from documents.models import Document
from documents.serializers import DocumentSerializer
from .models import DocumentCheckout
from .permissions import permission_document_checkout
class DocumentCheckoutSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
# Hide this import otherwise strange circular import error occur
from documents.serializers import DocumentSerializer
super(DocumentCheckoutSerializer, self).__init__(*args, **kwargs)
self.fields['document'] = DocumentSerializer()
@@ -17,7 +21,33 @@ class DocumentCheckoutSerializer(serializers.ModelSerializer):
model = DocumentCheckout
class NewDocumentCheckoutSerializer(serializers.Serializer):
document = serializers.IntegerField()
expiration_datetime = serializers.DateTimeField()
class NewDocumentCheckoutSerializer(serializers.ModelSerializer):
block_new_version = serializers.BooleanField()
document_pk = serializers.IntegerField(
help_text=_('Primary key of the document to be checked out.'),
write_only=True
)
expiration_datetime = serializers.DateTimeField()
class Meta:
fields = (
'block_new_version', 'document', 'document_pk',
'expiration_datetime', 'id'
)
model = DocumentCheckout
read_only_fields = ('document',)
write_only_fields = ('document_pk',)
def create(self, validated_data):
document = Document.objects.get(pk=validated_data.pop('document_pk'))
AccessControlList.objects.check_access(
permissions=permission_document_checkout,
user=self.context['request'].user, obj=document
)
validated_data['document'] = document
validated_data['user'] = self.context['request'].user
return super(NewDocumentCheckoutSerializer, self).create(
validated_data
)

View File

@@ -0,0 +1,75 @@
from __future__ import unicode_literals
import datetime
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse
from django.test import override_settings
from django.utils.encoding import force_text
from django.utils.timezone import now
from rest_framework.test import APITestCase
from documents.models import DocumentType
from documents.tests import TEST_DOCUMENT_TYPE, TEST_SMALL_DOCUMENT_PATH
from user_management.tests.literals import (
TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, TEST_ADMIN_USERNAME
)
from ..models import DocumentCheckout
@override_settings(OCR_AUTO_OCR=False)
class CheckoutAPITestCase(APITestCase):
def setUp(self):
super(CheckoutAPITestCase, self).setUp()
self.admin_user = get_user_model().objects.create_superuser(
username=TEST_ADMIN_USERNAME, email=TEST_ADMIN_EMAIL,
password=TEST_ADMIN_PASSWORD
)
self.client.login(
username=TEST_ADMIN_USERNAME, password=TEST_ADMIN_PASSWORD
)
self.document_type = DocumentType.objects.create(
label=TEST_DOCUMENT_TYPE
)
with open(TEST_SMALL_DOCUMENT_PATH) as file_object:
self.document = self.document_type.new_document(
file_object=file_object,
)
def tearDown(self):
self.document_type.delete()
super(CheckoutAPITestCase, self).tearDown()
def test_document_checkout_get_view(self):
expiration_datetime = now() + datetime.timedelta(days=1)
DocumentCheckout.objects.checkout_document(
document=self.document, expiration_datetime=expiration_datetime,
user=self.admin_user, block_new_version=True
)
response = self.client.get(reverse('rest_api:checkout-document-list'))
self.assertEqual(
response.data['results'][0]['document']['uuid'],
force_text(self.document.uuid)
)
def test_document_checkout_post_view(self):
response = self.client.post(
reverse('rest_api:checkout-document-list'), data={
'document_pk': self.document.pk,
'expiration_datetime': '2099-01-01T12:00'
}
)
self.assertEqual(response.status_code, 201)
self.assertEqual(
DocumentCheckout.objects.first().document, self.document
)

View File

@@ -26,11 +26,11 @@ urlpatterns = [
api_urls = [
url(
r'^documents/$', APICheckedoutDocumentListView.as_view(),
r'^checkouts/$', APICheckedoutDocumentListView.as_view(),
name='checkout-document-list'
),
url(
r'^documents/(?P<pk>[0-9]+)/$', APICheckedoutDocumentView.as_view(),
r'^checkouts/(?P<pk>[0-9]+)/$', APICheckedoutDocumentView.as_view(),
name='checkedout-document-view'
),
]

View File

@@ -25,11 +25,11 @@ urlpatterns = [
api_urls = [
url(
r'^document/(?P<document_pk>[0-9]+)/comments/$',
r'^documents/(?P<document_pk>[0-9]+)/comments/$',
APICommentListView.as_view(), name='comment-list'
),
url(
r'^document/(?P<document_pk>[0-9]+)/comments/(?P<comment_pk>[0-9]+)/$',
r'^documents/(?P<document_pk>[0-9]+)/comments/(?P<comment_pk>[0-9]+)/$',
APICommentView.as_view(), name='comment-detail'
),
]

View File

@@ -72,12 +72,12 @@ urlpatterns = [
api_urls = [
url(
r'^index/node/(?P<pk>[0-9]+)/documents/$',
r'^indexes/node/(?P<pk>[0-9]+)/documents/$',
APIIndexNodeInstanceDocumentListView.as_view(),
name='index-node-documents'
),
url(
r'^index/template/(?P<pk>[0-9]+)/$', APIIndexTemplateView.as_view(),
r'^indexes/template/(?P<pk>[0-9]+)/$', APIIndexTemplateView.as_view(),
name='index-template-detail'
),
url(
@@ -85,12 +85,12 @@ api_urls = [
name='index-detail'
),
url(
r'^index/(?P<pk>[0-9]+)/template/$',
r'^indexes/(?P<pk>[0-9]+)/template/$',
APIIndexTemplateListView.as_view(), name='index-template-detail'
),
url(r'^indexes/$', APIIndexListView.as_view(), name='index-list'),
url(
r'^document/(?P<pk>[0-9]+)/indexes/$',
r'^documents/(?P<pk>[0-9]+)/indexes/$',
APIDocumentIndexListView.as_view(), name='document-index-list'
),
]

View File

@@ -164,20 +164,20 @@ api_urls = [
APIWorkflowTransitionView.as_view(), name='workflowtransition-detail'
),
url(
r'^document/(?P<pk>[0-9]+)/workflows/$',
r'^documents/(?P<pk>[0-9]+)/workflows/$',
APIWorkflowInstanceListView.as_view(), name='workflowinstance-list'
),
url(
r'^document/(?P<pk>[0-9]+)/workflows/(?P<workflow_pk>[0-9]+)/$',
r'^documents/(?P<pk>[0-9]+)/workflows/(?P<workflow_pk>[0-9]+)/$',
APIWorkflowInstanceView.as_view(), name='workflowinstance-detail'
),
url(
r'^document/(?P<pk>[0-9]+)/workflows/(?P<workflow_pk>[0-9]+)/log_entries/$',
r'^documents/(?P<pk>[0-9]+)/workflows/(?P<workflow_pk>[0-9]+)/log_entries/$',
APIWorkflowInstanceLogEntryListView.as_view(),
name='workflowinstancelogentry-list'
),
url(
r'^document_type/(?P<pk>[0-9]+)/workflows/$',
r'^document_types/(?P<pk>[0-9]+)/workflows/$',
APIDocumentTypeWorkflowListView.as_view(),
name='documenttype-workflow-list'
),

View File

@@ -27,7 +27,7 @@ from events.links import link_events_for_object
from events.permissions import permission_events_view
from mayan.celery import app
from navigation import SourceColumn
from rest_api.classes import APIEndPoint
from rest_api.classes import APIEndPoint, APIResource
from rest_api.fields import DynamicSerializerField
from statistics.classes import StatisticNamespace, CharJSLine
@@ -91,6 +91,9 @@ class DocumentsApp(MayanAppConfig):
from actstream import registry
APIEndPoint(app=self, version_string='1')
APIResource(label=_('Document types'), name='document_types')
APIResource(label=_('Documents'), name='documents')
APIResource(label=_('Trashed documents'), name='trashed_documents')
DeletedDocument = self.get_model('DeletedDocument')
Document = self.get_model('Document')

View File

@@ -29,7 +29,7 @@ api_urls = [
name='search-view'
),
url(
r'^advanced/(?P<search_model>[\.\w]+)/$', APIAdvancedSearchView.as_view(),
r'^search/advanced/(?P<search_model>[\.\w]+)/$', APIAdvancedSearchView.as_view(),
name='advanced-search-view'
),
]

View File

@@ -20,10 +20,10 @@ urlpatterns = [
]
api_urls = [
url(r'^types/$', APIEventTypeListView.as_view(), name='event-type-list'),
url(r'^event_types/$', APIEventTypeListView.as_view(), name='event-type-list'),
url(r'^events/$', APIEventListView.as_view(), name='event-list'),
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_id>\d+)/events/$',
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_id>\d+)/events/$',
APIObjectEventListView.as_view(), name='object-event-list'
),
]

View File

@@ -60,7 +60,7 @@ api_urls = [
),
url(r'^folders/$', APIFolderListView.as_view(), name='folder-list'),
url(
r'^document/(?P<pk>[0-9]+)/folders/$',
r'^documents/(?P<pk>[0-9]+)/folders/$',
APIDocumentFolderListView.as_view(), name='document-folder-list'
),
]

View File

@@ -3,6 +3,8 @@ from __future__ import absolute_import, unicode_literals
from rest_framework import generics, status
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from documents.models import Document, DocumentPage, DocumentVersion
from rest_api.permissions import MayanPermission
@@ -26,10 +28,6 @@ class APIDocumentOCRView(generics.GenericAPIView):
Submit a document for OCR.
---
omit_serializer: true
parameters:
- name: pk
paramType: path
type: number
responseMessages:
- code: 202
message: Accepted
@@ -40,12 +38,19 @@ class APIDocumentOCRView(generics.GenericAPIView):
class APIDocumentVersionOCRView(generics.GenericAPIView):
lookup_url_kwarg = 'version_pk'
mayan_object_permissions = {
'POST': (permission_ocr_document,)
}
permission_classes = (MayanPermission,)
queryset = DocumentVersion.objects.all()
def get_document(self):
return get_object_or_404(Document, pk=self.kwargs['document_pk'])
def get_queryset(self):
return self.get_document().versions.all()
def get_serializer_class(self):
return None
@@ -54,10 +59,6 @@ class APIDocumentVersionOCRView(generics.GenericAPIView):
Submit a document version for OCR.
---
omit_serializer: true
parameters:
- name: pk
paramType: path
type: number
responseMessages:
- code: 202
message: Accepted
@@ -70,20 +71,25 @@ class APIDocumentVersionOCRView(generics.GenericAPIView):
class APIDocumentPageContentView(generics.RetrieveAPIView):
"""
Returns the OCR content of the selected document page.
---
GET:
parameters:
- name: pk
paramType: path
type: number
"""
lookup_url_kwarg = 'page_pk'
mayan_object_permissions = {
'GET': (permission_ocr_content_view,),
}
permission_classes = (MayanPermission,)
serializer_class = DocumentPageContentSerializer
queryset = DocumentPage.objects.all()
def get_document(self):
return get_object_or_404(Document, pk=self.kwargs['document_pk'])
def get_document_version(self):
return get_object_or_404(
self.get_document().versions.all(), pk=self.kwargs['version_pk']
)
def get_queryset(self):
return self.get_document_version().pages.all()
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()

View File

@@ -63,7 +63,9 @@ class OCRAPITestCase(BaseAPITestCase):
response = self.client.post(
reverse(
'rest_api:document-version-ocr-submit-view',
args=(self.document.latest_version.pk,)
args=(
self.document.pk, self.document.latest_version.pk,
)
)
)
@@ -77,7 +79,10 @@ class OCRAPITestCase(BaseAPITestCase):
response = self.client.get(
reverse(
'rest_api:document-page-content-view',
args=(self.document.latest_version.pages.first().pk,)
args=(
self.document.pk, self.document.latest_version.pk,
self.document.latest_version.pages.first().pk,
)
),
)

View File

@@ -43,16 +43,16 @@ urlpatterns = [
api_urls = [
url(
r'^document/(?P<pk>\d+)/submit/$', APIDocumentOCRView.as_view(),
r'^documents/(?P<pk>\d+)/ocr/$', APIDocumentOCRView.as_view(),
name='document-ocr-submit-view'
),
url(
r'^document_version/(?P<pk>\d+)/submit/$',
r'^documents/(?P<document_pk>\d+)/versions/(?P<version_pk>\d+)/ocr/$',
APIDocumentVersionOCRView.as_view(),
name='document-version-ocr-submit-view'
),
url(
r'^page/(?P<pk>\d+)/content/$', APIDocumentPageContentView.as_view(),
r'^documents/(?P<document_pk>\d+)/versions/(?P<version_pk>\d+)/pages/(?P<page_pk>\d+)/ocr/$', APIDocumentPageContentView.as_view(),
name='document-page-content-view'
),
]

View File

@@ -30,5 +30,4 @@ api_urls = [
url(r'^permissions/$', APIPermissionList.as_view(), name='permission-list'),
url(r'^roles/$', APIRoleListView.as_view(), name='role-list'),
url(r'^roles/(?P<pk>[0-9]+)/$', APIRoleView.as_view(), name='role-detail'),
url(r'^$', APIPermissionList.as_view(), name='permission-list'),
]

View File

@@ -0,0 +1,19 @@
from __future__ import unicode_literals
from rest_framework import generics
from rest_api.filters import MayanObjectPermissionsFilter
from rest_api.permissions import MayanPermission
from .classes import APIResource
from .serializers import APIResourceSerializer
class APIResourceTypeListView(generics.ListAPIView):
"""
Returns a list of all the available API resources.
"""
serializer_class = APIResourceSerializer
def get_queryset(self):
return APIResource.all()

View File

@@ -4,9 +4,33 @@ from django.conf.urls import include, url
from django.conf import settings
from django.utils.module_loading import import_string
from .exceptions import APIResourcePatternError
class APIResource(object):
_registry = {}
@classmethod
def all(cls):
return cls._registry.values()
@classmethod
def get(cls, name):
return cls._registry[name]
def __unicode__(self):
return unicode(self.name)
def __init__(self, name, label, description=None):
self.label = label
self.name = name
self.description = description
self.__class__._registry[self.name] = self
class APIEndPoint(object):
_registry = {}
_patterns = []
@classmethod
def get_all(cls):
@@ -46,6 +70,12 @@ class APIEndPoint(object):
def register_urls(self, urlpatterns):
from .urls import urlpatterns as app_urls
app_urls += [
url(r'^%s/' % (self.name or self.app.name), include(urlpatterns)),
]
for url in urlpatterns:
if url.regex.pattern not in self.__class__._patterns:
app_urls.append(url)
self.__class__._patterns.append(url.regex.pattern)
else:
raise APIResourcePatternError(
'App "{}" tried to register API URL pattern "{}", which '
'already exists'.format(self.app.label, url.regex.pattern)
)

View File

@@ -0,0 +1,16 @@
from __future__ import unicode_literals
class APIError(Exception):
"""
Base exception for the API app
"""
pass
class APIResourcePatternError(APIError):
"""
Raised when an app tries to override an existing URL regular expression
pattern
"""
pass

View File

@@ -0,0 +1,9 @@
from __future__ import unicode_literals
from rest_framework import serializers
class APIResourceSerializer(serializers.Serializer):
description = serializers.CharField()
label = serializers.CharField()
name = serializers.CharField()

View File

@@ -2,15 +2,18 @@ from __future__ import unicode_literals
from django.conf.urls import url
from .views import APIBase, APIAppView, BrowseableObtainAuthToken
from .api_views import APIResourceTypeListView
from .views import APIBase, BrowseableObtainAuthToken
urlpatterns = [
]
urlpatterns = []
api_urls = [
url(r'^$', APIBase.as_view(), name='api_root'),
url(r'^api/(?P<path>.*)/?$', APIAppView.as_view(), name='api_app'),
url(
r'^resources/$', APIResourceTypeListView.as_view(),
name='resource-list'
),
url(
r'^auth/token/obtain/$', BrowseableObtainAuthToken.as_view(),
name='auth_token_obtain'

View File

@@ -13,13 +13,6 @@ class APIBase(SwaggerResourcesView):
renderer_classes = (renderers.BrowsableAPIRenderer, renderers.JSONRenderer)
class APIAppView(SwaggerApiView):
"""
Entry points of the selected app.
"""
renderer_classes = (renderers.BrowsableAPIRenderer, renderers.JSONRenderer)
class BrowseableObtainAuthToken(ObtainAuthToken):
"""
Obtain an API authentication token.