From e71d2801fd9a0eb6dc3f3bcb7629e5a16a9cc96e Mon Sep 17 00:00:00 2001 From: Lukas Rychtecky Date: Wed, 13 Jan 2016 16:44:43 +0100 Subject: [PATCH] Enabled bulk changes for ListView #157 --- is_core/generic_views/table_views.py | 29 ++++- is_core/locale/cs/LC_MESSAGES/django.mo | Bin 4365 -> 5020 bytes is_core/locale/cs/LC_MESSAGES/django.po | 120 +++++++++++------- is_core/main.py | 16 ++- is_core/patterns.py | 8 +- is_core/rest/resource.py | 58 +++++++-- is_core/templates/generic_views/table.html | 9 +- is_core/templates/views/bulk-change-view.html | 8 ++ is_core/utils/immutable.py | 12 ++ is_core/views/__init__.py | 25 ++++ 10 files changed, 220 insertions(+), 65 deletions(-) create mode 100644 is_core/templates/views/bulk-change-view.html create mode 100644 is_core/utils/immutable.py diff --git a/is_core/generic_views/table_views.py b/is_core/generic_views/table_views.py index c642082b..a9f86e06 100644 --- a/is_core/generic_views/table_views.py +++ b/is_core/generic_views/table_views.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from django.core.urlresolvers import reverse from django.views.generic.base import TemplateView from django.db.models.fields import FieldDoesNotExist from django.forms.forms import pretty_name @@ -208,18 +209,32 @@ def _get_list_filter(self): def _get_add_url(self): return self.core.get_add_url(self.request) + def is_bulk_change_enabled(self): + return hasattr(self.core, 'get_bulk_change_url_name') and self.core.get_bulk_change_url_name() + def get_context_data(self, **kwargs): context_data = super(TableView, self).get_context_data(**kwargs) context_data.update({ - 'add_url': self._get_add_url(), - 'view_type': self.view_type, - 'add_button_value': self.core.model._ui_meta.add_button_verbose_name % - {'verbose_name': self.core.model._meta.verbose_name, - 'verbose_name_plural': self.core.model._meta.verbose_name_plural}, - 'export_types': self._get_export_types() - }) + 'add_url': self._get_add_url(), + 'view_type': self.view_type, + 'add_button_value': self.core.model._ui_meta.add_button_verbose_name % { + 'verbose_name': self.core.model._meta.verbose_name, + 'verbose_name_plural': self.core.model._meta.verbose_name_plural}, + 'export_types': self._get_export_types(), + 'enable_bulk_change': self.is_bulk_change_enabled(), + 'bulk_change_snippet_name': self.get_bulk_change_snippet_name(), + 'bulk_change_form_url': self.get_bulk_change_form_url(), + }) return context_data + def get_bulk_change_snippet_name(self): + return '-'.join(('default', self.model._meta.object_name.lower(), 'form')) + + def get_bulk_change_form_url(self): + return (reverse( + ''.join(('IS:', self.core.get_bulk_change_url_name(), '-', self.model._meta.object_name.lower()))) + if self.is_bulk_change_enabled() else None) + def has_get_permission(self, **kwargs): return self.core.has_ui_read_permission(self.request) diff --git a/is_core/locale/cs/LC_MESSAGES/django.mo b/is_core/locale/cs/LC_MESSAGES/django.mo index c08349c4b154a6eb5a5f9fb545b37b0dc14a7431..be1eef1a55ab0401f4ba4fdb961913291ad1a74d 100644 GIT binary patch delta 2155 zcmb8vZERCj9LMo9V9s@HJct55aTpsgpraid!!kBDF`yx^2njv~bJu%erR}|=w;kyW zsSk-74bh}xf|%&k7Z_tqTnPFC65TK{(clYAe4)ND`oj2tFAx($jNe~dh`urLq^FbB-I@5dC)gx>?3Nh<$VTqP<>k%zT{08}NN>zz?w*KdN~F z^~g8agx}Tt71#3nPrMaZ5=74r;zDCe=HBYSJc4{oz*i%V;|hEnm*ZR5hM%A&`U#0? zenSPkf&=&$-i-ZRRGhu2`}gB*n8aQzVTAe383uE?@G&aj1tf;~5?P`78WrFYk{ojx zm*F3%2^QaIOdOZvBHV*_;wVP(8EnDRsOM&okNKXj70hpbVXzn%5KSrVKuy?-3$PFG zz+I@o8RTQ0=c^x&V<%q37|xDV7v7CG@q0U-z=u)sen)M%fjX76;zbN@!470oW-HFd z9jL$WMD5(c=jx1cQD@>3QFNbHtp(3PrMMBb<91X=yHN{Cpq|@>EAi20@~@RY%>_+- z8kO?1sENNomEs4~j(u@~|VJqfP3p|eN@HOni&rlOKvTcUu zo|^mcO@5bB3{q`eCN*| z{q3u3!;Y|Tz3%AmwA%t(3<~y$3*Qy~(hAF)}&)KyrYQsuR7%q zn)Ws9$pnS5q&L+v_t{yApfKTGt4}&l1frZj=h>0zv&5~2@&h|=4|%R1WbIgR{+#1F zb-D5dt n+%0fel9cE_%GRd3^#^X4&pAGMQ!#UuX{X<{*QMDE`DJL delta 1479 zcmXxkOGs2v9LMpaIcnofzRF5XC)3Qwx7OoN(K`tbr@9#PtJoj_F_s%*0^Z%cj+lAwK;rX<< zNkiL9&!)%48q$uQr9LA(zEym*kOu$3v#%_$m6PSd9Sc2y;6CYw4zKZ$@HO~UB zHzsTr8SLQ3GH%CAp5|jcYT{nxkvWc9co6sD2yVhDWFqq(_5K`Y;a9B2Kj=g+A9i64 zYQ83nXMNMkKoNGLCI}%(Fawy2L#PGD(T_KABfi5NoW~8gihAFbXbgX*l#4c2g`2S( zm9b&eI$=y@eRGAuHoSqFa0<2HEH>ab+>P1X^kFM1;ER}!6PSt*P&=GP1^5~>@Cy=~ zSwdBHIr@AB!`f*w`RR*cQdF%+DWqN;Lq&QOwZm(u)ZIlb{0MjAb5wvoP=O^Ag#yS! zvTv%9wM+x5#LcMB4YM4)ZaC{AqzYRAB9>L)(Qa%^Bp+ z+~=YIo}mJMiwa-{b!I-JHuTF){*{3hZYZ)<)M3fxsU|E&O>`7h%VAVXCs8|iifr1< zVljS4)!Lc7_Ff)pW3{M-gII<~Fb~JV3>4WDEX66T$Dde-In+b7?TI>wI^CCW3*JI4 z7{RSLgB%yLfGXh<>b>7cg-sHZNi_u?uC#~76hu{e_s}(oGNSLWQd~nqP5 zb$HbRYuFjnZq-ProhgmAA&&+$m6Oct{I50DTc^J;`v0z|C8!#z9BO3PZdF{BTAc&^ zcqxm@jE}A}RZI8MNu=G%xGGc1F2~6}u=XzrnXzkIjpg+JP4!Vql}4QjrBUB+wFuukiRQ0+s#}pWo+m9_;XThDQ6( z_MblGZR{Nh1w7qN!A@^mhqp24Y3>NLwUqR9v^QA`Nr8lbXaD(7-$|>&b\n" -"Language: Czech\n" +"Language-Team: \n" +"Language: cs\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" +"X-Generator: Poedit 1.8.1\n" #: actions.py:49 filters/default_filters.py:192 -#: templates/forms/inline_table.html:26 templates/generic_views/table.html:52 -#: utils/__init__.py:154 +#: templates/forms/inline_table.html:54 templates/generic_views/table.html:81 +#: utils/__init__.py:170 msgid "Yes" msgstr "Ano" #: actions.py:50 filters/default_filters.py:192 -#: templates/forms/inline_table.html:26 templates/generic_views/table.html:52 -#: utils/__init__.py:154 +#: templates/forms/inline_table.html:54 templates/generic_views/table.html:81 +#: utils/__init__.py:170 msgid "No" msgstr "Ne" -#: actions.py:51 templates/forms/inline_table.html:26 -#: templates/generic_views/table.html:52 +#: actions.py:51 templates/forms/inline_table.html:54 +#: templates/generic_views/table.html:81 msgid "Are you sure?" msgstr "Jste si jisti?" +#: main.py:290 generic_views/__init__.py:176 +msgid "Home" +msgstr "Home" + +#: main.py:456 +#, python-format +msgid "Do you really want to delete \"%s\"" +msgstr "Opravdu chcete odstranit položku \"%s\"" + +#: main.py:458 +msgid "Delete" +msgstr "Smazat" + +#: main.py:459 +#, python-format +msgid "Record \"%s\" was deleted" +msgstr "Záznam \"%s\" byl smazán" + +#: main.py:488 +msgid "Edit" +msgstr "Upravit" + #: auth_token/forms.py:32 msgid "Remember user" msgstr "Zapamatovat uživatele" @@ -83,10 +107,6 @@ msgstr "Pole pro čtení nemůže být validováno" msgid "Search..." msgstr "Hledat..." -#: generic_views/__init__.py:176 main.py:276 -msgid "Home" -msgstr "Home" - #: generic_views/form_views.py:31 msgid "Object was saved successfully." msgstr "Položka byla úspěšně uložena." @@ -138,24 +158,6 @@ msgstr "Položka \"%(obj)s\" typu %(name)s byla úspěšně upravena." msgid "There are no items" msgstr "Žádné položky" -#: main.py:442 -#, python-format -msgid "Do you really want to delete \"%s\"" -msgstr "Opravdu chcete odstranit položku \"%s\"" - -#: main.py:444 -msgid "Delete" -msgstr "Smazat" - -#: main.py:445 -#, python-format -msgid "Record \"%s\" was deleted" -msgstr "Záznam\"%s\" byl smazán" - -#: main.py:474 -msgid "Edit" -msgstr "Upravit" - #: middleware/__init__.py:31 msgid "Unprocessable Entity" msgstr "Nezpracovatelná entita" @@ -173,16 +175,21 @@ msgstr "Přidat %(verbose_name)s" msgid "(None)" msgstr "---" -#: rest/resource.py:206 +#: rest/resource.py:209 #, python-format msgid "Cannot resolve filter \"%s\"" msgstr "Není možné filtrovat data dle hodnoty: \"%s\"" -#: rest/resource.py:219 +#: rest/resource.py:222 #, python-format msgid "Cannot resolve Order value \"%s\" into fields" msgstr "Není možné řadit dle hodnoty: \"%s\"" +#: rest/resource.py:272 +#, python-format +msgid "Only %s objects can be changed by one request" +msgstr "Pouze %s objektů může být změněno v jednom požadavku" + #: templates/500.html:5 msgid "Internal Server Error (500)" msgstr "Interní chyba serveru (500)" @@ -195,39 +202,58 @@ msgstr "Interní chyba. Služba je dočasně nedostupná." msgid "Loading..." msgstr "Načítám..." +#: templates/home.html:4 +msgid "Welcome" +msgstr "Vítejte" + #: templates/forms/default_form.html:27 msgid "Fields marked with * are required" msgstr "Položky označené * jsou povinné" -#: templates/forms/inline_table.html:9 templates/generic_views/table.html:20 +#: templates/forms/inline_table.html:10 templates/generic_views/table.html:20 msgid "Displayed" msgstr "Zobrazeny" -#: templates/forms/inline_table.html:9 templates/generic_views/table.html:20 +#: templates/forms/inline_table.html:10 templates/generic_views/table.html:20 msgid "%d to %d" msgstr "%d do %d" -#: templates/forms/inline_table.html:10 templates/generic_views/table.html:21 +#: templates/forms/inline_table.html:11 templates/generic_views/table.html:21 msgid "of" msgstr "z" -#: templates/forms/inline_table.html:17 templates/generic_views/table.html:28 +#: templates/forms/inline_table.html:18 templates/generic_views/table.html:28 msgid "Previous" msgstr "Předchozí" -#: templates/forms/inline_table.html:18 templates/generic_views/table.html:29 +#: templates/forms/inline_table.html:19 templates/generic_views/table.html:29 msgid "Next" msgstr "Následující" -#: templates/forms/inline_table.html:26 templates/generic_views/table.html:52 +#: templates/forms/inline_table.html:36 templates/generic_views/table.html:59 +msgid "Columns" +msgstr "Sloupce" + +#: templates/forms/inline_table.html:48 templates/generic_views/table.html:71 +#, python-format +msgid "" +"There are some filters on hidden fields: " +"%(columns)s. Do you want to delete " +"filtering?" +msgstr "" +"Na skryté sloupce jsou aplikovány filtry: " +"%(columns)s. Opravdu chcete odstranit filtrování ?" + +#: templates/forms/inline_table.html:54 templates/generic_views/table.html:81 msgid "Do you really want to delete %s?" msgstr "Opravdu chcete odstranit položku %s?" -#: templates/forms/inline_table.html:36 templates/generic_views/table.html:62 +#: templates/forms/inline_table.html:65 templates/generic_views/table.html:93 msgid "Actions" msgstr "Akce" -#: templates/forms/inline_table.html:46 templates/generic_views/table.html:72 +#: templates/forms/inline_table.html:76 templates/generic_views/table.html:104 msgid "There are no items." msgstr "Žádné položky" @@ -235,13 +261,13 @@ msgstr "Žádné položky" msgid "Login" msgstr "Login" -#: templates/generic_views/table.html:41 +#: templates/generic_views/table.html:42 msgid "Exports" msgstr "Export" -#: templates/home.html:4 -msgid "Welcome" -msgstr "Vítejte" +#: templates/generic_views/table.html:77 +msgid "Bulk change" +msgstr "Hromadná změna" #: templates/registration/login.html:14 msgid "Please Sign In" @@ -263,6 +289,10 @@ msgstr "Děkujeme za čas strávený s tímto webem." msgid "Log in again" msgstr "Přihlásit se znovu" +#: templates/views/bulk-change-view.html:6 +msgid "Affected rows" +msgstr "Počet záznamů ke změně" + #: views/csrf.py:7 msgid "Csrf Token expired" msgstr "Neplatný Csrf Token" diff --git a/is_core/main.py b/is_core/main.py index fac00e56..977cd706 100644 --- a/is_core/main.py +++ b/is_core/main.py @@ -28,6 +28,7 @@ from is_core.rest.factory import modelrest_factory from is_core.rest.datastructures import ModelRestFieldset from is_core.forms.models import SmartModelForm +from is_core.views import BulkChangeFormView class ISCoreBase(type): @@ -116,6 +117,13 @@ class ModelISCore(PermissionsMixin, ISCore): form_class = SmartModelForm ordering = None + bulk_change_url_name = None + + def get_view_classes(self): + view_classes = super(ModelISCore, self).get_view_classes() + if self.is_bulk_change_enabled(): + view_classes[self.get_bulk_change_url_name()] = (r'/bulk-change/?$', BulkChangeFormView) + return view_classes def get_form_fields(self, request, obj=None): return self.form_fields @@ -178,6 +186,12 @@ def get_list_actions(self, request, obj): def get_default_action(self, request, obj): return None + def is_bulk_change_enabled(self): + return self.get_bulk_change_url_name() is not None + + def get_bulk_change_url_name(self): + return self.bulk_change_url_name + class UIISCore(PermissionsUIMixin, ISCore): abstract = True @@ -431,7 +445,7 @@ def get_rest_class(self): def get_resource_patterns(self): resource_patterns = super(RestModelISCore, self).get_resource_patterns() resource_patterns.update(DoubleRestPattern( - self.get_rest_class(), + self.get_rest_class(), self.default_rest_resource_pattern_class, self ).patterns) return resource_patterns diff --git a/is_core/patterns.py b/is_core/patterns.py index a97f1a8e..6f819e49 100644 --- a/is_core/patterns.py +++ b/is_core/patterns.py @@ -230,7 +230,11 @@ def patterns(self): self.resource_class, self.core, ('get', 'put', 'delete'), clone_view_class=False ) result['api'] = self.pattern_class( - 'api-%s' % self.core.get_menu_group_pattern_name(), self.core.site_name, r'^/?$', self.resource_class, self.core, - ('get', 'post'), clone_view_class=False + 'api-%s' % self.core.get_menu_group_pattern_name(), self.core.site_name, r'^/?$', self.resource_class, + self.core, self._get_api_allowed_methods(), clone_view_class=False ) return result + + def _get_api_allowed_methods(self): + return (('get', 'post') + ( + ('put',) if hasattr(self.core, 'is_bulk_change_enabled') and self.core.is_bulk_change_enabled() else ())) diff --git a/is_core/rest/resource.py b/is_core/rest/resource.py index e6419779..8d9f39be 100644 --- a/is_core/rest/resource.py +++ b/is_core/rest/resource.py @@ -1,25 +1,29 @@ from __future__ import unicode_literals -from django.utils.translation import ugettext +from django.conf import settings from django.core.urlresolvers import NoReverseMatch +from django.db import transaction from django.http.response import Http404 +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext -from piston.resource import BaseResource, BaseModelResource from piston.exception import (RestException, MimerDataException, NotAllowedException, UnsupportedMediaTypeException, ResourceNotFoundException, NotAllowedMethodException, DuplicateEntryException, - ConflictException) + ConflictException, DataInvalidException) +from piston.resource import BaseResource, BaseModelResource +from piston.response import RestErrorResponse, RestErrorsResponse from chamber.shortcuts import get_object_or_none +from chamber.utils.decorators import classproperty -from is_core.filters import get_model_field_or_method_filter -from is_core.patterns import RestPattern, patterns from is_core.exceptions import HttpForbiddenResponseException from is_core.exceptions.response import (HttpBadRequestResponseException, HttpUnsupportedMediaTypeResponseException, HttpMethodNotAllowedResponseException, HttpDuplicateResponseException) +from is_core.filters import get_model_field_or_method_filter from is_core.forms.models import smartmodelform_factory +from is_core.patterns import RestPattern, patterns -from chamber.utils.decorators import classproperty -from django.utils.safestring import mark_safe +from utils.immutable import merge class RestResource(BaseResource): @@ -174,8 +178,7 @@ def _default_action(self, obj): return self.core.get_default_action(self.request, obj=obj) def _actions(self, obj): - ac = self.core.get_list_actions(self.request, obj) - return ac + return self.core.get_list_actions(self.request, obj) def _class_names(self, obj): return self.core.get_rest_obj_class_names(self.request, obj) @@ -256,3 +259,40 @@ def _generate_form_class(self, inst, exclude=[]): if hasattr(form_class, '_meta') and form_class._meta.exclude: exclude.extend(form_class._meta.exclude) return smartmodelform_factory(self.model, self.request, form=form_class, exclude=exclude, fields=fields) + + def put(self): + return super(RestModelResource, self).put() if self.kwargs.get(self.pk_name) else self.update_bulk() + + @transaction.atomic + def update_bulk(self): + qs = self._filter_queryset(self.core.get_queryset(self.request)) + BULK_CHANGE_LIMIT = getattr(settings, 'BULK_CHANGE_LIMIT', 200) + if qs.count() > BULK_CHANGE_LIMIT: + return RestErrorResponse( + msg=ugettext('Only %s objects can be changed by one request').format(BULK_CHANGE_LIMIT), + code=413) + + data = self.get_dict_data() + objects, errors = zip(*(self._update_obj(obj, data) for obj in qs)) + compact_errors = tuple(err for err in errors if err) + return RestErrorsResponse(compact_errors) if len(compact_errors) > 0 else objects + + def _update_obj(self, obj, data): + try: + return (self._create_or_update(merge(data, {self.pk_field_name: obj.pk})), None) + except DataInvalidException as ex: + return (None, self._format_message(obj, ex)) + except (ConflictException, NotAllowedException): + raise + except RestException as ex: + return (None, self._format_message(obj, ex)) + + def _extract_message(self, ex): + return '\n'.join(ex.errors.values()) if hasattr(ex, 'errors') else ex.message + + def _format_message(self, obj, ex): + return { + 'id': obj.pk, + 'errors': {k: mark_safe(v) for k, v in ex.errors.items()} if hasattr(ex, 'errors') else {}, + '_obj_name': mark_safe(''.join(('#', str(obj.pk), ' ', self._extract_message(ex)))), + } diff --git a/is_core/templates/generic_views/table.html b/is_core/templates/generic_views/table.html index 9134bed6..887a0c56 100644 --- a/is_core/templates/generic_views/table.html +++ b/is_core/templates/generic_views/table.html @@ -73,11 +73,18 @@ {% endif %} {% endblock %} + {% if enable_bulk_change %} + {% trans 'Bulk change' %} + {% endif %} + {% block table %} - +
{% block table-header %} + {% if enable_bulk_change %} + + {% endif %} {% for header in headers %}
{{ header.text|capfirst }}
{{ header.filter }} diff --git a/is_core/templates/views/bulk-change-view.html b/is_core/templates/views/bulk-change-view.html new file mode 100644 index 00000000..992c478f --- /dev/null +++ b/is_core/templates/views/bulk-change-view.html @@ -0,0 +1,8 @@ +{% extends 'forms/default_form.html' %} + +{% load i18n %} + +{% block form-content %} + {% trans 'Affected rows' %}: %s + {{ block.super }} +{% endblock %} diff --git a/is_core/utils/immutable.py b/is_core/utils/immutable.py new file mode 100644 index 00000000..2d50d087 --- /dev/null +++ b/is_core/utils/immutable.py @@ -0,0 +1,12 @@ +from __future__ import unicode_literals + + +def merge(origin, *args): + """ + Merges given dictionaries, `origin` will not be changed. + TODO: Remove this function after `django-chamber` upgrade + """ + copy = origin.copy() + for dictionary in args: + copy.update(dictionary) + return copy diff --git a/is_core/views/__init__.py b/is_core/views/__init__.py index e69de29b..544c73f6 100644 --- a/is_core/views/__init__.py +++ b/is_core/views/__init__.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals + +from django.http import Http404 + +from is_core.generic_views.form_views import DefaultModelFormView + + +class BulkChangeFormView(DefaultModelFormView): + form_template = 'views/bulk-change-view.html' + is_ajax_form = False + + def dispatch(self, request, *args, **kwargs): + if 'snippet' not in request.GET: + raise Http404 + return super(BulkChangeFormView, self).dispatch(request, *args, **kwargs) + + def get_fields(self): + return self.fields or self.core.bulk_change_fields if hasattr(self.core, 'bulk_change_fields') else () + + def get_fieldsets(self): + return (self.fieldsets or self.core.bulk_change_fieldsets + if hasattr(self.core, 'bulk_change_fieldsets') else None) + + def get_readonly_fields(self): + return ()