From a556f2a1ba6761d06fd6bb0dbfe26ed35e8250fa Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Wed, 24 Apr 2019 22:21:11 -0400 Subject: [PATCH] Add AddRemoveView to replace the AssignRemove Signed-off-by: Roberto Rosario --- HISTORY.rst | 1 + docs/releases/3.2.rst | 1 + mayan/apps/common/generics.py | 433 ++++++++++++++++++++++++++-------- mayan/apps/common/icons.py | 12 + 4 files changed, 354 insertions(+), 93 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 73d9427ae6..0a9e9daab8 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -147,6 +147,7 @@ * Add support for adding form button aside from the default submit and cancel. * Update ChoiceForm to be full height. +* Add AddRemoveView to replace AssignRemoveView 3.1.11 (2019-04-XX) =================== diff --git a/docs/releases/3.2.rst b/docs/releases/3.2.rst index 1da32ba85a..c16898a110 100644 --- a/docs/releases/3.2.rst +++ b/docs/releases/3.2.rst @@ -179,6 +179,7 @@ Other changes * Add support for adding form button aside from the default submit and cancel. * Update ChoiceForm to be full height. +* Add AddRemoveView to replace AssignRemoveView Removals -------- diff --git a/mayan/apps/common/generics.py b/mayan/apps/common/generics.py index e53d497cbd..afa6f3b0fb 100644 --- a/mayan/apps/common/generics.py +++ b/mayan/apps/common/generics.py @@ -5,6 +5,7 @@ from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponseRedirect +from django.urls import reverse from django.utils.encoding import force_text from django.utils.translation import ugettext_lazy as _ from django.views.generic import ( @@ -21,10 +22,12 @@ from django_downloadview import ( ) from pure_pagination.mixins import PaginationMixin +from mayan.apps.acls.models import AccessControlList + from .forms import ChoiceForm from .icons import ( - icon_assign_remove_add, icon_assign_remove_remove, icon_sort_down, - icon_sort_up + icon_add_all, icon_remove_all, icon_assign_remove_add, + icon_assign_remove_remove, icon_sort_down, icon_sort_up ) from .literals import ( TEXT_SORT_FIELD_PARAMETER, TEXT_SORT_FIELD_VARIABLE_NAME, @@ -32,10 +35,11 @@ from .literals import ( TEXT_SORT_ORDER_VARIABLE_NAME ) from .mixins import ( - DeleteExtraDataMixin, DynamicFormViewMixin, ExtraContextMixin, - FormExtraKwargsMixin, MultipleObjectMixin, ObjectActionMixin, - ObjectListPermissionFilterMixin, ObjectNameMixin, - ObjectPermissionCheckMixin, RedirectionMixin, ViewPermissionCheckMixin + DeleteExtraDataMixin, DynamicFormViewMixin, ExternalObjectMixin, + ExtraContextMixin, FormExtraKwargsMixin, MultipleObjectMixin, + ObjectActionMixin, ObjectListPermissionFilterMixin, ObjectNameMixin, + ObjectPermissionCheckMixin, RedirectionMixin, RestrictedQuerysetMixin, + ViewPermissionCheckMixin ) from .settings import setting_paginate_by @@ -49,6 +53,336 @@ __all__ = ( ) +# Required by other views, moved to the top +class MultiFormView(DjangoFormView): + prefix = None + prefixes = {} + template_name = 'appearance/generic_form.html' + + def _create_form(self, form_name, klass): + form_kwargs = self.get_form_kwargs(form_name) + form_create_method = 'create_%s_form' % form_name + if hasattr(self, form_create_method): + form = getattr(self, form_create_method)(**form_kwargs) + else: + form = klass(**form_kwargs) + return form + + def all_forms_valid(self, forms): + return None + + def dispatch(self, request, *args, **kwargs): + form_classes = self.get_form_classes() + self.forms = self.get_forms(form_classes) + return super(MultiFormView, self).dispatch(request, *args, **kwargs) + + def forms_valid(self, forms): + for form_name, form in forms.items(): + form_valid_method = '%s_form_valid' % form_name + + if hasattr(self, form_valid_method): + return getattr(self, form_valid_method)(form) + + self.all_forms_valid(forms) + + return HttpResponseRedirect(redirect_to=self.get_success_url()) + + def forms_invalid(self, forms): + return self.render_to_response(self.get_context_data(forms=forms)) + + def get_context_data(self, **kwargs): + """ + Insert the form into the context dict. + """ + if 'forms' not in kwargs: + kwargs['forms'] = self.get_forms( + form_classes=self.get_form_classes() + ) + return super(FormMixin, self).get_context_data(**kwargs) + + def get_form_classes(self): + return self.form_classes + + def get_form_kwargs(self, form_name): + kwargs = {} + kwargs.update({'initial': self.get_initial(form_name)}) + kwargs.update({'prefix': self.get_prefix(form_name)}) + + if self.request.method in ('POST', 'PUT'): + kwargs.update({ + 'data': self.request.POST, + 'files': self.request.FILES, + }) + + kwargs.update(self.get_form_extra_kwargs(form_name=form_name) or {}) + + return kwargs + + def get_form_extra_kwargs(self, form_name): + return None + + def get_forms(self, form_classes): + return dict( + [ + ( + key, self._create_form(key, klass) + ) for key, klass in form_classes.items() + ] + ) + + def get_initial(self, form_name): + initial_method = 'get_%s_initial' % form_name + if hasattr(self, initial_method): + return getattr(self, initial_method)() + else: + return self.initial.copy() + + def get_prefix(self, form_name): + return self.prefixes.get(form_name, self.prefix) + + def post(self, request, *args, **kwargs): + if all([form.is_valid() for form in self.forms.values()]): + return self.forms_valid(forms=self.forms) + else: + return self.forms_invalid(forms=self.forms) + + +class AddRemoveView(ExternalObjectMixin, ExtraContextMixin, ViewPermissionCheckMixin, RestrictedQuerysetMixin, MultiFormView): + form_classes = {'form_available': ChoiceForm, 'form_added': ChoiceForm} + list_added_help_text = _( + 'Select entries to be removed. Hold Control to select multiple ' + 'entries. Once the selection is complete, click the button below ' + 'or double click the list to activate the action.' + ) + list_available_help_text = _( + 'Select entries to be added. Hold Control to select multiple ' + 'entries. Once the selection is complete, click the button below ' + 'or double click the list to activate the action.' + ) + + # Form titles + list_added_title = None + list_available_title = None + + # Attributes to filter the object to which selections will be added or + # remove + main_object_model = None + main_object_permission = None + main_object_pk_url_kwarg = None + main_object_pk_url_kwargs = None + main_object_source_queryset = None + + # Attributes to filter the queryset of the selection + secondary_object_model = None + secondary_object_permission = None + secondary_object_source_queryset = None + + # Main object methods to use to add and remove selections + action_add_method = None + action_remove_method = None + + # If a method is not specified, use this related field to add and remove + # selections + related_field = None + + prefixes = {'form_available': 'available', 'form_added': 'added'} + + def __init__(self, *args, **kwargs): + self.external_object_class = self.main_object_model + self.external_object_permission = self.main_object_permission + self.external_object_pk_url_kwarg = self.main_object_pk_url_kwarg + self.external_object_pk_url_kwargs = self.main_object_pk_url_kwargs + self.external_object_queryset = self.main_object_source_queryset + + super(AddRemoveView, self).__init__(*args, **kwargs) + + def action_add(self, queryset): + if self.action_add_method: + kwargs = {'queryset': queryset} + kwargs.update(self.get_action_add_extra_kwargs()) + kwargs.update(self.get_actions_extra_kwargs()) + getattr(self.main_object, self.action_add_method)(**kwargs) + elif self.related_field: + getattr(self.main_object, self.related_field).add(*queryset) + else: + raise ImproperlyConfigured( + 'View %s must be called with either an action_add_method, a ' + 'related_field.' % self.__class__.__name__ + ) + + def action_remove(self, queryset): + if self.action_remove_method: + kwargs = {'queryset': queryset} + kwargs.update(self.get_action_remove_extra_kwargs()) + kwargs.update(self.get_actions_extra_kwargs()) + getattr(self.main_object, self.action_remove_method)(**kwargs) + elif self.related_field: + getattr(self.main_object, self.related_field).remove(*queryset) + else: + raise ImproperlyConfigured( + 'View %s must be called with either an action_remove_method, a ' + 'related_field.' % self.__class__.__name__ + ) + + def dispatch(self, request, *args, **kwargs): + self.main_object = self.get_external_object() + result = super(AddRemoveView, self).dispatch(request=request, *args, **kwargs) + return result + + def forms_valid(self, forms): + if 'available-add_all' in self.request.POST: + selection_add = self.get_list_available_queryset() + else: + selection_add = self.get_list_available_queryset().filter( + pk__in=forms['form_available'].cleaned_data['selection'] + ) + + self.action_add(queryset=selection_add) + + if 'added-remove_all' in self.request.POST: + selection_remove = self.get_list_added_queryset() + else: + selection_remove = self.get_list_added_queryset().filter( + pk__in=forms['form_added'].cleaned_data['selection'] + ) + + self.action_remove(queryset=selection_remove) + + return super(AddRemoveView, self).forms_valid(forms=forms) + + def generate_choices(self, queryset): + for obj in queryset: + yield (obj.pk, force_text(obj)) + + def get_action_add_extra_kwargs(self): + # Keyword arguments to apply to the add method + return {} + + def get_action_remove_extra_kwargs(self): + # Keyword arguments to apply to the remove method + return {} + + def get_actions_extra_kwargs(self): + # Keyword arguments to apply to both the add and remove methods + return {} + + def get_context_data(self, **kwargs): + # Use get_context_data to leave the get_extra_context for subclasses + context = super(AddRemoveView, self).get_context_data(**kwargs) + context.update( + { + 'subtemplates_list': [ + { + 'name': 'appearance/generic_form_subtemplate.html', + 'column_class': 'col-xs-12 col-sm-6 col-md-6 col-lg-6', + 'context': { + 'extra_buttons': [ + { + 'label': _('Add all'), + 'icon_class': icon_add_all, + 'name': 'add_all', + } + ], + 'form': self.forms['form_available'], + 'form_css_classes': 'form-hotkey-double-click', + 'hide_labels': True, + 'submit_icon_class': icon_assign_remove_add, + 'submit_label': _('Add'), + 'title': self.list_available_title or ' ', + } + }, + { + 'name': 'appearance/generic_form_subtemplate.html', + 'column_class': 'col-xs-12 col-sm-6 col-md-6 col-lg-6', + 'context': { + 'extra_buttons': [ + { + 'label': _('Remove all'), + 'icon_class': icon_remove_all, + 'name': 'remove_all', + } + ], + 'form': self.forms['form_added'], + 'form_css_classes': 'form-hotkey-double-click', + 'hide_labels': True, + 'submit_icon_class': icon_assign_remove_remove, + 'submit_label': _('Remove'), + 'title': self.list_added_title or ' ', + } + } + ] + } + ) + + return context + + def get_disabled_choices(self): + return () + + def get_form_extra_kwargs(self, form_name): + if form_name == 'form_available': + return { + 'choices': self.generate_choices( + queryset=self.get_list_available_queryset() + ), + 'help_text': self.get_list_available_help_text() + } + else: + return { + 'choices': self.generate_choices( + queryset=self.get_list_added_queryset() + ), + 'disabled_choices': self.get_disabled_choices(), + 'help_text': self.get_list_added_help_text() + } + + def get_list_added_help_text(self): + return self.list_added_help_text + + def get_list_added_queryset(self): + if not self.related_field: + raise ImproperlyConfigured( + 'View %s must be called with either a related_field or ' + 'override .get_list_added_queryset().' % self.__class__.__name__ + ) + + return self.get_secondary_object_list().filter( + pk__in=getattr(self.main_object, self.related_field).values('pk') + ) + + def get_list_available_help_text(self): + return self.list_available_help_text + + def get_list_available_queryset(self): + return self.get_secondary_object_list().exclude( + pk__in=self.get_list_added_queryset().values('pk') + ) + + def get_secondary_object_list(self): + queryset = self.get_secondary_object_source_queryset() + + if queryset is None: + queryset = self.secondary_object_model._meta.default_manager.all() + + if self.secondary_object_permission: + return AccessControlList.objects.filter_by_access( + permission=self.secondary_object_permission, queryset=queryset, + user=self.request.user + ) + else: + return queryset + + def get_secondary_object_source_queryset(self): + return self.secondary_object_source_queryset + + def get_success_url(self): + # Redirect to the same view + return reverse( + viewname=self.request.resolver_match.view_name, + kwargs=self.request.resolver_match.kwargs + ) + + class AssignRemoveView(ExtraContextMixin, ViewPermissionCheckMixin, ObjectPermissionCheckMixin, TemplateView): decode_content_type = False right_list_help_text = None @@ -204,93 +538,6 @@ class DynamicFormView(DynamicFormViewMixin, FormView): pass -class MultiFormView(DjangoFormView): - prefix = None - prefixes = {} - - def _create_form(self, form_name, klass): - form_kwargs = self.get_form_kwargs(form_name) - form_create_method = 'create_%s_form' % form_name - if hasattr(self, form_create_method): - form = getattr(self, form_create_method)(**form_kwargs) - else: - form = klass(**form_kwargs) - return form - - def forms_valid(self, forms): - for form_name, form in forms.items(): - form_valid_method = '%s_form_valid' % form_name - - if hasattr(self, form_valid_method): - return getattr(self, form_valid_method)(form) - - self.all_forms_valid(forms) - - return HttpResponseRedirect(self.get_success_url()) - - def forms_invalid(self, forms): - return self.render_to_response(self.get_context_data(forms=forms)) - - def get(self, request, *args, **kwargs): - form_classes = self.get_form_classes() - forms = self.get_forms(form_classes) - return self.render_to_response(self.get_context_data(forms=forms)) - - def get_context_data(self, **kwargs): - """ - Insert the form into the context dict. - """ - if 'forms' not in kwargs: - kwargs['forms'] = self.get_forms( - form_classes=self.get_form_classes() - ) - return super(FormMixin, self).get_context_data(**kwargs) - - def get_form_classes(self): - return self.form_classes - - def get_form_kwargs(self, form_name): - kwargs = {} - kwargs.update({'initial': self.get_initial(form_name)}) - kwargs.update({'prefix': self.get_prefix(form_name)}) - - if self.request.method in ('POST', 'PUT'): - kwargs.update({ - 'data': self.request.POST, - 'files': self.request.FILES, - }) - - return kwargs - - def get_forms(self, form_classes): - return dict( - [ - ( - key, self._create_form(key, klass) - ) for key, klass in form_classes.items() - ] - ) - - def get_initial(self, form_name): - initial_method = 'get_%s_initial' % form_name - if hasattr(self, initial_method): - return getattr(self, initial_method)() - else: - return self.initial.copy() - - def get_prefix(self, form_name): - return self.prefixes.get(form_name, self.prefix) - - def post(self, request, *args, **kwargs): - form_classes = self.get_form_classes() - forms = self.get_forms(form_classes) - - if all([form.is_valid() for form in forms.values()]): - return self.forms_valid(forms) - else: - return self.forms_invalid(forms) - - class MultipleObjectFormActionView(ObjectActionMixin, MultipleObjectMixin, FormExtraKwargsMixin, ViewPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, DjangoFormView): """ This view will present a form and upon receiving a POST request will diff --git a/mayan/apps/common/icons.py b/mayan/apps/common/icons.py index 5c2ed58c50..730235fd15 100644 --- a/mayan/apps/common/icons.py +++ b/mayan/apps/common/icons.py @@ -3,6 +3,12 @@ from __future__ import absolute_import, unicode_literals from mayan.apps.appearance.classes import Icon icon_about = Icon(driver_name='fontawesome', symbol='info') +icon_add_all = Icon( + driver_name='fontawesome-layers', data=[ + {'class': 'far fa-circle'}, + {'class': 'fas fa-plus', 'transform': 'shrink-6'} + ] +) icon_assign_remove_add = Icon(driver_name='fontawesome', symbol='plus') icon_assign_remove_remove = Icon(driver_name='fontawesome', symbol='minus') icon_check_version = Icon(driver_name='fontawesome', symbol='sync') @@ -37,6 +43,12 @@ icon_ok = Icon( icon_packages_licenses = Icon( driver_name='fontawesome', symbol='certificate' ) +icon_remove_all = Icon( + driver_name='fontawesome-layers', data=[ + {'class': 'far fa-circle'}, + {'class': 'fas fa-minus', 'transform': 'shrink-6'} + ] +) icon_setup = Icon( driver_name='fontawesome', symbol='cog' )