web/views/facets.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """the facets box and some basic facets"""
       
    19 
       
    20 __docformat__ = "restructuredtext en"
       
    21 from cubicweb import _
       
    22 
       
    23 from warnings import warn
       
    24 
       
    25 from logilab.mtconverter import xml_escape
       
    26 from logilab.common.decorators import cachedproperty
       
    27 from logilab.common.registry import objectify_predicate, yes
       
    28 
       
    29 from cubicweb import tags
       
    30 from cubicweb.predicates import (non_final_entity, multi_lines_rset,
       
    31                                  match_context_prop, relation_possible)
       
    32 from cubicweb.utils import json_dumps
       
    33 from cubicweb.uilib import css_em_num_value
       
    34 from cubicweb.view import AnyRsetView
       
    35 from cubicweb.web import component, facet as facetbase
       
    36 from cubicweb.web.views.ajaxcontroller import ajaxfunc
       
    37 
       
    38 def facets(req, rset, context, mainvar=None, **kwargs):
       
    39     """return the base rql and a list of widgets for facets applying to the
       
    40     given rset/context (cached version of :func:`_facet`)
       
    41 
       
    42     :param req: A :class:`~cubicweb.req.RequestSessionBase` object
       
    43     :param rset: A :class:`~cubicweb.rset.ResultSet`
       
    44     :param context: A string that match the ``__regid__`` of a ``FacetFilter``
       
    45     :param mainvar: A string that match a select var from the rset
       
    46     """
       
    47     try:
       
    48         cache = req.__rset_facets
       
    49     except AttributeError:
       
    50         cache = req.__rset_facets = {}
       
    51     try:
       
    52         return cache[(rset, context, mainvar)]
       
    53     except KeyError:
       
    54         facets = _facets(req, rset, context, mainvar, **kwargs)
       
    55         cache[(rset, context, mainvar)] = facets
       
    56         return facets
       
    57 
       
    58 def _facets(req, rset, context, mainvar, **kwargs):
       
    59     """return the base rql and a list of widgets for facets applying to the
       
    60     given rset/context
       
    61 
       
    62     :param req: A :class:`~cubicweb.req.RequestSessionBase` object
       
    63     :param rset: A :class:`~cubicweb.rset.ResultSet`
       
    64     :param context: A string that match the ``__regid__`` of a ``FacetFilter``
       
    65     :param mainvar: A string that match a select var from the rset
       
    66     """
       
    67     ### initialisation
       
    68     # XXX done by selectors, though maybe necessary when rset has been hijacked
       
    69     # (e.g. contextview_selector matched)
       
    70     origqlst = rset.syntax_tree()
       
    71     # union not yet supported
       
    72     if len(origqlst.children) != 1:
       
    73         req.debug('facette disabled on union request %s', origqlst)
       
    74         return None, ()
       
    75     rqlst = origqlst.copy()
       
    76     select = rqlst.children[0]
       
    77     filtered_variable, baserql = facetbase.init_facets(rset, select, mainvar)
       
    78     ### Selection
       
    79     possible_facets = req.vreg['facets'].poss_visible_objects(
       
    80         req, rset=rset, rqlst=origqlst, select=select,
       
    81         context=context, filtered_variable=filtered_variable, **kwargs)
       
    82     wdgs = [(facet, facet.get_widget()) for facet in possible_facets]
       
    83     return baserql, [wdg for facet, wdg in wdgs if wdg is not None]
       
    84 
       
    85 
       
    86 @objectify_predicate
       
    87 def contextview_selector(cls, req, rset=None, row=None, col=None, view=None,
       
    88                          **kwargs):
       
    89     if view:
       
    90         try:
       
    91             getcontext = getattr(view, 'filter_box_context_info')
       
    92         except AttributeError:
       
    93             return 0
       
    94         rset = getcontext()[0]
       
    95         if rset is None or rset.rowcount < 2:
       
    96             return 0
       
    97         wdgs = facets(req, rset, cls.__regid__, view=view)[1]
       
    98         return len(wdgs)
       
    99     return 0
       
   100 
       
   101 @objectify_predicate
       
   102 def has_facets(cls, req, rset=None, **kwargs):
       
   103     if rset is None or rset.rowcount < 2:
       
   104         return 0
       
   105     wdgs = facets(req, rset, cls.__regid__, **kwargs)[1]
       
   106     return len(wdgs)
       
   107 
       
   108 
       
   109 def filter_hiddens(w, baserql, wdgs, **kwargs):
       
   110     kwargs['facets'] = ','.join(wdg.facet.__regid__ for wdg in wdgs)
       
   111     kwargs['baserql'] = baserql
       
   112     for key, val in kwargs.items():
       
   113         w(u'<input type="hidden" name="%s" value="%s" />' % (
       
   114             key, xml_escape(val)))
       
   115 
       
   116 
       
   117 class FacetFilterMixIn(object):
       
   118     """Mixin Class to generate Facet Filter Form
       
   119 
       
   120     To generate the form, you need to explicitly call the following method:
       
   121 
       
   122     .. automethod:: generate_form
       
   123 
       
   124     The most useful function to override is:
       
   125 
       
   126     .. automethod:: layout_widgets
       
   127     """
       
   128 
       
   129     needs_js = ['cubicweb.ajax.js', 'cubicweb.facets.js']
       
   130     needs_css = ['cubicweb.facets.css']
       
   131 
       
   132     def generate_form(self, w, rset, divid, vid, vidargs=None, mainvar=None,
       
   133                       paginate=False, cssclass='', hiddens=None, **kwargs):
       
   134         """display a form to filter some view's content
       
   135 
       
   136         :param w:        Write function
       
   137 
       
   138         :param rset:     ResultSet to be filtered
       
   139 
       
   140         :param divid:    Dom ID of the div where the rendering of the view is done.
       
   141         :type divid:     string
       
   142 
       
   143         :param vid:      ID of the view display in the div
       
   144         :type vid:       string
       
   145 
       
   146         :param paginate: Is the view paginated?
       
   147         :type paginate:  boolean
       
   148 
       
   149         :param cssclass: Additional css classes to put on the form.
       
   150         :type cssclass:  string
       
   151 
       
   152         :param hiddens:  other hidden parametters to include in the forms.
       
   153         :type hiddens:   dict from extra keyword argument
       
   154         """
       
   155         # XXX Facet.context property hijacks an otherwise well-behaved
       
   156         #     vocabulary with its own notions
       
   157         #     Hence we whack here to avoid a clash
       
   158         kwargs.pop('context', None)
       
   159         baserql, wdgs = facets(self._cw, rset, context=self.__regid__,
       
   160                                mainvar=mainvar, **kwargs)
       
   161         assert wdgs
       
   162         self._cw.add_js(self.needs_js)
       
   163         self._cw.add_css(self.needs_css)
       
   164         self._cw.html_headers.define_var('facetLoadingMsg',
       
   165                                          self._cw._('facet-loading-msg'))
       
   166         if vidargs is not None:
       
   167             warn("[3.14] vidargs is deprecated. Maybe you're using some TableView?",
       
   168                  DeprecationWarning, stacklevel=2)
       
   169         else:
       
   170             vidargs = {}
       
   171         vidargs = dict((k, v) for k, v in vidargs.items() if v)
       
   172         facetargs = xml_escape(json_dumps([divid, vid, paginate, vidargs]))
       
   173         w(u'<form id="%sForm" class="%s" method="post" action="" '
       
   174           'cubicweb:facetargs="%s" >' % (divid, cssclass, facetargs))
       
   175         w(u'<fieldset>')
       
   176         if hiddens is None:
       
   177             hiddens = {}
       
   178         if mainvar:
       
   179             hiddens['mainvar'] = mainvar
       
   180         filter_hiddens(w, baserql, wdgs, **hiddens)
       
   181         self.layout_widgets(w, self.sorted_widgets(wdgs))
       
   182 
       
   183         # <Enter> is supposed to submit the form only if there is a single
       
   184         # input:text field. However most browsers will submit the form
       
   185         # on <Enter> anyway if there is an input:submit field.
       
   186         #
       
   187         # see: http://www.w3.org/MarkUp/html-spec/html-spec_8.html#SEC8.2
       
   188         #
       
   189         # Firefox 7.0.1 does not submit form on <Enter> if there is more than a
       
   190         # input:text field and not input:submit but does it if there is an
       
   191         # input:submit.
       
   192         #
       
   193         # IE 6 or Firefox 2 behave the same way.
       
   194         w(u'<input type="submit" class="hidden" />')
       
   195         #
       
   196         w(u'</fieldset>\n')
       
   197         w(u'</form>\n')
       
   198 
       
   199     def sorted_widgets(self, wdgs):
       
   200         """sort widgets: by default sort by widget height, then according to
       
   201         widget.order (the original widgets order)
       
   202         """
       
   203         return sorted(wdgs, key=lambda x: 99 * (not x.facet.start_unfolded) or x.height )
       
   204 
       
   205     def layout_widgets(self, w, wdgs):
       
   206         """layout widgets: by default simply render each of them
       
   207         (i.e. succession of <div>)
       
   208         """
       
   209         for wdg in wdgs:
       
   210             wdg.render(w=w)
       
   211 
       
   212 
       
   213 class FilterBox(FacetFilterMixIn, component.CtxComponent):
       
   214     """filter results of a query"""
       
   215     __regid__ = 'facet.filterbox'
       
   216     __select__ = ((non_final_entity() & has_facets())
       
   217                   | contextview_selector()) # can't use has_facets because of
       
   218                                             # contextview mecanism
       
   219     context = 'left' # XXX doesn't support 'incontext', only 'left' or 'right'
       
   220     title = _('facet.filters')
       
   221     visible = True # functionality provided by the search box by default
       
   222     order = 1
       
   223 
       
   224     bk_linkbox_template = u'<div class="facetTitle">%s</div>'
       
   225 
       
   226     def render_body(self, w, **kwargs):
       
   227         req = self._cw
       
   228         rset, vid, divid, paginate = self._get_context()
       
   229         assert len(rset) > 1
       
   230         if vid is None:
       
   231             vid = req.form.get('vid')
       
   232         if self.bk_linkbox_template and req.vreg.schema['Bookmark'].has_perm(req, 'add'):
       
   233             w(self.bookmark_link(rset))
       
   234         w(self.focus_link(rset))
       
   235         hiddens = {}
       
   236         for param in ('subvid', 'vtitle'):
       
   237             if param in req.form:
       
   238                 hiddens[param] = req.form[param]
       
   239         self.generate_form(w, rset, divid, vid, paginate=paginate,
       
   240                            hiddens=hiddens, **self.cw_extra_kwargs)
       
   241 
       
   242     def _get_context(self):
       
   243         view = self.cw_extra_kwargs.get('view')
       
   244         context = getattr(view, 'filter_box_context_info', lambda: None)()
       
   245         if context:
       
   246             rset, vid, divid, paginate = context
       
   247         else:
       
   248             rset = self.cw_rset
       
   249             vid, divid = None, 'pageContent'
       
   250             paginate = view and view.paginable
       
   251         return rset, vid, divid, paginate
       
   252 
       
   253     def bookmark_link(self, rset):
       
   254         req = self._cw
       
   255         bk_path = u'rql=%s' % req.url_quote(rset.printable_rql())
       
   256         if req.form.get('vid'):
       
   257             bk_path += u'&vid=%s' % req.url_quote(req.form['vid'])
       
   258         bk_path = u'view?' + bk_path
       
   259         bk_title = req._('my custom search')
       
   260         linkto = u'bookmarked_by:%s:subject' % req.user.eid
       
   261         bkcls = req.vreg['etypes'].etype_class('Bookmark')
       
   262         bk_add_url = bkcls.cw_create_url(req, path=bk_path, title=bk_title,
       
   263                                          __linkto=linkto)
       
   264         bk_base_url = bkcls.cw_create_url(req, title=bk_title, __linkto=linkto)
       
   265         bk_link = u'<a cubicweb:target="%s" id="facetBkLink" href="%s">%s</a>' % (
       
   266                 xml_escape(bk_base_url), xml_escape(bk_add_url),
       
   267                 req._('bookmark this search'))
       
   268         return self.bk_linkbox_template % bk_link
       
   269 
       
   270     def focus_link(self, rset):
       
   271         return self.bk_linkbox_template % tags.a(self._cw._('focus on this selection'),
       
   272                                                  href=self._cw.url(), id='focusLink')
       
   273 
       
   274 class FilterTable(FacetFilterMixIn, AnyRsetView):
       
   275     __regid__ = 'facet.filtertable'
       
   276     __select__ = has_facets()
       
   277     average_perfacet_uncomputable_overhead = .3
       
   278 
       
   279     def call(self, vid, divid, vidargs=None, cssclass=''):
       
   280         hiddens = self.cw_extra_kwargs.setdefault('hiddens', {})
       
   281         hiddens['fromformfilter'] = '1'
       
   282         self.generate_form(self.w, self.cw_rset, divid, vid, vidargs=vidargs,
       
   283                            cssclass=cssclass, **self.cw_extra_kwargs)
       
   284 
       
   285     @cachedproperty
       
   286     def per_facet_height_overhead(self):
       
   287         return (css_em_num_value(self._cw.vreg, 'facet_MarginBottom', .2) +
       
   288                 css_em_num_value(self._cw.vreg, 'facet_Padding', .2) +
       
   289                 self.average_perfacet_uncomputable_overhead)
       
   290 
       
   291     def layout_widgets(self, w, wdgs):
       
   292         """layout widgets: put them in a table where each column should have
       
   293         sum(wdg.height) < wdg_stack_size.
       
   294         """
       
   295         w(u'<div class="filter">\n')
       
   296         widget_queue = []
       
   297         queue_height = 0
       
   298         wdg_stack_size = facetbase._DEFAULT_FACET_GROUP_HEIGHT
       
   299         for wdg in wdgs:
       
   300             height = wdg.height + self.per_facet_height_overhead
       
   301             if queue_height + height <= wdg_stack_size:
       
   302                 widget_queue.append(wdg)
       
   303                 queue_height += height
       
   304                 continue
       
   305             w(u'<div class="facetGroup">')
       
   306             for queued in widget_queue:
       
   307                 queued.render(w=w)
       
   308             w(u'</div>')
       
   309             widget_queue = [wdg]
       
   310             queue_height = height
       
   311         if widget_queue:
       
   312             w(u'<div class="facetGroup">')
       
   313             for queued in widget_queue:
       
   314                 queued.render(w=w)
       
   315             w(u'</div>')
       
   316         w(u'</div>\n')
       
   317 
       
   318 # python-ajax remote functions used by facet widgets #########################
       
   319 
       
   320 @ajaxfunc(output_type='json')
       
   321 def filter_build_rql(self, names, values):
       
   322     form = self._rebuild_posted_form(names, values)
       
   323     self._cw.form = form
       
   324     builder = facetbase.FilterRQLBuilder(self._cw)
       
   325     return builder.build_rql()
       
   326 
       
   327 @ajaxfunc(output_type='json')
       
   328 def filter_select_content(self, facetids, rql, mainvar):
       
   329     # Union unsupported yet
       
   330     select = self._cw.vreg.parse(self._cw, rql).children[0]
       
   331     filtered_variable = facetbase.get_filtered_variable(select, mainvar)
       
   332     facetbase.prepare_select(select, filtered_variable)
       
   333     update_map = {}
       
   334     for fid in facetids:
       
   335         fobj = facetbase.get_facet(self._cw, fid, select, filtered_variable)
       
   336         update_map[fid] = fobj.possible_values()
       
   337     return update_map
       
   338 
       
   339 
       
   340 
       
   341 # facets ######################################################################
       
   342 
       
   343 class CWSourceFacet(facetbase.RelationFacet):
       
   344     __regid__ = 'cw_source-facet'
       
   345     rtype = 'cw_source'
       
   346     target_attr = 'name'
       
   347 
       
   348 class CreatedByFacet(facetbase.RelationFacet):
       
   349     __regid__ = 'created_by-facet'
       
   350     rtype = 'created_by'
       
   351     target_attr = 'login'
       
   352 
       
   353 class InGroupFacet(facetbase.RelationFacet):
       
   354     __regid__ = 'in_group-facet'
       
   355     rtype = 'in_group'
       
   356     target_attr = 'name'
       
   357 
       
   358 class InStateFacet(facetbase.RelationAttributeFacet):
       
   359     __regid__ = 'in_state-facet'
       
   360     rtype = 'in_state'
       
   361     target_attr = 'name'
       
   362 
       
   363 
       
   364 # inherit from RelationFacet to benefit from its possible_values implementation
       
   365 class ETypeFacet(facetbase.RelationFacet):
       
   366     __regid__ = 'etype-facet'
       
   367     __select__ = yes()
       
   368     order = 1
       
   369     rtype = 'is'
       
   370     target_attr = 'name'
       
   371 
       
   372     @property
       
   373     def title(self):
       
   374         return self._cw._('entity type')
       
   375 
       
   376     def vocabulary(self):
       
   377         """return vocabulary for this facet, eg a list of 2-uple (label, value)
       
   378         """
       
   379         etypes = self.cw_rset.column_types(0)
       
   380         return sorted((self._cw._(etype), etype) for etype in etypes)
       
   381 
       
   382     def add_rql_restrictions(self):
       
   383         """add restriction for this facet into the rql syntax tree"""
       
   384         value = self._cw.form.get(self.__regid__)
       
   385         if not value:
       
   386             return
       
   387         self.select.add_type_restriction(self.filtered_variable, value)
       
   388 
       
   389     def possible_values(self):
       
   390         """return a list of possible values (as string since it's used to
       
   391         compare to a form value in javascript) for this facet
       
   392         """
       
   393         select = self.select
       
   394         select.save_state()
       
   395         try:
       
   396             facetbase.cleanup_select(select, self.filtered_variable)
       
   397             etype_var = facetbase.prepare_vocabulary_select(
       
   398                 select, self.filtered_variable, self.rtype, self.role)
       
   399             attrvar = select.make_variable()
       
   400             select.add_selected(attrvar)
       
   401             select.add_relation(etype_var, 'name', attrvar)
       
   402             return [etype for _, etype in self.rqlexec(select.as_string())]
       
   403         finally:
       
   404             select.recover()
       
   405 
       
   406 
       
   407 class HasTextFacet(facetbase.AbstractFacet):
       
   408     __select__ = relation_possible('has_text', 'subject') & match_context_prop()
       
   409     __regid__ = 'has_text-facet'
       
   410     rtype = 'has_text'
       
   411     role = 'subject'
       
   412     order = 0
       
   413 
       
   414     @property
       
   415     def wdgclass(self):
       
   416         return facetbase.FacetStringWidget
       
   417 
       
   418     @property
       
   419     def title(self):
       
   420         return self._cw._('has_text')
       
   421 
       
   422     def get_widget(self):
       
   423         """return the widget instance to use to display this facet
       
   424 
       
   425         default implentation expects a .vocabulary method on the facet and
       
   426         return a combobox displaying this vocabulary
       
   427         """
       
   428         return self.wdgclass(self)
       
   429 
       
   430     def add_rql_restrictions(self):
       
   431         """add restriction for this facet into the rql syntax tree"""
       
   432         value = self._cw.form.get(self.__regid__)
       
   433         if not value:
       
   434             return
       
   435         self.select.add_constant_restriction(self.filtered_variable, 'has_text', value, 'String')