Merge remote-tracking branch 'origin/master' into feature/master_merge

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
This commit is contained in:
Roberto Rosario
2017-03-14 15:57:38 -04:00
44 changed files with 1810 additions and 162 deletions

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
===============================
================================
Mayan EDMS v2.1.10 release notes
===============================
================================
Released: February 13, 2017

94
docs/releases/2.1.11.rst Normal file
View File

@@ -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 <https://github.com/mayan-edms/mayan-edms/issues/249>`_ Add document comments API [$50 US]
* `GitLab issue #321 <https://gitlab.com/mayan-edms/mayan-edms/issues/321>`_ Transition ACLS
* `GitLab issue #357 <https://gitlab.com/mayan-edms/mayan-edms/issues/357>`_ It should be possible to retrieve all workflows for a given DocumentType from the API
.. _PyPI: https://pypi.python.org/pypi/mayan-edms/

View File

@@ -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

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

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

View File

@@ -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
)

View File

@@ -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

View File

@@ -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):

View File

@@ -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
}

View File

@@ -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'
)

View File

@@ -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']
)

View File

@@ -0,0 +1,3 @@
from __future__ import unicode_literals
TEST_COMMENT_TEXT = 'test comment text'

View File

@@ -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
)

View File

@@ -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<document_pk>[0-9]+)/comments/$',
APICommentListView.as_view(), name='comment-list'
),
url(
r'^document/(?P<document_pk>[0-9]+)/comments/(?P<comment_pk>[0-9]+)/$',
APICommentView.as_view(), name='comment-detail'
),
]

View File

@@ -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,15 +551,21 @@ 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'])
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_required, user=self.request.user,
permissions=permission_workflow_view, user=self.request.user,
obj=document
)

View File

@@ -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,)
)

View File

@@ -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()
)

View File

@@ -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'
)

View File

@@ -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.'))

View File

@@ -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

View File

@@ -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'

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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',

View File

@@ -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)

18
mayan/conf.py Normal file
View File

@@ -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'
)

5
mayan/runtime.py Normal file
View File

@@ -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)