import json from abc import ABCMeta from collections import defaultdict from django.template.loader import render_to_string from django.utils.encoding import force_str from django.utils.functional import Promise from cms.constants import LEFT, REFRESH_PAGE, RIGHT, URL_CHANGE class ItemSearchResult: """ Returned by the find APIs in :class:`ToolbarAPIMixin`. An ``ItemSearchResult`` will have two useful attributes: .. attribute:: item The item found. .. attribute:: index The index of the item (its position amongst the other items). The ``ItemSearchResult`` itself can be cast to an integer, and supports addition and subtraction of numbers. See the :option:`position` parameter for more details, and :ref:`toolbar_control_item_position` for examples. """ def __init__(self, item, index): self.item = item self.index = index def __add__(self, other): return ItemSearchResult(self.item, self.index + other) def __sub__(self, other): return ItemSearchResult(self.item, self.index - other) def __int__(self): return self.index def may_be_lazy(thing): """Normalise a (possibly lazy) attribute value for comparison in the find APIs. Toolbar item names are frequently ``gettext_lazy`` strings. To let callers -- including third-party apps -- look items up by their *source* (English) string regardless of the active language, we compare against the lazy callable's source argument rather than its resolved/translated value. ``Promise._args`` is a private Django internal; there is no public API to recover a lazy's source argument. Only single-argument lazies such as ``_("label")`` have a meaningful source string, so anything else (e.g. ``format_lazy``) falls back to its resolved value instead of raising -- the find APIs run this over *every* candidate, so a crash here would break unrelated lookups too. """ if isinstance(thing, Promise): if len(thing._args) == 1: return thing._args[0] return force_str(thing) return thing class ToolbarAPIMixin(metaclass=ABCMeta): """ Provides APIs used by :class:`~cms.toolbar.toolbar.CMSToolbar` and :class:`Menu`. """ REFRESH_PAGE = REFRESH_PAGE URL_CHANGE = URL_CHANGE LEFT = LEFT RIGHT = RIGHT def __init__(self): self.items = [] self.menus = {} self._memo = defaultdict(list) def _memoize(self, item): self._memo[item.__class__].append(item) def _unmemoize(self, item): self._memo[item.__class__].remove(item) def _item_position(self, item): return self.items.index(item) def _add_item(self, item, position): if position is not None: self.items.insert(position, item) else: self.items.append(item) def _remove_item(self, item): if item in self.items: self.items.remove(item) else: raise KeyError("Item %r not found" % item) def get_item_count(self): """Returns the number of items in the menu.""" return len(self.items) def add_item(self, item, position=None): """Adds an item (which must be a subclass of :class:`~cms.toolbar.items.BaseItem`), and returns it. This is a low-level API, and you should always use one of the built-in object-specific methods to add items in preference if possible, using this method **only** for custom item classes.""" if not isinstance(item, BaseItem): raise ValueError("Items must be subclasses of cms.toolbar.items.BaseItem, %r isn't" % item) if isinstance(position, ItemSearchResult): position = position.index elif isinstance(position, BaseItem): position = self._item_position(position) elif not (position is None or isinstance(position, (int,))): raise ValueError( "Position must be None, an integer, an item or an ItemSearchResult, got %r instead" % position ) self._add_item(item, position) self._memoize(item) return item def find_items(self, item_type, **attributes): """Returns a list of :class:`~cms.toolbar.items.ItemSearchResult` objects matching all items of ``item_type`` (e.g. ``LinkItem``). Items whose attributes are lazy (e.g. a ``gettext_lazy`` ``name``) are matched by their *source* string, not their translation. Search for an item by the original (untranslated, usually English) string -- e.g. ``find_items(LinkItem, name="Save")`` -- so the lookup works regardless of the active language. Passing the lazy itself or a translated string is not reliable across languages. This source matching only works for single-argument lazies such as ``_("Save")``. Composite lazy values -- e.g. ``format_lazy("Save {}", model_name)`` -- have no single source string and are matched by their *resolved* (translated) value, so they cannot be looked up language-independently. Give such items a stable ``identifier`` and search by that instead.""" results = [] attr_items = attributes.items() notfound = object() for candidate in self._memo[item_type]: if all(may_be_lazy(getattr(candidate, key, notfound)) == value for key, value in attr_items): results.append(ItemSearchResult(candidate, self._item_position(candidate))) return results def find_first(self, item_type, **attributes): """Returns the first :class:`~cms.toolbar.items.ItemSearchResult` that matches the search, or ``None``. The search strategy is the same as in :meth:`find_items`. The return value of this method is safe to use as the :option:`position` argument of the various APIs to add items.""" try: return self.find_items(item_type, **attributes)[0] except IndexError: return None # # This will only work if it is used to determine the insert position for # all items in the same menu. # def get_alphabetical_insert_position(self, new_menu_name, item_type, default=0): results = self.find_items(item_type) # No items yet? Use the default value provided if not len(results): return default last_position = 0 for result in sorted(results, key=lambda x: x.item.name): if result.item.name > new_menu_name: return result.index last_position = max(result.index, last_position) else: return last_position + 1 def remove_item(self, item): self._remove_item(item) self._unmemoize(item) def add_sideframe_item(self, name, url, active=False, disabled=False, extra_classes=None, on_close=None, side=LEFT, position=None): """Adds a :class:`~cms.toolbar.items.SideframeItem` that opens ``url`` in the sideframe and returns it.""" item = SideframeItem( name, url, active=active, disabled=disabled, extra_classes=extra_classes, on_close=on_close, side=side, ) self.add_item(item, position=position) return item def add_modal_item(self, name, url, active=False, disabled=False, extra_classes=None, on_close=REFRESH_PAGE, side=LEFT, position=None): """Similar to :meth:`add_sideframe_item`, but adds a :class:`~cms.toolbar.items.ModalItem` that opens the ``url`` in a modal dialog instead of the sideframe, and returns it.""" item = ModalItem( name, url, active=active, disabled=disabled, extra_classes=extra_classes, on_close=on_close, side=side, ) self.add_item(item, position=position) return item def add_link_item(self, name, url, active=False, disabled=False, extra_classes=None, side=LEFT, position=None): """Adds a :class:`~cms.toolbar.items.LinkItem` that opens ``url``, and returns it.""" item = LinkItem( name, url, active=active, disabled=disabled, extra_classes=extra_classes, side=side ) self.add_item(item, position=position) return item def add_ajax_item(self, name, action, active=False, disabled=False, extra_classes=None, data=None, question=None, side=LEFT, position=None, on_success=None, method='POST'): """Adds :class:`~cms.toolbar.items.AjaxItem` that sends a request to ``action`` with ``data``, and returns it. ``data`` should be ``None`` or a dictionary. By default a ``POST`` request is sent; pass ``method`` to use a different HTTP method. For unsafe methods (anything other than ``GET``, ``HEAD``, ``OPTIONS`` and ``TRACE``) the CSRF token is automatically added to the item. If a string is provided for ``question``, it will be presented to the user to allow confirmation before the request is sent. Once the request succeeds, the response is inspected: * If ``on_success`` is given, the browser navigates to that URL. The special values ``REFRESH_PAGE`` reloads the current page and ``"FOLLOW_REDIRECT"`` redirects to the ``url`` returned in the (JSON) response instead. * Otherwise, if the response carries a *data bridge* -- either an HTML document containing a ``