Implement document workflows transition ACLs. GitLab issue #321.

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
This commit is contained in:
Roberto Rosario
2017-02-20 02:34:47 -04:00
parent 9942da601e
commit 6e1cf57079
11 changed files with 336 additions and 89 deletions

View File

@@ -177,7 +177,7 @@ class ConfirmView(ObjectListPermissionFilterMixin, ObjectPermissionCheckMixin, V
return HttpResponseRedirect(self.get_success_url())
class FormView(ViewPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, DjangoFormView):
class FormView(FormExtraKwargsMixin, ViewPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, DjangoFormView):
template_name = 'appearance/generic_form.html'

View File

@@ -12,8 +12,8 @@ from permissions import Permission
__all__ = (
'DeleteExtraDataMixin', 'ExtraContextMixin',
'ObjectListPermissionFilterMixin', 'ObjectNameMixin',
'ObjectPermissionCheckMixin', 'RedirectionMixin',
'FormExtraKwargsMixin', 'ObjectListPermissionFilterMixin',
'ObjectNameMixin', 'ObjectPermissionCheckMixin', 'RedirectionMixin',
'ViewPermissionCheckMixin'
)
@@ -42,6 +42,22 @@ class ExtraContextMixin(object):
return context
class FormExtraKwargsMixin(object):
"""
Mixin that allows a view to pass extra keyword arguments to forms
"""
form_extra_kwargs = {}
def get_form_extra_kwargs(self):
return self.form_extra_kwargs
def get_form_kwargs(self):
result = super(FormExtraKwargsMixin, self).get_form_kwargs()
result.update(self.get_form_extra_kwargs())
return result
class MultipleInstanceActionMixin(object):
model = None
success_message = 'Operation performed on %(count)d object'

View File

@@ -76,6 +76,11 @@ class GenericViewTestCase(BaseTestCase):
data=data, follow=follow
)
def grant(self, permission):
self.role.permissions.add(
permission.stored_permission
)
def login(self, username, password):
logged_in = self.client.login(username=username, password=password)
@@ -84,6 +89,15 @@ class GenericViewTestCase(BaseTestCase):
self.assertTrue(logged_in)
self.assertTrue(user.is_authenticated())
def login_user(self):
self.login(username=TEST_USER_USERNAME, password=TEST_USER_PASSWORD)
def login_admin_user(self):
self.login(username=TEST_ADMIN_USERNAME, password=TEST_ADMIN_PASSWORD)
def logout(self):
self.client.logout()
def post(self, viewname, *args, **kwargs):
data = kwargs.pop('data', {})
follow = kwargs.pop('follow', False)

View File

@@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404
from rest_framework import generics
from acls.models import AccessControlList
from documents.models import Document
from documents.models import Document, DocumentType
from documents.permissions import permission_document_type_view
from permissions import Permission
from rest_api.filters import MayanObjectPermissionsFilter
@@ -27,6 +27,33 @@ from .serializers import (
)
class APIDocumentTypeWorkflowListView(generics.ListAPIView):
serializer_class = WorkflowSerializer
def get(self, *args, **kwargs):
"""
Returns a list of all the document type workflows.
"""
return super(APIDocumentTypeWorkflowListView, self).get(*args, **kwargs)
def get_document_type(self):
document_type = get_object_or_404(DocumentType, pk=self.kwargs['pk'])
try:
Permission.check_permissions(
self.request.user, (permission_workflow_view,)
)
except PermissionDenied:
AccessControlList.objects.check_access(
permission_workflow_view, self.request.user, document_type
)
return document_type
def get_queryset(self):
return self.get_document_type().workflows.all()
class APIWorkflowDocumentTypeList(generics.ListCreateAPIView):
filter_backends = (MayanObjectPermissionsFilter,)
mayan_object_permissions = {

View File

@@ -4,6 +4,8 @@ from django.apps import apps
from django.db.models.signals import post_save
from django.utils.translation import ugettext_lazy as _
from acls import ModelPermission
from acls.links import link_acl_list
from common import (
MayanAppConfig, menu_facet, menu_object, menu_secondary, menu_setup,
menu_sidebar
@@ -23,6 +25,7 @@ from .links import (
link_setup_workflow_transition_delete, link_setup_workflow_transition_edit,
link_workflow_instance_detail, link_workflow_instance_transition
)
from .permissions import permission_workflow_transition
class DocumentStatesApp(MayanAppConfig):
@@ -46,6 +49,15 @@ class DocumentStatesApp(MayanAppConfig):
WorkflowState = self.get_model('WorkflowState')
WorkflowTransition = self.get_model('WorkflowTransition')
ModelPermission.register(
model=Workflow, permissions=(permission_workflow_transition,)
)
ModelPermission.register(
model=WorkflowTransition,
permissions=(permission_workflow_transition,)
)
SourceColumn(
source=Workflow, label=_('Initial state'),
func=lambda context: context['object'].get_initial_state() or _('None')
@@ -118,7 +130,7 @@ class DocumentStatesApp(MayanAppConfig):
links=(
link_setup_workflow_states, link_setup_workflow_transitions,
link_setup_workflow_document_types, link_setup_workflow_edit,
link_setup_workflow_delete
link_acl_list, link_setup_workflow_delete
), sources=(Workflow,)
)
menu_object.bind_links(
@@ -129,7 +141,7 @@ class DocumentStatesApp(MayanAppConfig):
)
menu_object.bind_links(
links=(
link_setup_workflow_transition_edit,
link_setup_workflow_transition_edit, link_acl_list,
link_setup_workflow_transition_delete
), sources=(WorkflowTransition,)
)

View File

@@ -1,9 +1,14 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
from django import forms
from django.core.exceptions import PermissionDenied
from django.utils.translation import ugettext_lazy as _
from acls.models import AccessControlList
from permissions import Permission
from .models import Workflow, WorkflowState, WorkflowTransition
from .permissions import permission_workflow_transition
class WorkflowForm(forms.ModelForm):
@@ -32,11 +37,36 @@ class WorkflowTransitionForm(forms.ModelForm):
class WorkflowInstanceTransitionForm(forms.Form):
def __init__(self, *args, **kwargs):
workflow = kwargs.pop('workflow')
user = kwargs.pop('user')
workflow_instance = kwargs.pop('workflow_instance')
super(WorkflowInstanceTransitionForm, self).__init__(*args, **kwargs)
self.fields['transition'].choices = workflow.get_transition_choices().values_list('pk', 'label')
queryset = workflow_instance.get_transition_choices().all()
transition = forms.ChoiceField(label=_('Transition'))
try:
Permission.check_permissions(
requester=user, permissions=(permission_workflow_transition,)
)
except PermissionDenied:
try:
# Check for ACL access to the workflow, if true, allow all
# transition options.
AccessControlList.objects.check_access(
permissions=permission_workflow_transition, user=user,
obj=workflow_instance.workflow
)
except PermissionDenied:
# If not ACL access to the workflow, filter transition options
# by each transition ACL access
queryset = AccessControlList.objects.filter_by_access(
permission=permission_workflow_transition, user=user,
queryset=queryset
)
self.fields['transition'].queryset = queryset
transition = forms.ModelChoiceField(
label=_('Transition'), queryset=WorkflowTransition.objects.none()
)
comment = forms.CharField(
label=_('Comment'), required=False, widget=forms.widgets.Textarea()
)

View File

@@ -9,4 +9,5 @@ TEST_WORKFLOW_STATE_LABEL = 'test state label'
TEST_WORKFLOW_STATE_LABEL_EDITED = 'test state label edited'
TEST_WORKFLOW_STATE_COMPLETION = 66
TEST_WORKFLOW_TRANSITION_LABEL = 'test transtition label'
TEST_WORKFLOW_TRANSITION_LABEL_2 = 'test transtition label 2'
TEST_WORKFLOW_TRANSITION_LABEL_EDITED = 'test transtition label edited'

View File

@@ -186,6 +186,19 @@ class WorkflowAPITestCase(APITestCase):
workflow.refresh_from_db()
self.assertEqual(workflow.label, TEST_WORKFLOW_LABEL_EDITED)
def test_document_type_workflow_list(self):
workflow = self._create_workflow()
workflow.document_types.add(self.document_type)
response = self.client.get(
reverse(
'rest_api:documenttype-workflow-list',
args=(self.document_type.pk,)
),
)
self.assertEqual(response.data['results'][0]['label'], workflow.label)
@override_settings(OCR_AUTO_OCR=False)
class WorkflowStatesAPITestCase(APITestCase):

View File

@@ -5,20 +5,24 @@ from django.core.urlresolvers import reverse
from django.test.client import Client
from django.test import TestCase
from acls.models import AccessControlList
from documents.models import DocumentType
from documents.tests.literals import (
TEST_DOCUMENT_TYPE, TEST_SMALL_DOCUMENT_PATH
)
from documents.tests.test_views import GenericDocumentViewTestCase
from user_management.tests import (
TEST_ADMIN_PASSWORD, TEST_ADMIN_USERNAME, TEST_ADMIN_EMAIL
)
from ..models import Workflow, WorkflowState, WorkflowTransition
from ..permissions import permission_workflow_transition
from .literals import (
TEST_WORKFLOW_LABEL, TEST_WORKFLOW_INITIAL_STATE_LABEL,
TEST_WORKFLOW_INITIAL_STATE_COMPLETION, TEST_WORKFLOW_STATE_LABEL,
TEST_WORKFLOW_STATE_COMPLETION, TEST_WORKFLOW_TRANSITION_LABEL
TEST_WORKFLOW_STATE_COMPLETION, TEST_WORKFLOW_TRANSITION_LABEL,
TEST_WORKFLOW_TRANSITION_LABEL_2
)
@@ -48,6 +52,26 @@ class DocumentStateViewTestCase(TestCase):
def tearDown(self):
self.document_type.delete()
def _create_workflow(self):
self.workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL)
def _create_workflow_states(self):
self.workflow_initial_state = WorkflowState.objects.create(
workflow=self.workflow, label=TEST_WORKFLOW_INITIAL_STATE_LABEL,
completion=TEST_WORKFLOW_INITIAL_STATE_COMPLETION, initial=True
)
self.workflow_state = WorkflowState.objects.create(
workflow=self.workflow, label=TEST_WORKFLOW_STATE_LABEL,
completion=TEST_WORKFLOW_STATE_COMPLETION
)
def _create_workflow_transition(self):
self.workflow_transition = WorkflowTransition.objects.create(
workflow=self.workflow, label=TEST_WORKFLOW_TRANSITION_LABEL,
origin_state=self.workflow_initial_state,
destination_state=self.workflow_state
)
def test_creating_workflow(self):
response = self.client.post(
reverse(
@@ -63,14 +87,12 @@ class DocumentStateViewTestCase(TestCase):
self.assertEquals(Workflow.objects.all()[0].label, TEST_WORKFLOW_LABEL)
def test_delete_workflow(self):
workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL)
self.assertEquals(Workflow.objects.count(), 1)
self.assertEquals(Workflow.objects.all()[0].label, TEST_WORKFLOW_LABEL)
self._create_workflow()
response = self.client.post(
reverse(
'document_states:setup_workflow_delete', args=(workflow.pk,)
'document_states:setup_workflow_delete',
args=(self.workflow.pk,)
), follow=True
)
@@ -79,12 +101,12 @@ class DocumentStateViewTestCase(TestCase):
self.assertEquals(Workflow.objects.count(), 0)
def test_create_workflow_state(self):
workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL)
self._create_workflow()
response = self.client.post(
reverse(
'document_states:setup_workflow_state_create',
args=(workflow.pk,)
args=(self.workflow.pk,)
), data={
'label': TEST_WORKFLOW_STATE_LABEL,
'completion': TEST_WORKFLOW_STATE_COMPLETION,
@@ -103,43 +125,33 @@ class DocumentStateViewTestCase(TestCase):
)
def test_delete_workflow_state(self):
workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL)
workflow_state = WorkflowState.objects.create(
workflow=workflow, label=TEST_WORKFLOW_STATE_LABEL,
completion=TEST_WORKFLOW_STATE_COMPLETION
)
self._create_workflow()
self._create_workflow_states()
response = self.client.post(
reverse(
'document_states:setup_workflow_state_delete',
args=(workflow_state.pk,)
args=(self.workflow_state.pk,)
), follow=True
)
self.assertEquals(response.status_code, 200)
self.assertEquals(WorkflowState.objects.count(), 0)
self.assertEquals(WorkflowState.objects.count(), 1)
self.assertEquals(Workflow.objects.count(), 1)
def test_create_workflow_transition(self):
workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL)
workflow_initial_state = WorkflowState.objects.create(
workflow=workflow, label=TEST_WORKFLOW_INITIAL_STATE_LABEL,
completion=TEST_WORKFLOW_INITIAL_STATE_COMPLETION, initial=True
)
workflow_state = WorkflowState.objects.create(
workflow=workflow, label=TEST_WORKFLOW_STATE_LABEL,
completion=TEST_WORKFLOW_STATE_COMPLETION
)
self._create_workflow()
self._create_workflow_states()
response = self.client.post(
reverse(
'document_states:setup_workflow_transition_create',
args=(workflow.pk,)
args=(self.workflow.pk,)
), data={
'label': TEST_WORKFLOW_TRANSITION_LABEL,
'origin_state': workflow_initial_state.pk,
'destination_state': workflow_state.pk,
'origin_state': self.workflow_initial_state.pk,
'destination_state': self.workflow_state.pk,
}, follow=True
)
@@ -152,35 +164,22 @@ class DocumentStateViewTestCase(TestCase):
)
self.assertEquals(
WorkflowTransition.objects.all()[0].origin_state,
workflow_initial_state
self.workflow_initial_state
)
self.assertEquals(
WorkflowTransition.objects.all()[0].destination_state,
workflow_state
self.workflow_state
)
def test_delete_workflow_transition(self):
workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL)
workflow_initial_state = WorkflowState.objects.create(
workflow=workflow, label=TEST_WORKFLOW_INITIAL_STATE_LABEL,
completion=TEST_WORKFLOW_INITIAL_STATE_COMPLETION, initial=True
)
workflow_state = WorkflowState.objects.create(
workflow=workflow, label=TEST_WORKFLOW_STATE_LABEL,
completion=TEST_WORKFLOW_STATE_COMPLETION
)
workflow_transition = WorkflowTransition.objects.create(
workflow=workflow, label=TEST_WORKFLOW_TRANSITION_LABEL,
origin_state=workflow_initial_state,
destination_state=workflow_state
)
self.assertEquals(WorkflowTransition.objects.count(), 1)
self._create_workflow()
self._create_workflow_states()
self._create_workflow_transition()
response = self.client.post(
reverse(
'document_states:setup_workflow_transition_delete',
args=(workflow_transition.pk,)
args=(self.workflow_transition.pk,)
), follow=True
)
@@ -189,3 +188,152 @@ class DocumentStateViewTestCase(TestCase):
self.assertEquals(WorkflowState.objects.count(), 2)
self.assertEquals(Workflow.objects.count(), 1)
self.assertEquals(WorkflowTransition.objects.count(), 0)
class DocumentStateTransitionViewTestCase(GenericDocumentViewTestCase):
def _create_workflow(self):
self.workflow = Workflow.objects.create(label=TEST_WORKFLOW_LABEL)
self.workflow.document_types.add(self.document_type)
def _create_workflow_states(self):
self.workflow_initial_state = WorkflowState.objects.create(
workflow=self.workflow, label=TEST_WORKFLOW_INITIAL_STATE_LABEL,
completion=TEST_WORKFLOW_INITIAL_STATE_COMPLETION, initial=True
)
self.workflow_state = WorkflowState.objects.create(
workflow=self.workflow, label=TEST_WORKFLOW_STATE_LABEL,
completion=TEST_WORKFLOW_STATE_COMPLETION
)
def _create_workflow_transitions(self):
self.workflow_transition = WorkflowTransition.objects.create(
workflow=self.workflow, label=TEST_WORKFLOW_TRANSITION_LABEL,
origin_state=self.workflow_initial_state,
destination_state=self.workflow_state
)
self.workflow_transition_2 = WorkflowTransition.objects.create(
workflow=self.workflow, label=TEST_WORKFLOW_TRANSITION_LABEL_2,
origin_state=self.workflow_initial_state,
destination_state=self.workflow_state
)
def _create_document(self):
with open(TEST_SMALL_DOCUMENT_PATH) as file_object:
self.document_2 = self.document_type.new_document(
file_object=file_object
)
def _request_workflow_transition(self, workflow_instance):
return self.post(
'document_states:workflow_instance_transition',
args=(workflow_instance.pk,), data={
'transition': self.workflow_transition.pk,
}
)
def test_transition_workflow_no_permission(self):
self.login_user()
self._create_workflow()
self._create_workflow_states()
self._create_workflow_transitions()
self._create_document()
workflow_instance = self.document_2.workflows.first()
response = self._request_workflow_transition(
workflow_instance=workflow_instance
)
self.assertEqual(response.status_code, 200)
# Workflow should remain in the same initial state
self.assertEqual(
workflow_instance.get_current_state(), self.workflow_initial_state
)
def test_transition_workflow_with_permission(self):
"""
Test transitioning a workflow by granting the transition workflow
permission to the role.
"""
self.login_user()
self._create_workflow()
self._create_workflow_states()
self._create_workflow_transitions()
self._create_document()
workflow_instance = self.document_2.workflows.first()
self.grant(permission_workflow_transition)
response = self._request_workflow_transition(
workflow_instance=workflow_instance
)
self.assertEqual(response.status_code, 302)
# Workflow should remain in the same initial state
self.assertEqual(
workflow_instance.get_current_state(), self.workflow_state
)
def test_transition_workflow_with_workflow_acl(self):
"""
Test transitioning a workflow by granting the transition workflow
permission to the workflow itself via ACL.
"""
self.login_user()
self._create_workflow()
self._create_workflow_states()
self._create_workflow_transitions()
self._create_document()
workflow_instance = self.document_2.workflows.first()
acl = AccessControlList.objects.create(
content_object=self.workflow, role=self.role
)
acl.permissions.add(permission_workflow_transition.stored_permission)
response = self._request_workflow_transition(
workflow_instance=workflow_instance
)
self.assertEqual(response.status_code, 302)
# Workflow should remain in the same initial state
self.assertEqual(
workflow_instance.get_current_state(), self.workflow_state
)
def test_transition_workflow_with_transition_acl(self):
"""
Test transitioning a workflow by granting the transition workflow
permission to the transition via ACL.
"""
self.login_user()
self._create_workflow()
self._create_workflow_states()
self._create_workflow_transitions()
self._create_document()
workflow_instance = self.document_2.workflows.first()
acl = AccessControlList.objects.create(
content_object=self.workflow_transition, role=self.role
)
acl.permissions.add(permission_workflow_transition.stored_permission)
response = self._request_workflow_transition(
workflow_instance=workflow_instance
)
self.assertEqual(response.status_code, 302)
# Workflow should remain in the same initial state
self.assertEqual(
workflow_instance.get_current_state(), self.workflow_state
)

View File

@@ -3,10 +3,10 @@ from __future__ import unicode_literals
from django.conf.urls import patterns, url
from .api_views import (
APIWorkflowDocumentTypeList, APIWorkflowDocumentTypeView,
APIWorkflowInstanceListView, APIWorkflowInstanceView,
APIWorkflowInstanceLogEntryListView, APIWorkflowListView,
APIWorkflowStateListView, APIWorkflowStateView,
APIDocumentTypeWorkflowListView, APIWorkflowDocumentTypeList,
APIWorkflowDocumentTypeView, APIWorkflowInstanceListView,
APIWorkflowInstanceView, APIWorkflowInstanceLogEntryListView,
APIWorkflowListView, APIWorkflowStateListView, APIWorkflowStateView,
APIWorkflowTransitionListView, APIWorkflowTransitionView, APIWorkflowView
)
from .views import (
@@ -150,4 +150,9 @@ api_urls = [
APIWorkflowInstanceLogEntryListView.as_view(),
name='workflowinstancelogentry-list'
),
url(
r'^document_type/(?P<pk>[0-9]+)/workflows/$',
APIDocumentTypeWorkflowListView.as_view(),
name='documenttype-workflow-list'
),
]

View File

@@ -7,12 +7,11 @@ from django.db.utils import IntegrityError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext_lazy as _
from django.views.generic import FormView
from acls.models import AccessControlList
from common.views import (
AssignRemoveView, SingleObjectCreateView, SingleObjectDeleteView,
SingleObjectEditView, SingleObjectListView
AssignRemoveView, FormView, SingleObjectCreateView,
SingleObjectDeleteView, SingleObjectEditView, SingleObjectListView
)
from documents.models import Document
from documents.views import DocumentListView
@@ -25,8 +24,7 @@ from .forms import (
from .models import Workflow, WorkflowInstance, WorkflowState, WorkflowTransition
from .permissions import (
permission_workflow_create, permission_workflow_delete,
permission_workflow_edit, permission_workflow_transition,
permission_workflow_view,
permission_workflow_edit, permission_workflow_view,
)
@@ -130,28 +128,10 @@ class WorkflowInstanceTransitionView(FormView):
form_class = WorkflowInstanceTransitionForm
template_name = 'appearance/generic_form.html'
def dispatch(self, request, *args, **kwargs):
try:
Permission.check_permissions(
request.user, (permission_workflow_transition,)
)
except PermissionDenied:
AccessControlList.objects.check_access(
permission_workflow_transition, request.user,
self.get_workflow_instance().document
)
return super(
WorkflowInstanceTransitionView, self
).dispatch(request, *args, **kwargs)
def form_valid(self, form):
transition = self.get_workflow_instance().workflow.transitions.get(
pk=form.cleaned_data['transition']
)
self.get_workflow_instance().do_transition(
comment=form.cleaned_data['comment'], transition=transition,
user=self.request.user
comment=form.cleaned_data['comment'],
transition=form.cleaned_data['transition'], user=self.request.user
)
return HttpResponseRedirect(self.get_success_url())
@@ -166,10 +146,11 @@ class WorkflowInstanceTransitionView(FormView):
'workflow_instance': self.get_workflow_instance(),
}
def get_form_kwargs(self):
kwargs = super(WorkflowInstanceTransitionView, self).get_form_kwargs()
kwargs['workflow'] = self.get_workflow_instance()
return kwargs
def get_form_extra_kwargs(self):
return {
'user': self.request.user,
'workflow_instance': self.get_workflow_instance()
}
def get_success_url(self):
return self.get_workflow_instance().get_absolute_url()