/feincms/admin/tree_editor.py

http://github.com/feincms/feincms · Python · 573 lines · 464 code · 50 blank · 59 comment · 42 complexity · a1dd70150bd54b795c3deeb54be06161 MD5 · raw file

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