diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/web/views/facets.py --- /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 . +"""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'' % ( + 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'
' % (divid, cssclass, facetargs)) + w(u'
') + if hiddens is None: + hiddens = {} + if mainvar: + hiddens['mainvar'] = mainvar + filter_hiddens(w, baserql, wdgs, **hiddens) + self.layout_widgets(w, self.sorted_widgets(wdgs)) + + # is supposed to submit the form only if there is a single + # input:text field. However most browsers will submit the form + # on 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 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'') + # + w(u'
\n') + w(u'
\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
) + """ + 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'
%s
' + + 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'%s' % ( + 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'
\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'
') + for queued in widget_queue: + queued.render(w=w) + w(u'
') + widget_queue = [wdg] + queue_height = height + if widget_queue: + w(u'
') + for queued in widget_queue: + queued.render(w=w) + w(u'
') + w(u'
\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')