From 8b20015f645f291437209a8efdff9846dd5f7e86 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Wed, 15 Feb 2017 20:20:14 -0400 Subject: [PATCH 01/25] Add per document type, workflow list API view. GitLab issue #357, GitLab merge request #!9. cc @jeverling --- mayan/apps/document_states/api_views.py | 29 +++++++++++++++++++- mayan/apps/document_states/tests/test_api.py | 13 +++++++++ mayan/apps/document_states/urls.py | 13 ++++++--- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/mayan/apps/document_states/api_views.py b/mayan/apps/document_states/api_views.py index 2c3735e159..08a1e55d43 100644 --- a/mayan/apps/document_states/api_views.py +++ b/mayan/apps/document_states/api_views.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics from acls.models import AccessControlList -from documents.models import Document +from documents.models import Document, DocumentType from documents.permissions import permission_document_type_view from permissions import Permission from rest_api.filters import MayanObjectPermissionsFilter @@ -27,6 +27,33 @@ from .serializers import ( ) +class APIDocumentTypeWorkflowListView(generics.ListAPIView): + serializer_class = WorkflowSerializer + + def get(self, *args, **kwargs): + """ + Returns a list of all the document type workflows. + """ + return super(APIDocumentTypeWorkflowListView, self).get(*args, **kwargs) + + def get_document_type(self): + document_type = get_object_or_404(DocumentType, pk=self.kwargs['pk']) + + try: + Permission.check_permissions( + self.request.user, (permission_workflow_view,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_workflow_view, self.request.user, document_type + ) + + return document_type + + def get_queryset(self): + return self.get_document_type().workflows.all() + + class APIWorkflowDocumentTypeList(generics.ListCreateAPIView): filter_backends = (MayanObjectPermissionsFilter,) mayan_object_permissions = { diff --git a/mayan/apps/document_states/tests/test_api.py b/mayan/apps/document_states/tests/test_api.py index 6545450ee7..9ee947abe2 100644 --- a/mayan/apps/document_states/tests/test_api.py +++ b/mayan/apps/document_states/tests/test_api.py @@ -186,6 +186,19 @@ class WorkflowAPITestCase(APITestCase): workflow.refresh_from_db() self.assertEqual(workflow.label, TEST_WORKFLOW_LABEL_EDITED) + def test_document_type_workflow_list(self): + workflow = self._create_workflow() + workflow.document_types.add(self.document_type) + + response = self.client.get( + reverse( + 'rest_api:documenttype-workflow-list', + args=(self.document_type.pk,) + ), + ) + + self.assertEqual(response.data['results'][0]['label'], workflow.label) + @override_settings(OCR_AUTO_OCR=False) class WorkflowStatesAPITestCase(APITestCase): diff --git a/mayan/apps/document_states/urls.py b/mayan/apps/document_states/urls.py index d612b28324..2194d26285 100644 --- a/mayan/apps/document_states/urls.py +++ b/mayan/apps/document_states/urls.py @@ -3,10 +3,10 @@ from __future__ import unicode_literals from django.conf.urls import patterns, url from .api_views import ( - APIWorkflowDocumentTypeList, APIWorkflowDocumentTypeView, - APIWorkflowInstanceListView, APIWorkflowInstanceView, - APIWorkflowInstanceLogEntryListView, APIWorkflowListView, - APIWorkflowStateListView, APIWorkflowStateView, + APIDocumentTypeWorkflowListView, APIWorkflowDocumentTypeList, + APIWorkflowDocumentTypeView, APIWorkflowInstanceListView, + APIWorkflowInstanceView, APIWorkflowInstanceLogEntryListView, + APIWorkflowListView, APIWorkflowStateListView, APIWorkflowStateView, APIWorkflowTransitionListView, APIWorkflowTransitionView, APIWorkflowView ) from .views import ( @@ -150,4 +150,9 @@ api_urls = [ APIWorkflowInstanceLogEntryListView.as_view(), name='workflowinstancelogentry-list' ), + url( + r'^document_type/(?P[0-9]+)/workflows/$', + APIDocumentTypeWorkflowListView.as_view(), + name='documenttype-workflow-list' + ), ] From 05e5363bfc8df18f9503dbaee5aa1b159060daa2 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Sun, 19 Feb 2017 02:33:29 -0400 Subject: [PATCH 02/25] Add the url of a permission in the permission serializer. --- mayan/apps/permissions/serializers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mayan/apps/permissions/serializers.py b/mayan/apps/permissions/serializers.py index f311070999..6774a9c3b6 100644 --- a/mayan/apps/permissions/serializers.py +++ b/mayan/apps/permissions/serializers.py @@ -32,7 +32,10 @@ class RoleSerializer(serializers.HyperlinkedModelSerializer): groups = GroupSerializer(many=True) class Meta: - fields = ('groups', 'id', 'label') + extra_kwargs = { + 'url': {'view_name': 'rest_api:role-detail'}, + } + fields = ('groups', 'id', 'label', 'url') model = Role From e3f9dd9d201fc6cf8bdbfdb4a3aa04f4532881aa Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Sun, 19 Feb 2017 02:34:17 -0400 Subject: [PATCH 03/25] Add a generic relation to any model that registers itself for ACLs. This helps reference the ACLs of the model with using ContentType. --- mayan/apps/acls/classes.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mayan/apps/acls/classes.py b/mayan/apps/acls/classes.py index 9ddaa03428..7f2c47b9d2 100644 --- a/mayan/apps/acls/classes.py +++ b/mayan/apps/acls/classes.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals, absolute_import import logging from django.apps import apps +from django.contrib.contenttypes.fields import GenericRelation logger = logging.getLogger(__name__) @@ -18,6 +19,12 @@ class ModelPermission(object): for permission in permissions: cls._registry[model].append(permission) + AccessControlList = apps.get_model( + app_label='acls', model_name='AccessControlList' + ) + + model.add_to_class('acls', GenericRelation(AccessControlList)) + @classmethod def get_for_instance(cls, instance): StoredPermission = apps.get_model( From 2544b569f0f4800363024c6ecf3fa7469f785bce Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Sun, 19 Feb 2017 02:35:19 -0400 Subject: [PATCH 04/25] Add new read only endpoints for the ACL app API. --- mayan/apps/acls/api_views.py | 202 ++++++++++++++++++++++++++++++ mayan/apps/acls/apps.py | 3 + mayan/apps/acls/serializers.py | 74 +++++++++++ mayan/apps/acls/tests/test_api.py | 146 +++++++++++++++++++++ mayan/apps/acls/urls.py | 24 ++++ 5 files changed, 449 insertions(+) create mode 100644 mayan/apps/acls/api_views.py create mode 100644 mayan/apps/acls/serializers.py create mode 100644 mayan/apps/acls/tests/test_api.py diff --git a/mayan/apps/acls/api_views.py b/mayan/apps/acls/api_views.py new file mode 100644 index 0000000000..36aece2dd7 --- /dev/null +++ b/mayan/apps/acls/api_views.py @@ -0,0 +1,202 @@ +from __future__ import absolute_import, unicode_literals + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404 + +from rest_framework import generics + +from permissions import Permission + +from .models import AccessControlList +from .permissions import permission_acl_edit, permission_acl_view +from .serializers import ( + AccessControlListPermissionSerializer, AccessControlListSerializer +) + + +class APIObjectACLListView(generics.ListAPIView): + serializer_class = AccessControlListSerializer + + def get(self, *args, **kwargs): + """ + Returns a list of all the object's access control lists + """ + + return super(APIObjectACLListView, self).get(*args, **kwargs) + + def get_content_object(self): + content_type = get_object_or_404( + ContentType, app_label=self.kwargs['app_label'], + model=self.kwargs['model'] + ) + + content_object = get_object_or_404( + content_type.model_class(), pk=self.kwargs['object_pk'] + ) + + try: + Permission.check_permissions( + self.request.user, permissions=(permission_acl_view,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_acl_view, self.request.user, content_object + ) + + return content_object + + def get_queryset(self): + return self.get_content_object().acls.all() + + def get_serializer_context(self): + """ + Extra context provided to the serializer class. + """ + + return { + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } + + +class APIObjectACLView(generics.RetrieveAPIView): + serializer_class = AccessControlListSerializer + + def get(self, *args, **kwargs): + """ + Returns the details of the selected access control list. + """ + + return super(APIObjectACLView, self).get(*args, **kwargs) + + def get_content_object(self): + if self.request.method == 'GET': + permission_required = permission_acl_view + else: + permission_required = permission_acl_edit + + content_type = get_object_or_404( + ContentType, app_label=self.kwargs['app_label'], + model=self.kwargs['model'] + ) + + content_object = get_object_or_404( + content_type.model_class(), pk=self.kwargs['object_pk'] + ) + + try: + Permission.check_permissions( + self.request.user, permissions=(permission_required,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_required, self.request.user, content_object + ) + + return content_object + + def get_queryset(self): + return self.get_content_object().acls.all() + + +class APIObjectACLPermissionListView(generics.ListAPIView): + serializer_class = AccessControlListPermissionSerializer + + def get(self, *args, **kwargs): + """ + Returns the access control list permission list. + """ + + return super( + APIObjectACLPermissionListView, self + ).get(*args, **kwargs) + + def get_acl(self): + return get_object_or_404( + self.get_content_object().acls, pk=self.kwargs['pk'] + ) + + def get_content_object(self): + content_type = get_object_or_404( + ContentType, app_label=self.kwargs['app_label'], + model=self.kwargs['model'] + ) + + content_object = get_object_or_404( + content_type.model_class(), pk=self.kwargs['object_pk'] + ) + + try: + Permission.check_permissions( + self.request.user, permissions=(permission_acl_view,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_acl_view, self.request.user, content_object + ) + + return content_object + + def get_queryset(self): + return self.get_acl().permissions.all() + + def get_serializer_context(self): + return { + 'acl': self.get_acl(), + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } + + +class APIObjectACLPermissionView(generics.RetrieveAPIView): + lookup_url_kwarg = 'permission_pk' + serializer_class = AccessControlListPermissionSerializer + + def get(self, *args, **kwargs): + """ + Returns the details of the selected access control list permission. + """ + + return super( + APIObjectACLPermissionView, self + ).get(*args, **kwargs) + + def get_acl(self): + return get_object_or_404( + self.get_content_object().acls, pk=self.kwargs['pk'] + ) + + def get_content_object(self): + content_type = get_object_or_404( + ContentType, app_label=self.kwargs['app_label'], + model=self.kwargs['model'] + ) + + content_object = get_object_or_404( + content_type.model_class(), pk=self.kwargs['object_pk'] + ) + + try: + Permission.check_permissions( + self.request.user, permissions=(permission_acl_view,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_acl_view, self.request.user, content_object + ) + + return content_object + + def get_queryset(self): + return self.get_acl().permissions.all() + + def get_serializer_context(self): + return { + 'acl': self.get_acl(), + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } diff --git a/mayan/apps/acls/apps.py b/mayan/apps/acls/apps.py index ebce7ca4ce..9f3404e80b 100644 --- a/mayan/apps/acls/apps.py +++ b/mayan/apps/acls/apps.py @@ -4,6 +4,7 @@ from django.utils.translation import ugettext_lazy as _ from common import MayanAppConfig, menu_object, menu_sidebar from navigation import SourceColumn +from rest_api.classes import APIEndPoint from .links import link_acl_create, link_acl_delete, link_acl_permissions @@ -16,6 +17,8 @@ class ACLsApp(MayanAppConfig): def ready(self): super(ACLsApp, self).ready() + APIEndPoint(app=self, version_string='1') + AccessControlList = self.get_model('AccessControlList') SourceColumn( diff --git a/mayan/apps/acls/serializers.py b/mayan/apps/acls/serializers.py new file mode 100644 index 0000000000..72d9163589 --- /dev/null +++ b/mayan/apps/acls/serializers.py @@ -0,0 +1,74 @@ +from __future__ import absolute_import, unicode_literals + +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import serializers +from rest_framework.reverse import reverse + +from common.serializers import ContentTypeSerializer +from permissions.serializers import PermissionSerializer, RoleSerializer + +from .models import AccessControlList + + +class AccessControlListSerializer(serializers.ModelSerializer): + content_type = ContentTypeSerializer(read_only=True) + permissions_url = serializers.SerializerMethodField( + help_text=_( + 'API URL pointing to the list of permissions for this access ' + 'control list.' + ) + ) + role = RoleSerializer(read_only=True) + url = serializers.SerializerMethodField() + + class Meta: + fields = ( + 'content_type', 'id', 'object_id', 'permissions_url', 'role', 'url' + ) + model = AccessControlList + + def get_permissions_url(self, instance): + return reverse( + 'rest_api:accesscontrollist-permission-list', args=( + instance.content_type.app_label, instance.content_type.model, + instance.object_id, instance.pk + ), request=self.context['request'], format=self.context['format'] + ) + + def get_url(self, instance): + return reverse( + 'rest_api:accesscontrollist-detail', args=( + instance.content_type.app_label, instance.content_type.model, + instance.object_id, instance.pk + ), request=self.context['request'], format=self.context['format'] + ) + + +class AccessControlListPermissionSerializer(PermissionSerializer): + acl_permission_url = serializers.SerializerMethodField( + help_text=_( + 'API URL pointing to a permission in relation to the ' + 'access control list to which it is attached. This URL is ' + 'different than the canonical workflow URL.' + ) + ) + + def __init__(self, *args, **kwargs): + super( + AccessControlListPermissionSerializer, self + ).__init__(*args, **kwargs) + + # Make all fields (inherited and local) read ony. + for field in self._readable_fields: + field.read_only = True + + def get_acl_permission_url(self, instance): + return reverse( + 'rest_api:accesscontrollist-permission-detail', args=( + self.context['acl'].content_type.app_label, + self.context['acl'].content_type.model, + self.context['acl'].object_id, self.context['acl'].pk, + instance.stored_permission.pk + ), request=self.context['request'], format=self.context['format'] + ) diff --git a/mayan/apps/acls/tests/test_api.py b/mayan/apps/acls/tests/test_api.py new file mode 100644 index 0000000000..9c7eade47c --- /dev/null +++ b/mayan/apps/acls/tests/test_api.py @@ -0,0 +1,146 @@ +from __future__ import absolute_import, unicode_literals + +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.core.urlresolvers import reverse +from django.test import override_settings + +from rest_framework.test import APITestCase + +from documents.models import DocumentType +from documents.permissions import permission_document_view +from documents.tests.literals import ( + TEST_DOCUMENT_TYPE, TEST_SMALL_DOCUMENT_PATH +) +from permissions.classes import Permission +from permissions.models import Role +from permissions.tests.literals import TEST_ROLE_LABEL +from user_management.tests.literals import ( + TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, TEST_ADMIN_USERNAME +) + +from ..models import AccessControlList + + +@override_settings(OCR_AUTO_OCR=False) +class ACLAPITestCase(APITestCase): + def setUp(self): + 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 + ) + + self.role = Role.objects.create(label=TEST_ROLE_LABEL) + + self.document_content_type = ContentType.objects.get_for_model( + self.document + ) + Permission.invalidate_cache() + + def tearDown(self): + if hasattr(self, 'document_type'): + self.document_type.delete() + + def _create_acl(self): + self.acl = AccessControlList.objects.create( + content_object=self.document, + role=self.role + ) + + self.acl.permissions.add(permission_document_view.stored_permission) + + def test_object_acl_list_view(self): + self._create_acl() + + response = self.client.get( + reverse( + 'rest_api:accesscontrollist-list', + args=( + self.document_content_type.app_label, + self.document_content_type.model, + self.document.pk + ) + ) + ) + + self.assertEqual( + response.data['results'][0]['content_type']['app_label'], + self.document_content_type.app_label + ) + self.assertEqual( + response.data['results'][0]['role']['label'], TEST_ROLE_LABEL + ) + + def test_object_acl_detail_view(self): + self._create_acl() + + response = self.client.get( + reverse( + 'rest_api:accesscontrollist-detail', + args=( + self.document_content_type.app_label, + self.document_content_type.model, + self.document.pk, self.acl.pk + ) + ) + ) + + self.assertEqual( + response.data['content_type']['app_label'], + self.document_content_type.app_label + ) + self.assertEqual( + response.data['role']['label'], TEST_ROLE_LABEL + ) + + def test_object_acl_permission_list_view(self): + self._create_acl() + + response = self.client.get( + reverse( + 'rest_api:accesscontrollist-permission-list', + args=( + self.document_content_type.app_label, + self.document_content_type.model, + self.document.pk, self.acl.pk + ) + ) + ) + + self.assertEqual( + response.data['results'][0]['pk'], + permission_document_view.pk + ) + + def test_object_acl_permission_detail_view(self): + self._create_acl() + permission = self.acl.permissions.first() + + response = self.client.get( + reverse( + 'rest_api:accesscontrollist-permission-detail', + args=( + self.document_content_type.app_label, + self.document_content_type.model, + self.document.pk, self.acl.pk, + permission.pk + ) + ) + ) + + self.assertEqual( + response.data['pk'], permission_document_view.pk + ) diff --git a/mayan/apps/acls/urls.py b/mayan/apps/acls/urls.py index f68cc5e0b8..bb52d1a031 100644 --- a/mayan/apps/acls/urls.py +++ b/mayan/apps/acls/urls.py @@ -2,6 +2,10 @@ from __future__ import unicode_literals from django.conf.urls import patterns, url +from .api_views import ( + APIObjectACLListView, APIObjectACLPermissionListView, + APIObjectACLPermissionView, APIObjectACLView +) from .views import ( ACLCreateView, ACLDeleteView, ACLListView, ACLPermissionsView ) @@ -22,3 +26,23 @@ urlpatterns = patterns( name='acl_permissions' ), ) + + +api_urls = [ + url( + r'^object/(?P[-\w]+)/(?P[-\w]+)/(?P\d+)/acls/$', + APIObjectACLListView.as_view(), name='accesscontrollist-list' + ), + url( + r'^object/(?P[-\w]+)/(?P[-\w]+)/(?P\d+)/acls/(?P\d+)/$', + APIObjectACLView.as_view(), name='accesscontrollist-detail' + ), + url( + r'^object/(?P[-\w]+)/(?P[-\w]+)/(?P\d+)/acls/(?P\d+)/permissions/$', + APIObjectACLPermissionListView.as_view(), name='accesscontrollist-permission-list' + ), + url( + r'^object/(?P[-\w]+)/(?P[-\w]+)/(?P\d+)/acls/(?P\d+)/permissions/(?P\d+)/$', + APIObjectACLPermissionView.as_view(), name='accesscontrollist-permission-detail' + ), +] From 6e1cf570790a23d8de85ef57e2180ef3d6ab52d0 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Mon, 20 Feb 2017 02:34:47 -0400 Subject: [PATCH 05/25] Implement document workflows transition ACLs. GitLab issue #321. Signed-off-by: Roberto Rosario --- mayan/apps/common/generics.py | 2 +- mayan/apps/common/mixins.py | 20 +- mayan/apps/common/tests/test_views.py | 14 + mayan/apps/document_states/api_views.py | 29 ++- mayan/apps/document_states/apps.py | 16 +- mayan/apps/document_states/forms.py | 38 ++- mayan/apps/document_states/tests/literals.py | 1 + mayan/apps/document_states/tests/test_api.py | 13 + .../apps/document_states/tests/test_views.py | 240 ++++++++++++++---- mayan/apps/document_states/urls.py | 13 +- mayan/apps/document_states/views.py | 39 +-- 11 files changed, 336 insertions(+), 89 deletions(-) diff --git a/mayan/apps/common/generics.py b/mayan/apps/common/generics.py index d79cfb145d..4efc317922 100644 --- a/mayan/apps/common/generics.py +++ b/mayan/apps/common/generics.py @@ -177,7 +177,7 @@ class ConfirmView(ObjectListPermissionFilterMixin, ObjectPermissionCheckMixin, V return HttpResponseRedirect(self.get_success_url()) -class FormView(ViewPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, DjangoFormView): +class FormView(FormExtraKwargsMixin, ViewPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, DjangoFormView): template_name = 'appearance/generic_form.html' diff --git a/mayan/apps/common/mixins.py b/mayan/apps/common/mixins.py index 34b89ff736..aefabb9d57 100644 --- a/mayan/apps/common/mixins.py +++ b/mayan/apps/common/mixins.py @@ -12,8 +12,8 @@ from permissions import Permission __all__ = ( 'DeleteExtraDataMixin', 'ExtraContextMixin', - 'ObjectListPermissionFilterMixin', 'ObjectNameMixin', - 'ObjectPermissionCheckMixin', 'RedirectionMixin', + 'FormExtraKwargsMixin', 'ObjectListPermissionFilterMixin', + 'ObjectNameMixin', 'ObjectPermissionCheckMixin', 'RedirectionMixin', 'ViewPermissionCheckMixin' ) @@ -42,6 +42,22 @@ class ExtraContextMixin(object): return context +class FormExtraKwargsMixin(object): + """ + Mixin that allows a view to pass extra keyword arguments to forms + """ + + form_extra_kwargs = {} + + def get_form_extra_kwargs(self): + return self.form_extra_kwargs + + def get_form_kwargs(self): + result = super(FormExtraKwargsMixin, self).get_form_kwargs() + result.update(self.get_form_extra_kwargs()) + return result + + class MultipleInstanceActionMixin(object): model = None success_message = 'Operation performed on %(count)d object' diff --git a/mayan/apps/common/tests/test_views.py b/mayan/apps/common/tests/test_views.py index 6fa95a8721..ec56a1b9ea 100644 --- a/mayan/apps/common/tests/test_views.py +++ b/mayan/apps/common/tests/test_views.py @@ -76,6 +76,11 @@ class GenericViewTestCase(BaseTestCase): data=data, follow=follow ) + def grant(self, permission): + self.role.permissions.add( + permission.stored_permission + ) + def login(self, username, password): logged_in = self.client.login(username=username, password=password) @@ -84,6 +89,15 @@ class GenericViewTestCase(BaseTestCase): self.assertTrue(logged_in) self.assertTrue(user.is_authenticated()) + def login_user(self): + self.login(username=TEST_USER_USERNAME, password=TEST_USER_PASSWORD) + + def login_admin_user(self): + self.login(username=TEST_ADMIN_USERNAME, password=TEST_ADMIN_PASSWORD) + + def logout(self): + self.client.logout() + def post(self, viewname, *args, **kwargs): data = kwargs.pop('data', {}) follow = kwargs.pop('follow', False) diff --git a/mayan/apps/document_states/api_views.py b/mayan/apps/document_states/api_views.py index 2c3735e159..08a1e55d43 100644 --- a/mayan/apps/document_states/api_views.py +++ b/mayan/apps/document_states/api_views.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics from acls.models import AccessControlList -from documents.models import Document +from documents.models import Document, DocumentType from documents.permissions import permission_document_type_view from permissions import Permission from rest_api.filters import MayanObjectPermissionsFilter @@ -27,6 +27,33 @@ from .serializers import ( ) +class APIDocumentTypeWorkflowListView(generics.ListAPIView): + serializer_class = WorkflowSerializer + + def get(self, *args, **kwargs): + """ + Returns a list of all the document type workflows. + """ + return super(APIDocumentTypeWorkflowListView, self).get(*args, **kwargs) + + def get_document_type(self): + document_type = get_object_or_404(DocumentType, pk=self.kwargs['pk']) + + try: + Permission.check_permissions( + self.request.user, (permission_workflow_view,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_workflow_view, self.request.user, document_type + ) + + return document_type + + def get_queryset(self): + return self.get_document_type().workflows.all() + + class APIWorkflowDocumentTypeList(generics.ListCreateAPIView): filter_backends = (MayanObjectPermissionsFilter,) mayan_object_permissions = { diff --git a/mayan/apps/document_states/apps.py b/mayan/apps/document_states/apps.py index 706d7435d3..76ffb2673d 100644 --- a/mayan/apps/document_states/apps.py +++ b/mayan/apps/document_states/apps.py @@ -4,6 +4,8 @@ from django.apps import apps from django.db.models.signals import post_save from django.utils.translation import ugettext_lazy as _ +from acls import ModelPermission +from acls.links import link_acl_list from common import ( MayanAppConfig, menu_facet, menu_object, menu_secondary, menu_setup, menu_sidebar @@ -23,6 +25,7 @@ from .links import ( link_setup_workflow_transition_delete, link_setup_workflow_transition_edit, link_workflow_instance_detail, link_workflow_instance_transition ) +from .permissions import permission_workflow_transition class DocumentStatesApp(MayanAppConfig): @@ -46,6 +49,15 @@ class DocumentStatesApp(MayanAppConfig): WorkflowState = self.get_model('WorkflowState') WorkflowTransition = self.get_model('WorkflowTransition') + ModelPermission.register( + model=Workflow, permissions=(permission_workflow_transition,) + ) + + ModelPermission.register( + model=WorkflowTransition, + permissions=(permission_workflow_transition,) + ) + SourceColumn( source=Workflow, label=_('Initial state'), func=lambda context: context['object'].get_initial_state() or _('None') @@ -118,7 +130,7 @@ class DocumentStatesApp(MayanAppConfig): links=( link_setup_workflow_states, link_setup_workflow_transitions, link_setup_workflow_document_types, link_setup_workflow_edit, - link_setup_workflow_delete + link_acl_list, link_setup_workflow_delete ), sources=(Workflow,) ) menu_object.bind_links( @@ -129,7 +141,7 @@ class DocumentStatesApp(MayanAppConfig): ) menu_object.bind_links( links=( - link_setup_workflow_transition_edit, + link_setup_workflow_transition_edit, link_acl_list, link_setup_workflow_transition_delete ), sources=(WorkflowTransition,) ) diff --git a/mayan/apps/document_states/forms.py b/mayan/apps/document_states/forms.py index 98ead3a160..ed1687296f 100644 --- a/mayan/apps/document_states/forms.py +++ b/mayan/apps/document_states/forms.py @@ -1,9 +1,14 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from django import forms +from django.core.exceptions import PermissionDenied from django.utils.translation import ugettext_lazy as _ +from acls.models import AccessControlList +from permissions import Permission + from .models import Workflow, WorkflowState, WorkflowTransition +from .permissions import permission_workflow_transition class WorkflowForm(forms.ModelForm): @@ -32,11 +37,36 @@ class WorkflowTransitionForm(forms.ModelForm): class WorkflowInstanceTransitionForm(forms.Form): def __init__(self, *args, **kwargs): - workflow = kwargs.pop('workflow') + user = kwargs.pop('user') + workflow_instance = kwargs.pop('workflow_instance') super(WorkflowInstanceTransitionForm, self).__init__(*args, **kwargs) - self.fields['transition'].choices = workflow.get_transition_choices().values_list('pk', 'label') + queryset = workflow_instance.get_transition_choices().all() - transition = forms.ChoiceField(label=_('Transition')) + try: + Permission.check_permissions( + requester=user, permissions=(permission_workflow_transition,) + ) + except PermissionDenied: + try: + # Check for ACL access to the workflow, if true, allow all + # transition options. + AccessControlList.objects.check_access( + permissions=permission_workflow_transition, user=user, + obj=workflow_instance.workflow + ) + except PermissionDenied: + # If not ACL access to the workflow, filter transition options + # by each transition ACL access + queryset = AccessControlList.objects.filter_by_access( + permission=permission_workflow_transition, user=user, + queryset=queryset + ) + + self.fields['transition'].queryset = queryset + + transition = forms.ModelChoiceField( + label=_('Transition'), queryset=WorkflowTransition.objects.none() + ) comment = forms.CharField( label=_('Comment'), required=False, widget=forms.widgets.Textarea() ) diff --git a/mayan/apps/document_states/tests/literals.py b/mayan/apps/document_states/tests/literals.py index fac41db489..31685e0e70 100644 --- a/mayan/apps/document_states/tests/literals.py +++ b/mayan/apps/document_states/tests/literals.py @@ -9,4 +9,5 @@ TEST_WORKFLOW_STATE_LABEL = 'test state label' TEST_WORKFLOW_STATE_LABEL_EDITED = 'test state label edited' TEST_WORKFLOW_STATE_COMPLETION = 66 TEST_WORKFLOW_TRANSITION_LABEL = 'test transtition label' +TEST_WORKFLOW_TRANSITION_LABEL_2 = 'test transtition label 2' TEST_WORKFLOW_TRANSITION_LABEL_EDITED = 'test transtition label edited' diff --git a/mayan/apps/document_states/tests/test_api.py b/mayan/apps/document_states/tests/test_api.py index 6545450ee7..9ee947abe2 100644 --- a/mayan/apps/document_states/tests/test_api.py +++ b/mayan/apps/document_states/tests/test_api.py @@ -186,6 +186,19 @@ class WorkflowAPITestCase(APITestCase): workflow.refresh_from_db() self.assertEqual(workflow.label, TEST_WORKFLOW_LABEL_EDITED) + def test_document_type_workflow_list(self): + workflow = self._create_workflow() + workflow.document_types.add(self.document_type) + + response = self.client.get( + reverse( + 'rest_api:documenttype-workflow-list', + args=(self.document_type.pk,) + ), + ) + + self.assertEqual(response.data['results'][0]['label'], workflow.label) + @override_settings(OCR_AUTO_OCR=False) class WorkflowStatesAPITestCase(APITestCase): diff --git a/mayan/apps/document_states/tests/test_views.py b/mayan/apps/document_states/tests/test_views.py index 028818247d..5f37b0bacf 100644 --- a/mayan/apps/document_states/tests/test_views.py +++ b/mayan/apps/document_states/tests/test_views.py @@ -5,20 +5,24 @@ from django.core.urlresolvers import reverse from django.test.client import Client from django.test import TestCase +from acls.models import AccessControlList from documents.models import DocumentType from documents.tests.literals import ( TEST_DOCUMENT_TYPE, TEST_SMALL_DOCUMENT_PATH ) +from documents.tests.test_views import GenericDocumentViewTestCase from user_management.tests import ( TEST_ADMIN_PASSWORD, TEST_ADMIN_USERNAME, TEST_ADMIN_EMAIL ) from ..models import Workflow, WorkflowState, WorkflowTransition +from ..permissions import permission_workflow_transition from .literals import ( TEST_WORKFLOW_LABEL, TEST_WORKFLOW_INITIAL_STATE_LABEL, TEST_WORKFLOW_INITIAL_STATE_COMPLETION, TEST_WORKFLOW_STATE_LABEL, - TEST_WORKFLOW_STATE_COMPLETION, TEST_WORKFLOW_TRANSITION_LABEL + TEST_WORKFLOW_STATE_COMPLETION, TEST_WORKFLOW_TRANSITION_LABEL, + TEST_WORKFLOW_TRANSITION_LABEL_2 ) @@ -48,6 +52,26 @@ class DocumentStateViewTestCase(TestCase): def tearDown(self): self.document_type.delete() + def _create_workflow(self): + self.workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL) + + def _create_workflow_states(self): + self.workflow_initial_state = WorkflowState.objects.create( + workflow=self.workflow, label=TEST_WORKFLOW_INITIAL_STATE_LABEL, + completion=TEST_WORKFLOW_INITIAL_STATE_COMPLETION, initial=True + ) + self.workflow_state = WorkflowState.objects.create( + workflow=self.workflow, label=TEST_WORKFLOW_STATE_LABEL, + completion=TEST_WORKFLOW_STATE_COMPLETION + ) + + def _create_workflow_transition(self): + self.workflow_transition = WorkflowTransition.objects.create( + workflow=self.workflow, label=TEST_WORKFLOW_TRANSITION_LABEL, + origin_state=self.workflow_initial_state, + destination_state=self.workflow_state + ) + def test_creating_workflow(self): response = self.client.post( reverse( @@ -63,14 +87,12 @@ class DocumentStateViewTestCase(TestCase): self.assertEquals(Workflow.objects.all()[0].label, TEST_WORKFLOW_LABEL) def test_delete_workflow(self): - workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL) - - self.assertEquals(Workflow.objects.count(), 1) - self.assertEquals(Workflow.objects.all()[0].label, TEST_WORKFLOW_LABEL) + self._create_workflow() response = self.client.post( reverse( - 'document_states:setup_workflow_delete', args=(workflow.pk,) + 'document_states:setup_workflow_delete', + args=(self.workflow.pk,) ), follow=True ) @@ -79,12 +101,12 @@ class DocumentStateViewTestCase(TestCase): self.assertEquals(Workflow.objects.count(), 0) def test_create_workflow_state(self): - workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL) + self._create_workflow() response = self.client.post( reverse( 'document_states:setup_workflow_state_create', - args=(workflow.pk,) + args=(self.workflow.pk,) ), data={ 'label': TEST_WORKFLOW_STATE_LABEL, 'completion': TEST_WORKFLOW_STATE_COMPLETION, @@ -103,43 +125,33 @@ class DocumentStateViewTestCase(TestCase): ) def test_delete_workflow_state(self): - workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL) - workflow_state = WorkflowState.objects.create( - workflow=workflow, label=TEST_WORKFLOW_STATE_LABEL, - completion=TEST_WORKFLOW_STATE_COMPLETION - ) + self._create_workflow() + self._create_workflow_states() response = self.client.post( reverse( 'document_states:setup_workflow_state_delete', - args=(workflow_state.pk,) + args=(self.workflow_state.pk,) ), follow=True ) self.assertEquals(response.status_code, 200) - self.assertEquals(WorkflowState.objects.count(), 0) + self.assertEquals(WorkflowState.objects.count(), 1) self.assertEquals(Workflow.objects.count(), 1) def test_create_workflow_transition(self): - workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL) - workflow_initial_state = WorkflowState.objects.create( - workflow=workflow, label=TEST_WORKFLOW_INITIAL_STATE_LABEL, - completion=TEST_WORKFLOW_INITIAL_STATE_COMPLETION, initial=True - ) - workflow_state = WorkflowState.objects.create( - workflow=workflow, label=TEST_WORKFLOW_STATE_LABEL, - completion=TEST_WORKFLOW_STATE_COMPLETION - ) + self._create_workflow() + self._create_workflow_states() response = self.client.post( reverse( 'document_states:setup_workflow_transition_create', - args=(workflow.pk,) + args=(self.workflow.pk,) ), data={ 'label': TEST_WORKFLOW_TRANSITION_LABEL, - 'origin_state': workflow_initial_state.pk, - 'destination_state': workflow_state.pk, + 'origin_state': self.workflow_initial_state.pk, + 'destination_state': self.workflow_state.pk, }, follow=True ) @@ -152,35 +164,22 @@ class DocumentStateViewTestCase(TestCase): ) self.assertEquals( WorkflowTransition.objects.all()[0].origin_state, - workflow_initial_state + self.workflow_initial_state ) self.assertEquals( WorkflowTransition.objects.all()[0].destination_state, - workflow_state + self.workflow_state ) def test_delete_workflow_transition(self): - workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL) - workflow_initial_state = WorkflowState.objects.create( - workflow=workflow, label=TEST_WORKFLOW_INITIAL_STATE_LABEL, - completion=TEST_WORKFLOW_INITIAL_STATE_COMPLETION, initial=True - ) - workflow_state = WorkflowState.objects.create( - workflow=workflow, label=TEST_WORKFLOW_STATE_LABEL, - completion=TEST_WORKFLOW_STATE_COMPLETION - ) - workflow_transition = WorkflowTransition.objects.create( - workflow=workflow, label=TEST_WORKFLOW_TRANSITION_LABEL, - origin_state=workflow_initial_state, - destination_state=workflow_state - ) - - self.assertEquals(WorkflowTransition.objects.count(), 1) + self._create_workflow() + self._create_workflow_states() + self._create_workflow_transition() response = self.client.post( reverse( 'document_states:setup_workflow_transition_delete', - args=(workflow_transition.pk,) + args=(self.workflow_transition.pk,) ), follow=True ) @@ -189,3 +188,152 @@ class DocumentStateViewTestCase(TestCase): self.assertEquals(WorkflowState.objects.count(), 2) self.assertEquals(Workflow.objects.count(), 1) self.assertEquals(WorkflowTransition.objects.count(), 0) + + +class DocumentStateTransitionViewTestCase(GenericDocumentViewTestCase): + def _create_workflow(self): + self.workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL) + self.workflow.document_types.add(self.document_type) + + def _create_workflow_states(self): + self.workflow_initial_state = WorkflowState.objects.create( + workflow=self.workflow, label=TEST_WORKFLOW_INITIAL_STATE_LABEL, + completion=TEST_WORKFLOW_INITIAL_STATE_COMPLETION, initial=True + ) + self.workflow_state = WorkflowState.objects.create( + workflow=self.workflow, label=TEST_WORKFLOW_STATE_LABEL, + completion=TEST_WORKFLOW_STATE_COMPLETION + ) + + def _create_workflow_transitions(self): + self.workflow_transition = WorkflowTransition.objects.create( + workflow=self.workflow, label=TEST_WORKFLOW_TRANSITION_LABEL, + origin_state=self.workflow_initial_state, + destination_state=self.workflow_state + ) + + self.workflow_transition_2 = WorkflowTransition.objects.create( + workflow=self.workflow, label=TEST_WORKFLOW_TRANSITION_LABEL_2, + origin_state=self.workflow_initial_state, + destination_state=self.workflow_state + ) + + def _create_document(self): + with open(TEST_SMALL_DOCUMENT_PATH) as file_object: + self.document_2 = self.document_type.new_document( + file_object=file_object + ) + + def _request_workflow_transition(self, workflow_instance): + return self.post( + 'document_states:workflow_instance_transition', + args=(workflow_instance.pk,), data={ + 'transition': self.workflow_transition.pk, + } + ) + + def test_transition_workflow_no_permission(self): + self.login_user() + self._create_workflow() + self._create_workflow_states() + self._create_workflow_transitions() + self._create_document() + + workflow_instance = self.document_2.workflows.first() + + response = self._request_workflow_transition( + workflow_instance=workflow_instance + ) + + self.assertEqual(response.status_code, 200) + + # Workflow should remain in the same initial state + self.assertEqual( + workflow_instance.get_current_state(), self.workflow_initial_state + ) + + def test_transition_workflow_with_permission(self): + """ + Test transitioning a workflow by granting the transition workflow + permission to the role. + """ + + self.login_user() + self._create_workflow() + self._create_workflow_states() + self._create_workflow_transitions() + self._create_document() + + workflow_instance = self.document_2.workflows.first() + + self.grant(permission_workflow_transition) + response = self._request_workflow_transition( + workflow_instance=workflow_instance + ) + + self.assertEqual(response.status_code, 302) + + # Workflow should remain in the same initial state + self.assertEqual( + workflow_instance.get_current_state(), self.workflow_state + ) + + def test_transition_workflow_with_workflow_acl(self): + """ + Test transitioning a workflow by granting the transition workflow + permission to the workflow itself via ACL. + """ + + self.login_user() + self._create_workflow() + self._create_workflow_states() + self._create_workflow_transitions() + self._create_document() + + workflow_instance = self.document_2.workflows.first() + + acl = AccessControlList.objects.create( + content_object=self.workflow, role=self.role + ) + acl.permissions.add(permission_workflow_transition.stored_permission) + + response = self._request_workflow_transition( + workflow_instance=workflow_instance + ) + + self.assertEqual(response.status_code, 302) + + # Workflow should remain in the same initial state + self.assertEqual( + workflow_instance.get_current_state(), self.workflow_state + ) + + def test_transition_workflow_with_transition_acl(self): + """ + Test transitioning a workflow by granting the transition workflow + permission to the transition via ACL. + """ + + self.login_user() + self._create_workflow() + self._create_workflow_states() + self._create_workflow_transitions() + self._create_document() + + workflow_instance = self.document_2.workflows.first() + + acl = AccessControlList.objects.create( + content_object=self.workflow_transition, role=self.role + ) + acl.permissions.add(permission_workflow_transition.stored_permission) + + response = self._request_workflow_transition( + workflow_instance=workflow_instance + ) + + self.assertEqual(response.status_code, 302) + + # Workflow should remain in the same initial state + self.assertEqual( + workflow_instance.get_current_state(), self.workflow_state + ) diff --git a/mayan/apps/document_states/urls.py b/mayan/apps/document_states/urls.py index d612b28324..2194d26285 100644 --- a/mayan/apps/document_states/urls.py +++ b/mayan/apps/document_states/urls.py @@ -3,10 +3,10 @@ from __future__ import unicode_literals from django.conf.urls import patterns, url from .api_views import ( - APIWorkflowDocumentTypeList, APIWorkflowDocumentTypeView, - APIWorkflowInstanceListView, APIWorkflowInstanceView, - APIWorkflowInstanceLogEntryListView, APIWorkflowListView, - APIWorkflowStateListView, APIWorkflowStateView, + APIDocumentTypeWorkflowListView, APIWorkflowDocumentTypeList, + APIWorkflowDocumentTypeView, APIWorkflowInstanceListView, + APIWorkflowInstanceView, APIWorkflowInstanceLogEntryListView, + APIWorkflowListView, APIWorkflowStateListView, APIWorkflowStateView, APIWorkflowTransitionListView, APIWorkflowTransitionView, APIWorkflowView ) from .views import ( @@ -150,4 +150,9 @@ api_urls = [ APIWorkflowInstanceLogEntryListView.as_view(), name='workflowinstancelogentry-list' ), + url( + r'^document_type/(?P[0-9]+)/workflows/$', + APIDocumentTypeWorkflowListView.as_view(), + name='documenttype-workflow-list' + ), ] diff --git a/mayan/apps/document_states/views.py b/mayan/apps/document_states/views.py index 132212c8f0..d8cb69611b 100644 --- a/mayan/apps/document_states/views.py +++ b/mayan/apps/document_states/views.py @@ -7,12 +7,11 @@ from django.db.utils import IntegrityError from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext_lazy as _ -from django.views.generic import FormView from acls.models import AccessControlList from common.views import ( - AssignRemoveView, SingleObjectCreateView, SingleObjectDeleteView, - SingleObjectEditView, SingleObjectListView + AssignRemoveView, FormView, SingleObjectCreateView, + SingleObjectDeleteView, SingleObjectEditView, SingleObjectListView ) from documents.models import Document from documents.views import DocumentListView @@ -25,8 +24,7 @@ from .forms import ( from .models import Workflow, WorkflowInstance, WorkflowState, WorkflowTransition from .permissions import ( permission_workflow_create, permission_workflow_delete, - permission_workflow_edit, permission_workflow_transition, - permission_workflow_view, + permission_workflow_edit, permission_workflow_view, ) @@ -130,28 +128,10 @@ class WorkflowInstanceTransitionView(FormView): form_class = WorkflowInstanceTransitionForm template_name = 'appearance/generic_form.html' - def dispatch(self, request, *args, **kwargs): - try: - Permission.check_permissions( - request.user, (permission_workflow_transition,) - ) - except PermissionDenied: - AccessControlList.objects.check_access( - permission_workflow_transition, request.user, - self.get_workflow_instance().document - ) - - return super( - WorkflowInstanceTransitionView, self - ).dispatch(request, *args, **kwargs) - def form_valid(self, form): - transition = self.get_workflow_instance().workflow.transitions.get( - pk=form.cleaned_data['transition'] - ) self.get_workflow_instance().do_transition( - comment=form.cleaned_data['comment'], transition=transition, - user=self.request.user + comment=form.cleaned_data['comment'], + transition=form.cleaned_data['transition'], user=self.request.user ) return HttpResponseRedirect(self.get_success_url()) @@ -166,10 +146,11 @@ class WorkflowInstanceTransitionView(FormView): 'workflow_instance': self.get_workflow_instance(), } - def get_form_kwargs(self): - kwargs = super(WorkflowInstanceTransitionView, self).get_form_kwargs() - kwargs['workflow'] = self.get_workflow_instance() - return kwargs + def get_form_extra_kwargs(self): + return { + 'user': self.request.user, + 'workflow_instance': self.get_workflow_instance() + } def get_success_url(self): return self.get_workflow_instance().get_absolute_url() From 7ded52be09f50a8e11adcfc9ab354abbe29a8fa0 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Wed, 15 Feb 2017 20:20:14 -0400 Subject: [PATCH 06/25] Add per document type, workflow list API view. GitLab issue #357, GitLab merge request #!9. cc @jeverling --- mayan/apps/document_states/api_views.py | 29 +++++++++++++++++++- mayan/apps/document_states/tests/test_api.py | 13 +++++++++ mayan/apps/document_states/urls.py | 13 ++++++--- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/mayan/apps/document_states/api_views.py b/mayan/apps/document_states/api_views.py index 2c3735e159..08a1e55d43 100644 --- a/mayan/apps/document_states/api_views.py +++ b/mayan/apps/document_states/api_views.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics from acls.models import AccessControlList -from documents.models import Document +from documents.models import Document, DocumentType from documents.permissions import permission_document_type_view from permissions import Permission from rest_api.filters import MayanObjectPermissionsFilter @@ -27,6 +27,33 @@ from .serializers import ( ) +class APIDocumentTypeWorkflowListView(generics.ListAPIView): + serializer_class = WorkflowSerializer + + def get(self, *args, **kwargs): + """ + Returns a list of all the document type workflows. + """ + return super(APIDocumentTypeWorkflowListView, self).get(*args, **kwargs) + + def get_document_type(self): + document_type = get_object_or_404(DocumentType, pk=self.kwargs['pk']) + + try: + Permission.check_permissions( + self.request.user, (permission_workflow_view,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_workflow_view, self.request.user, document_type + ) + + return document_type + + def get_queryset(self): + return self.get_document_type().workflows.all() + + class APIWorkflowDocumentTypeList(generics.ListCreateAPIView): filter_backends = (MayanObjectPermissionsFilter,) mayan_object_permissions = { diff --git a/mayan/apps/document_states/tests/test_api.py b/mayan/apps/document_states/tests/test_api.py index 6545450ee7..9ee947abe2 100644 --- a/mayan/apps/document_states/tests/test_api.py +++ b/mayan/apps/document_states/tests/test_api.py @@ -186,6 +186,19 @@ class WorkflowAPITestCase(APITestCase): workflow.refresh_from_db() self.assertEqual(workflow.label, TEST_WORKFLOW_LABEL_EDITED) + def test_document_type_workflow_list(self): + workflow = self._create_workflow() + workflow.document_types.add(self.document_type) + + response = self.client.get( + reverse( + 'rest_api:documenttype-workflow-list', + args=(self.document_type.pk,) + ), + ) + + self.assertEqual(response.data['results'][0]['label'], workflow.label) + @override_settings(OCR_AUTO_OCR=False) class WorkflowStatesAPITestCase(APITestCase): diff --git a/mayan/apps/document_states/urls.py b/mayan/apps/document_states/urls.py index d612b28324..2194d26285 100644 --- a/mayan/apps/document_states/urls.py +++ b/mayan/apps/document_states/urls.py @@ -3,10 +3,10 @@ from __future__ import unicode_literals from django.conf.urls import patterns, url from .api_views import ( - APIWorkflowDocumentTypeList, APIWorkflowDocumentTypeView, - APIWorkflowInstanceListView, APIWorkflowInstanceView, - APIWorkflowInstanceLogEntryListView, APIWorkflowListView, - APIWorkflowStateListView, APIWorkflowStateView, + APIDocumentTypeWorkflowListView, APIWorkflowDocumentTypeList, + APIWorkflowDocumentTypeView, APIWorkflowInstanceListView, + APIWorkflowInstanceView, APIWorkflowInstanceLogEntryListView, + APIWorkflowListView, APIWorkflowStateListView, APIWorkflowStateView, APIWorkflowTransitionListView, APIWorkflowTransitionView, APIWorkflowView ) from .views import ( @@ -150,4 +150,9 @@ api_urls = [ APIWorkflowInstanceLogEntryListView.as_view(), name='workflowinstancelogentry-list' ), + url( + r'^document_type/(?P[0-9]+)/workflows/$', + APIDocumentTypeWorkflowListView.as_view(), + name='documenttype-workflow-list' + ), ] From 81c0f90b4f06d55266664cba2e5660865fe4b9af Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Tue, 21 Feb 2017 02:48:02 -0400 Subject: [PATCH 07/25] Add document comments API endpoints. GitHub issue #249. Signed-off-by: Roberto Rosario --- mayan/apps/document_comments/api_views.py | 120 ++++++++++++++++++ mayan/apps/document_comments/apps.py | 4 + mayan/apps/document_comments/serializers.py | 71 +++++++++++ .../apps/document_comments/tests/__init__.py | 0 .../apps/document_comments/tests/literals.py | 3 + .../apps/document_comments/tests/test_api.py | 97 ++++++++++++++ mayan/apps/document_comments/urls.py | 12 ++ 7 files changed, 307 insertions(+) create mode 100644 mayan/apps/document_comments/api_views.py create mode 100644 mayan/apps/document_comments/serializers.py create mode 100644 mayan/apps/document_comments/tests/__init__.py create mode 100644 mayan/apps/document_comments/tests/literals.py create mode 100644 mayan/apps/document_comments/tests/test_api.py diff --git a/mayan/apps/document_comments/api_views.py b/mayan/apps/document_comments/api_views.py new file mode 100644 index 0000000000..2ce872e2db --- /dev/null +++ b/mayan/apps/document_comments/api_views.py @@ -0,0 +1,120 @@ +from __future__ import absolute_import, unicode_literals + +from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404 + +from rest_framework import generics + +from acls.models import AccessControlList +from documents.models import Document +from permissions import Permission + +from .permissions import ( + permission_comment_create, permission_comment_delete, + permission_comment_view +) +from .serializers import CommentSerializer, WritableCommentSerializer + + +class APICommentListView(generics.ListCreateAPIView): + def get(self, *args, **kwargs): + """ + Returns a list of all the document comments. + """ + return super(APICommentListView, self).get(*args, **kwargs) + + def get_document(self): + if self.request.method == 'GET': + permission_required = permission_comment_view + else: + permission_required = permission_comment_create + + document = get_object_or_404(Document, pk=self.kwargs['document_pk']) + + try: + Permission.check_permissions( + self.request.user, (permission_required,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_required, self.request.user, document + ) + + return document + + def get_queryset(self): + return self.get_document().comments.all() + + def get_serializer_class(self): + if self.request.method == 'GET': + return CommentSerializer + else: + return WritableCommentSerializer + + def get_serializer_context(self): + """ + Extra context provided to the serializer class. + """ + return { + 'document': self.get_document(), + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } + + def post(self, *args, **kwargs): + """ + Create a new document comment. + """ + return super(APICommentListView, self).post(*args, **kwargs) + + +class APICommentView(generics.RetrieveDestroyAPIView): + lookup_url_kwarg = 'comment_pk' + serializer_class = CommentSerializer + + def delete(self, request, *args, **kwargs): + """ + Delete the selected document comment. + """ + + return super(APICommentView, self).delete(request, *args, **kwargs) + + def get(self, *args, **kwargs): + """ + Returns the details of the selected document comment. + """ + + return super(APICommentView, self).get(*args, **kwargs) + + def get_document(self): + if self.request.method == 'GET': + permission_required = permission_comment_view + else: + permission_required = permission_comment_delete + + document = get_object_or_404(Document, pk=self.kwargs['document_pk']) + + try: + Permission.check_permissions( + self.request.user, (permission_required,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_required, self.request.user, document + ) + + return document + + def get_queryset(self): + return self.get_document().comments.all() + + def get_serializer_context(self): + """ + Extra context provided to the serializer class. + """ + return { + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } diff --git a/mayan/apps/document_comments/apps.py b/mayan/apps/document_comments/apps.py index c4d5f3dfd4..905af2c7cf 100644 --- a/mayan/apps/document_comments/apps.py +++ b/mayan/apps/document_comments/apps.py @@ -6,6 +6,7 @@ from django.utils.translation import ugettext_lazy as _ from acls import ModelPermission from common import MayanAppConfig, menu_facet, menu_object, menu_sidebar from navigation import SourceColumn +from rest_api.classes import APIEndPoint from .links import ( link_comment_add, link_comment_delete, link_comments_for_document @@ -20,11 +21,14 @@ class DocumentCommentsApp(MayanAppConfig): app_namespace = 'comments' app_url = 'comments' name = 'document_comments' + test = True verbose_name = _('Document comments') def ready(self): super(DocumentCommentsApp, self).ready() + APIEndPoint(app=self, version_string='1') + Document = apps.get_model( app_label='documents', model_name='Document' ) diff --git a/mayan/apps/document_comments/serializers.py b/mayan/apps/document_comments/serializers.py new file mode 100644 index 0000000000..4090cdadfd --- /dev/null +++ b/mayan/apps/document_comments/serializers.py @@ -0,0 +1,71 @@ +from __future__ import unicode_literals + +from rest_framework import serializers +from rest_framework.reverse import reverse + +from documents.serializers import DocumentSerializer +from user_management.serializers import UserSerializer + +from .models import Comment + + +class CommentSerializer(serializers.HyperlinkedModelSerializer): + document = DocumentSerializer(read_only=True) + document_comments_url = serializers.SerializerMethodField() + url = serializers.SerializerMethodField() + user = UserSerializer(read_only=True) + + class Meta: + fields = ( + 'comment', 'document', 'document_comments_url', 'id', + 'submit_date', 'url', 'user' + ) + model = Comment + + def get_document_comments_url(self, instance): + return reverse( + 'rest_api:comment-list', args=( + instance.document.pk, + ), request=self.context['request'], format=self.context['format'] + ) + + def get_url(self, instance): + return reverse( + 'rest_api:comment-detail', args=( + instance.document.pk, instance.pk + ), request=self.context['request'], format=self.context['format'] + ) + + +class WritableCommentSerializer(serializers.ModelSerializer): + document = DocumentSerializer(read_only=True) + document_comments_url = serializers.SerializerMethodField() + url = serializers.SerializerMethodField() + user = UserSerializer(read_only=True) + + class Meta: + fields = ( + 'comment', 'document', 'document_comments_url', 'id', + 'submit_date', 'url', 'user' + ) + model = Comment + read_only_fields = ('document',) + + def create(self, validated_data): + validated_data['document'] = self.context['document'] + validated_data['user'] = self.context['request'].user + return super(WritableCommentSerializer, self).create(validated_data) + + def get_document_comments_url(self, instance): + return reverse( + 'rest_api:comment-list', args=( + instance.document.pk, + ), request=self.context['request'], format=self.context['format'] + ) + + def get_url(self, instance): + return reverse( + 'rest_api:comment-detail', args=( + instance.document.pk, instance.pk + ), request=self.context['request'], format=self.context['format'] + ) diff --git a/mayan/apps/document_comments/tests/__init__.py b/mayan/apps/document_comments/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mayan/apps/document_comments/tests/literals.py b/mayan/apps/document_comments/tests/literals.py new file mode 100644 index 0000000000..489cba38be --- /dev/null +++ b/mayan/apps/document_comments/tests/literals.py @@ -0,0 +1,3 @@ +from __future__ import unicode_literals + +TEST_COMMENT_TEXT = 'test comment text' diff --git a/mayan/apps/document_comments/tests/test_api.py b/mayan/apps/document_comments/tests/test_api.py new file mode 100644 index 0000000000..72dd645028 --- /dev/null +++ b/mayan/apps/document_comments/tests/test_api.py @@ -0,0 +1,97 @@ +from __future__ import unicode_literals + +from django.contrib.auth import get_user_model +from django.core.urlresolvers import reverse +from django.test import override_settings + +from rest_framework.test import APITestCase + +from documents.models import DocumentType +from documents.tests.literals 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 Comment + +from .literals import TEST_COMMENT_TEXT + + +@override_settings(OCR_AUTO_OCR=False) +class CommentAPITestCase(APITestCase): + def setUp(self): + 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): + if hasattr(self, 'document_type'): + self.document_type.delete() + + def _create_comment(self): + return self.document.comments.create( + comment=TEST_COMMENT_TEXT, user=self.admin_user + ) + + def test_comment_create_view(self): + response = self.client.post( + reverse( + 'rest_api:comment-list', args=(self.document.pk,) + ), { + 'comment': TEST_COMMENT_TEXT + } + ) + + self.assertEqual(response.status_code, 201) + comment = Comment.objects.first() + self.assertEqual(Comment.objects.count(), 1) + self.assertEqual(response.data['id'], comment.pk) + + def test_comment_delete_view(self): + comment = self._create_comment() + + self.client.delete( + reverse( + 'rest_api:comment-detail', args=(self.document.pk, comment.pk,) + ) + ) + + self.assertEqual(Comment.objects.count(), 0) + + def test_comment_detail_view(self): + comment = self._create_comment() + + response = self.client.get( + reverse( + 'rest_api:comment-detail', args=(self.document.pk, comment.pk,) + ) + ) + + self.assertEqual(response.data['comment'], comment.comment) + + def test_comment_list_view(self): + comment = self._create_comment() + + response = self.client.get( + reverse('rest_api:comment-list', args=(self.document.pk,)) + ) + + self.assertEqual( + response.data['results'][0]['comment'], comment.comment + ) diff --git a/mayan/apps/document_comments/urls.py b/mayan/apps/document_comments/urls.py index b2c68b989e..b538962d34 100644 --- a/mayan/apps/document_comments/urls.py +++ b/mayan/apps/document_comments/urls.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from django.conf.urls import patterns, url +from .api_views import APICommentListView, APICommentView from .views import ( DocumentCommentCreateView, DocumentCommentDeleteView, DocumentCommentListView @@ -22,3 +23,14 @@ urlpatterns = patterns( DocumentCommentListView.as_view(), name='comments_for_document' ), ) + +api_urls = [ + url( + r'^document/(?P[0-9]+)/comments/$', + APICommentListView.as_view(), name='comment-list' + ), + url( + r'^document/(?P[0-9]+)/comments/(?P[0-9]+)/$', + APICommentView.as_view(), name='comment-detail' + ), +] From 6e75cba4c71cff46f4970521ff2398990fcb2a96 Mon Sep 17 00:00:00 2001 From: Roger Hunwicks Date: Thu, 23 Feb 2017 16:22:21 +0200 Subject: [PATCH 08/25] More detailed logging for permissions checks - see #321 --- mayan/apps/acls/managers.py | 23 +++++++++++++++++++++++ mayan/apps/permissions/classes.py | 5 +++-- mayan/apps/permissions/models.py | 12 ++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/mayan/apps/acls/managers.py b/mayan/apps/acls/managers.py index e9bbb88b5b..20c00391e9 100644 --- a/mayan/apps/acls/managers.py +++ b/mayan/apps/acls/managers.py @@ -48,6 +48,10 @@ class AccessControlListManager(models.Manager): def check_access(self, permissions, user, obj, related=None): if user.is_superuser or user.is_staff: + logger.debug('Permissions "%s" on "%s" granted to user "%s" as superuser or staff', + permissions, + obj, + user) return True try: @@ -77,15 +81,31 @@ class AccessControlListManager(models.Manager): for group in user.groups.all(): for role in group.roles.all(): if set(stored_permissions).intersection(set(self.get_inherited_permissions(role=role, obj=obj))): + logger.debug('Permissions "%s" on "%s" granted to user "%s" through role "%s" via inherited ACL', + permissions, + obj, + user, + role) return True user_roles.append(role) if not self.filter(content_type=ContentType.objects.get_for_model(obj), object_id=obj.pk, permissions__in=stored_permissions, role__in=user_roles).exists(): + logger.debug('Permissions "%s" on "%s" denied for user "%s"', + permissions, + obj, + user) raise PermissionDenied(ugettext('Insufficient access.')) + logger.debug('Permissions "%s" on "%s" granted to user "%s" through roles "%s" by direct ACL', + permissions, + obj, + user, + user_roles) 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) return queryset user_roles = [] @@ -124,5 +144,8 @@ class AccessControlListManager(models.Manager): content_type=content_type, role__in=user_roles, permissions=permission.stored_permission ).values_list('object_id', flat=True)) + logger.debug('Filtered queryset returned to user "%s" based on roles "%s"', + user, + user_roles) return queryset.filter(parent_acl_query | acl_query) diff --git a/mayan/apps/permissions/classes.py b/mayan/apps/permissions/classes.py index 0de231d8b0..e1655484a5 100644 --- a/mayan/apps/permissions/classes.py +++ b/mayan/apps/permissions/classes.py @@ -61,8 +61,9 @@ class Permission(object): if permission.stored_permission.requester_has_this(requester): return True - logger.debug('no permission') - + logger.debug('User "%s" does not have permissions "%s"', + requester, + permissions) raise PermissionDenied(_('Insufficient permissions.')) @classmethod diff --git a/mayan/apps/permissions/models.py b/mayan/apps/permissions/models.py index af35e599ea..9182e57094 100644 --- a/mayan/apps/permissions/models.py +++ b/mayan/apps/permissions/models.py @@ -46,17 +46,25 @@ class StoredPermission(models.Model): verbose_name_plural = _('Permissions') def requester_has_this(self, user): - logger.debug('user: %s', user) if user.is_superuser or user.is_staff: + logger.debug('Permission "%s" granted to user "%s" as superuser or staff', + self, + user) return True # Request is one of the permission's holders? for group in user.groups.all(): for role in group.roles.all(): if self in role.permissions.all(): + logger.debug('Permission "%s" granted to user "%s" through role "%s"', + self, + user, + role) return True - logger.debug('Fallthru') + logger.debug('Fallthru: Permission "%s" not granted to user "%s"', + self, + user) return False From ed0145cc1c41126614be9a9800733b0949e9fbba Mon Sep 17 00:00:00 2001 From: Roger Hunwicks Date: Thu, 23 Feb 2017 16:22:21 +0200 Subject: [PATCH 09/25] More detailed logging for permissions checks - see #321 Signed-off-by: Roger Hunwicks --- mayan/apps/acls/managers.py | 23 +++++++++++++++++++++++ mayan/apps/permissions/classes.py | 5 +++-- mayan/apps/permissions/models.py | 12 ++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/mayan/apps/acls/managers.py b/mayan/apps/acls/managers.py index e9bbb88b5b..20c00391e9 100644 --- a/mayan/apps/acls/managers.py +++ b/mayan/apps/acls/managers.py @@ -48,6 +48,10 @@ class AccessControlListManager(models.Manager): def check_access(self, permissions, user, obj, related=None): if user.is_superuser or user.is_staff: + logger.debug('Permissions "%s" on "%s" granted to user "%s" as superuser or staff', + permissions, + obj, + user) return True try: @@ -77,15 +81,31 @@ class AccessControlListManager(models.Manager): for group in user.groups.all(): for role in group.roles.all(): if set(stored_permissions).intersection(set(self.get_inherited_permissions(role=role, obj=obj))): + logger.debug('Permissions "%s" on "%s" granted to user "%s" through role "%s" via inherited ACL', + permissions, + obj, + user, + role) return True user_roles.append(role) if not self.filter(content_type=ContentType.objects.get_for_model(obj), object_id=obj.pk, permissions__in=stored_permissions, role__in=user_roles).exists(): + logger.debug('Permissions "%s" on "%s" denied for user "%s"', + permissions, + obj, + user) raise PermissionDenied(ugettext('Insufficient access.')) + logger.debug('Permissions "%s" on "%s" granted to user "%s" through roles "%s" by direct ACL', + permissions, + obj, + user, + user_roles) 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) return queryset user_roles = [] @@ -124,5 +144,8 @@ class AccessControlListManager(models.Manager): content_type=content_type, role__in=user_roles, permissions=permission.stored_permission ).values_list('object_id', flat=True)) + logger.debug('Filtered queryset returned to user "%s" based on roles "%s"', + user, + user_roles) return queryset.filter(parent_acl_query | acl_query) diff --git a/mayan/apps/permissions/classes.py b/mayan/apps/permissions/classes.py index 0de231d8b0..e1655484a5 100644 --- a/mayan/apps/permissions/classes.py +++ b/mayan/apps/permissions/classes.py @@ -61,8 +61,9 @@ class Permission(object): if permission.stored_permission.requester_has_this(requester): return True - logger.debug('no permission') - + logger.debug('User "%s" does not have permissions "%s"', + requester, + permissions) raise PermissionDenied(_('Insufficient permissions.')) @classmethod diff --git a/mayan/apps/permissions/models.py b/mayan/apps/permissions/models.py index af35e599ea..9182e57094 100644 --- a/mayan/apps/permissions/models.py +++ b/mayan/apps/permissions/models.py @@ -46,17 +46,25 @@ class StoredPermission(models.Model): verbose_name_plural = _('Permissions') def requester_has_this(self, user): - logger.debug('user: %s', user) if user.is_superuser or user.is_staff: + logger.debug('Permission "%s" granted to user "%s" as superuser or staff', + self, + user) return True # Request is one of the permission's holders? for group in user.groups.all(): for role in group.roles.all(): if self in role.permissions.all(): + logger.debug('Permission "%s" granted to user "%s" through role "%s"', + self, + user, + role) return True - logger.debug('Fallthru') + logger.debug('Fallthru: Permission "%s" not granted to user "%s"', + self, + user) return False From 976e6d552f24aa6566186dbde8777e3374446f42 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Thu, 23 Feb 2017 13:24:55 -0400 Subject: [PATCH 10/25] Turn off permission checking for the workflow transition link to allow it to display even when users have been granted the transition permission to only a few transitions and no for the whole workflow itself. GitLab issue #321. cc: @roger.hunwicks Signed-off-by: Roberto Rosario --- mayan/apps/document_states/links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mayan/apps/document_states/links.py b/mayan/apps/document_states/links.py index f9a0e3bba3..3165b3329c 100644 --- a/mayan/apps/document_states/links.py +++ b/mayan/apps/document_states/links.py @@ -76,7 +76,7 @@ link_workflow_instance_detail = Link( view='document_states:workflow_instance_detail', args='resolved_object.pk' ) link_workflow_instance_transition = Link( - permissions=(permission_workflow_transition,), text=_('Transition'), + text=_('Transition'), view='document_states:workflow_instance_transition', args='resolved_object.pk' ) From 01c2e262ebfd8005083c9391f2ca8c3e21a8f52e Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Thu, 23 Feb 2017 20:06:24 -0400 Subject: [PATCH 11/25] Ignore LibreOffice fontconfig cache dir when testing for orphan temporary files. Signed-off-by: Roberto Rosario --- mayan/apps/common/tests/mixins.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mayan/apps/common/tests/mixins.py b/mayan/apps/common/tests/mixins.py index d5bfaf2e31..e202cf3883 100644 --- a/mayan/apps/common/tests/mixins.py +++ b/mayan/apps/common/tests/mixins.py @@ -34,7 +34,8 @@ class ContentTypeCheckMixin(object): class TempfileCheckMixin(object): # Ignore the jvmstat instrumentation and GitLab's CI .config files - ignore_globs = ('hsperfdata_*', '.config') + # Ignore LibreOffice fontconfig cache dir + ignore_globs = ('hsperfdata_*', '.config', '.cache') def _get_temporary_entries(self): ignored_result = [] From dda2bfd7a830216fa945e25dc63b2fe423501607 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Thu, 23 Feb 2017 23:38:49 -0400 Subject: [PATCH 12/25] Move transition ACLs filtering from the form to the model. This way it is usable from many places without duplication. Signed-off-by: Roberto Rosario --- mayan/apps/document_states/forms.py | 26 ++--------------- mayan/apps/document_states/models.py | 43 ++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/mayan/apps/document_states/forms.py b/mayan/apps/document_states/forms.py index ed1687296f..7d0f202a8f 100644 --- a/mayan/apps/document_states/forms.py +++ b/mayan/apps/document_states/forms.py @@ -40,29 +40,9 @@ class WorkflowInstanceTransitionForm(forms.Form): user = kwargs.pop('user') workflow_instance = kwargs.pop('workflow_instance') super(WorkflowInstanceTransitionForm, self).__init__(*args, **kwargs) - queryset = workflow_instance.get_transition_choices().all() - - try: - Permission.check_permissions( - requester=user, permissions=(permission_workflow_transition,) - ) - except PermissionDenied: - try: - # Check for ACL access to the workflow, if true, allow all - # transition options. - AccessControlList.objects.check_access( - permissions=permission_workflow_transition, user=user, - obj=workflow_instance.workflow - ) - except PermissionDenied: - # If not ACL access to the workflow, filter transition options - # by each transition ACL access - queryset = AccessControlList.objects.filter_by_access( - permission=permission_workflow_transition, user=user, - queryset=queryset - ) - - self.fields['transition'].queryset = queryset + self.fields[ + 'transition' + ].queryset = workflow_instance.get_transition_choices(_user=user) transition = forms.ModelChoiceField( label=_('Transition'), queryset=WorkflowTransition.objects.none() diff --git a/mayan/apps/document_states/models.py b/mayan/apps/document_states/models.py index 203b29574f..6963d07751 100644 --- a/mayan/apps/document_states/models.py +++ b/mayan/apps/document_states/models.py @@ -1,17 +1,20 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging from django.conf import settings -from django.core.exceptions import ValidationError +from django.core.exceptions import PermissionDenied, ValidationError from django.core.urlresolvers import reverse from django.db import IntegrityError, models from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ +from acls.models import AccessControlList from documents.models import Document, DocumentType +from permissions import Permission from .managers import WorkflowManager +from .permissions import permission_workflow_transition logger = logging.getLogger(__name__) @@ -166,11 +169,41 @@ class WorkflowInstance(models.Model): except AttributeError: return None - def get_transition_choices(self): + def get_transition_choices(self, _user=None): current_state = self.get_current_state() if current_state: - return current_state.origin_transitions.all() + queryset = current_state.origin_transitions.all() + + if _user: + try: + Permission.check_permissions( + requester=_user, permissions=( + permission_workflow_transition, + ) + ) + except PermissionDenied: + try: + """ + Check for ACL access to the workflow, if true, allow + all transition options. + """ + + AccessControlList.objects.check_access( + permissions=permission_workflow_transition, + user=_user, obj=self.workflow + ) + except PermissionDenied: + """ + If not ACL access to the workflow, filter transition + options by each transition ACL access + """ + + queryset = AccessControlList.objects.filter_by_access( + permission=permission_workflow_transition, + user=_user, queryset=queryset + ) + return queryset else: """ This happens when a workflow has no initial state and a document @@ -209,5 +242,5 @@ class WorkflowInstanceLogEntry(models.Model): verbose_name_plural = _('Workflow instance log entries') def clean(self): - if self.transition not in self.workflow_instance.get_transition_choices(): + if self.transition not in self.workflow_instance.get_transition_choices(_user=self.user): raise ValidationError(_('Not a valid transition choice.')) From 206776441c5c4854c3768a8215f0eb6c20720a6a Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Thu, 23 Feb 2017 23:40:10 -0400 Subject: [PATCH 13/25] Add transition ACLs support to the API view and serializer. Signed-off-by: Roberto Rosario --- mayan/apps/document_states/api_views.py | 32 ++-- mayan/apps/document_states/serializers.py | 35 ++-- mayan/apps/document_states/tests/test_api.py | 164 ++++++++++++++++++- 3 files changed, 197 insertions(+), 34 deletions(-) diff --git a/mayan/apps/document_states/api_views.py b/mayan/apps/document_states/api_views.py index 08a1e55d43..4440effedf 100644 --- a/mayan/apps/document_states/api_views.py +++ b/mayan/apps/document_states/api_views.py @@ -590,21 +590,27 @@ class APIWorkflowInstanceLogEntryListView(generics.ListCreateAPIView): ) def get_document(self): - if self.request.method == 'GET': - permission_required = permission_workflow_view - else: - permission_required = permission_workflow_transition - document = get_object_or_404(Document, pk=self.kwargs['pk']) - try: - Permission.check_permissions( - self.request.user, (permission_required,) - ) - except PermissionDenied: - AccessControlList.objects.check_access( - permission_required, self.request.user, document - ) + if self.request.method == 'GET': + """ + Only test for permission if reading. If writing, the permission + will be checked in the serializer + + IMPROVEMENT: + When writing, add check for permission or ACL for the workflow. + Failing that, check for ACLs for any of the workflow's transitions. + Failing that, then raise PermissionDenied + """ + + try: + Permission.check_permissions( + self.request.user, (permission_workflow_view,) + ) + except PermissionDenied: + AccessControlList.objects.check_access( + permission_workflow_view, self.request.user, document + ) return document diff --git a/mayan/apps/document_states/serializers.py b/mayan/apps/document_states/serializers.py index 4193a46104..3a0daf4ab2 100644 --- a/mayan/apps/document_states/serializers.py +++ b/mayan/apps/document_states/serializers.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers @@ -328,24 +329,6 @@ class WritableWorkflowInstanceLogEntrySerializer(serializers.ModelSerializer): ) model = WorkflowInstanceLogEntry - def create(self, validated_data): - validated_data['transition'] = WorkflowTransition.objects.get( - pk=validated_data.pop('transition_pk') - ) - validated_data['user'] = self.context['request'].user - validated_data['workflow_instance'] = self.context['workflow_instance'] - - if validated_data['transition'] not in validated_data['workflow_instance'].get_transition_choices(): - raise ValidationError( - { - 'transition_pk': _('Not a valid transition choice.') - } - ) - - return super(WritableWorkflowInstanceLogEntrySerializer, self).create( - validated_data - ) - def get_document_workflow_url(self, instance): return reverse( 'rest_api:workflowinstance-detail', args=( @@ -353,3 +336,19 @@ class WritableWorkflowInstanceLogEntrySerializer(serializers.ModelSerializer): instance.workflow_instance.pk, ), request=self.context['request'], format=self.context['format'] ) + + def validate(self, attrs): + attrs['user'] = self.context['request'].user + attrs['workflow_instance'] = self.context['workflow_instance'] + attrs['transition'] = WorkflowTransition.objects.get( + pk=attrs.pop('transition_pk') + ) + + instance = WorkflowInstanceLogEntry(**attrs) + + try: + instance.full_clean() + except DjangoValidationError as exception: + raise ValidationError(exception) + + return attrs diff --git a/mayan/apps/document_states/tests/test_api.py b/mayan/apps/document_states/tests/test_api.py index 9ee947abe2..ffc9366894 100644 --- a/mayan/apps/document_states/tests/test_api.py +++ b/mayan/apps/document_states/tests/test_api.py @@ -1,20 +1,27 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.core.urlresolvers import reverse from django.test import override_settings from rest_framework.test import APITestCase +from acls.models import AccessControlList from documents.models import DocumentType from documents.tests.literals import ( TEST_DOCUMENT_TYPE, TEST_SMALL_DOCUMENT_PATH ) -from user_management.tests.literals import ( - TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, TEST_ADMIN_USERNAME +from permissions import Permission +from permissions.models import Role +from permissions.tests.literals import TEST_ROLE_LABEL +from user_management.tests import ( + TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, TEST_ADMIN_USERNAME, TEST_GROUP, + TEST_USER_EMAIL, TEST_USER_USERNAME, TEST_USER_PASSWORD ) from ..models import Workflow +from ..permissions import permission_workflow_transition from .literals import ( TEST_WORKFLOW_LABEL, TEST_WORKFLOW_LABEL_EDITED, @@ -624,3 +631,154 @@ class DocumentWorkflowsAPITestCase(APITestCase): response.data['results'][0]['transition']['label'], TEST_WORKFLOW_TRANSITION_LABEL ) + + +@override_settings(OCR_AUTO_OCR=False) +class DocumentWorkflowsTransitionACLsAPITestCase(APITestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username=TEST_USER_USERNAME, email=TEST_USER_EMAIL, + password=TEST_USER_PASSWORD + ) + + self.client.login( + username=TEST_USER_USERNAME, password=TEST_USER_PASSWORD + ) + + self.document_type = DocumentType.objects.create( + label=TEST_DOCUMENT_TYPE + ) + + self.group = Group.objects.create(name=TEST_GROUP) + self.role = Role.objects.create(label=TEST_ROLE_LABEL) + self.group.user_set.add(self.user) + self.role.groups.add(self.group) + Permission.invalidate_cache() + + def tearDown(self): + if hasattr(self, 'document_type'): + self.document_type.delete() + + def _create_document(self): + with open(TEST_SMALL_DOCUMENT_PATH) as file_object: + self.document = self.document_type.new_document( + file_object=file_object + ) + + def _create_workflow(self): + self.workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL) + self.workflow.document_types.add(self.document_type) + + def _create_workflow_states(self): + self._create_workflow() + self.workflow_state_1 = self.workflow.states.create( + completion=TEST_WORKFLOW_INITIAL_STATE_COMPLETION, + initial=True, label=TEST_WORKFLOW_INITIAL_STATE_LABEL + ) + self.workflow_state_2 = self.workflow.states.create( + completion=TEST_WORKFLOW_STATE_COMPLETION, + label=TEST_WORKFLOW_STATE_LABEL + ) + + def _create_workflow_transition(self): + self._create_workflow_states() + self.workflow_transition = self.workflow.transitions.create( + label=TEST_WORKFLOW_TRANSITION_LABEL, + origin_state=self.workflow_state_1, + destination_state=self.workflow_state_2, + ) + + def test_workflow_transition_view_no_permission(self): + self._create_workflow_transition() + self._create_document() + + workflow_instance = self.document.workflows.first() + + self.client.post( + reverse( + 'rest_api:workflowinstancelogentry-list', args=( + self.document.pk, workflow_instance.pk + ), + ), data={'transition_pk': self.workflow_transition.pk} + ) + + workflow_instance.refresh_from_db() + + self.assertEqual(workflow_instance.log_entries.count(), 0) + + def test_workflow_transition_view_with_permission(self): + self._create_workflow_transition() + self._create_document() + + workflow_instance = self.document.workflows.first() + + self.role.permissions.add( + permission_workflow_transition.stored_permission + ) + + self.client.post( + reverse( + 'rest_api:workflowinstancelogentry-list', args=( + self.document.pk, workflow_instance.pk + ), + ), data={'transition_pk': self.workflow_transition.pk} + ) + + workflow_instance.refresh_from_db() + + self.assertEqual( + workflow_instance.log_entries.first().transition.label, + TEST_WORKFLOW_TRANSITION_LABEL + ) + + def test_workflow_transition_view_with_workflow_acl(self): + self._create_workflow_transition() + self._create_document() + + workflow_instance = self.document.workflows.first() + + acl = AccessControlList.objects.create( + content_object=self.workflow, role=self.role + ) + acl.permissions.add(permission_workflow_transition.stored_permission) + + self.client.post( + reverse( + 'rest_api:workflowinstancelogentry-list', args=( + self.document.pk, workflow_instance.pk + ), + ), data={'transition_pk': self.workflow_transition.pk} + ) + + workflow_instance.refresh_from_db() + + self.assertEqual( + workflow_instance.log_entries.first().transition.label, + TEST_WORKFLOW_TRANSITION_LABEL + ) + + def test_workflow_transition_view_transition_acl(self): + self._create_workflow_transition() + self._create_document() + + workflow_instance = self.document.workflows.first() + + acl = AccessControlList.objects.create( + content_object=self.workflow_transition, role=self.role + ) + acl.permissions.add(permission_workflow_transition.stored_permission) + + self.client.post( + reverse( + 'rest_api:workflowinstancelogentry-list', args=( + self.document.pk, workflow_instance.pk + ), + ), data={'transition_pk': self.workflow_transition.pk} + ) + + workflow_instance.refresh_from_db() + + self.assertEqual( + workflow_instance.log_entries.first().transition.label, + TEST_WORKFLOW_TRANSITION_LABEL + ) From 137c9daa57ad9dd89de3e091aa3a146cd89f47f3 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Thu, 23 Feb 2017 23:40:51 -0400 Subject: [PATCH 14/25] Fix typo in test literal string. Signed-off-by: Roberto Rosario --- mayan/apps/document_states/tests/literals.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mayan/apps/document_states/tests/literals.py b/mayan/apps/document_states/tests/literals.py index 31685e0e70..004d17de8d 100644 --- a/mayan/apps/document_states/tests/literals.py +++ b/mayan/apps/document_states/tests/literals.py @@ -8,6 +8,6 @@ TEST_WORKFLOW_INSTANCE_LOG_ENTRY_COMMENT = 'test workflow instance log entry com TEST_WORKFLOW_STATE_LABEL = 'test state label' TEST_WORKFLOW_STATE_LABEL_EDITED = 'test state label edited' TEST_WORKFLOW_STATE_COMPLETION = 66 -TEST_WORKFLOW_TRANSITION_LABEL = 'test transtition label' -TEST_WORKFLOW_TRANSITION_LABEL_2 = 'test transtition label 2' -TEST_WORKFLOW_TRANSITION_LABEL_EDITED = 'test transtition label edited' +TEST_WORKFLOW_TRANSITION_LABEL = 'test transition label' +TEST_WORKFLOW_TRANSITION_LABEL_2 = 'test transition label 2' +TEST_WORKFLOW_TRANSITION_LABEL_EDITED = 'test transition label edited' From 406f8cb245daa5078fd68ab04ac8fd42e6f52f5c Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Thu, 23 Feb 2017 23:42:10 -0400 Subject: [PATCH 15/25] PEP8 cleanups. Signed-off-by: Roberto Rosario --- mayan/apps/document_states/api_views.py | 3 +-- mayan/apps/document_states/forms.py | 5 ----- mayan/apps/document_states/links.py | 3 +-- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/mayan/apps/document_states/api_views.py b/mayan/apps/document_states/api_views.py index 4440effedf..ea99a0eb74 100644 --- a/mayan/apps/document_states/api_views.py +++ b/mayan/apps/document_states/api_views.py @@ -15,8 +15,7 @@ from rest_api.permissions import MayanPermission from .models import Workflow from .permissions import ( permission_workflow_create, permission_workflow_delete, - permission_workflow_edit, permission_workflow_transition, - permission_workflow_view + permission_workflow_edit, permission_workflow_view ) from .serializers import ( NewWorkflowDocumentTypeSerializer, WorkflowDocumentTypeSerializer, diff --git a/mayan/apps/document_states/forms.py b/mayan/apps/document_states/forms.py index 7d0f202a8f..df288b255b 100644 --- a/mayan/apps/document_states/forms.py +++ b/mayan/apps/document_states/forms.py @@ -1,14 +1,9 @@ from __future__ import absolute_import, unicode_literals from django import forms -from django.core.exceptions import PermissionDenied from django.utils.translation import ugettext_lazy as _ -from acls.models import AccessControlList -from permissions import Permission - from .models import Workflow, WorkflowState, WorkflowTransition -from .permissions import permission_workflow_transition class WorkflowForm(forms.ModelForm): diff --git a/mayan/apps/document_states/links.py b/mayan/apps/document_states/links.py index 3165b3329c..c0ea98a89b 100644 --- a/mayan/apps/document_states/links.py +++ b/mayan/apps/document_states/links.py @@ -6,8 +6,7 @@ from navigation import Link from .permissions import ( permission_workflow_create, permission_workflow_delete, - permission_workflow_edit, permission_workflow_transition, - permission_workflow_view, + permission_workflow_edit, permission_workflow_view, ) link_document_workflow_instance_list = Link( From ba467e274927755a7118f96e3345a1d449310684 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Thu, 2 Mar 2017 02:31:54 -0400 Subject: [PATCH 16/25] Add support for overriding the celery class. --- mayan/celery.py | 5 +++-- mayan/conf.py | 18 ++++++++++++++++++ mayan/runtime.py | 5 +++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 mayan/conf.py create mode 100644 mayan/runtime.py diff --git a/mayan/celery.py b/mayan/celery.py index 4c3995bc8b..ca8fc9cdad 100644 --- a/mayan/celery.py +++ b/mayan/celery.py @@ -2,12 +2,13 @@ from __future__ import absolute_import, unicode_literals import os -from celery import Celery from django.conf import settings +from .runtime import celery_class + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mayan.settings.production') -app = Celery('mayan') +app = celery_class('mayan') app.config_from_object('django.conf:settings') app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) diff --git a/mayan/conf.py b/mayan/conf.py new file mode 100644 index 0000000000..5ddcfc8185 --- /dev/null +++ b/mayan/conf.py @@ -0,0 +1,18 @@ +""" +This module should be called settings.py but is named conf.py to avoid a +class with the mayan/settings/* module +""" + +from __future__ import unicode_literals + +from django.utils.translation import ugettext_lazy as _ + +from smart_settings import Namespace + +namespace = Namespace(name='mayan', label=_('Mayan')) + +setting_celery_class = namespace.add_setting( + help_text=_('The class used to instanciate the main Celery app.'), + global_name='MAYAN_CELERY_CLASS', + default='celery.Celery' +) diff --git a/mayan/runtime.py b/mayan/runtime.py new file mode 100644 index 0000000000..16a382b63e --- /dev/null +++ b/mayan/runtime.py @@ -0,0 +1,5 @@ +from django.utils.module_loading import import_string + +from .conf import setting_celery_class + +celery_class = import_string(setting_celery_class.value) From 432a2c51555f4be5cd22c6440db3912beaa02b21 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Fri, 3 Mar 2017 05:03:23 -0400 Subject: [PATCH 17/25] Don't user referer URL blindly, recompose using know view name. --- mayan/apps/sources/views.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/mayan/apps/sources/views.py b/mayan/apps/sources/views.py index 288cfcd40d..3287ffa668 100644 --- a/mayan/apps/sources/views.py +++ b/mayan/apps/sources/views.py @@ -263,7 +263,13 @@ class UploadInteractiveView(UploadBaseView): 'shortly.' ) ) - return HttpResponseRedirect(self.request.get_full_path()) + + return HttpResponseRedirect( + '{}?{}'.format( + reverse(self.request.resolver_match.view_name), + self.request.META['QUERY_STRING'] + ), + ) def create_source_form_form(self, **kwargs): return self.get_form_classes()['source_form']( @@ -304,7 +310,10 @@ class UploadInteractiveView(UploadBaseView): if not isinstance(self.source, StagingFolderSource): context['subtemplates_list'][0]['context'].update( { - 'form_action': self.request.get_full_path(), + 'form_action': '{}?{}'.format( + reverse(self.request.resolver_match.view_name), + self.request.META['QUERY_STRING'] + ), 'form_class': 'dropzone', 'form_disable_submit': True, 'form_id': 'html5upload', From 31580ee51dadd4b3d175049179bc05db2cb5730e Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Thu, 9 Mar 2017 13:32:03 -0400 Subject: [PATCH 18/25] Add size field to the document version serializer. Signed-off-by: Roberto Rosario --- mayan/apps/documents/serializers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mayan/apps/documents/serializers.py b/mayan/apps/documents/serializers.py index 0dde168a52..232e24e88e 100644 --- a/mayan/apps/documents/serializers.py +++ b/mayan/apps/documents/serializers.py @@ -100,6 +100,7 @@ class DocumentVersionSerializer(serializers.HyperlinkedModelSerializer): revert = serializers.HyperlinkedIdentityField( view_name='rest_api:documentversion-revert' ) + size = serializers.SerializerMethodField() class Meta: extra_kwargs = { @@ -108,7 +109,10 @@ class DocumentVersionSerializer(serializers.HyperlinkedModelSerializer): 'url': {'view_name': 'rest_api:documentversion-detail'}, } model = DocumentVersion - read_only_fields = ('document', 'file') + read_only_fields = ('document', 'file', 'size') + + def get_size(self, instance): + return instance.size class WritableDocumentVersionSerializer(serializers.ModelSerializer): From ffb98cdba6140b2e1a23fcde3182b7ea645a5561 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Fri, 10 Mar 2017 01:27:10 -0400 Subject: [PATCH 19/25] Add ACL creation API endpoint. --- mayan/apps/acls/api_views.py | 30 ++++++-- mayan/apps/acls/serializers.py | 120 ++++++++++++++++++++++++++++++ mayan/apps/acls/tests/test_api.py | 17 +++++ 3 files changed, 161 insertions(+), 6 deletions(-) diff --git a/mayan/apps/acls/api_views.py b/mayan/apps/acls/api_views.py index 36aece2dd7..9933b99e29 100644 --- a/mayan/apps/acls/api_views.py +++ b/mayan/apps/acls/api_views.py @@ -11,13 +11,12 @@ from permissions import Permission from .models import AccessControlList from .permissions import permission_acl_edit, permission_acl_view from .serializers import ( - AccessControlListPermissionSerializer, AccessControlListSerializer + AccessControlListPermissionSerializer, AccessControlListSerializer, + WritableAccessControlListSerializer ) -class APIObjectACLListView(generics.ListAPIView): - serializer_class = AccessControlListSerializer - +class APIObjectACLListView(generics.ListCreateAPIView): def get(self, *args, **kwargs): """ Returns a list of all the object's access control lists @@ -35,13 +34,18 @@ class APIObjectACLListView(generics.ListAPIView): content_type.model_class(), pk=self.kwargs['object_pk'] ) + if self.request.method == 'GET': + permission_required = permission_acl_view + else: + permission_required = permission_acl_edit + try: Permission.check_permissions( - self.request.user, permissions=(permission_acl_view,) + self.request.user, permissions=(permission_required,) ) except PermissionDenied: AccessControlList.objects.check_access( - permission_acl_view, self.request.user, content_object + permission_required, self.request.user, content_object ) return content_object @@ -55,11 +59,25 @@ class APIObjectACLListView(generics.ListAPIView): """ return { + 'content_object': self.get_content_object(), 'format': self.format_kwarg, 'request': self.request, 'view': self } + def get_serializer_class(self): + if self.request.method == 'GET': + return AccessControlListSerializer + else: + return WritableAccessControlListSerializer + + def post(self, *args, **kwargs): + """ + Create a new access control list for the selected object. + """ + + return super(APIObjectACLListView, self).post(*args, **kwargs) + class APIObjectACLView(generics.RetrieveAPIView): serializer_class = AccessControlListSerializer diff --git a/mayan/apps/acls/serializers.py b/mayan/apps/acls/serializers.py index 72d9163589..857d99bea6 100644 --- a/mayan/apps/acls/serializers.py +++ b/mayan/apps/acls/serializers.py @@ -1,11 +1,17 @@ from __future__ import absolute_import, unicode_literals +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils.encoding import force_text from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers +from rest_framework.exceptions import ValidationError from rest_framework.reverse import reverse from common.serializers import ContentTypeSerializer +from permissions import Permission +from permissions.models import Role from permissions.serializers import PermissionSerializer, RoleSerializer from .models import AccessControlList @@ -72,3 +78,117 @@ class AccessControlListPermissionSerializer(PermissionSerializer): instance.stored_permission.pk ), request=self.context['request'], format=self.context['format'] ) + + +class WritableAccessControlListSerializer(serializers.ModelSerializer): + permissions_pk_list = serializers.CharField( + help_text=_( + 'Comma separated list of permission primary keys to grant to this ' + 'access control list.' + ), required=False + ) + permissions_url = serializers.SerializerMethodField( + help_text=_( + 'API URL pointing to the list of permissions for this access ' + 'control list.' + ), read_only=True + ) + role_pk = serializers.IntegerField( + help_text=_( + 'Primary keys of the role to which this access control list ' + 'binds to.' + ), required=False + ) + url = serializers.SerializerMethodField() + + class Meta: + fields = ( + 'content_type', 'id', 'object_id', 'permissions_pk_list', + 'permissions_url', 'role_pk', 'url' + ) + model = AccessControlList + read_only_fields = ('content_type', 'object_id',) + + def _add_permissions(self, instance): + for pk in self.permissions_pk_list.split(','): + try: + stored_permission = Permission.get(get_dict={'pk': pk}) + instance.permissions.add(stored_permission) + instance.save() + except KeyError: + raise ValidationError(_('No such permission: %s') % pk) + + def create(self, validated_data): + validated_data['content_type'] = ContentType.objects.get_for_model(self.context['content_object']) + validated_data['object_id'] = self.context['content_object'].pk + + self.permissions_pk_list = validated_data.pop( + 'permissions_pk_list', '' + ) + + instance = super( + WritableAccessControlListSerializer, self + ).create(validated_data) + + if self.permissions_pk_list: + self._add_permissions(instance=instance) + + return instance + + def get_permissions_url(self, instance): + return reverse( + 'rest_api:accesscontrollist-permission-list', args=( + instance.content_type.app_label, instance.content_type.model, + instance.object_id, instance.pk + ), request=self.context['request'], format=self.context['format'] + ) + + def get_url(self, instance): + return reverse( + 'rest_api:accesscontrollist-detail', args=( + instance.content_type.app_label, instance.content_type.model, + instance.object_id, instance.pk + ), request=self.context['request'], format=self.context['format'] + ) + + def update(self, instance, validated_data): + self.permissions_pk_list = validated_data.pop( + 'permissions_pk_list', '' + ) + + instance = super(WritableAccessControlListSerializer, self).update( + instance, validated_data + ) + + if self.permissions_pk_list: + instance.permissions.clear() + self._add_permissions(instance=instance) + + return instance + + def validate(self, attrs): + attrs['content_type'] = ContentType.objects.get_for_model(self.context['content_object']) + attrs['object_id'] = self.context['content_object'].pk + + role_pk = attrs.pop('role_pk', None) + if not role_pk: + raise ValidationError( + { + 'role_pk': + _( + 'This field cannot be null.' + ) + } + ) + try: + attrs['role'] = Role.objects.get(pk=role_pk) + except Role.DoesNotExist as exception: + raise ValidationError(force_text(exception)) + + instance = AccessControlList(**attrs) + try: + instance.full_clean() + except DjangoValidationError as exception: + raise ValidationError(exception) + + return attrs diff --git a/mayan/apps/acls/tests/test_api.py b/mayan/apps/acls/tests/test_api.py index 9c7eade47c..55705eb152 100644 --- a/mayan/apps/acls/tests/test_api.py +++ b/mayan/apps/acls/tests/test_api.py @@ -144,3 +144,20 @@ class ACLAPITestCase(APITestCase): self.assertEqual( response.data['pk'], permission_document_view.pk ) + + def test_object_acl_post_no_permissions_added_view(self): + response = self.client.post( + reverse( + 'rest_api:accesscontrollist-list', + args=( + self.document_content_type.app_label, + self.document_content_type.model, + self.document.pk + ) + ), data={'role_pk': self.role.pk} + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual( + self.document.acls.first().content_object, self.document + ) From f6b58655e8153618af1c0ca38f3b0c60d103bf67 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Mon, 13 Mar 2017 21:02:22 -0400 Subject: [PATCH 20/25] Permissions are read only always. Signed-off-by: Roberto Rosario --- mayan/apps/permissions/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mayan/apps/permissions/serializers.py b/mayan/apps/permissions/serializers.py index 6774a9c3b6..cd234f4001 100644 --- a/mayan/apps/permissions/serializers.py +++ b/mayan/apps/permissions/serializers.py @@ -13,9 +13,9 @@ from .models import Role, StoredPermission class PermissionSerializer(serializers.Serializer): - namespace = serializers.CharField() - pk = serializers.CharField() - label = serializers.CharField() + namespace = serializers.CharField(read_only=True) + pk = serializers.CharField(read_only=True) + label = serializers.CharField(read_only=True) def to_representation(self, instance): if isinstance(instance, StoredPermission): From 858eb8b020d827ab08af4931e3970f8c4810bdc0 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Mon, 13 Mar 2017 21:04:12 -0400 Subject: [PATCH 21/25] Add writable ACLs API endpoints. Signed-off-by: Roberto Rosario --- mayan/apps/acls/api_views.py | 40 +++++++-- mayan/apps/acls/classes.py | 4 +- mayan/apps/acls/models.py | 4 +- mayan/apps/acls/serializers.py | 140 ++++++++++++++++-------------- mayan/apps/acls/tests/test_api.py | 111 ++++++++++++++++++++--- mayan/apps/acls/views.py | 3 +- 6 files changed, 219 insertions(+), 83 deletions(-) diff --git a/mayan/apps/acls/api_views.py b/mayan/apps/acls/api_views.py index 9933b99e29..a93f3e09c8 100644 --- a/mayan/apps/acls/api_views.py +++ b/mayan/apps/acls/api_views.py @@ -12,6 +12,7 @@ from .models import AccessControlList from .permissions import permission_acl_edit, permission_acl_view from .serializers import ( AccessControlListPermissionSerializer, AccessControlListSerializer, + WritableAccessControlListPermissionSerializer, WritableAccessControlListSerializer ) @@ -79,9 +80,16 @@ class APIObjectACLListView(generics.ListCreateAPIView): return super(APIObjectACLListView, self).post(*args, **kwargs) -class APIObjectACLView(generics.RetrieveAPIView): +class APIObjectACLView(generics.RetrieveDestroyAPIView): serializer_class = AccessControlListSerializer + def delete(self, *args, **kwargs): + """ + Delete the selected access control list. + """ + + return super(APIObjectACLView, self).delete(*args, **kwargs) + def get(self, *args, **kwargs): """ Returns the details of the selected access control list. @@ -119,9 +127,7 @@ class APIObjectACLView(generics.RetrieveAPIView): return self.get_content_object().acls.all() -class APIObjectACLPermissionListView(generics.ListAPIView): - serializer_class = AccessControlListPermissionSerializer - +class APIObjectACLPermissionListView(generics.ListCreateAPIView): def get(self, *args, **kwargs): """ Returns the access control list permission list. @@ -160,6 +166,12 @@ class APIObjectACLPermissionListView(generics.ListAPIView): def get_queryset(self): return self.get_acl().permissions.all() + def get_serializer_class(self): + if self.request.method == 'GET': + return AccessControlListPermissionSerializer + else: + return WritableAccessControlListPermissionSerializer + def get_serializer_context(self): return { 'acl': self.get_acl(), @@ -168,11 +180,29 @@ class APIObjectACLPermissionListView(generics.ListAPIView): 'view': self } + def post(self, *args, **kwargs): + """ + Add a new permission to the selected access control list. + """ -class APIObjectACLPermissionView(generics.RetrieveAPIView): + return super( + APIObjectACLPermissionListView, self + ).post(*args, **kwargs) + + +class APIObjectACLPermissionView(generics.RetrieveDestroyAPIView): lookup_url_kwarg = 'permission_pk' serializer_class = AccessControlListPermissionSerializer + def delete(self, *args, **kwargs): + """ + Remove the permission from the selected access control list. + """ + + return super( + APIObjectACLPermissionView, self + ).delete(*args, **kwargs) + def get(self, *args, **kwargs): """ Returns the details of the selected access control list permission. diff --git a/mayan/apps/acls/classes.py b/mayan/apps/acls/classes.py index 7f2c47b9d2..428279ba90 100644 --- a/mayan/apps/acls/classes.py +++ b/mayan/apps/acls/classes.py @@ -43,7 +43,9 @@ class ModelPermission(object): if proxy: permissions.extend(cls._registry.get(proxy)) - pks = [permission.stored_permission.pk for permission in set(permissions)] + pks = [ + permission.stored_permission.pk for permission in set(permissions) + ] return StoredPermission.objects.filter(pk__in=pks) @classmethod diff --git a/mayan/apps/acls/models.py b/mayan/apps/acls/models.py index 03db1cbebd..57737b5114 100644 --- a/mayan/apps/acls/models.py +++ b/mayan/apps/acls/models.py @@ -45,7 +45,9 @@ class AccessControlList(models.Model): verbose_name_plural = _('Access entries') def __str__(self): - return _('Permissions "%(permissions)s" to role "%(role)s" for "%(object)s"') % { + return _( + 'Permissions "%(permissions)s" to role "%(role)s" for "%(object)s"' + ) % { 'permissions': self.get_permission_titles(), 'object': self.content_object, 'role': self.role diff --git a/mayan/apps/acls/serializers.py b/mayan/apps/acls/serializers.py index 857d99bea6..9ddea1ad27 100644 --- a/mayan/apps/acls/serializers.py +++ b/mayan/apps/acls/serializers.py @@ -11,7 +11,7 @@ from rest_framework.reverse import reverse from common.serializers import ContentTypeSerializer from permissions import Permission -from permissions.models import Role +from permissions.models import Role, StoredPermission from permissions.serializers import PermissionSerializer, RoleSerializer from .models import AccessControlList @@ -59,15 +59,7 @@ class AccessControlListPermissionSerializer(PermissionSerializer): 'different than the canonical workflow URL.' ) ) - - def __init__(self, *args, **kwargs): - super( - AccessControlListPermissionSerializer, self - ).__init__(*args, **kwargs) - - # Make all fields (inherited and local) read ony. - for field in self._readable_fields: - field.read_only = True + acl_url = serializers.SerializerMethodField() def get_acl_permission_url(self, instance): return reverse( @@ -79,8 +71,56 @@ class AccessControlListPermissionSerializer(PermissionSerializer): ), request=self.context['request'], format=self.context['format'] ) + def get_acl_url(self, instance): + return reverse( + 'rest_api:accesscontrollist-detail', args=( + self.context['acl'].content_type.app_label, + self.context['acl'].content_type.model, + self.context['acl'].object_id, self.context['acl'].pk + ), request=self.context['request'], format=self.context['format'] + ) + + +class WritableAccessControlListPermissionSerializer(AccessControlListPermissionSerializer): + permission_pk = serializers.CharField( + help_text=_( + 'Primary key of the new permission to grant to the access control ' + 'list.' + ), write_only=True + ) + + class Meta: + fields = ('namespace',) + read_only_fields = ('namespace',) + + def create(self, validated_data): + for permission in validated_data['permissions']: + self.context['acl'].permissions.add(permission) + + return validated_data['permissions'][0] + + def validate(self, attrs): + permissions_pk_list = attrs.pop('permission_pk', None) + permissions_result = [] + + if permissions_pk_list: + for pk in permissions_pk_list.split(','): + try: + permission = Permission.get(get_dict={'pk': pk}) + except KeyError: + raise ValidationError(_('No such permission: %s') % pk) + else: + # Accumulate valid stored permission pks + permissions_result.append(permission.pk) + + attrs['permissions'] = StoredPermission.objects.filter( + pk__in=permissions_result + ) + return attrs + class WritableAccessControlListSerializer(serializers.ModelSerializer): + content_type = ContentTypeSerializer(read_only=True) permissions_pk_list = serializers.CharField( help_text=_( 'Comma separated list of permission primary keys to grant to this ' @@ -97,7 +137,7 @@ class WritableAccessControlListSerializer(serializers.ModelSerializer): help_text=_( 'Primary keys of the role to which this access control list ' 'binds to.' - ), required=False + ), write_only=True ) url = serializers.SerializerMethodField() @@ -107,33 +147,7 @@ class WritableAccessControlListSerializer(serializers.ModelSerializer): 'permissions_url', 'role_pk', 'url' ) model = AccessControlList - read_only_fields = ('content_type', 'object_id',) - - def _add_permissions(self, instance): - for pk in self.permissions_pk_list.split(','): - try: - stored_permission = Permission.get(get_dict={'pk': pk}) - instance.permissions.add(stored_permission) - instance.save() - except KeyError: - raise ValidationError(_('No such permission: %s') % pk) - - def create(self, validated_data): - validated_data['content_type'] = ContentType.objects.get_for_model(self.context['content_object']) - validated_data['object_id'] = self.context['content_object'].pk - - self.permissions_pk_list = validated_data.pop( - 'permissions_pk_list', '' - ) - - instance = super( - WritableAccessControlListSerializer, self - ).create(validated_data) - - if self.permissions_pk_list: - self._add_permissions(instance=instance) - - return instance + read_only_fields = ('content_type', 'object_id') def get_permissions_url(self, instance): return reverse( @@ -151,44 +165,40 @@ class WritableAccessControlListSerializer(serializers.ModelSerializer): ), request=self.context['request'], format=self.context['format'] ) - def update(self, instance, validated_data): - self.permissions_pk_list = validated_data.pop( - 'permissions_pk_list', '' - ) - - instance = super(WritableAccessControlListSerializer, self).update( - instance, validated_data - ) - - if self.permissions_pk_list: - instance.permissions.clear() - self._add_permissions(instance=instance) - - return instance - def validate(self, attrs): - attrs['content_type'] = ContentType.objects.get_for_model(self.context['content_object']) + attrs['content_type'] = ContentType.objects.get_for_model( + self.context['content_object'] + ) attrs['object_id'] = self.context['content_object'].pk - role_pk = attrs.pop('role_pk', None) - if not role_pk: - raise ValidationError( - { - 'role_pk': - _( - 'This field cannot be null.' - ) - } - ) try: - attrs['role'] = Role.objects.get(pk=role_pk) + attrs['role'] = Role.objects.get(pk=attrs.pop('role_pk')) except Role.DoesNotExist as exception: raise ValidationError(force_text(exception)) + permissions_pk_list = attrs.pop('permissions_pk_list', None) + permissions_result = [] + + if permissions_pk_list: + for pk in permissions_pk_list.split(','): + try: + permission = Permission.get(get_dict={'pk': pk}) + except KeyError: + raise ValidationError(_('No such permission: %s') % pk) + else: + # Accumulate valid stored permission pks + permissions_result.append(permission.pk) + instance = AccessControlList(**attrs) + try: instance.full_clean() except DjangoValidationError as exception: raise ValidationError(exception) + # Add a queryset of valid stored permissions so that they get added + # after the ACL gets created. + attrs['permissions'] = StoredPermission.objects.filter( + pk__in=permissions_result + ) return attrs diff --git a/mayan/apps/acls/tests/test_api.py b/mayan/apps/acls/tests/test_api.py index 55705eb152..244fbc54d7 100644 --- a/mayan/apps/acls/tests/test_api.py +++ b/mayan/apps/acls/tests/test_api.py @@ -20,6 +20,7 @@ from user_management.tests.literals import ( ) from ..models import AccessControlList +from ..permissions import permission_acl_view @override_settings(OCR_AUTO_OCR=False) @@ -84,6 +85,23 @@ class ACLAPITestCase(APITestCase): response.data['results'][0]['role']['label'], TEST_ROLE_LABEL ) + def test_object_acl_delete_view(self): + self._create_acl() + + response = self.client.delete( + reverse( + 'rest_api:accesscontrollist-detail', + args=( + self.document_content_type.app_label, + self.document_content_type.model, + self.document.pk, self.acl.pk + ) + ) + ) + + self.assertEqual(response.status_code, 204) + self.assertEqual(AccessControlList.objects.count(), 0) + def test_object_acl_detail_view(self): self._create_acl() @@ -97,7 +115,6 @@ class ACLAPITestCase(APITestCase): ) ) ) - self.assertEqual( response.data['content_type']['app_label'], self.document_content_type.app_label @@ -106,24 +123,23 @@ class ACLAPITestCase(APITestCase): response.data['role']['label'], TEST_ROLE_LABEL ) - def test_object_acl_permission_list_view(self): + def test_object_acl_permission_delete_view(self): self._create_acl() + permission = self.acl.permissions.first() - response = self.client.get( + response = self.client.delete( reverse( - 'rest_api:accesscontrollist-permission-list', + 'rest_api:accesscontrollist-permission-detail', args=( self.document_content_type.app_label, self.document_content_type.model, - self.document.pk, self.acl.pk + self.document.pk, self.acl.pk, + permission.pk ) ) ) - - self.assertEqual( - response.data['results'][0]['pk'], - permission_document_view.pk - ) + self.assertEqual(response.status_code, 204) + self.assertEqual(self.acl.permissions.count(), 0) def test_object_acl_permission_detail_view(self): self._create_acl() @@ -145,6 +161,47 @@ class ACLAPITestCase(APITestCase): response.data['pk'], permission_document_view.pk ) + def test_object_acl_permission_list_view(self): + self._create_acl() + + response = self.client.get( + reverse( + 'rest_api:accesscontrollist-permission-list', + args=( + self.document_content_type.app_label, + self.document_content_type.model, + self.document.pk, self.acl.pk + ) + ) + ) + + self.assertEqual( + response.data['results'][0]['pk'], + permission_document_view.pk + ) + + def test_object_acl_permission_list_post_view(self): + self._create_acl() + + response = self.client.post( + reverse( + 'rest_api:accesscontrollist-permission-list', + args=( + self.document_content_type.app_label, + self.document_content_type.model, + self.document.pk, self.acl.pk + ) + ), data={'permission_pk': permission_acl_view.pk} + ) + + self.assertEqual(response.status_code, 201) + self.assertQuerysetEqual( + ordered=False, qs=self.acl.permissions.all(), values=( + repr(permission_document_view.stored_permission), + repr(permission_acl_view.stored_permission) + ) + ) + def test_object_acl_post_no_permissions_added_view(self): response = self.client.post( reverse( @@ -158,6 +215,40 @@ class ACLAPITestCase(APITestCase): ) self.assertEqual(response.status_code, 201) + self.assertEqual( + self.document.acls.first().role, self.role + ) self.assertEqual( self.document.acls.first().content_object, self.document ) + self.assertEqual( + self.document.acls.first().permissions.count(), 0 + ) + + def test_object_acl_post_with_permissions_added_view(self): + response = self.client.post( + reverse( + 'rest_api:accesscontrollist-list', + args=( + self.document_content_type.app_label, + self.document_content_type.model, + self.document.pk + ) + ), data={ + 'role_pk': self.role.pk, + 'permissions_pk_list': permission_acl_view.pk + + } + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual( + self.document.acls.first().content_object, self.document + ) + self.assertEqual( + self.document.acls.first().role, self.role + ) + self.assertEqual( + self.document.acls.first().permissions.first(), + permission_acl_view.stored_permission + ) diff --git a/mayan/apps/acls/views.py b/mayan/apps/acls/views.py index 0d1795d058..1f23802eb7 100644 --- a/mayan/apps/acls/views.py +++ b/mayan/apps/acls/views.py @@ -153,7 +153,8 @@ class ACLListView(SingleObjectListView): def get_queryset(self): return AccessControlList.objects.filter( - content_type=self.object_content_type, object_id=self.content_object.pk + content_type=self.object_content_type, + object_id=self.content_object.pk ) From cc174a563cafc7bc65df558d63dd1c3a38b92cb2 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Mon, 13 Mar 2017 21:33:19 -0400 Subject: [PATCH 22/25] Display a placeholder error document image for documents with no pages. Signed-off-by: Roberto Rosario --- mayan/apps/documents/widgets.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mayan/apps/documents/widgets.py b/mayan/apps/documents/widgets.py index f0e8a71dfc..3e5148136f 100644 --- a/mayan/apps/documents/widgets.py +++ b/mayan/apps/documents/widgets.py @@ -82,10 +82,15 @@ class DocumentPagesCarouselWidget(forms.widgets.Widget): def document_thumbnail(document, **kwargs): - return document_html_widget( - document.latest_version.pages.first(), - click_view='documents:document_display', **kwargs - ) + if document.latest_version: + return document_html_widget( + document.latest_version.pages.first(), + click_view='documents:document_display', **kwargs + ) + else: + return mark_safe( + '' + ) def document_link(document): From 7341971c86207b42321910e39da2de86e0325d61 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Mon, 13 Mar 2017 22:35:44 -0400 Subject: [PATCH 23/25] The deleted document restore API endpoint doesn't need a serializer. Signed-off-by: Roberto Rosario --- mayan/apps/documents/api_views.py | 5 +++-- mayan/apps/documents/serializers.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mayan/apps/documents/api_views.py b/mayan/apps/documents/api_views.py index a21d20278e..b1f3561661 100644 --- a/mayan/apps/documents/api_views.py +++ b/mayan/apps/documents/api_views.py @@ -79,10 +79,11 @@ class APIDeletedDocumentRestoreView(generics.GenericAPIView): mayan_object_permissions = { 'POST': (permission_document_restore,) } - permission_classes = (MayanPermission,) queryset = Document.trash.all() - serializer_class = DeletedDocumentSerializer + + def get_serializer_class(self): + return None def post(self, *args, **kwargs): self.get_object().restore() diff --git a/mayan/apps/documents/serializers.py b/mayan/apps/documents/serializers.py index 232e24e88e..bfa8e4ae80 100644 --- a/mayan/apps/documents/serializers.py +++ b/mayan/apps/documents/serializers.py @@ -162,9 +162,6 @@ class DeletedDocumentSerializer(serializers.HyperlinkedModelSerializer): view_name='rest_api:trasheddocument-restore' ) - def get_document_type_label(self, instance): - return instance.document_type.label - class Meta: extra_kwargs = { 'document_type': {'view_name': 'rest_api:documenttype-detail'}, @@ -181,6 +178,9 @@ class DeletedDocumentSerializer(serializers.HyperlinkedModelSerializer): 'language' ) + def get_document_type_label(self, instance): + return instance.document_type.label + class DocumentSerializer(serializers.HyperlinkedModelSerializer): document_type_label = serializers.SerializerMethodField() From 5ddb3f1cff20b601128e1f467ad7af73cd59a3f4 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Mon, 13 Mar 2017 22:55:29 -0400 Subject: [PATCH 24/25] Add support for adding or editing document types to smart links the API. Signed-off-by: Roberto Rosario --- mayan/apps/linking/serializers.py | 38 +++++++++++++++++++++++++--- mayan/apps/linking/tests/test_api.py | 26 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/mayan/apps/linking/serializers.py b/mayan/apps/linking/serializers.py index 23a0db21ff..3bc1b3ba50 100644 --- a/mayan/apps/linking/serializers.py +++ b/mayan/apps/linking/serializers.py @@ -1,9 +1,13 @@ from __future__ import absolute_import, unicode_literals +from django.utils.translation import ugettext_lazy as _ + from rest_framework import serializers +from rest_framework.exceptions import ValidationError from rest_framework.reverse import reverse -from documents.serializers import DocumentSerializer +from documents.models import DocumentType +from documents.serializers import DocumentSerializer, DocumentTypeSerializer from .models import SmartLink, SmartLinkCondition @@ -41,13 +45,15 @@ class SmartLinkSerializer(serializers.HyperlinkedModelSerializer): conditions_url = serializers.HyperlinkedIdentityField( view_name='rest_api:smartlinkcondition-list' ) + document_types = DocumentTypeSerializer(read_only=True, many=True) class Meta: extra_kwargs = { 'url': {'view_name': 'rest_api:smartlink-detail'}, } fields = ( - 'conditions_url', 'dynamic_label', 'enabled', 'label', 'id', 'url' + 'conditions_url', 'document_types', 'dynamic_label', 'enabled', + 'label', 'id', 'url' ) model = SmartLink @@ -104,12 +110,38 @@ class WritableSmartLinkSerializer(serializers.ModelSerializer): conditions_url = serializers.HyperlinkedIdentityField( view_name='rest_api:smartlinkcondition-list' ) + document_types_pk_list = serializers.CharField( + help_text=_( + 'Comma separated list of document type primary keys to which this ' + 'smart link will be attached.' + ), required=False + ) class Meta: extra_kwargs = { 'url': {'view_name': 'rest_api:smartlink-detail'}, } fields = ( - 'conditions_url', 'dynamic_label', 'enabled', 'label', 'id', 'url' + 'conditions_url', 'document_types_pk_list', 'dynamic_label', + 'enabled', 'label', 'id', 'url' ) model = SmartLink + + def validate(self, attrs): + document_types_pk_list = attrs.pop('document_types_pk_list', None) + document_types_result = [] + + if document_types_pk_list: + for pk in document_types_pk_list.split(','): + try: + document_type = DocumentType.objects.get(pk=pk) + except DocumentType.DoesNotExist: + raise ValidationError(_('No such document type: %s') % pk) + else: + # Accumulate valid stored document_type pks + document_types_result.append(document_type.pk) + + attrs['document_types'] = DocumentType.objects.filter( + pk__in=document_types_result + ) + return attrs diff --git a/mayan/apps/linking/tests/test_api.py b/mayan/apps/linking/tests/test_api.py index 4e795f830e..ed905bfbba 100644 --- a/mayan/apps/linking/tests/test_api.py +++ b/mayan/apps/linking/tests/test_api.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from django.test import override_settings +from rest_framework.exceptions import ValidationError from rest_framework.test import APITestCase from documents.models import DocumentType @@ -72,6 +73,26 @@ class SmartLinkAPITestCase(APITestCase): self.assertEqual(SmartLink.objects.count(), 1) self.assertEqual(smart_link.label, TEST_SMART_LINK_LABEL) + def test_smart_link_create_with_document_types_view(self): + self._create_document_type() + + response = self.client.post( + reverse('rest_api:smartlink-list'), data={ + 'label': TEST_SMART_LINK_LABEL, + 'document_types_pk_list': self.document_type.pk + }, + ) + + smart_link = SmartLink.objects.first() + self.assertEqual(response.data['id'], smart_link.pk) + self.assertEqual(response.data['label'], TEST_SMART_LINK_LABEL) + + self.assertEqual(SmartLink.objects.count(), 1) + self.assertEqual(smart_link.label, TEST_SMART_LINK_LABEL) + self.assertQuerysetEqual( + smart_link.document_types.all(), (repr(self.document_type),) + ) + def test_smart_link_delete_view(self): smart_link = self._create_smart_link() @@ -93,18 +114,23 @@ class SmartLinkAPITestCase(APITestCase): ) def test_smart_link_patch_view(self): + self._create_document_type() smart_link = self._create_smart_link() self.client.patch( reverse('rest_api:smartlink-detail', args=(smart_link.pk,)), data={ 'label': TEST_SMART_LINK_LABEL_EDITED, + 'document_types_pk_list': self.document_type.pk } ) smart_link.refresh_from_db() self.assertEqual(smart_link.label, TEST_SMART_LINK_LABEL_EDITED) + self.assertQuerysetEqual( + smart_link.document_types.all(), (repr(self.document_type),) + ) def test_smart_link_put_view(self): smart_link = self._create_smart_link() From ca7b8301a1f68548e8e718d42a728a500d67286e Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Mon, 13 Mar 2017 23:58:25 -0400 Subject: [PATCH 25/25] Bump version to 2.1.11. Add changelog and release notes. Signed-off-by: Roberto Rosario --- HISTORY.rst | 18 ++++++++ docs/releases/2.1.10.rst | 4 +- docs/releases/2.1.11.rst | 94 ++++++++++++++++++++++++++++++++++++++++ docs/releases/index.rst | 1 + mayan/__init__.py | 4 +- 5 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 docs/releases/2.1.11.rst diff --git a/HISTORY.rst b/HISTORY.rst index 2e7b617934..a6392e726a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,3 +1,21 @@ +2.1.11 (2017-03-14) +=================== +- Added a quick rename serializer to the document type API serializer. +- Added per document type, workflow list API view. +- Mayan EDMS was adopted a version 1.1 of the Linux Foundation Developer Certificate of Origin. +- Added the detail url of a permission in the permission serializer. +- Added endpoints for the ACL app API. +- Implemented document workflows transition ACLs. GitLab issue #321. +- Add document comments API endpoints. GitHub issue #249. +- Add support for overriding the Celery class. +- Changed the document upload view in source app to not use the HTTP referer + URL blindly, but instead recompose the URL using known view name. Needed + when integrating Mayan EDMS into other app via using iframes. +- Addes size field to the document version serializer. +- Removed the serializer from the deleted document restore API endpoint. +- Added support for adding or editing document types to smart links via the + API. + 2.1.10 (2017-02-13) ================== - Update Makefile to use twine for releases. diff --git a/docs/releases/2.1.10.rst b/docs/releases/2.1.10.rst index 8445af2a09..66f9252867 100644 --- a/docs/releases/2.1.10.rst +++ b/docs/releases/2.1.10.rst @@ -1,6 +1,6 @@ -=============================== +================================ Mayan EDMS v2.1.10 release notes -=============================== +================================ Released: February 13, 2017 diff --git a/docs/releases/2.1.11.rst b/docs/releases/2.1.11.rst new file mode 100644 index 0000000000..06415422c8 --- /dev/null +++ b/docs/releases/2.1.11.rst @@ -0,0 +1,94 @@ +================================ +Mayan EDMS v2.1.11 release notes +================================ + +Released: March 14, 2017 + +What's new +========== + +This is a bug-fix release and all users are encouraged to upgrade. The focus +of this micro release was REST API improvement. + +Changes +------------- + +- Added a quick rename serializer to the document type API serializer. +- Added per document type, workflow list API view. The URL for this endpoint is + GET /api/document_states/document_type/{pk}/workflows/ +- Added Developer Certificate of Origin. Mayan EDMS was adopted a version 1.1 of + the Linux Foundation Developer Certificate of Origin. All commits must be + signed (`git commit -s`) in order to be merged. +- Added the detail url of a permission in the permission serializer. +- Added endpoints for the ACL app API. +- Implemented document workflows transition ACLs. GitLab issue #321. +- Add document comments API endpoints. GitHub issue #249. +- Add support for overriding the Celery class. The setting is named + MAYAN_CELERY_CLASS and expects a dotted python path to the class to use. +- Changed the document upload view in source app to not use the HTTP referer + URL blindly, but instead recompose the URL using known view name. Needed + when integrating Mayan EDMS into other app via using iframes. +- Addes size field to the document version serializer. +- Removed the serializer from the deleted document restore API endpoint + it doesn't need a serializer being just an action POST endpoint. +- Added support for adding or editing document types to smart links via the + API. + +Removals +-------- +* None + +Upgrading from a previous version +--------------------------------- + +Using PIP +~~~~~~~~~ + +Type in the console:: + + $ pip install -U mayan-edms + +the requirements will also be updated automatically. + +Using Git +~~~~~~~~~ + +If you installed Mayan EDMS by cloning the Git repository issue the commands:: + + $ git reset --hard HEAD + $ git pull + +otherwise download the compressed archived and uncompress it overriding the +existing installation. + +Next upgrade/add the new requirements:: + + $ pip install --upgrade -r requirements.txt + +Common steps +~~~~~~~~~~~~ + +Migrate existing database schema with:: + + $ mayan-edms.py performupgrade + +Add new static media:: + + $ mayan-edms.py collectstatic --noinput + +The upgrade procedure is now complete. + + +Backward incompatible changes +============================= + +* None + +Bugs fixed or issues closed +=========================== + +* `Github issue #249 `_ Add document comments API [$50 US] +* `GitLab issue #321 `_ Transition ACLS +* `GitLab issue #357 `_ It should be possible to retrieve all workflows for a given DocumentType from the API + +.. _PyPI: https://pypi.python.org/pypi/mayan-edms/ diff --git a/docs/releases/index.rst b/docs/releases/index.rst index 896a3f1948..1a0a0b45bd 100644 --- a/docs/releases/index.rst +++ b/docs/releases/index.rst @@ -22,6 +22,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 2.1.11 2.1.10 2.1.9 2.1.8 diff --git a/mayan/__init__.py b/mayan/__init__.py index bf64ac09c6..37ead34a74 100644 --- a/mayan/__init__.py +++ b/mayan/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals __title__ = 'Mayan EDMS' -__version__ = '2.1.10' -__build__ = 0x020110 +__version__ = '2.1.11' +__build__ = 0x020111 __author__ = 'Roberto Rosario' __author_email__ = 'roberto.rosario@mayan-edms.com' __description__ = 'Free Open Source Electronic Document Management System'