diff --git a/HISTORY.rst b/HISTORY.rst index 64cd15ae0e..8ea2bef805 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -5,6 +5,7 @@ links. - Use Select2 widget for the document type selection form. - Backport the vertical main menu update. +- Backport workflow preview refactor. GitLab issue #532. 3.2.5 (2019-07-05) ================== diff --git a/docs/releases/3.3.rst b/docs/releases/3.3.rst index b75446368b..9edd482f8b 100644 --- a/docs/releases/3.3.rst +++ b/docs/releases/3.3.rst @@ -17,6 +17,7 @@ Changes The vertical menu remain open even when clicking on items and upon a browser refresh will also restore its state to match the selected view. +- Backport workflow preview refactor. GitLab issue #532. Removals -------- @@ -105,6 +106,6 @@ Backward incompatible changes Bugs fixed or issues closed --------------------------- -- :gitlab-issue:`XX` +- :gitlab-issue:`532` Workflow preview isn't updated right after transitions are modified .. _PyPI: https://pypi.python.org/pypi/mayan-edms/ diff --git a/mayan/apps/document_states/api_views.py b/mayan/apps/document_states/api_views.py index 78e4e31893..5f998db115 100644 --- a/mayan/apps/document_states/api_views.py +++ b/mayan/apps/document_states/api_views.py @@ -1,6 +1,8 @@ from __future__ import absolute_import, unicode_literals +from django.http import HttpResponse from django.shortcuts import get_object_or_404 +from django.views.decorators.cache import cache_control, patch_cache_control from rest_framework import generics @@ -10,6 +12,7 @@ from mayan.apps.documents.permissions import permission_document_type_view from mayan.apps.rest_api.filters import MayanObjectPermissionsFilter from mayan.apps.rest_api.permissions import MayanPermission +from .literals import WORKFLOW_IMAGE_TASK_TIMEOUT from .models import Workflow from .permissions import ( permission_workflow_create, permission_workflow_delete, @@ -23,6 +26,10 @@ from .serializers import ( WritableWorkflowTransitionSerializer ) +from .settings import settings_workflow_image_cache_time +from .storages import storage_workflowimagecache +from .tasks import task_generate_workflow_image + class APIDocumentTypeWorkflowListView(generics.ListAPIView): """ @@ -172,6 +179,41 @@ class APIWorkflowDocumentTypeView(generics.RetrieveDestroyAPIView): self.get_workflow().document_types.remove(instance) +class APIWorkflowImageView(generics.RetrieveAPIView): + """ + get: Returns an image representation of the selected workflow. + """ + filter_backends = (MayanObjectPermissionsFilter,) + mayan_object_permissions = { + 'GET': (permission_workflow_view,), + } + queryset = Workflow.objects.all() + + def get_serializer(self, *args, **kwargs): + return None + + def get_serializer_class(self): + return None + + @cache_control(private=True) + def retrieve(self, request, *args, **kwargs): + task = task_generate_workflow_image.apply_async( + kwargs=dict( + document_state_id=self.get_object().pk, + ) + ) + + cache_filename = task.get(timeout=WORKFLOW_IMAGE_TASK_TIMEOUT) + with storage_workflowimagecache.open(cache_filename) as file_object: + response = HttpResponse(file_object.read(), content_type='image') + if '_hash' in request.GET: + patch_cache_control( + response, + max_age=settings_workflow_image_cache_time.value + ) + return response + + class APIWorkflowListView(generics.ListCreateAPIView): """ get: Returns a list of all the workflows. diff --git a/mayan/apps/document_states/fields.py b/mayan/apps/document_states/fields.py new file mode 100644 index 0000000000..75a3e7b958 --- /dev/null +++ b/mayan/apps/document_states/fields.py @@ -0,0 +1,9 @@ +from __future__ import absolute_import, unicode_literals + +from django import forms + +from .widgets import WorkflowImageWidget + + +class WorfklowImageField(forms.fields.Field): + widget = WorkflowImageWidget diff --git a/mayan/apps/document_states/forms.py b/mayan/apps/document_states/forms.py index 971b17bd2d..930dbf49e9 100644 --- a/mayan/apps/document_states/forms.py +++ b/mayan/apps/document_states/forms.py @@ -12,10 +12,10 @@ from django.utils.translation import ugettext_lazy as _ from mayan.apps.common.forms import DynamicModelForm from .classes import WorkflowAction +from .fields import WorfklowImageField from .models import ( Workflow, WorkflowState, WorkflowStateAction, WorkflowTransition ) -from .widgets import WorkflowImageWidget class WorkflowActionSelectionForm(forms.Form): @@ -188,9 +188,9 @@ class WorkflowInstanceTransitionForm(forms.Form): class WorkflowPreviewForm(forms.Form): - preview = forms.CharField(widget=WorkflowImageWidget()) + workflow = WorfklowImageField() def __init__(self, *args, **kwargs): instance = kwargs.pop('instance', None) super(WorkflowPreviewForm, self).__init__(*args, **kwargs) - self.fields['preview'].initial = instance + self.fields['workflow'].initial = instance diff --git a/mayan/apps/document_states/literals.py b/mayan/apps/document_states/literals.py index 79fc51f828..674bbeebef 100644 --- a/mayan/apps/document_states/literals.py +++ b/mayan/apps/document_states/literals.py @@ -9,3 +9,4 @@ WORKFLOW_ACTION_WHEN_CHOICES = ( (WORKFLOW_ACTION_ON_ENTRY, _('On entry')), (WORKFLOW_ACTION_ON_EXIT, _('On exit')), ) +WORKFLOW_IMAGE_TASK_TIMEOUT = 60 diff --git a/mayan/apps/document_states/models.py b/mayan/apps/document_states/models.py index a5da8ab106..edfa431691 100644 --- a/mayan/apps/document_states/models.py +++ b/mayan/apps/document_states/models.py @@ -1,12 +1,16 @@ from __future__ import absolute_import, unicode_literals +import hashlib import json import logging +from furl import furl from graphviz import Digraph from django.conf import settings +from django.core import serializers from django.core.exceptions import PermissionDenied, ValidationError +from django.core.files.base import ContentFile from django.db import IntegrityError, models, transaction from django.db.models import F, Max, Q from django.urls import reverse @@ -27,6 +31,7 @@ from .literals import ( ) from .managers import WorkflowManager from .permissions import permission_workflow_transition +from .storages import storage_workflowimagecache logger = logging.getLogger(__name__) @@ -63,9 +68,49 @@ class Workflow(models.Model): def __str__(self): return self.label + def generate_image(self): + cache_filename = '{}-{}'.format(self.id, self.get_hash()) + image = self.render() + + # Since open "wb+" doesn't create files, check if the file + # exists, if not then create it + if not storage_workflowimagecache.exists(cache_filename): + storage_workflowimagecache.save( + name=cache_filename, content=ContentFile(content='') + ) + + with storage_workflowimagecache.open(cache_filename, mode='wb+') as file_object: + file_object.write(image) + + return cache_filename + + def get_api_image_url(self, *args, **kwargs): + final_url = furl() + final_url.args = kwargs + final_url.path = reverse( + viewname='rest_api:workflow-image', + kwargs={'pk': self.pk} + ) + final_url.args['_hash'] = self.get_hash() + + return final_url.tostr() + def get_document_types_not_in_workflow(self): return DocumentType.objects.exclude(pk__in=self.document_types.all()) + def get_hash(self): + objects_lists = list( + Workflow.objects.filter(pk=self.pk) + ) + list( + WorkflowState.objects.filter(workflow__pk=self.pk) + ) + list( + WorkflowTransition.objects.filter(workflow__pk=self.pk) + ) + + return hashlib.sha256( + serializers.serialize('json', objects_lists) + ).hexdigest() + def get_initial_state(self): try: return self.states.get(initial=True) diff --git a/mayan/apps/document_states/queues.py b/mayan/apps/document_states/queues.py index 6b269d5b99..b68354e8b2 100644 --- a/mayan/apps/document_states/queues.py +++ b/mayan/apps/document_states/queues.py @@ -3,12 +3,21 @@ from __future__ import absolute_import, unicode_literals from django.utils.translation import ugettext_lazy as _ from mayan.apps.task_manager.classes import CeleryQueue -from mayan.apps.task_manager.workers import worker_slow +from mayan.apps.task_manager.workers import worker_fast, worker_slow queue_document_states = CeleryQueue( - name='document_states', label=_('Document states'), worker=worker_slow + label=_('Document states'), name='document_states', worker=worker_slow ) +queue_document_states_fast = CeleryQueue( + label=_('Document states fast'), name='document_states_fast', + worker=worker_fast +) + queue_document_states.add_task_type( - dotted_path='mayan.apps.document_states.tasks.task_launch_all_workflows', - label=_('Launch all workflows') + label=_('Launch all workflows'), + dotted_path='mayan.apps.document_states.tasks.task_launch_all_workflows' +) +queue_document_states_fast.add_task_type( + label=_('Generate workflow previews'), + dotted_path='mayan.apps.document_states.tasks.task_generate_workflow_image' ) diff --git a/mayan/apps/document_states/settings.py b/mayan/apps/document_states/settings.py new file mode 100644 index 0000000000..b90ae5e382 --- /dev/null +++ b/mayan/apps/document_states/settings.py @@ -0,0 +1,32 @@ +from __future__ import unicode_literals + +import os + +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ + +from mayan.apps.smart_settings import Namespace + +namespace = Namespace(label=_('Workflows'), name='document_states') + +settings_workflow_image_cache_time = namespace.add_setting( + global_name='WORKFLOWS_IMAGE_CACHE_TIME', default='31556926', + help_text=_( + 'Time in seconds that the browser should cache the supplied workflow ' + 'images. The default of 31559626 seconds corresponde to 1 year.' + ) +) +setting_workflowimagecache_storage = namespace.add_setting( + global_name='WORKFLOWS_IMAGE_CACHE_STORAGE_BACKEND', + default='django.core.files.storage.FileSystemStorage', help_text=_( + 'Path to the Storage subclass to use when storing the cached ' + 'workflow image files.' + ) +) +setting_workflowimagecache_storage_arguments = namespace.add_setting( + global_name='WORKFLOWS_IMAGE_CACHE_STORAGE_BACKEND_ARGUMENTS', + default={'location': os.path.join(settings.MEDIA_ROOT, 'workflows')}, + help_text=_( + 'Arguments to pass to the WORKFLOWS_IMAGE_CACHE_STORAGE_BACKEND.' + ) +) diff --git a/mayan/apps/document_states/storages.py b/mayan/apps/document_states/storages.py new file mode 100644 index 0000000000..8a689634c7 --- /dev/null +++ b/mayan/apps/document_states/storages.py @@ -0,0 +1,12 @@ +from __future__ import unicode_literals + +from django.utils.module_loading import import_string + +from .settings import ( + setting_workflowimagecache_storage, + setting_workflowimagecache_storage_arguments +) + +storage_workflowimagecache = import_string( + dotted_path=setting_workflowimagecache_storage.value +)(**setting_workflowimagecache_storage_arguments.value) diff --git a/mayan/apps/document_states/tasks.py b/mayan/apps/document_states/tasks.py index 2156a45ead..3ec8d90acb 100644 --- a/mayan/apps/document_states/tasks.py +++ b/mayan/apps/document_states/tasks.py @@ -9,6 +9,17 @@ from mayan.celery import app logger = logging.getLogger(__name__) +@app.task() +def task_generate_workflow_image(document_state_id): + Workflow = apps.get_model( + app_label='document_states', model_name='Workflow' + ) + + workflow = Workflow.objects.get(pk=document_state_id) + + return workflow.generate_image() + + @app.task(ignore_result=True) def task_launch_all_workflows(): Document = apps.get_model(app_label='documents', model_name='Document') diff --git a/mayan/apps/document_states/templates/document_states/forms/widgets/workflow_image.html b/mayan/apps/document_states/templates/document_states/forms/widgets/workflow_image.html new file mode 100644 index 0000000000..6aa520bc92 --- /dev/null +++ b/mayan/apps/document_states/templates/document_states/forms/widgets/workflow_image.html @@ -0,0 +1,2 @@ + + diff --git a/mayan/apps/document_states/urls.py b/mayan/apps/document_states/urls.py index e139e3ff8a..f37af45452 100644 --- a/mayan/apps/document_states/urls.py +++ b/mayan/apps/document_states/urls.py @@ -4,9 +4,10 @@ from django.conf.urls import url from .api_views import ( APIDocumentTypeWorkflowListView, APIWorkflowDocumentTypeList, - APIWorkflowDocumentTypeView, APIWorkflowInstanceListView, - APIWorkflowInstanceView, APIWorkflowInstanceLogEntryListView, - APIWorkflowListView, APIWorkflowStateListView, APIWorkflowStateView, + APIWorkflowDocumentTypeView, APIWorkflowImageView, + APIWorkflowInstanceListView, APIWorkflowInstanceView, + APIWorkflowInstanceLogEntryListView, APIWorkflowListView, + APIWorkflowStateListView, APIWorkflowStateView, APIWorkflowTransitionListView, APIWorkflowTransitionView, APIWorkflowView ) from .views import ( @@ -22,7 +23,7 @@ from .views import ( SetupWorkflowTransitionEditView, SetupWorkflowTransitionTriggerEventListView, ToolLaunchAllWorkflows, WorkflowDocumentListView, WorkflowInstanceDetailView, - WorkflowImageView, WorkflowInstanceTransitionView, WorkflowListView, + WorkflowInstanceTransitionView, WorkflowListView, WorkflowPreviewView, WorkflowStateDocumentListView, WorkflowStateListView, ) from .views.workflow_views import SetupDocumentTypeWorkflowsView @@ -167,11 +168,6 @@ urlpatterns = [ view=WorkflowStateListView.as_view(), name='workflow_state_list' ), - url( - regex=r'^(?P\d+)/image/$', - view=WorkflowImageView.as_view(), - name='workflow_image' - ), url( regex=r'^(?P\d+)/preview/$', view=WorkflowPreviewView.as_view(), @@ -204,6 +200,10 @@ api_urls = [ view=APIWorkflowDocumentTypeView.as_view(), name='workflow-document-type-detail' ), + url( + regex=r'^workflows/(?P\d+)/image/$', + name='workflow-image', view=APIWorkflowImageView.as_view() + ), url( regex=r'^workflows/(?P[0-9]+)/states/$', view=APIWorkflowStateListView.as_view(), name='workflowstate-list' diff --git a/mayan/apps/document_states/views/workflow_views.py b/mayan/apps/document_states/views/workflow_views.py index 423134e0b1..4abab87386 100644 --- a/mayan/apps/document_states/views/workflow_views.py +++ b/mayan/apps/document_states/views/workflow_views.py @@ -1,7 +1,6 @@ from __future__ import absolute_import, unicode_literals from django.contrib import messages -from django.core.files.base import ContentFile from django.db import transaction from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404 @@ -13,7 +12,7 @@ from mayan.apps.common.generics import ( AddRemoveView, ConfirmView, FormView, SingleObjectCreateView, SingleObjectDeleteView, SingleObjectDetailView, SingleObjectDynamicFormCreateView, SingleObjectDynamicFormEditView, - SingleObjectDownloadView, SingleObjectEditView, SingleObjectListView + SingleObjectEditView, SingleObjectListView ) from mayan.apps.common.mixins import ExternalObjectMixin from mayan.apps.documents.events import event_document_type_edited @@ -49,7 +48,6 @@ from ..permissions import ( from ..tasks import task_launch_all_workflows __all__ = ( - 'WorkflowImageView', 'WorkflowPreviewView', 'SetupWorkflowListView', 'SetupWorkflowCreateView', 'SetupWorkflowEditView', 'SetupWorkflowDeleteView', 'SetupWorkflowDocumentTypesView', 'SetupWorkflowStateActionCreateView', 'SetupWorkflowStateActionDeleteView', @@ -59,7 +57,8 @@ __all__ = ( 'SetupWorkflowStateListView', 'SetupWorkflowTransitionCreateView', 'SetupWorkflowTransitionDeleteView', 'SetupWorkflowTransitionEditView', 'SetupWorkflowTransitionListView', - 'SetupWorkflowTransitionTriggerEventListView', 'ToolLaunchAllWorkflows' + 'SetupWorkflowTransitionTriggerEventListView', 'ToolLaunchAllWorkflows', + 'WorkflowPreviewView' ) @@ -750,26 +749,15 @@ class ToolLaunchAllWorkflows(ConfirmView): ) -class WorkflowImageView(SingleObjectDownloadView): - attachment = False - model = Workflow - object_permission = permission_workflow_view - - def get_file(self): - workflow = self.get_object() - return ContentFile(workflow.render(), name=workflow.label) - - def get_mimetype(self): - return 'image' - - class WorkflowPreviewView(SingleObjectDetailView): form_class = WorkflowPreviewForm model = Workflow object_permission = permission_workflow_view + pk_url_kwarg = 'pk' def get_extra_context(self): return { 'hide_labels': True, + 'object': self.get_object(), 'title': _('Preview of: %s') % self.get_object() } diff --git a/mayan/apps/document_states/widgets.py b/mayan/apps/document_states/widgets.py index 75e563baa1..92e761b7b9 100644 --- a/mayan/apps/document_states/widgets.py +++ b/mayan/apps/document_states/widgets.py @@ -1,8 +1,7 @@ from __future__ import unicode_literals from django import forms -from django.urls import reverse -from django.utils.html import format_html_join, mark_safe +from django.utils.html import format_html_join def widget_transition_events(transition): @@ -15,23 +14,10 @@ def widget_transition_events(transition): ) -def widget_workflow_diagram(workflow): - return mark_safe( - ''.format( - reverse( - viewname='document_states:workflow_image', kwargs={ - 'pk': workflow.pk - } - ) - ) - ) - - class WorkflowImageWidget(forms.widgets.Widget): - def render(self, name, value, attrs=None): - if value: - output = [] - output.append(widget_workflow_diagram(value)) - return mark_safe(''.join(output)) - else: - return '' + template_name = 'document_states/forms/widgets/workflow_image.html' + + def format_value(self, value): + if value == '' or value is None: + return None + return value