web/views/facets.py
author Rémi Cardona <remi.cardona@logilab.fr>, Julien Cristau <julien.cristau@logilab.fr>
Thu, 26 Nov 2015 11:30:54 +0100
changeset 10935 049209b9e9d6
parent 10666 7f6b5f023884
permissions -rw-r--r--
[qunit] stop dealing with filesystem paths qunit tests need a few things served by cubicweb: - qunit itself, which is now handled by CWDevtoolsStaticController (serving files in cubicweb/devtools/data) - standard cubicweb or cubes data files, handled by the DataController - the tests themselves and their dependencies. These can live in <apphome>/data or <apphome>/static and be served by one of the STATIC_CONTROLLERS This avoids having to guess in CWSoftwareRootStaticController where to serve things from (some files may be installed, others are in the source tree), and should hopefully make it possible to have these tests pass when using tox, and to write qunit tests for cubes, outside of cubicweb itself. This requires modifying the tests to only declare URL paths instead of filesystem paths, and moving support files below test/data/static.

# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
"""the facets box and some basic facets"""

__docformat__ = "restructuredtext en"
from cubicweb import _

from warnings import warn

from logilab.mtconverter import xml_escape
from logilab.common.decorators import cachedproperty
from logilab.common.registry import objectify_predicate, yes

from cubicweb import tags
from cubicweb.predicates import (non_final_entity, multi_lines_rset,
                                 match_context_prop, relation_possible)
from cubicweb.utils import json_dumps
from cubicweb.uilib import css_em_num_value
from cubicweb.view import AnyRsetView
from cubicweb.web import component, facet as facetbase
from cubicweb.web.views.ajaxcontroller import ajaxfunc

def facets(req, rset, context, mainvar=None, **kwargs):
    """return the base rql and a list of widgets for facets applying to the
    given rset/context (cached version of :func:`_facet`)

    :param req: A :class:`~cubicweb.req.RequestSessionBase` object
    :param rset: A :class:`~cubicweb.rset.ResultSet`
    :param context: A string that match the ``__regid__`` of a ``FacetFilter``
    :param mainvar: A string that match a select var from the rset
    """
    try:
        cache = req.__rset_facets
    except AttributeError:
        cache = req.__rset_facets = {}
    try:
        return cache[(rset, context, mainvar)]
    except KeyError:
        facets = _facets(req, rset, context, mainvar, **kwargs)
        cache[(rset, context, mainvar)] = facets
        return facets

def _facets(req, rset, context, mainvar, **kwargs):
    """return the base rql and a list of widgets for facets applying to the
    given rset/context

    :param req: A :class:`~cubicweb.req.RequestSessionBase` object
    :param rset: A :class:`~cubicweb.rset.ResultSet`
    :param context: A string that match the ``__regid__`` of a ``FacetFilter``
    :param mainvar: A string that match a select var from the rset
    """
    ### initialisation
    # XXX done by selectors, though maybe necessary when rset has been hijacked
    # (e.g. contextview_selector matched)
    origqlst = rset.syntax_tree()
    # union not yet supported
    if len(origqlst.children) != 1:
        req.debug('facette disabled on union request %s', origqlst)
        return None, ()
    rqlst = origqlst.copy()
    select = rqlst.children[0]
    filtered_variable, baserql = facetbase.init_facets(rset, select, mainvar)
    ### Selection
    possible_facets = req.vreg['facets'].poss_visible_objects(
        req, rset=rset, rqlst=origqlst, select=select,
        context=context, filtered_variable=filtered_variable, **kwargs)
    wdgs = [(facet, facet.get_widget()) for facet in possible_facets]
    return baserql, [wdg for facet, wdg in wdgs if wdg is not None]


@objectify_predicate
def contextview_selector(cls, req, rset=None, row=None, col=None, view=None,
                         **kwargs):
    if view:
        try:
            getcontext = getattr(view, 'filter_box_context_info')
        except AttributeError:
            return 0
        rset = getcontext()[0]
        if rset is None or rset.rowcount < 2:
            return 0
        wdgs = facets(req, rset, cls.__regid__, view=view)[1]
        return len(wdgs)
    return 0

@objectify_predicate
def has_facets(cls, req, rset=None, **kwargs):
    if rset is None or rset.rowcount < 2:
        return 0
    wdgs = facets(req, rset, cls.__regid__, **kwargs)[1]
    return len(wdgs)


def filter_hiddens(w, baserql, wdgs, **kwargs):
    kwargs['facets'] = ','.join(wdg.facet.__regid__ for wdg in wdgs)
    kwargs['baserql'] = baserql
    for key, val in kwargs.items():
        w(u'<input type="hidden" name="%s" value="%s" />' % (
            key, xml_escape(val)))


class FacetFilterMixIn(object):
    """Mixin Class to generate Facet Filter Form

    To generate the form, you need to explicitly call the following method:

    .. automethod:: generate_form

    The most useful function to override is:

    .. automethod:: layout_widgets
    """

    needs_js = ['cubicweb.ajax.js', 'cubicweb.facets.js']
    needs_css = ['cubicweb.facets.css']

    def generate_form(self, w, rset, divid, vid, vidargs=None, mainvar=None,
                      paginate=False, cssclass='', hiddens=None, **kwargs):
        """display a form to filter some view's content

        :param w:        Write function

        :param rset:     ResultSet to be filtered

        :param divid:    Dom ID of the div where the rendering of the view is done.
        :type divid:     string

        :param vid:      ID of the view display in the div
        :type vid:       string

        :param paginate: Is the view paginated?
        :type paginate:  boolean

        :param cssclass: Additional css classes to put on the form.
        :type cssclass:  string

        :param hiddens:  other hidden parametters to include in the forms.
        :type hiddens:   dict from extra keyword argument
        """
        # XXX Facet.context property hijacks an otherwise well-behaved
        #     vocabulary with its own notions
        #     Hence we whack here to avoid a clash
        kwargs.pop('context', None)
        baserql, wdgs = facets(self._cw, rset, context=self.__regid__,
                               mainvar=mainvar, **kwargs)
        assert wdgs
        self._cw.add_js(self.needs_js)
        self._cw.add_css(self.needs_css)
        self._cw.html_headers.define_var('facetLoadingMsg',
                                         self._cw._('facet-loading-msg'))
        if vidargs is not None:
            warn("[3.14] vidargs is deprecated. Maybe you're using some TableView?",
                 DeprecationWarning, stacklevel=2)
        else:
            vidargs = {}
        vidargs = dict((k, v) for k, v in vidargs.items() if v)
        facetargs = xml_escape(json_dumps([divid, vid, paginate, vidargs]))
        w(u'<form id="%sForm" class="%s" method="post" action="" '
          'cubicweb:facetargs="%s" >' % (divid, cssclass, facetargs))
        w(u'<fieldset>')
        if hiddens is None:
            hiddens = {}
        if mainvar:
            hiddens['mainvar'] = mainvar
        filter_hiddens(w, baserql, wdgs, **hiddens)
        self.layout_widgets(w, self.sorted_widgets(wdgs))

        # <Enter> is supposed to submit the form only if there is a single
        # input:text field. However most browsers will submit the form
        # on <Enter> anyway if there is an input:submit field.
        #
        # see: http://www.w3.org/MarkUp/html-spec/html-spec_8.html#SEC8.2
        #
        # Firefox 7.0.1 does not submit form on <Enter> if there is more than a
        # input:text field and not input:submit but does it if there is an
        # input:submit.
        #
        # IE 6 or Firefox 2 behave the same way.
        w(u'<input type="submit" class="hidden" />')
        #
        w(u'</fieldset>\n')
        w(u'</form>\n')

    def sorted_widgets(self, wdgs):
        """sort widgets: by default sort by widget height, then according to
        widget.order (the original widgets order)
        """
        return sorted(wdgs, key=lambda x: 99 * (not x.facet.start_unfolded) or x.height )

    def layout_widgets(self, w, wdgs):
        """layout widgets: by default simply render each of them
        (i.e. succession of <div>)
        """
        for wdg in wdgs:
            wdg.render(w=w)


class FilterBox(FacetFilterMixIn, component.CtxComponent):
    """filter results of a query"""
    __regid__ = 'facet.filterbox'
    __select__ = ((non_final_entity() & has_facets())
                  | contextview_selector()) # can't use has_facets because of
                                            # contextview mecanism
    context = 'left' # XXX doesn't support 'incontext', only 'left' or 'right'
    title = _('facet.filters')
    visible = True # functionality provided by the search box by default
    order = 1

    bk_linkbox_template = u'<div class="facetTitle">%s</div>'

    def render_body(self, w, **kwargs):
        req = self._cw
        rset, vid, divid, paginate = self._get_context()
        assert len(rset) > 1
        if vid is None:
            vid = req.form.get('vid')
        if self.bk_linkbox_template and req.vreg.schema['Bookmark'].has_perm(req, 'add'):
            w(self.bookmark_link(rset))
        w(self.focus_link(rset))
        hiddens = {}
        for param in ('subvid', 'vtitle'):
            if param in req.form:
                hiddens[param] = req.form[param]
        self.generate_form(w, rset, divid, vid, paginate=paginate,
                           hiddens=hiddens, **self.cw_extra_kwargs)

    def _get_context(self):
        view = self.cw_extra_kwargs.get('view')
        context = getattr(view, 'filter_box_context_info', lambda: None)()
        if context:
            rset, vid, divid, paginate = context
        else:
            rset = self.cw_rset
            vid, divid = None, 'pageContent'
            paginate = view and view.paginable
        return rset, vid, divid, paginate

    def bookmark_link(self, rset):
        req = self._cw
        bk_path = u'rql=%s' % req.url_quote(rset.printable_rql())
        if req.form.get('vid'):
            bk_path += u'&vid=%s' % req.url_quote(req.form['vid'])
        bk_path = u'view?' + bk_path
        bk_title = req._('my custom search')
        linkto = u'bookmarked_by:%s:subject' % req.user.eid
        bkcls = req.vreg['etypes'].etype_class('Bookmark')
        bk_add_url = bkcls.cw_create_url(req, path=bk_path, title=bk_title,
                                         __linkto=linkto)
        bk_base_url = bkcls.cw_create_url(req, title=bk_title, __linkto=linkto)
        bk_link = u'<a cubicweb:target="%s" id="facetBkLink" href="%s">%s</a>' % (
                xml_escape(bk_base_url), xml_escape(bk_add_url),
                req._('bookmark this search'))
        return self.bk_linkbox_template % bk_link

    def focus_link(self, rset):
        return self.bk_linkbox_template % tags.a(self._cw._('focus on this selection'),
                                                 href=self._cw.url(), id='focusLink')

class FilterTable(FacetFilterMixIn, AnyRsetView):
    __regid__ = 'facet.filtertable'
    __select__ = has_facets()
    average_perfacet_uncomputable_overhead = .3

    def call(self, vid, divid, vidargs=None, cssclass=''):
        hiddens = self.cw_extra_kwargs.setdefault('hiddens', {})
        hiddens['fromformfilter'] = '1'
        self.generate_form(self.w, self.cw_rset, divid, vid, vidargs=vidargs,
                           cssclass=cssclass, **self.cw_extra_kwargs)

    @cachedproperty
    def per_facet_height_overhead(self):
        return (css_em_num_value(self._cw.vreg, 'facet_MarginBottom', .2) +
                css_em_num_value(self._cw.vreg, 'facet_Padding', .2) +
                self.average_perfacet_uncomputable_overhead)

    def layout_widgets(self, w, wdgs):
        """layout widgets: put them in a table where each column should have
        sum(wdg.height) < wdg_stack_size.
        """
        w(u'<div class="filter">\n')
        widget_queue = []
        queue_height = 0
        wdg_stack_size = facetbase._DEFAULT_FACET_GROUP_HEIGHT
        for wdg in wdgs:
            height = wdg.height + self.per_facet_height_overhead
            if queue_height + height <= wdg_stack_size:
                widget_queue.append(wdg)
                queue_height += height
                continue
            w(u'<div class="facetGroup">')
            for queued in widget_queue:
                queued.render(w=w)
            w(u'</div>')
            widget_queue = [wdg]
            queue_height = height
        if widget_queue:
            w(u'<div class="facetGroup">')
            for queued in widget_queue:
                queued.render(w=w)
            w(u'</div>')
        w(u'</div>\n')

# python-ajax remote functions used by facet widgets #########################

@ajaxfunc(output_type='json')
def filter_build_rql(self, names, values):
    form = self._rebuild_posted_form(names, values)
    self._cw.form = form
    builder = facetbase.FilterRQLBuilder(self._cw)
    return builder.build_rql()

@ajaxfunc(output_type='json')
def filter_select_content(self, facetids, rql, mainvar):
    # Union unsupported yet
    select = self._cw.vreg.parse(self._cw, rql).children[0]
    filtered_variable = facetbase.get_filtered_variable(select, mainvar)
    facetbase.prepare_select(select, filtered_variable)
    update_map = {}
    for fid in facetids:
        fobj = facetbase.get_facet(self._cw, fid, select, filtered_variable)
        update_map[fid] = fobj.possible_values()
    return update_map



# facets ######################################################################

class CWSourceFacet(facetbase.RelationFacet):
    __regid__ = 'cw_source-facet'
    rtype = 'cw_source'
    target_attr = 'name'

class CreatedByFacet(facetbase.RelationFacet):
    __regid__ = 'created_by-facet'
    rtype = 'created_by'
    target_attr = 'login'

class InGroupFacet(facetbase.RelationFacet):
    __regid__ = 'in_group-facet'
    rtype = 'in_group'
    target_attr = 'name'

class InStateFacet(facetbase.RelationAttributeFacet):
    __regid__ = 'in_state-facet'
    rtype = 'in_state'
    target_attr = 'name'


# inherit from RelationFacet to benefit from its possible_values implementation
class ETypeFacet(facetbase.RelationFacet):
    __regid__ = 'etype-facet'
    __select__ = yes()
    order = 1
    rtype = 'is'
    target_attr = 'name'

    @property
    def title(self):
        return self._cw._('entity type')

    def vocabulary(self):
        """return vocabulary for this facet, eg a list of 2-uple (label, value)
        """
        etypes = self.cw_rset.column_types(0)
        return sorted((self._cw._(etype), etype) for etype in etypes)

    def add_rql_restrictions(self):
        """add restriction for this facet into the rql syntax tree"""
        value = self._cw.form.get(self.__regid__)
        if not value:
            return
        self.select.add_type_restriction(self.filtered_variable, value)

    def possible_values(self):
        """return a list of possible values (as string since it's used to
        compare to a form value in javascript) for this facet
        """
        select = self.select
        select.save_state()
        try:
            facetbase.cleanup_select(select, self.filtered_variable)
            etype_var = facetbase.prepare_vocabulary_select(
                select, self.filtered_variable, self.rtype, self.role)
            attrvar = select.make_variable()
            select.add_selected(attrvar)
            select.add_relation(etype_var, 'name', attrvar)
            return [etype for _, etype in self.rqlexec(select.as_string())]
        finally:
            select.recover()


class HasTextFacet(facetbase.AbstractFacet):
    __select__ = relation_possible('has_text', 'subject') & match_context_prop()
    __regid__ = 'has_text-facet'
    rtype = 'has_text'
    role = 'subject'
    order = 0

    @property
    def wdgclass(self):
        return facetbase.FacetStringWidget

    @property
    def title(self):
        return self._cw._('has_text')

    def get_widget(self):
        """return the widget instance to use to display this facet

        default implentation expects a .vocabulary method on the facet and
        return a combobox displaying this vocabulary
        """
        return self.wdgclass(self)

    def add_rql_restrictions(self):
        """add restriction for this facet into the rql syntax tree"""
        value = self._cw.form.get(self.__regid__)
        if not value:
            return
        self.select.add_constant_restriction(self.filtered_variable, 'has_text', value, 'String')