diff --git a/HISTORY.rst b/HISTORY.rst index 7a8c9ae169..2131e4a58a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,4 +1,4 @@ -2.2 (2016-XX-XX) +2.2 (2017-04-XX) ================ - Remove the installation app (GitLab #301). - Add support for document page search @@ -17,6 +17,24 @@ the user links - Add support for attaching multiple tags (GitLab #307). - Integrate the Cabinets app. +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 f3010bf6f3..091513a7f8 100644 --- a/docs/releases/index.rst +++ b/docs/releases/index.rst @@ -23,6 +23,7 @@ versions of the documentation contain the release notes for any later releases. :maxdepth: 1 2.2 + 2.1.11 2.1.10 2.1.9 2.1.8 diff --git a/mayan/__init__.py b/mayan/__init__.py index cb1a32b04f..933135bf15 100644 --- a/mayan/__init__.py +++ b/mayan/__init__.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals __title__ = 'Mayan EDMS' -__version__ = '2.2b1' +__version__ = '2.2b2' __build__ = 0x020200 __author__ = 'Roberto Rosario' __author_email__ = 'roberto.rosario@mayan-edms.com' diff --git a/mayan/apps/acls/api_views.py b/mayan/apps/acls/api_views.py new file mode 100644 index 0000000000..a93f3e09c8 --- /dev/null +++ b/mayan/apps/acls/api_views.py @@ -0,0 +1,250 @@ +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, + WritableAccessControlListPermissionSerializer, + WritableAccessControlListSerializer +) + + +class APIObjectACLListView(generics.ListCreateAPIView): + 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'] + ) + + 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_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() + + def get_serializer_context(self): + """ + Extra context provided to the serializer class. + """ + + 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.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. + """ + + 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.ListCreateAPIView): + 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_class(self): + if self.request.method == 'GET': + return AccessControlListPermissionSerializer + else: + return WritableAccessControlListPermissionSerializer + + def get_serializer_context(self): + return { + 'acl': self.get_acl(), + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } + + def post(self, *args, **kwargs): + """ + Add a new permission to the selected access control list. + """ + + 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. + """ + + 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/classes.py b/mayan/apps/acls/classes.py index 9ddaa03428..30ec654778 100644 --- a/mayan/apps/acls/classes.py +++ b/mayan/apps/acls/classes.py @@ -14,10 +14,18 @@ class ModelPermission(object): @classmethod def register(cls, model, permissions): + from django.contrib.contenttypes.fields import GenericRelation + cls._registry.setdefault(model, []) 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( @@ -36,7 +44,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/managers.py b/mayan/apps/acls/managers.py index eb5bbb207a..bfe0c5dd0c 100644 --- a/mayan/apps/acls/managers.py +++ b/mayan/apps/acls/managers.py @@ -49,6 +49,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: @@ -89,15 +93,30 @@ 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 try: @@ -145,6 +164,10 @@ 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) else: 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 new file mode 100644 index 0000000000..9ddea1ad27 --- /dev/null +++ b/mayan/apps/acls/serializers.py @@ -0,0 +1,204 @@ +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, StoredPermission +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.' + ) + ) + acl_url = serializers.SerializerMethodField() + + 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'] + ) + + 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 ' + '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.' + ), write_only=True + ) + 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 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 validate(self, attrs): + attrs['content_type'] = ContentType.objects.get_for_model( + self.context['content_object'] + ) + attrs['object_id'] = self.context['content_object'].pk + + try: + 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 new file mode 100644 index 0000000000..244fbc54d7 --- /dev/null +++ b/mayan/apps/acls/tests/test_api.py @@ -0,0 +1,254 @@ +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 +from ..permissions import permission_acl_view + + +@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_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() + + 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_delete_view(self): + self._create_acl() + permission = self.acl.permissions.first() + + response = self.client.delete( + 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.status_code, 204) + self.assertEqual(self.acl.permissions.count(), 0) + + 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 + ) + + 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( + '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().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/urls.py b/mayan/apps/acls/urls.py index 1816a04169..325dccb67a 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 url +from .api_views import ( + APIObjectACLListView, APIObjectACLPermissionListView, + APIObjectACLPermissionView, APIObjectACLView +) from .views import ( ACLCreateView, ACLDeleteView, ACLListView, ACLPermissionsView ) @@ -21,3 +25,22 @@ urlpatterns = [ 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' + ), +] diff --git a/mayan/apps/acls/views.py b/mayan/apps/acls/views.py index cacbca3e17..4e6747b432 100644 --- a/mayan/apps/acls/views.py +++ b/mayan/apps/acls/views.py @@ -140,7 +140,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 ) diff --git a/mayan/apps/common/mixins.py b/mayan/apps/common/mixins.py index 22edd785a2..0a04321e20 100644 --- a/mayan/apps/common/mixins.py +++ b/mayan/apps/common/mixins.py @@ -34,22 +34,6 @@ class DeleteExtraDataMixin(object): return HttpResponseRedirect(success_url) -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 ExtraContextMixin(object): """ Mixin that allows views to pass extra context to the template @@ -66,6 +50,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): # TODO: Deprecated, replace views using this with # MultipleObjectFormActionView or MultipleObjectConfirmActionView diff --git a/mayan/apps/common/tests/mixins.py b/mayan/apps/common/tests/mixins.py index f772800270..32f7b1cd6b 100644 --- a/mayan/apps/common/tests/mixins.py +++ b/mayan/apps/common/tests/mixins.py @@ -64,6 +64,7 @@ class OpenFileCheckMixin(object): class TempfileCheckMixin(object): # Ignore the jvmstat instrumentation and GitLab's CI .config files + # Ignore LibreOffice fontconfig cache dir ignore_globs = ('hsperfdata_*', '.config', '.cache') def _get_temporary_entries(self): 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 ddb4659ef2..b46f295399 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 url +from .api_views import APICommentListView, APICommentView from .views import ( DocumentCommentCreateView, DocumentCommentDeleteView, DocumentCommentListView @@ -21,3 +22,14 @@ urlpatterns = [ 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' + ), +] diff --git a/mayan/apps/document_states/api_views.py b/mayan/apps/document_states/api_views.py index 9fa335d69c..62de61d99e 100644 --- a/mayan/apps/document_states/api_views.py +++ b/mayan/apps/document_states/api_views.py @@ -13,8 +13,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, @@ -552,17 +551,23 @@ 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']) - AccessControlList.objects.check_access( - permissions=permission_required, user=self.request.user, - obj=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 + """ + + AccessControlList.objects.check_access( + permissions=permission_workflow_view, user=self.request.user, + obj=document + ) return document diff --git a/mayan/apps/document_states/apps.py b/mayan/apps/document_states/apps.py index b4c1a01c21..01b43c550e 100644 --- a/mayan/apps/document_states/apps.py +++ b/mayan/apps/document_states/apps.py @@ -6,6 +6,8 @@ from django.utils.translation import ugettext_lazy as _ from kombu import Exchange, Queue +from acls import ModelPermission +from acls.links import link_acl_list from common import ( MayanAppConfig, menu_facet, menu_main, menu_object, menu_secondary, menu_setup, menu_sidebar, menu_tools @@ -29,6 +31,7 @@ from .links import ( link_workflow_list, link_workflow_state_document_list, link_workflow_state_list ) +from .permissions import permission_workflow_transition class DocumentStatesApp(MayanAppConfig): @@ -54,6 +57,15 @@ class DocumentStatesApp(MayanAppConfig): WorkflowStateRuntimeProxy = self.get_model('WorkflowStateRuntimeProxy') 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') @@ -144,7 +156,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( @@ -155,7 +167,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..df288b255b 100644 --- a/mayan/apps/document_states/forms.py +++ b/mayan/apps/document_states/forms.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from django import forms from django.utils.translation import ugettext_lazy as _ @@ -32,11 +32,16 @@ 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') + self.fields[ + 'transition' + ].queryset = workflow_instance.get_transition_choices(_user=user) - transition = forms.ChoiceField(label=_('Transition')) + 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/links.py b/mayan/apps/document_states/links.py index fcef813012..f2d41f7e58 100644 --- a/mayan/apps/document_states/links.py +++ b/mayan/apps/document_states/links.py @@ -6,8 +6,8 @@ from navigation import Link from .permissions import ( permission_workflow_create, permission_workflow_delete, - permission_workflow_edit, permission_workflow_transition, - permission_workflow_tools, permission_workflow_view, + permission_workflow_edit, permission_workflow_tools, + permission_workflow_view, ) link_document_workflow_instance_list = Link( @@ -82,7 +82,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' ) diff --git a/mayan/apps/document_states/models.py b/mayan/apps/document_states/models.py index 41fcd55907..1f6b78965f 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__) @@ -169,11 +172,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 @@ -212,7 +245,7 @@ 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.')) 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/literals.py b/mayan/apps/document_states/tests/literals.py index fac41db489..004d17de8d 100644 --- a/mayan/apps/document_states/tests/literals.py +++ b/mayan/apps/document_states/tests/literals.py @@ -8,5 +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_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' diff --git a/mayan/apps/document_states/tests/test_api.py b/mayan/apps/document_states/tests/test_api.py index d684f3e36e..1defd70f7e 100644 --- a/mayan/apps/document_states/tests/test_api.py +++ b/mayan/apps/document_states/tests/test_api.py @@ -1,19 +1,28 @@ -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 permissions import Permission +from permissions.models import Role +from permissions.tests.literals import TEST_ROLE_LABEL from rest_api.tests import BaseAPITestCase -from user_management.tests.literals import ( - TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, TEST_ADMIN_USERNAME +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, @@ -634,3 +643,154 @@ class DocumentWorkflowsAPITestCase(BaseAPITestCase): 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 + ) diff --git a/mayan/apps/document_states/tests/test_views.py b/mayan/apps/document_states/tests/test_views.py index dc3df31c93..a2a02c24e3 100644 --- a/mayan/apps/document_states/tests/test_views.py +++ b/mayan/apps/document_states/tests/test_views.py @@ -1,15 +1,20 @@ from __future__ import unicode_literals +from acls.models import AccessControlList from common.tests.test_views import GenericViewTestCase +from documents.tests.literals import TEST_SMALL_DOCUMENT_PATH from documents.tests.test_views import GenericDocumentViewTestCase from ..models import Workflow, WorkflowState, WorkflowTransition -from ..permissions import permission_workflow_tools +from ..permissions import ( + permission_workflow_tools, 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 ) @@ -19,6 +24,26 @@ class DocumentStateViewTestCase(GenericViewTestCase): self.login_admin_user() + 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.post( 'document_states:setup_workflow_create', @@ -33,13 +58,10 @@ class DocumentStateViewTestCase(GenericViewTestCase): 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.post( - 'document_states:setup_workflow_delete', args=(workflow.pk,), + 'document_states:setup_workflow_delete', args=(self.workflow.pk,), follow=True ) @@ -48,11 +70,11 @@ class DocumentStateViewTestCase(GenericViewTestCase): 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.post( 'document_states:setup_workflow_state_create', - args=(workflow.pk,), + args=(self.workflow.pk,), data={ 'label': TEST_WORKFLOW_STATE_LABEL, 'completion': TEST_WORKFLOW_STATE_COMPLETION, @@ -71,39 +93,29 @@ class DocumentStateViewTestCase(GenericViewTestCase): ) 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.post( 'document_states:setup_workflow_state_delete', - args=(workflow_state.pk,), follow=True + 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.post( 'document_states:setup_workflow_transition_create', - args=(workflow.pk,), data={ + 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 ) @@ -116,34 +128,21 @@ class DocumentStateViewTestCase(GenericViewTestCase): ) 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.post( 'document_states:setup_workflow_transition_delete', - args=(workflow_transition.pk,), follow=True + args=(self.workflow_transition.pk,), follow=True ) self.assertEquals(response.status_code, 200) @@ -206,3 +205,152 @@ class DocumentStateToolViewTestCase(GenericDocumentViewTestCase): self.assertEqual( self.document.workflows.first().workflow, self.workflow ) + + +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/views.py b/mayan/apps/document_states/views.py index d343eabd90..ffc0e4a86d 100644 --- a/mayan/apps/document_states/views.py +++ b/mayan/apps/document_states/views.py @@ -7,11 +7,10 @@ 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, ConfirmView, SingleObjectCreateView, + AssignRemoveView, ConfirmView, FormView, SingleObjectCreateView, SingleObjectDeleteView, SingleObjectEditView, SingleObjectListView ) from documents.models import Document @@ -27,8 +26,8 @@ from .models import ( ) from .permissions import ( permission_workflow_create, permission_workflow_delete, - permission_workflow_edit, permission_workflow_transition, - permission_workflow_tools, permission_workflow_view, + permission_workflow_edit, permission_workflow_tools, + permission_workflow_view, ) from .tasks import task_launch_all_workflows @@ -93,23 +92,10 @@ class WorkflowInstanceTransitionView(FormView): form_class = WorkflowInstanceTransitionForm template_name = 'appearance/generic_form.html' - def dispatch(self, request, *args, **kwargs): - AccessControlList.objects.check_access( - permissions=permission_workflow_transition, user=request.user, - obj=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()) @@ -124,10 +110,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() diff --git a/mayan/apps/documents/api_views.py b/mayan/apps/documents/api_views.py index ea2d460cd1..99d2d90d1e 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 302d9b57ff..2642036a1b 100644 --- a/mayan/apps/documents/serializers.py +++ b/mayan/apps/documents/serializers.py @@ -99,6 +99,7 @@ class DocumentVersionSerializer(serializers.HyperlinkedModelSerializer): document_url = serializers.SerializerMethodField() download_url = serializers.SerializerMethodField() pages_url = serializers.SerializerMethodField() + size = serializers.SerializerMethodField() url = serializers.SerializerMethodField() class Meta: @@ -111,7 +112,10 @@ class DocumentVersionSerializer(serializers.HyperlinkedModelSerializer): 'file', 'mimetype', 'pages_url', 'timestamp', 'url' ) model = DocumentVersion - read_only_fields = ('document', 'file') + read_only_fields = ('document', 'file', 'size') + + def get_size(self, instance): + return instance.size def get_document_url(self, instance): return reverse( @@ -210,9 +214,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'}, @@ -229,6 +230,9 @@ class DeletedDocumentSerializer(serializers.HyperlinkedModelSerializer): 'language' ) + def get_document_type_label(self, instance): + return instance.document_type.label + class DocumentSerializer(serializers.HyperlinkedModelSerializer): document_type = DocumentTypeSerializer() 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 2e791d5cb1..08d5cba9ae 100644 --- a/mayan/apps/linking/tests/test_api.py +++ b/mayan/apps/linking/tests/test_api.py @@ -73,6 +73,26 @@ class SmartLinkAPITestCase(BaseAPITestCase): 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() @@ -94,18 +114,23 @@ class SmartLinkAPITestCase(BaseAPITestCase): ) 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() diff --git a/mayan/apps/permissions/classes.py b/mayan/apps/permissions/classes.py index 2eb6031dec..32cf056d67 100644 --- a/mayan/apps/permissions/classes.py +++ b/mayan/apps/permissions/classes.py @@ -66,8 +66,9 @@ class Permission(object): if permissions.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 e7bec758fc..0099eb2d81 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 diff --git a/mayan/apps/permissions/serializers.py b/mayan/apps/permissions/serializers.py index 9cff8afff2..248452016c 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): @@ -33,7 +33,10 @@ class RoleSerializer(serializers.HyperlinkedModelSerializer): permissions = PermissionSerializer(many=True, read_only=True) class Meta: - fields = ('id', 'label', 'groups', 'permissions') + extra_kwargs = { + 'url': {'view_name': 'rest_api:role-detail'}, + } + fields = ('groups', 'id', 'label', 'permissions', 'url') model = Role diff --git a/mayan/apps/sources/views.py b/mayan/apps/sources/views.py index cd3f67fd8b..bf8ea5386d 100644 --- a/mayan/apps/sources/views.py +++ b/mayan/apps/sources/views.py @@ -257,7 +257,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']( @@ -298,7 +304,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', 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)