abilian.web.action — Abilian 0.1 documentation

# Source code for abilian.web.action

    # coding=utf-8
    """
    """
    from __future__ import absolute_import

    import logging
    import re
    from jinja2 import Template, Markup
    from flask import current_app, g
    from flask.signals import appcontext_pushed

    from abilian.web.util import url_for
    from abilian.core.singleton import UniqueName

    log = logging.getLogger(__name__)

    __all__ = ('Action', 'ModalActionMixin', 'actions')

    class Status(UniqueName):
      """
      Action UI status names
      """

    #: default action status: show in UID, usable, not marked "current"
    ENABLED = Status(u'enabled')
    #: action is "active" or "current". For example the current navigation item.
    ACTIVE = Status(u'active')
    #: action should be shown in a disabled state
    DISABLED = Status(u'disabled')

    def getset(f):
      """
      Shortcut for a custom getter/ standard setter.

      Usage::
          @getset
          def my_property(self, value=None):
              if value is None:
                  return getter_value
              set_value(value)

      Default value for `value` should be any marker that helps distinguish
      between getter or setter mode. If None is not appropriate a good approach is
      to use a unique object instance::

          MARK = object()
          # test like this
          if value is MARK:
            # getter mode
      """
      return property(f, f)

    class Icon(object):
      """
      Base abstract class for icons
      """
      def __html__(self):
        raise NotImplementedError

      def __unicode__(self):
        return self.__html__()

    class NamedIconBase(Icon):
      """
      Renders markup for named icons set
      """
      template = None

      def __init__(self, name=u''):
        self.name = name

      def __html__(self):
        return self.template.render(name=self.name)

    class Glyphicon(NamedIconBase):
      """
      Renders markup for bootstrap's glyphicons
      """
      template = Template(u'<i class="glyphicon glyphicon-{{ name }}"></i>')

    class FAIcon(NamedIconBase):
      """
      Renders markup for FontAwesome icons
      """
      template = Template(u'<i class="fa fa-{{ name }}"></i>')

    class DynamicIcon(Icon):
      """

      """
      template = Template(u'<img {%- if css %} class="{{ css }}"{% endif %} '
                          u'src="{{ url }}" '
                          u'width="{{ width }}" height="{{ height }}" />')

      def __init__(self, endpoint=None, width=12, height=12, css=u'',
                   size=None, url_args=None, **fixed_url_args):
        self.endpoint = endpoint
        self.css = css
        self.fixed_url_args = dict()
        self.fixed_url_args.update(fixed_url_args)
        self.url_args_callback = url_args

        if size is not None:
          width = height = size

        self.width = width
        self.height = height

      def get_url_args(self):
        kw = dict()
        kw.update(self.fixed_url_args)
        return kw

      def __html__(self):
        endpoint = self.endpoint
        if callable(endpoint):
          endpoint = endpoint()

        url_args = self.get_url_args()
        if self.url_args_callback is not None:
          url_args = self.url_args_callback(self, url_args)

        return self.template.render(
          url=url_for(endpoint, **url_args),
          width=self.width,
          height=self.height,
          css=self.css)

    class StaticIcon(DynamicIcon):
      """
      Renders markup for icon located in static folder served by `endpoint`.

      Default endpoint is application static folder.
      """
      def __init__(self, filename, endpoint='static', width=12, height=12, css=u'',
                   size=None):
        DynamicIcon.__init__(self, endpoint, width, height, css, size,
                             filename=filename)

    class Endpoint(object):

      # FIXME: *args doesn't seem to be relevant.
      def __init__(self, name, *args, **kwargs):
        self.name = name
        self.args = args
        self.kwargs = kwargs

      def get_kwargs(self):
        """
        Hook for subclasses.

        The key and values in the returned dictionnary can be safely changed
        without side effects on self.kwargs (provided you don't alter
        mutable values, like calling list.pop()).
        """
        return self.kwargs.copy()

      def __unicode__(self):
        return unicode(url_for(self.name, *self.args, **self.get_kwargs()))

      def __repr__(self):
        return '{cls}({name}, *{args}, **{kwargs})'.format(
          cls=self.__class__.__name__,
          name=repr(self.name),
          args=repr(self.args),
          kwargs=repr(self.kwargs),
        )

    class Action(object):
      """ Action interface.
      """
      Endpoint = Endpoint
      category = None
      name = None
      title = None
      description = None
      icon = None
      _url = None
      CSS_CLASS = u'action action-{category} action-{category}-{name}'

      #: A :class:`Endpoint` instance, a string for a simple endpoint, a tuple
      #: ``(endpoint_name, kwargs)``  or a callable which accept a : context dict
      #: and returns one of those a valid values.
      endpoint = None

      #: A boolean (or something that can be converted to boolean), or a callable
      #: which accepts a context dict as parameter. See :meth:`available`.
      condition = None

      template_string = (
          u'<a class="{{ action.css_class }}" href="{{ url }}">'
          u'{%- if action.icon %}{{ action.icon }} {% endif %}'
          u'{{ action.title }}'
          u'</a>'
      )

      def __init__(self, category, name, title=None, description=None, icon=None,
                   url=None, endpoint=None, condition=None, status=None):
        self.category = category
        self.name = name
        self._build_css_class()

        self.title = title
        self.description = description
        if isinstance(icon, basestring):
          icon = Glyphicon(icon)
        self.icon = icon
        self._url = url
        self._status = Status(status) if status is not None else ENABLED
        self.endpoint = endpoint
        if not callable(endpoint) and not isinstance(endpoint, Endpoint):
        # property getter will make it and Endpoint instance
          self.endpoint = self.endpoint
        self.condition = condition

        self._enabled = True
        self.template = None

      #: ui status. A :class:`Status` instance
      @getset
      def status(self, value=None):
        status = self._status
        if value is not None:
          self._status = status = Status(value)
        return status

      #: Boolean. Disabled actions are unconditionnaly skipped.
      @getset
      def enabled(self, value=None):
        enabled = self._enabled
        if value is not None:
          assert isinstance(value, bool)
          self._enabled = enabled = value
        return enabled

      def _get_and_call(self, attr):
        attr = '_' + attr
        value = getattr(self, attr)
        if callable(value):
          value = value(actions.context)
        return value

      @property
      def title(self):
        return self._get_and_call('title')

      @title.setter
      def title(self, title):
        self._title = title

      def _build_css_class(self):
        css_cat = self.CSS_CLASS.format(action=self, category=self.category,
                                        name=self.name)
        css_cat = re.sub(r'[^ _a-zA-Z0-9-]', '-', css_cat)
        self.css_class = css_cat

      @property
      def description(self):
        return self._get_and_call('description')

      @description.setter
      def description(self, description):
        self._description = description

      @property
      def icon(self):
        return self._get_and_call('icon')

      @icon.setter
      def icon(self, icon):
        self._icon = icon

      @property
      def endpoint(self):
        endpoint = self._get_and_call('endpoint')
        if endpoint is None:
          return

        if not isinstance(endpoint, Endpoint):
          if isinstance(endpoint, basestring):
            endpoint = self.Endpoint(endpoint)
          elif isinstance(endpoint, (tuple, list)):
            assert len(endpoint) == 2
            endpoint, kwargs = endpoint
            assert isinstance(endpoint, basestring)
            assert isinstance(kwargs, dict)
            endpoint = self.Endpoint(endpoint, **kwargs)
          else:
            raise ValueError('Invalid endpoint specifier: "%s"' % repr(endpoint))

        return endpoint

      @endpoint.setter
      def endpoint(self, endpoint):
        self._endpoint = endpoint

      def available(self, context):
        """
        Determine if this actions is available in this `context`.

        :param context: a dict whose content is left to application needs; if
                        :attr:`.condition` is a callable it receives `context`
                        in parameter.
        """
        if not self._enabled:
          return False
        return self.pre_condition(context) and self._check_condition(context)

      def pre_condition(self, context):
        """
        Called by :meth:`.available` before checking condition.

        Subclasses may override it to ease creating actions with repetitive
        check (for example: actions that apply on a given content type
        only).
        """
        return True

      def _check_condition(self, context):
        if self.condition is None:
          return True

        if callable(self.condition):
          return self.condition(context)
        else:
          return bool(self.condition)

      def render(self, **kwargs):
        if not self.template:
          self.template = Template(self.template_string)

        params = self.get_render_args(**kwargs)
        return Markup(self.template.render(params))

      def get_render_args(self, **kwargs):
        params = dict(action=self)
        params.update(actions.context)
        params.update(kwargs)
        params['url'] = self.url(params)
        return params

      def url(self, context=None):
        if callable(self._url):
          return self._url(context)

        endpoint = self.endpoint
        if endpoint:
          return unicode(endpoint)
        return self._url

    class ModalActionMixin(object):
      template_string = (
          u'<a class="{{ action.css_class }}" href="{{ url }}" data-toggle="modal">'
          u'{%- if action.icon %}{{ action.icon}} {% endif %}'
          u'{{ action.title }}'
          u'</a>'
      )

    class ButtonAction(Action):
      template_string = (
          u'<button type="submit" '
          u'class="btn btn-{{ action.btn_class }} {{ action.css_class}}"'
          u'name="{{ action.submit_name }}" '
          u'value="{{ action.name }}">'
          u'{%- if action.icon %}{{ action.icon }} {% endif %}'
          u'{{ action.title }}</button>'
      )

      btn_class = 'default'

      def __init__(self, category, name, submit_name="__action", btn_class='default',
                   *args, **kwargs):
        Action.__init__(self, category, name, *args, **kwargs)
        self.submit_name = submit_name
        self.btn_class = btn_class

    class ActionRegistry(object):
      """ The Action registry.

      This is a Flask extension which registers :class:`.Action` sets. Actions are
      grouped by category and are ordered by registering order.

      From your application use the instanciated registry :data:`.actions`.

      The registry is available in jinja2 templates as `actions`.
      """
      __EXTENSION_NAME = 'abilian:actions'

      def init_app(self, app):
        if self.__EXTENSION_NAME in app.extensions:
          log.warning('ActionRegistry.init_app: actions already enabled on this application')
          return

        app.extensions[self.__EXTENSION_NAME] = dict(categories=dict())
        appcontext_pushed.connect(self._init_context, app)

        @app.context_processor
        def add_registry_to_jinja_context():
          return dict(actions=self)

      def installed(self, app=None):
        """ Return `True` if the registry has been installed in current applications
        """
        if app is None:
          app = current_app
        return self.__EXTENSION_NAME in app.extensions

      def register(self, *actions):
        """ Register `actions` in the current application. All `actions` must be an
        instance of :class:`.Action` or one of its subclasses.

        If `overwrite` is `True`, then it is allowed to overwrite an existing
        action with same name and category; else `ValueError` is raised.
        """
        assert self.installed(), "Actions not enabled on this application"
        assert all(map(lambda a: isinstance(a, Action), actions))

        for action in actions:
          cat = action.category
          reg = self._state['categories'].setdefault(cat, [])
          reg.append(action)

      def actions(self, context=None):
        """ Return a mapping of category => actions list.

        Actions are filtered according to :meth:`.Action.available`.

        if `context` is None, then current action context is used
        (:attr:`context`).
        """
        assert self.installed(), "Actions not enabled on this application"
        result = {}
        if context is None:
          context = self.context

        for cat, actions in self._state['categories'].items():
          result[cat] = [a for a in actions if a.available(context)]
        return result

      def for_category(self, category, context=None):
        """ Returns actions list for this category in current application.

        Actions are filtered according to :meth:`.Action.available`.

        if `context` is None, then current action context is used
        (:attr:`context`)
        """
        assert self.installed(), "Actions not enabled on this application"
        actions = self._state['categories'].get(category, [])

        if context is None:
          context = self.context

        return filter(lambda a: a.available(context), actions)

      @property
      def _state(self):
        return current_app.extensions[self.__EXTENSION_NAME]

      def _init_context(self, sender):
        g.action_context = {}

      @property
      def context(self):
        """ Return action context (dict type). Applications can modify it to suit
        their needs.
        """
        return g.action_context

    actions = ActionRegistry()