cubicweb/web/views/facets.py
changeset 11057 0b59724cb3f2
parent 10666 7f6b5f023884
child 11767 432f87a63057
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/web/views/facets.py	Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,435 @@
+# 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')