web/facet.py
changeset 0 b97547f5f1fa
child 203 60cd67acf7fd
equal deleted inserted replaced
-1:000000000000 0:b97547f5f1fa
       
     1 """contains utility functions and some visual component to restrict results of
       
     2 a search
       
     3 
       
     4 :organization: Logilab
       
     5 :copyright: 2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     6 :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     7 """
       
     8 __docformat__ = "restructuredtext en"
       
     9 
       
    10 from itertools import chain
       
    11 from copy import deepcopy
       
    12 
       
    13 from logilab.mtconverter import html_escape
       
    14 
       
    15 from logilab.common.graph import has_path
       
    16 from logilab.common.decorators import cached
       
    17 from logilab.common.compat import all
       
    18 
       
    19 from rql import parse, nodes
       
    20 
       
    21 from cubicweb import Unauthorized, typed_eid
       
    22 from cubicweb.common.selectors import contextprop_selector, one_has_relation_selector
       
    23 from cubicweb.common.registerers import priority_registerer
       
    24 from cubicweb.common.appobject import AppRsetObject
       
    25 from cubicweb.common.utils import AcceptMixIn
       
    26 from cubicweb.web.htmlwidgets import HTMLWidget
       
    27 
       
    28 ## rqlst manipulation functions used by facets ################################
       
    29 
       
    30 def prepare_facets_rqlst(rqlst, args=None):
       
    31     """prepare a syntax tree to generate facet filters
       
    32     
       
    33     * remove ORDERBY clause
       
    34     * cleanup selection (remove everything)
       
    35     * undefine unnecessary variables
       
    36     * set DISTINCT
       
    37     * unset LIMIT/OFFSET
       
    38     """
       
    39     if len(rqlst.children) > 1:
       
    40         raise NotImplementedError('FIXME: union not yet supported')
       
    41     select = rqlst.children[0]
       
    42     mainvar = filtered_variable(select)
       
    43     select.set_limit(None)
       
    44     select.set_offset(None)
       
    45     baserql = select.as_string(kwargs=args)
       
    46     # cleanup sort terms
       
    47     select.remove_sort_terms()
       
    48     # selection: only vocabulary entity
       
    49     for term in select.selection[:]:
       
    50         select.remove_selected(term)
       
    51     # remove unbound variables which only have some type restriction
       
    52     for dvar in select.defined_vars.values():
       
    53         if not (dvar is mainvar or dvar.stinfo['relations']):
       
    54             select.undefine_variable(dvar)
       
    55     # global tree config: DISTINCT, LIMIT, OFFSET
       
    56     select.set_distinct(True)
       
    57     return mainvar, baserql
       
    58 
       
    59 def filtered_variable(rqlst):
       
    60     vref = rqlst.selection[0].iget_nodes(nodes.VariableRef).next()
       
    61     return vref.variable
       
    62 
       
    63 
       
    64 def get_facet(req, facetid, rqlst, mainvar):
       
    65     return req.vreg.object_by_id('facets', facetid, req, rqlst=rqlst,
       
    66                                  filtered_variable=mainvar)
       
    67     
       
    68 
       
    69 def filter_hiddens(w, **kwargs):
       
    70     for key, val in kwargs.items():
       
    71         w(u'<input type="hidden" name="%s" value="%s" />' % (
       
    72             key, html_escape(val)))
       
    73 
       
    74 
       
    75 def _may_be_removed(rel, schema, mainvar):
       
    76     """if the given relation may be removed from the tree, return the variable
       
    77     on the other side of `mainvar`, else return None
       
    78     Conditions:
       
    79     * the relation is an attribute selection of the main variable
       
    80     * the relation is optional relation linked to the main variable
       
    81     * the relation is a mandatory relation linked to the main variable
       
    82       without any restriction on the other variable
       
    83     """
       
    84     lhs, rhs = rel.get_variable_parts()
       
    85     rschema = schema.rschema(rel.r_type)
       
    86     if lhs.variable is mainvar:
       
    87         try:
       
    88             ovar = rhs.variable
       
    89         except AttributeError:
       
    90             # constant restriction
       
    91             # XXX: X title LOWER(T) if it makes sense?
       
    92             return None
       
    93         if rschema.is_final():
       
    94             if len(ovar.stinfo['relations']) == 1:
       
    95                 # attribute selection
       
    96                 return ovar
       
    97             return None
       
    98         opt = 'right'
       
    99         cardidx = 0
       
   100     elif getattr(rhs, 'variable', None) is mainvar:
       
   101         ovar = lhs.variable
       
   102         opt = 'left'
       
   103         cardidx = 1
       
   104     else:
       
   105         # not directly linked to the main variable
       
   106         return None
       
   107     if rel.optional in (opt, 'both'):
       
   108         # optional relation
       
   109         return ovar
       
   110     if all(rschema.rproperty(s, o, 'cardinality')[cardidx] in '1+'
       
   111            for s,o in rschema.iter_rdefs()):
       
   112         # mandatory relation without any restriction on the other variable
       
   113         for orel in ovar.stinfo['relations']:
       
   114             if rel is orel:
       
   115                 continue
       
   116             if _may_be_removed(orel, schema, ovar) is None:
       
   117                 return None
       
   118         return ovar
       
   119     return None
       
   120 
       
   121 def _add_rtype_relation(rqlst, mainvar, rtype, role):
       
   122     newvar = rqlst.make_variable()
       
   123     if role == 'object':
       
   124         rel = rqlst.add_relation(newvar, rtype, mainvar)
       
   125     else:
       
   126         rel = rqlst.add_relation(mainvar, rtype, newvar)
       
   127     return newvar, rel
       
   128 
       
   129 def _prepare_vocabulary_rqlst(rqlst, mainvar, rtype, role):
       
   130     """prepare a syntax tree to generate a filter vocabulary rql using the given
       
   131     relation:
       
   132     * create a variable to filter on this relation
       
   133     * add the relation
       
   134     * add the new variable to GROUPBY clause if necessary
       
   135     * add the new variable to the selection
       
   136     """
       
   137     newvar, rel = _add_rtype_relation(rqlst, mainvar, rtype, role)
       
   138     if rqlst.groupby:
       
   139         rqlst.add_group_var(newvar)
       
   140     rqlst.add_selected(newvar)
       
   141     return newvar, rel
       
   142         
       
   143 def _remove_relation(rqlst, rel, var):
       
   144     """remove a constraint relation from the syntax tree"""
       
   145     # remove the relation
       
   146     rqlst.remove_node(rel)
       
   147     # remove relations where the filtered variable appears on the
       
   148     # lhs and rhs is a constant restriction
       
   149     extra = []
       
   150     for vrel in var.stinfo['relations']:
       
   151         if vrel is rel:
       
   152             continue
       
   153         if vrel.children[0].variable is var:
       
   154             if not vrel.children[1].get_nodes(nodes.Constant):
       
   155                 extra.append(vrel)
       
   156             rqlst.remove_node(vrel)
       
   157     return extra
       
   158 
       
   159 def _set_orderby(rqlst, newvar, sortasc, sortfuncname):
       
   160     if sortfuncname is None:
       
   161         rqlst.add_sort_var(newvar, sortasc)
       
   162     else:
       
   163         vref = nodes.variable_ref(newvar)
       
   164         vref.register_reference()
       
   165         sortfunc = nodes.Function(sortfuncname)
       
   166         sortfunc.append(vref)
       
   167         term = nodes.SortTerm(sortfunc, sortasc)
       
   168         rqlst.add_sort_term(term)
       
   169 
       
   170 def insert_attr_select_relation(rqlst, mainvar, rtype, role, attrname,
       
   171                                 sortfuncname=None, sortasc=True):
       
   172     """modify a syntax tree to retrieve only relevant attribute `attr` of `var`"""
       
   173     _cleanup_rqlst(rqlst, mainvar)
       
   174     var, mainrel = _prepare_vocabulary_rqlst(rqlst, mainvar, rtype, role)
       
   175     # not found, create one
       
   176     attrvar = rqlst.make_variable()
       
   177     attrrel = rqlst.add_relation(var, attrname, attrvar)
       
   178     # if query is grouped, we have to add the attribute variable
       
   179     if rqlst.groupby:
       
   180         if not attrvar in rqlst.groupby:
       
   181             rqlst.add_group_var(attrvar)
       
   182     _set_orderby(rqlst, attrvar, sortasc, sortfuncname)
       
   183     # add attribute variable to selection
       
   184     rqlst.add_selected(attrvar)
       
   185     # add is restriction if necessary
       
   186     if not mainvar.stinfo['typerels']:
       
   187         etypes = frozenset(sol[mainvar.name] for sol in rqlst.solutions)
       
   188         rqlst.add_type_restriction(mainvar, etypes)
       
   189     return var
       
   190 
       
   191 def _cleanup_rqlst(rqlst, mainvar):
       
   192     """cleanup tree from unnecessary restriction:
       
   193     * attribute selection
       
   194     * optional relations linked to the main variable
       
   195     * mandatory relations linked to the main variable
       
   196     """
       
   197     if rqlst.where is None:
       
   198         return
       
   199     schema = rqlst.root.schema
       
   200     toremove = set()
       
   201     vargraph = deepcopy(rqlst.vargraph) # graph representing links between variable
       
   202     for rel in rqlst.where.get_nodes(nodes.Relation):
       
   203         ovar = _may_be_removed(rel, schema, mainvar)
       
   204         if ovar is not None:
       
   205             toremove.add(ovar)
       
   206     removed = set()
       
   207     while toremove:
       
   208         trvar = toremove.pop()
       
   209         trvarname = trvar.name
       
   210         # remove paths using this variable from the graph
       
   211         linkedvars = vargraph.pop(trvarname)
       
   212         for ovarname in linkedvars:
       
   213             vargraph[ovarname].remove(trvarname)
       
   214         # remove relation using this variable
       
   215         for rel in chain(trvar.stinfo['relations'], trvar.stinfo['typerels']):
       
   216             if rel in removed:
       
   217                 # already removed
       
   218                 continue
       
   219             rqlst.remove_node(rel)
       
   220             removed.add(rel)
       
   221         # cleanup groupby clause
       
   222         if rqlst.groupby:
       
   223             for vref in rqlst.groupby[:]:
       
   224                 if vref.name == trvarname:
       
   225                     rqlst.remove_group_var(vref)
       
   226         # we can also remove all variables which are linked to this variable
       
   227         # and have no path to the main variable
       
   228         for ovarname in linkedvars:
       
   229             if not has_path(vargraph, ovarname, mainvar.name):
       
   230                 toremove.add(rqlst.defined_vars[ovarname])            
       
   231 
       
   232         
       
   233         
       
   234 ## base facet classes #########################################################
       
   235 class AbstractFacet(AcceptMixIn, AppRsetObject):
       
   236     __registerer__ = priority_registerer
       
   237     __abstract__ = True
       
   238     __registry__ = 'facets'
       
   239     property_defs = {
       
   240         _('visible'): dict(type='Boolean', default=True,
       
   241                            help=_('display the box or not')),
       
   242         _('order'):   dict(type='Int', default=99,
       
   243                            help=_('display order of the box')),
       
   244         _('context'): dict(type='String', default=None,
       
   245                            # None <-> both
       
   246                            vocabulary=(_('tablefilter'), _('facetbox'), None),
       
   247                            help=_('context where this box should be displayed')),
       
   248         }
       
   249     visible = True
       
   250     context = None
       
   251     needs_update = False
       
   252     start_unfolded = True
       
   253     
       
   254     @classmethod
       
   255     def selected(cls, req, rset=None, rqlst=None, context=None,
       
   256                  filtered_variable=None):
       
   257         assert rset is not None or rqlst is not None
       
   258         assert filtered_variable
       
   259         instance = super(AbstractFacet, cls).selected(req, rset)
       
   260         #instance = AppRsetObject.selected(req, rset)
       
   261         #instance.__class__ = cls
       
   262         # facet retreived using `object_by_id` from an ajax call
       
   263         if rset is None:
       
   264             instance.init_from_form(rqlst=rqlst)
       
   265         # facet retreived from `select` using the result set to filter
       
   266         else:
       
   267             instance.init_from_rset()
       
   268         instance.filtered_variable = filtered_variable
       
   269         return instance
       
   270 
       
   271     def init_from_rset(self):
       
   272         self.rqlst = self.rset.syntax_tree().children[0]
       
   273 
       
   274     def init_from_form(self, rqlst):
       
   275         self.rqlst = rqlst
       
   276 
       
   277     @property
       
   278     def operator(self):
       
   279         # OR between selected values by default
       
   280         return self.req.form.get(self.id + '_andor', 'OR')
       
   281     
       
   282     def get_widget(self):
       
   283         """return the widget instance to use to display this facet
       
   284         """
       
   285         raise NotImplementedError
       
   286     
       
   287     def add_rql_restrictions(self):
       
   288         """add restriction for this facet into the rql syntax tree"""
       
   289         raise NotImplementedError
       
   290     
       
   291 
       
   292 class VocabularyFacet(AbstractFacet):
       
   293     needs_update = True
       
   294     
       
   295     def get_widget(self):
       
   296         """return the widget instance to use to display this facet
       
   297 
       
   298         default implentation expects a .vocabulary method on the facet and
       
   299         return a combobox displaying this vocabulary
       
   300         """
       
   301         vocab = self.vocabulary()
       
   302         if len(vocab) <= 1:
       
   303             return None
       
   304         wdg = FacetVocabularyWidget(self)
       
   305         selected = frozenset(typed_eid(eid) for eid in self.req.list_form_param(self.id))
       
   306         for label, value in vocab:
       
   307             if value is None:
       
   308                 wdg.append(FacetSeparator(label))
       
   309             else:
       
   310                 wdg.append(FacetItem(label, value, value in selected))
       
   311         return wdg
       
   312     
       
   313     def vocabulary(self):
       
   314         """return vocabulary for this facet, eg a list of 2-uple (label, value)
       
   315         """
       
   316         raise NotImplementedError
       
   317     
       
   318     def possible_values(self):
       
   319         """return a list of possible values (as string since it's used to
       
   320         compare to a form value in javascript) for this facet
       
   321         """
       
   322         raise NotImplementedError
       
   323 
       
   324     def support_and(self):
       
   325         return False
       
   326     
       
   327     def rqlexec(self, rql, args=None, cachekey=None):
       
   328         try:
       
   329             return self.req.execute(rql, args, cachekey)
       
   330         except Unauthorized:
       
   331             return []
       
   332         
       
   333 
       
   334 class RelationFacet(VocabularyFacet):
       
   335     __selectors__ = (one_has_relation_selector, contextprop_selector)
       
   336     # class attributes to configure the relation facet
       
   337     rtype = None
       
   338     role = 'subject'
       
   339     target_attr = 'eid'
       
   340     # set this to a stored procedure name if you want to sort on the result of
       
   341     # this function's result instead of direct value
       
   342     sortfunc = None
       
   343     # ascendant/descendant sorting
       
   344     sortasc = True
       
   345     
       
   346     @property
       
   347     def title(self):
       
   348         return display_name(self.req, self.rtype, form=self.role)        
       
   349 
       
   350     def vocabulary(self):
       
   351         """return vocabulary for this facet, eg a list of 2-uple (label, value)
       
   352         """
       
   353         rqlst = self.rqlst
       
   354         rqlst.save_state()
       
   355         try:
       
   356             mainvar = self.filtered_variable
       
   357             insert_attr_select_relation(rqlst, mainvar, self.rtype, self.role,
       
   358                                         self.target_attr, self.sortfunc, self.sortasc)
       
   359             rset = self.rqlexec(rqlst.as_string(), self.rset.args, self.rset.cachekey)
       
   360         finally:
       
   361             rqlst.recover()
       
   362         return self.rset_vocabulary(rset)
       
   363     
       
   364     def possible_values(self):
       
   365         """return a list of possible values (as string since it's used to
       
   366         compare to a form value in javascript) for this facet
       
   367         """
       
   368         rqlst = self.rqlst
       
   369         rqlst.save_state()
       
   370         try:
       
   371             _cleanup_rqlst(rqlst, self.filtered_variable)
       
   372             _prepare_vocabulary_rqlst(rqlst, self.filtered_variable, self.rtype, self.role)
       
   373             return [str(x) for x, in self.rqlexec(rqlst.as_string())]
       
   374         finally:
       
   375             rqlst.recover()
       
   376     
       
   377     def rset_vocabulary(self, rset):
       
   378         _ = self.req._
       
   379         return [(_(label), eid) for eid, label in rset]
       
   380 
       
   381     @cached
       
   382     def support_and(self):
       
   383         rschema = self.schema.rschema(self.rtype)
       
   384         if self.role == 'subject':
       
   385             cardidx = 0
       
   386         else:
       
   387             cardidx = 1
       
   388         # XXX when called via ajax, no rset to compute possible types
       
   389         possibletypes = self.rset and self.rset.column_types(0)
       
   390         for subjtype, objtype in rschema.iter_rdefs():
       
   391             if possibletypes is not None:
       
   392                 if self.role == 'subject':
       
   393                     if not subjtype in possibletypes:
       
   394                         continue
       
   395                 elif not objtype in possibletypes:
       
   396                     continue
       
   397             if rschema.rproperty(subjtype, objtype, 'cardinality')[cardidx] in '+*':
       
   398                 return True
       
   399         return False
       
   400 
       
   401     def add_rql_restrictions(self):
       
   402         """add restriction for this facet into the rql syntax tree"""
       
   403         value = self.req.form.get(self.id)
       
   404         if not value:
       
   405             return
       
   406         mainvar = self.filtered_variable
       
   407         restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype, self.role)[0]
       
   408         if isinstance(value, basestring):
       
   409             # only one value selected
       
   410             self.rqlst.add_eid_restriction(restrvar, value)
       
   411         elif self.operator == 'OR':
       
   412             #  multiple values with OR operator
       
   413             # set_distinct only if rtype cardinality is > 1
       
   414             if self.support_and():
       
   415                 self.rqlst.set_distinct(True)
       
   416             self.rqlst.add_eid_restriction(restrvar, value)
       
   417         else:
       
   418             # multiple values with AND operator
       
   419             self.rqlst.add_eid_restriction(restrvar, value.pop())
       
   420             while value:
       
   421                 restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype, self.role)[0]
       
   422                 self.rqlst.add_eid_restriction(restrvar, value.pop())
       
   423 
       
   424 
       
   425 class AttributeFacet(RelationFacet):
       
   426     # attribute type
       
   427     attrtype = 'String'
       
   428     
       
   429     def vocabulary(self):
       
   430         """return vocabulary for this facet, eg a list of 2-uple (label, value)
       
   431         """
       
   432         rqlst = self.rqlst
       
   433         rqlst.save_state()
       
   434         try:
       
   435             mainvar = self.filtered_variable
       
   436             _cleanup_rqlst(rqlst, mainvar)
       
   437             newvar, rel = _prepare_vocabulary_rqlst(rqlst, mainvar, self.rtype, self.role)
       
   438             _set_orderby(rqlst, newvar, self.sortasc, self.sortfunc)
       
   439             rset = self.rqlexec(rqlst.as_string(), self.rset.args,
       
   440                                 self.rset.cachekey)
       
   441         finally:
       
   442             rqlst.recover()
       
   443         return self.rset_vocabulary(rset)
       
   444     
       
   445     def rset_vocabulary(self, rset):
       
   446         _ = self.req._
       
   447         return [(_(value), value) for value, in rset]
       
   448 
       
   449     def support_and(self):
       
   450         return False
       
   451             
       
   452     def add_rql_restrictions(self):
       
   453         """add restriction for this facet into the rql syntax tree"""
       
   454         value = self.req.form.get(self.id)
       
   455         if not value:
       
   456             return
       
   457         mainvar = self.filtered_variable
       
   458         self.rqlst.add_constant_restriction(mainvar, self.rtype, value,
       
   459                                             self.attrtype)
       
   460 
       
   461 
       
   462         
       
   463 class FilterRQLBuilder(object):
       
   464     """called by javascript to get a rql string from filter form"""
       
   465 
       
   466     def __init__(self, req):
       
   467         self.req = req
       
   468                 
       
   469     def build_rql(self):#, tablefilter=False):
       
   470         form = self.req.form
       
   471         facetids = form['facets'].split(',')
       
   472         select = parse(form['baserql']).children[0] # XXX Union unsupported yet
       
   473         mainvar = filtered_variable(select)
       
   474         toupdate = []
       
   475         for facetid in facetids:
       
   476             facet = get_facet(self.req, facetid, select, mainvar)
       
   477             facet.add_rql_restrictions()
       
   478             if facet.needs_update:
       
   479                 toupdate.append(facetid)
       
   480         return select.as_string(), toupdate
       
   481 
       
   482         
       
   483 ## html widets ################################################################
       
   484 
       
   485 class FacetVocabularyWidget(HTMLWidget):
       
   486     
       
   487     def __init__(self, facet):
       
   488         self.facet = facet
       
   489         self.items = []
       
   490 
       
   491     def append(self, item):
       
   492         self.items.append(item)
       
   493             
       
   494     def _render(self):
       
   495         title = html_escape(self.facet.title)
       
   496         facetid = html_escape(self.facet.id)
       
   497         self.w(u'<div id="%s" class="facet">\n' % facetid)
       
   498         self.w(u'<div class="facetTitle" cubicweb:facetName="%s">%s</div>\n' %
       
   499                (html_escape(facetid), title))
       
   500         if self.facet.support_and():
       
   501             _ = self.facet.req._
       
   502             self.w(u'''<select name="%s" class="radio facetOperator" title="%s">
       
   503   <option value="OR">%s</option>
       
   504   <option value="AND">%s</option>
       
   505 </select>''' % (facetid + '_andor', _('and/or between different values'),
       
   506                 _('OR'), _('AND')))
       
   507         if self.facet.start_unfolded:
       
   508             cssclass = ''
       
   509         else:
       
   510             cssclass = ' hidden'
       
   511         self.w(u'<div class="facetBody%s">\n' % cssclass)
       
   512         for item in self.items:
       
   513             item.render(self.w)
       
   514         self.w(u'</div>\n')
       
   515         self.w(u'</div>\n')
       
   516 
       
   517         
       
   518 class FacetStringWidget(HTMLWidget):
       
   519     def __init__(self, facet):
       
   520         self.facet = facet
       
   521         self.value = None
       
   522 
       
   523     def _render(self):
       
   524         title = html_escape(self.facet.title)
       
   525         facetid = html_escape(self.facet.id)
       
   526         self.w(u'<div id="%s" class="facet">\n' % facetid)
       
   527         self.w(u'<div class="facetTitle" cubicweb:facetName="%s">%s</div>\n' %
       
   528                (facetid, title))
       
   529         self.w(u'<input name="%s" type="text" value="%s" />\n' % (facetid, self.value or u''))
       
   530         self.w(u'</div>\n')
       
   531 
       
   532 
       
   533 class FacetItem(HTMLWidget):
       
   534 
       
   535     selected_img = "http://static.simile.mit.edu/exhibit/api-2.0/images/black-check.png"
       
   536     unselected_img = "http://static.simile.mit.edu/exhibit/api-2.0/images/no-check-no-border.png"
       
   537 
       
   538     def __init__(self, label, value, selected=False):
       
   539         self.label = label
       
   540         self.value = value
       
   541         self.selected = selected
       
   542 
       
   543     def _render(self):
       
   544         if self.selected:
       
   545             cssclass = ' facetValueSelected'
       
   546             imgsrc = self.selected_img
       
   547         else:
       
   548             cssclass = ''
       
   549             imgsrc = self.unselected_img            
       
   550         self.w(u'<div class="facetValue facetCheckBox%s" cubicweb:value="%s">\n'
       
   551                % (cssclass, html_escape(unicode(self.value))))
       
   552         self.w(u'<img src="%s" />&nbsp;' % imgsrc)
       
   553         self.w(u'<a href="javascript: {}">%s</a>' % html_escape(self.label))
       
   554         self.w(u'</div>')
       
   555 
       
   556 
       
   557 class FacetSeparator(HTMLWidget):
       
   558     def __init__(self, label=None):
       
   559         self.label = label or u'&nbsp;'
       
   560         
       
   561     def _render(self):
       
   562         pass
       
   563