PageRenderTime 43ms CodeModel.GetById 14ms app.highlight 24ms RepoModel.GetById 1ms app.codeStats 0ms

/feincms/admin/tree_editor.py

http://github.com/feincms/feincms
Python | 573 lines | 487 code | 47 blank | 39 comment | 38 complexity | a1dd70150bd54b795c3deeb54be06161 MD5 | raw file
  1# ------------------------------------------------------------------------
  2# coding=utf-8
  3# ------------------------------------------------------------------------
  4
  5from __future__ import absolute_import, unicode_literals
  6
  7from functools import reduce
  8import json
  9import logging
 10
 11from django.contrib.admin.views import main
 12from django.contrib.admin.actions import delete_selected
 13from django.contrib.auth import get_permission_codename
 14from django.db.models import Q
 15from django.http import (
 16    HttpResponse,
 17    HttpResponseBadRequest,
 18    HttpResponseForbidden,
 19    HttpResponseNotFound,
 20    HttpResponseServerError,
 21)
 22from django.utils.html import escape
 23from django.utils.safestring import mark_safe
 24from django.utils.translation import gettext_lazy as _, gettext
 25from django.utils.encoding import force_text
 26
 27from mptt.exceptions import InvalidMove
 28from mptt.forms import MPTTAdminForm
 29
 30from feincms import settings
 31from feincms.extensions import ExtensionModelAdmin
 32
 33try:
 34    # Django<3
 35    from django.contrib.staticfiles.templatetags.staticfiles import static
 36except ImportError:
 37    from django.templatetags.static import static
 38
 39
 40logger = logging.getLogger(__name__)
 41
 42
 43# ------------------------------------------------------------------------
 44def django_boolean_icon(field_val, alt_text=None, title=None):
 45    """
 46    Return HTML code for a nice representation of true/false.
 47    """
 48
 49    # Origin: contrib/admin/templatetags/admin_list.py
 50    BOOLEAN_MAPPING = {True: "yes", False: "no", None: "unknown"}
 51    alt_text = alt_text or BOOLEAN_MAPPING[field_val]
 52    if title is not None:
 53        title = 'title="%s" ' % title
 54    else:
 55        title = ""
 56    icon_url = static("feincms/img/icon-%s.gif" % BOOLEAN_MAPPING[field_val])
 57    return mark_safe('<img src="%s" alt="%s" %s/>' % (icon_url, alt_text, title))
 58
 59
 60def _build_tree_structure(queryset):
 61    """
 62    Build an in-memory representation of the item tree, trying to keep
 63    database accesses down to a minimum. The returned dictionary looks like
 64    this (as json dump):
 65
 66        {"6": [7, 8, 10]
 67         "7": [12],
 68         "8": [],
 69         ...
 70         }
 71    """
 72    all_nodes = {}
 73
 74    mptt_opts = queryset.model._mptt_meta
 75    items = queryset.order_by(mptt_opts.tree_id_attr, mptt_opts.left_attr).values_list(
 76        "pk", "%s_id" % mptt_opts.parent_attr
 77    )
 78    for p_id, parent_id in items:
 79        all_nodes.setdefault(str(parent_id) if parent_id else 0, []).append(p_id)
 80    return all_nodes
 81
 82
 83# ------------------------------------------------------------------------
 84def ajax_editable_boolean_cell(item, attr, text="", override=None):
 85    """
 86    Generate a html snippet for showing a boolean value on the admin page.
 87    Item is an object, attr is the attribute name we should display. Text
 88    is an optional explanatory text to be included in the output.
 89
 90    This function will emit code to produce a checkbox input with its state
 91    corresponding to the item.attr attribute if no override value is passed.
 92    This input is wired to run a JS ajax updater to toggle the value.
 93
 94    If override is passed in, ignores the attr attribute and returns a
 95    static image for the override boolean with no user interaction possible
 96    (useful for "disabled and you can't change it" situations).
 97    """
 98    if text:
 99        text = "&nbsp;(%s)" % text
100
101    if override is not None:
102        a = [django_boolean_icon(override, text), text]
103    else:
104        value = getattr(item, attr)
105        a = [
106            '<input type="checkbox" data-inplace data-inplace-id="%s"'
107            ' data-inplace-attribute="%s" %s>'
108            % (item.pk, attr, 'checked="checked"' if value else "")
109        ]
110
111    a.insert(0, '<div id="wrap_%s_%d">' % (attr, item.pk))
112    a.append("</div>")
113    return mark_safe("".join(a))
114
115
116# ------------------------------------------------------------------------
117def ajax_editable_boolean(attr, short_description):
118    """
119    Convenience function: Assign the return value of this method to a variable
120    of your ModelAdmin class and put the variable name into list_display.
121
122    Example::
123
124        class MyTreeEditor(TreeEditor):
125            list_display = ('__str__', 'active_toggle')
126
127            active_toggle = ajax_editable_boolean('active', _('is active'))
128    """
129
130    def _fn(self, item):
131        return ajax_editable_boolean_cell(item, attr)
132
133    _fn.short_description = short_description
134    _fn.editable_boolean_field = attr
135    return _fn
136
137
138# ------------------------------------------------------------------------
139class ChangeList(main.ChangeList):
140    """
141    Custom ``ChangeList`` class which ensures that the tree entries are always
142    ordered in depth-first order (order by ``tree_id``, ``lft``).
143    """
144
145    def __init__(self, request, *args, **kwargs):
146        self.user = request.user
147        super(ChangeList, self).__init__(request, *args, **kwargs)
148
149    def get_queryset(self, *args, **kwargs):
150        mptt_opts = self.model._mptt_meta
151        qs = (
152            super(ChangeList, self)
153            .get_queryset(*args, **kwargs)
154            .order_by(mptt_opts.tree_id_attr, mptt_opts.left_attr)
155        )
156        # Force has_filters, so that the expand/collapse in sidebar is visible
157        self.has_filters = True
158        return qs
159
160    def get_results(self, request):
161        mptt_opts = self.model._mptt_meta
162        if settings.FEINCMS_TREE_EDITOR_INCLUDE_ANCESTORS:
163            clauses = [
164                Q(
165                    **{
166                        mptt_opts.tree_id_attr: tree_id,
167                        mptt_opts.left_attr + "__lte": lft,
168                        mptt_opts.right_attr + "__gte": rght,
169                    }
170                )
171                for lft, rght, tree_id in self.queryset.values_list(
172                    mptt_opts.left_attr, mptt_opts.right_attr, mptt_opts.tree_id_attr
173                )
174            ]
175            # We could optimise a bit here by explicitely filtering out
176            # any clauses that are for parents of nodes included in the
177            # queryset anyway. (ie: drop all clauses that refer to a node
178            # that is a parent to another node)
179
180            if clauses:
181                # Note: Django ORM is smart enough to drop additional
182                # clauses if the initial query set is unfiltered. This
183                # is good.
184                self.queryset |= self.model._default_manager.filter(
185                    reduce(lambda p, q: p | q, clauses)
186                )
187
188        super(ChangeList, self).get_results(request)
189
190        # Pre-process permissions because we still have the request here,
191        # which is not passed in later stages in the tree editor
192        for item in self.result_list:
193            item.feincms_changeable = self.model_admin.has_change_permission(
194                request, item
195            )
196
197            item.feincms_addable = (
198                item.feincms_changeable
199                and self.model_admin.has_add_permission(request, item)
200            )
201
202
203# ------------------------------------------------------------------------
204class TreeEditor(ExtensionModelAdmin):
205    """
206    The ``TreeEditor`` modifies the standard Django administration change list
207    to a drag-drop enabled interface for django-mptt_-managed Django models.
208
209    .. _django-mptt: https://github.com/django-mptt/django-mptt/
210    """
211
212    form = MPTTAdminForm
213
214    if settings.FEINCMS_TREE_EDITOR_INCLUDE_ANCESTORS:
215        # Make sure that no pagination is displayed. Slicing is disabled
216        # anyway, therefore this value does not have an influence on the
217        # queryset
218        list_per_page = 999999999
219
220    def __init__(self, *args, **kwargs):
221        super(TreeEditor, self).__init__(*args, **kwargs)
222
223        self.list_display = list(self.list_display)
224
225        if "indented_short_title" not in self.list_display:
226            if self.list_display[0] == "action_checkbox":
227                self.list_display[1] = "indented_short_title"
228            else:
229                self.list_display[0] = "indented_short_title"
230        self.list_display_links = ("indented_short_title",)
231
232        opts = self.model._meta
233        self.change_list_template = [
234            "admin/feincms/%s/%s/tree_editor.html"
235            % (opts.app_label, opts.object_name.lower()),
236            "admin/feincms/%s/tree_editor.html" % opts.app_label,
237            "admin/feincms/tree_editor.html",
238        ]
239        self.object_change_permission = (
240            opts.app_label + "." + get_permission_codename("change", opts)
241        )
242        self.object_add_permission = (
243            opts.app_label + "." + get_permission_codename("add", opts)
244        )
245        self.object_delete_permission = (
246            opts.app_label + "." + get_permission_codename("delete", opts)
247        )
248
249    def changeable(self, item):
250        return getattr(item, "feincms_changeable", True)
251
252    def indented_short_title(self, item):
253        """
254        Generate a short title for an object, indent it depending on
255        the object's depth in the hierarchy.
256        """
257        mptt_opts = item._mptt_meta
258        r = ""
259        try:
260            url = item.get_absolute_url()
261        except (AttributeError,):
262            url = None
263
264        if url:
265            r = (
266                '<input type="hidden" class="medialibrary_file_path"'
267                ' value="%s" id="_refkey_%d" />'
268            ) % (url, item.pk)
269
270        changeable_class = ""
271        if not self.changeable(item):
272            changeable_class = " tree-item-not-editable"
273        tree_root_class = ""
274        if not item.parent_id:
275            tree_root_class = " tree-root"
276
277        r += (
278            '<span id="page_marker-%d" class="page_marker%s%s"'
279            ' style="width: %dpx;">&nbsp;</span>&nbsp;'
280        ) % (
281            item.pk,
282            changeable_class,
283            tree_root_class,
284            14 + getattr(item, mptt_opts.level_attr) * 18,
285        )
286
287        #        r += '<span tabindex="0">'
288        if hasattr(item, "short_title") and callable(item.short_title):
289            r += escape(item.short_title())
290        else:
291            r += escape("%s" % item)
292        #        r += '</span>'
293        return mark_safe(r)
294
295    indented_short_title.short_description = _("title")
296
297    def _collect_editable_booleans(self):
298        """
299        Collect all fields marked as editable booleans. We do not
300        want the user to be able to edit arbitrary fields by crafting
301        an AJAX request by hand.
302        """
303        if hasattr(self, "_ajax_editable_booleans"):
304            return
305
306        self._ajax_editable_booleans = {}
307
308        for field in self.list_display:
309            # The ajax_editable_boolean return value has to be assigned
310            # to the ModelAdmin class
311            try:
312                item = getattr(self.__class__, field)
313            except (AttributeError, TypeError):
314                continue
315
316            attr = getattr(item, "editable_boolean_field", None)
317            if attr:
318                if hasattr(item, "editable_boolean_result"):
319                    result_func = item.editable_boolean_result
320                else:
321
322                    def _fn(attr):
323                        return lambda self, instance: [
324                            ajax_editable_boolean_cell(instance, attr)
325                        ]
326
327                    result_func = _fn(attr)
328                self._ajax_editable_booleans[attr] = result_func
329
330    def _toggle_boolean(self, request):
331        """
332        Handle an AJAX toggle_boolean request
333        """
334        try:
335            item_id = int(request.POST.get("item_id", None))
336            attr = str(request.POST.get("attr", None))
337        except Exception:
338            return HttpResponseBadRequest("Malformed request")
339
340        if not request.user.is_staff:
341            logger.warning(
342                'Denied AJAX request by non-staff "%s" to toggle boolean'
343                " %s for object #%s",
344                request.user,
345                attr,
346                item_id,
347            )
348            return HttpResponseForbidden(
349                _("You do not have permission to modify this object")
350            )
351
352        self._collect_editable_booleans()
353
354        if attr not in self._ajax_editable_booleans:
355            return HttpResponseBadRequest("not a valid attribute %s" % attr)
356
357        try:
358            obj = self.model._default_manager.get(pk=item_id)
359        except self.model.DoesNotExist:
360            return HttpResponseNotFound("Object does not exist")
361
362        if not self.has_change_permission(request, obj=obj):
363            logger.warning(
364                'Denied AJAX request by "%s" to toggle boolean %s for' " object %s",
365                request.user,
366                attr,
367                item_id,
368            )
369            return HttpResponseForbidden(
370                _("You do not have permission to modify this object")
371            )
372
373        new_state = not getattr(obj, attr)
374        logger.info(
375            'Toggle %s on #%d %s to %s by "%s"',
376            attr,
377            obj.pk,
378            obj,
379            "on" if new_state else "off",
380            request.user,
381        )
382
383        try:
384            before_data = self._ajax_editable_booleans[attr](self, obj)
385
386            setattr(obj, attr, new_state)
387            obj.save()
388
389            # Construct html snippets to send back to client for status update
390            data = self._ajax_editable_booleans[attr](self, obj)
391
392        except Exception:
393            logger.exception("Unhandled exception while toggling %s on %s", attr, obj)
394            return HttpResponseServerError("Unable to toggle %s on %s" % (attr, obj))
395
396        # Weed out unchanged cells to keep the updates small. This assumes
397        # that the order a possible get_descendents() returns does not change
398        # before and after toggling this attribute. Unlikely, but still...
399        return HttpResponse(
400            json.dumps([b for a, b in zip(before_data, data) if a != b]),
401            content_type="application/json",
402        )
403
404    def get_changelist(self, request, **kwargs):
405        return ChangeList
406
407    def changelist_view(self, request, extra_context=None, *args, **kwargs):
408        """
409        Handle the changelist view, the django view for the model instances
410        change list/actions page.
411        """
412
413        if "actions_column" not in self.list_display:
414            self.list_display.append("actions_column")
415
416        # handle common AJAX requests
417        if request.is_ajax():
418            cmd = request.POST.get("__cmd")
419            if cmd == "toggle_boolean":
420                return self._toggle_boolean(request)
421            elif cmd == "move_node":
422                return self._move_node(request)
423
424            return HttpResponseBadRequest("Oops. AJAX request not understood.")
425
426        extra_context = extra_context or {}
427        extra_context["tree_structure"] = mark_safe(
428            json.dumps(_build_tree_structure(self.get_queryset(request)))
429        )
430        extra_context["node_levels"] = mark_safe(
431            json.dumps(
432                dict(
433                    self.get_queryset(request)
434                    .order_by()
435                    .values_list("pk", self.model._mptt_meta.level_attr)
436                )
437            )
438        )
439
440        return super(TreeEditor, self).changelist_view(
441            request, extra_context, *args, **kwargs
442        )
443
444    def has_add_permission(self, request, obj=None):
445        """
446        Implement a lookup for object level permissions. Basically the same as
447        ModelAdmin.has_add_permission, but also passes the obj parameter in.
448        """
449        perm = self.object_add_permission
450        if settings.FEINCMS_TREE_EDITOR_OBJECT_PERMISSIONS:
451            r = request.user.has_perm(perm, obj)
452        else:
453            r = request.user.has_perm(perm)
454
455        return r and super(TreeEditor, self).has_add_permission(request)
456
457    def has_change_permission(self, request, obj=None):
458        """
459        Implement a lookup for object level permissions. Basically the same as
460        ModelAdmin.has_change_permission, but also passes the obj parameter in.
461        """
462        perm = self.object_change_permission
463        if settings.FEINCMS_TREE_EDITOR_OBJECT_PERMISSIONS:
464            r = request.user.has_perm(perm, obj)
465        else:
466            r = request.user.has_perm(perm)
467
468        return r and super(TreeEditor, self).has_change_permission(request, obj)
469
470    def has_delete_permission(self, request, obj=None):
471        """
472        Implement a lookup for object level permissions. Basically the same as
473        ModelAdmin.has_delete_permission, but also passes the obj parameter in.
474        """
475        perm = self.object_delete_permission
476        if settings.FEINCMS_TREE_EDITOR_OBJECT_PERMISSIONS:
477            r = request.user.has_perm(perm, obj)
478        else:
479            r = request.user.has_perm(perm)
480
481        return r and super(TreeEditor, self).has_delete_permission(request, obj)
482
483    def _move_node(self, request):
484        if hasattr(self.model.objects, "move_node"):
485            tree_manager = self.model.objects
486        else:
487            tree_manager = self.model._tree_manager
488
489        queryset = self.get_queryset(request)
490        cut_item = queryset.get(pk=request.POST.get("cut_item"))
491        pasted_on = queryset.get(pk=request.POST.get("pasted_on"))
492        position = request.POST.get("position")
493
494        if not self.has_change_permission(request, cut_item):
495            self.message_user(request, _("No permission"))
496            return HttpResponse("FAIL")
497
498        if position in ("last-child", "left", "right"):
499            try:
500                tree_manager.move_node(cut_item, pasted_on, position)
501            except InvalidMove as e:
502                self.message_user(request, "%s" % e)
503                return HttpResponse("FAIL")
504
505            # Ensure that model save methods have been run (required to
506            # update Page._cached_url values, might also be helpful for other
507            # models inheriting MPTTModel)
508            for item in queryset.filter(id__in=(cut_item.pk, pasted_on.pk)):
509                item.save()
510
511            self.message_user(
512                request, gettext("%s has been moved to a new position.") % cut_item
513            )
514            return HttpResponse("OK")
515
516        self.message_user(request, _("Did not understand moving instruction."))
517        return HttpResponse("FAIL")
518
519    def _actions_column(self, instance):
520        if self.changeable(instance):
521            return ['<div class="drag_handle"></div>']
522        return []
523
524    def actions_column(self, instance):
525        return mark_safe(" ".join(self._actions_column(instance)))
526
527    actions_column.short_description = _("actions")
528
529    def delete_selected_tree(self, modeladmin, request, queryset):
530        """
531        Deletes multiple instances and makes sure the MPTT fields get
532        recalculated properly. (Because merely doing a bulk delete doesn't
533        trigger the post_delete hooks.)
534        """
535        # If this is True, the confirmation page has been displayed
536        if request.POST.get("post"):
537            n = 0
538            # TODO: The disable_mptt_updates / rebuild is a work around
539            # for what seems to be a mptt problem when deleting items
540            # in a loop. Revisit this, there should be a better solution.
541            with queryset.model.objects.disable_mptt_updates():
542                for obj in queryset:
543                    if self.has_delete_permission(request, obj):
544                        obj.delete()
545                        n += 1
546                        obj_display = force_text(obj)
547                        self.log_deletion(request, obj, obj_display)
548                    else:
549                        logger.warning(
550                            'Denied delete request by "%s" for object #%s',
551                            request.user,
552                            obj.id,
553                        )
554            if n > 0:
555                queryset.model.objects.rebuild()
556            self.message_user(
557                request, _("Successfully deleted %(count)d items.") % {"count": n}
558            )
559            # Return None to display the change list page again
560            return None
561        else:
562            # (ab)using the built-in action to display the confirmation page
563            return delete_selected(self, request, queryset)
564
565    def get_actions(self, request):
566        actions = super(TreeEditor, self).get_actions(request)
567        if "delete_selected" in actions:
568            actions["delete_selected"] = (
569                self.delete_selected_tree,
570                "delete_selected",
571                _("Delete selected %(verbose_name_plural)s"),
572            )
573        return actions