author | Sylvain Thénault <sylvain.thenault@logilab.fr> |
Wed, 14 Apr 2010 11:26:36 +0200 | |
changeset 5246 | 3246b1f88a18 |
parent 5238 | 31c12863fd9d |
child 5423 | e15abfdcce38 |
permissions | -rw-r--r-- |
"""contains utility functions and some visual component to restrict results of a search :organization: Logilab :copyright: 2008-2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2. :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr :license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses """ __docformat__ = "restructuredtext en" from copy import deepcopy from datetime import date, datetime, timedelta from logilab.mtconverter import xml_escape from logilab.common.graph import has_path from logilab.common.decorators import cached from logilab.common.date import datetime2ticks from logilab.common.compat import all from rql import parse, nodes from cubicweb import Unauthorized, typed_eid from cubicweb.schema import display_name from cubicweb.utils import make_uid from cubicweb.selectors import match_context_prop, partial_relation_possible from cubicweb.appobject import AppObject from cubicweb.web.htmlwidgets import HTMLWidget ## rqlst manipulation functions used by facets ################################ def prepare_facets_rqlst(rqlst, args=None): """prepare a syntax tree to generate facet filters * remove ORDERBY clause * cleanup selection (remove everything) * undefine unnecessary variables * set DISTINCT * unset LIMIT/OFFSET """ if len(rqlst.children) > 1: raise NotImplementedError('FIXME: union not yet supported') select = rqlst.children[0] mainvar = filtered_variable(select) select.set_limit(None) select.set_offset(None) baserql = select.as_string(kwargs=args) # cleanup sort terms select.remove_sort_terms() # selection: only vocabulary entity for term in select.selection[:]: select.remove_selected(term) # remove unbound variables which only have some type restriction for dvar in select.defined_vars.values(): if not (dvar is mainvar or dvar.stinfo['relations']): select.undefine_variable(dvar) # global tree config: DISTINCT, LIMIT, OFFSET select.set_distinct(True) return mainvar, baserql def filtered_variable(rqlst): vref = rqlst.selection[0].iget_nodes(nodes.VariableRef).next() return vref.variable def get_facet(req, facetid, rqlst, mainvar): return req.vreg['facets'].object_by_id(facetid, req, rqlst=rqlst, filtered_variable=mainvar) def filter_hiddens(w, **kwargs): for key, val in kwargs.items(): w(u'<input type="hidden" name="%s" value="%s" />' % ( key, xml_escape(val))) def _may_be_removed(rel, schema, mainvar): """if the given relation may be removed from the tree, return the variable on the other side of `mainvar`, else return None Conditions: * the relation is an attribute selection of the main variable * the relation is optional relation linked to the main variable * the relation is a mandatory relation linked to the main variable without any restriction on the other variable """ lhs, rhs = rel.get_variable_parts() rschema = schema.rschema(rel.r_type) if lhs.variable is mainvar: try: ovar = rhs.variable except AttributeError: # constant restriction # XXX: X title LOWER(T) if it makes sense? return None if rschema.final: if len(ovar.stinfo['relations']) == 1: # attribute selection return ovar return None opt = 'right' cardidx = 0 elif getattr(rhs, 'variable', None) is mainvar: ovar = lhs.variable opt = 'left' cardidx = 1 else: # not directly linked to the main variable return None if rel.optional in (opt, 'both'): # optional relation return ovar if all(rdef.cardinality[cardidx] in '1+' for rdef in rschema.rdefs.values()): # mandatory relation without any restriction on the other variable for orel in ovar.stinfo['relations']: if rel is orel: continue if _may_be_removed(orel, schema, ovar) is None: return None return ovar return None def _add_rtype_relation(rqlst, mainvar, rtype, role): """add a relation relying `mainvar` to entities linked by the `rtype` relation (where `mainvar` has `role`) return the inserted variable for linked entities. """ newvar = rqlst.make_variable() if role == 'object': rqlst.add_relation(newvar, rtype, mainvar) else: rqlst.add_relation(mainvar, rtype, newvar) return newvar def _prepare_vocabulary_rqlst(rqlst, mainvar, rtype, role): """prepare a syntax tree to generate a filter vocabulary rql using the given relation: * create a variable to filter on this relation * add the relation * add the new variable to GROUPBY clause if necessary * add the new variable to the selection """ newvar = _add_rtype_relation(rqlst, mainvar, rtype, role) if rqlst.groupby: rqlst.add_group_var(newvar) rqlst.add_selected(newvar) return newvar def _remove_relation(rqlst, rel, var): """remove a constraint relation from the syntax tree""" # remove the relation rqlst.remove_node(rel) # remove relations where the filtered variable appears on the # lhs and rhs is a constant restriction extra = [] for vrel in var.stinfo['relations']: if vrel is rel: continue if vrel.children[0].variable is var: if not vrel.children[1].get_nodes(nodes.Constant): extra.append(vrel) rqlst.remove_node(vrel) return extra def _set_orderby(rqlst, newvar, sortasc, sortfuncname): if sortfuncname is None: rqlst.add_sort_var(newvar, sortasc) else: vref = nodes.variable_ref(newvar) vref.register_reference() sortfunc = nodes.Function(sortfuncname) sortfunc.append(vref) term = nodes.SortTerm(sortfunc, sortasc) rqlst.add_sort_term(term) def insert_attr_select_relation(rqlst, mainvar, rtype, role, attrname, sortfuncname=None, sortasc=True): """modify a syntax tree to : * link a new variable to `mainvar` through `rtype` (where mainvar has `role`) * retrieve only the newly inserted variable and its `attrname` Sorting: * on `attrname` ascendant (`sortasc`=True) or descendant (`sortasc`=False) * on `sortfuncname`(`attrname`) if `sortfuncname` is specified * no sort if `sortasc` is None """ _cleanup_rqlst(rqlst, mainvar) var = _prepare_vocabulary_rqlst(rqlst, mainvar, rtype, role) # not found, create one attrvar = rqlst.make_variable() rqlst.add_relation(var, attrname, attrvar) # if query is grouped, we have to add the attribute variable if rqlst.groupby: if not attrvar in rqlst.groupby: rqlst.add_group_var(attrvar) if sortasc is not None: _set_orderby(rqlst, attrvar, sortasc, sortfuncname) # add attribute variable to selection rqlst.add_selected(attrvar) # add is restriction if necessary if mainvar.stinfo['typerel'] is None: etypes = frozenset(sol[mainvar.name] for sol in rqlst.solutions) rqlst.add_type_restriction(mainvar, etypes) return var def _cleanup_rqlst(rqlst, mainvar): """cleanup tree from unnecessary restriction: * attribute selection * optional relations linked to the main variable * mandatory relations linked to the main variable """ if rqlst.where is None: return schema = rqlst.root.schema toremove = set() vargraph = deepcopy(rqlst.vargraph) # graph representing links between variable for rel in rqlst.where.get_nodes(nodes.Relation): ovar = _may_be_removed(rel, schema, mainvar) if ovar is not None: toremove.add(ovar) removed = set() while toremove: trvar = toremove.pop() trvarname = trvar.name # remove paths using this variable from the graph linkedvars = vargraph.pop(trvarname) for ovarname in linkedvars: vargraph[ovarname].remove(trvarname) # remove relation using this variable for rel in trvar.stinfo['relations']: if rel in removed: # already removed continue rqlst.remove_node(rel) removed.add(rel) rel = trvar.stinfo['typerel'] if rel is not None and not rel in removed: rqlst.remove_node(rel) removed.add(rel) # cleanup groupby clause if rqlst.groupby: for vref in rqlst.groupby[:]: if vref.name == trvarname: rqlst.remove_group_var(vref) # we can also remove all variables which are linked to this variable # and have no path to the main variable for ovarname in linkedvars: if ovarname == mainvar.name: continue if not has_path(vargraph, ovarname, mainvar.name): toremove.add(rqlst.defined_vars[ovarname]) ## base facet classes ######################################################### class AbstractFacet(AppObject): __abstract__ = True __registry__ = 'facets' cw_property_defs = { _('visible'): dict(type='Boolean', default=True, help=_('display the box or not')), _('order'): dict(type='Int', default=99, help=_('display order of the box')), _('context'): dict(type='String', default='', # None <-> both vocabulary=(_('tablefilter'), _('facetbox'), ''), help=_('context where this box should be displayed')), } visible = True context = '' needs_update = False start_unfolded = True cw_rset = None # ensure facets have a cw_rset attribute def __init__(self, req, rqlst=None, filtered_variable=None, **kwargs): super(AbstractFacet, self).__init__(req, **kwargs) assert rqlst is not None assert filtered_variable # take care: facet may be retreived using `object_by_id` from an ajax call # or from `select` using the result set to filter self.rqlst = rqlst self.filtered_variable = filtered_variable @property def operator(self): # OR between selected values by default return self._cw.form.get(self.__regid__ + '_andor', 'OR') def get_widget(self): """return the widget instance to use to display this facet """ raise NotImplementedError def add_rql_restrictions(self): """add restriction for this facet into the rql syntax tree""" raise NotImplementedError class VocabularyFacet(AbstractFacet): needs_update = True 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 """ vocab = self.vocabulary() if len(vocab) <= 1: return None wdg = FacetVocabularyWidget(self) selected = frozenset(typed_eid(eid) for eid in self._cw.list_form_param(self.__regid__)) for label, value in vocab: if value is None: wdg.append(FacetSeparator(label)) else: wdg.append(FacetItem(self._cw, label, value, value in selected)) return wdg def vocabulary(self): """return vocabulary for this facet, eg a list of 2-uple (label, value) """ raise NotImplementedError 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 """ raise NotImplementedError def support_and(self): return False def rqlexec(self, rql, args=None): try: return self._cw.execute(rql, args) except Unauthorized: return [] class RelationFacet(VocabularyFacet): __select__ = partial_relation_possible() & match_context_prop() # class attributes to configure the relation facet rtype = None role = 'subject' target_attr = 'eid' target_type = None # set this to a stored procedure name if you want to sort on the result of # this function's result instead of direct value sortfunc = None # ascendant/descendant sorting sortasc = True # if you want to call a view on the entity instead of using `target_attr` label_vid = None @property def title(self): return display_name(self._cw, self.rtype, form=self.role) def vocabulary(self): """return vocabulary for this facet, eg a list of 2-uple (label, value) """ rqlst = self.rqlst rqlst.save_state() if self.label_vid is not None and self.sortfunc is None: sort = None # will be sorted on label else: sort = self.sortasc try: mainvar = self.filtered_variable var = insert_attr_select_relation( rqlst, mainvar, self.rtype, self.role, self.target_attr, self.sortfunc, sort) if self.target_type is not None: rqlst.add_type_restriction(var, self.target_type) try: rset = self.rqlexec(rqlst.as_string(), self.cw_rset.args) except: self.exception('error while getting vocabulary for %s, rql: %s', self, rqlst.as_string()) return () finally: rqlst.recover() # don't call rset_vocabulary on empty result set, it may be an empty # *list* (see rqlexec implementation) return rset and self.rset_vocabulary(rset) 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 """ rqlst = self.rqlst rqlst.save_state() try: _cleanup_rqlst(rqlst, self.filtered_variable) _prepare_vocabulary_rqlst(rqlst, self.filtered_variable, self.rtype, self.role) return [str(x) for x, in self.rqlexec(rqlst.as_string())] finally: rqlst.recover() def rset_vocabulary(self, rset): if self.label_vid is None: _ = self._cw._ return [(_(label), eid) for eid, label in rset] if self.sortfunc is None: return sorted((entity.view(self.label_vid), entity.eid) for entity in rset.entities()) return [(entity.view(self.label_vid), entity.eid) for entity in rset.entities()] @cached def support_and(self): rschema = self._cw.vreg.schema.rschema(self.rtype) # XXX when called via ajax, no rset to compute possible types possibletypes = self.cw_rset and self.cw_rset.column_types(0) for rdef in rschema.rdefs.itervalues(): if possibletypes is not None: if self.role == 'subject': if not rdef.subject in possibletypes: continue elif not rdef.object in possibletypes: continue if rdef.role_cardinality(self.role) in '+*': return True return False 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 mainvar = self.filtered_variable restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype, self.role) if isinstance(value, basestring): # only one value selected self.rqlst.add_eid_restriction(restrvar, value) elif self.operator == 'OR': # multiple values with OR operator # set_distinct only if rtype cardinality is > 1 if self.support_and(): self.rqlst.set_distinct(True) self.rqlst.add_eid_restriction(restrvar, value) else: # multiple values with AND operator self.rqlst.add_eid_restriction(restrvar, value.pop()) while value: restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype, self.role) self.rqlst.add_eid_restriction(restrvar, value.pop()) class AttributeFacet(RelationFacet): # attribute type attrtype = 'String' # type of comparison: default is an exact match on the attribute value comparator = '=' # could be '<', '<=', '>', '>=' def vocabulary(self): """return vocabulary for this facet, eg a list of 2-uple (label, value) """ rqlst = self.rqlst rqlst.save_state() try: mainvar = self.filtered_variable _cleanup_rqlst(rqlst, mainvar) newvar = _prepare_vocabulary_rqlst(rqlst, mainvar, self.rtype, self.role) _set_orderby(rqlst, newvar, self.sortasc, self.sortfunc) try: rset = self.rqlexec(rqlst.as_string(), self.cw_rset.args) except: self.exception('error while getting vocabulary for %s, rql: %s', self, rqlst.as_string()) return () finally: rqlst.recover() # don't call rset_vocabulary on empty result set, it may be an empty # *list* (see rqlexec implementation) return rset and self.rset_vocabulary(rset) def rset_vocabulary(self, rset): _ = self._cw._ return [(_(value), value) for value, in rset] def support_and(self): return False 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 mainvar = self.filtered_variable self.rqlst.add_constant_restriction(mainvar, self.rtype, value, self.attrtype, self.comparator) class FilterRQLBuilder(object): """called by javascript to get a rql string from filter form""" def __init__(self, req): self._cw = req def build_rql(self):#, tablefilter=False): form = self._cw.form facetids = form['facets'].split(',') select = parse(form['baserql']).children[0] # XXX Union unsupported yet mainvar = filtered_variable(select) toupdate = [] for facetid in facetids: facet = get_facet(self._cw, facetid, select, mainvar) facet.add_rql_restrictions() if facet.needs_update: toupdate.append(facetid) return select.as_string(), toupdate class RangeFacet(AttributeFacet): attrtype = 'Float' # only numerical types are supported @property def wdgclass(self): return FacetRangeWidget def get_widget(self): """return the widget instance to use to display this facet""" values = set(value for _, value in self.vocabulary() if value is not None) # Rset with entities (the facet is selected) but without values if len(values) == 0: return None return self.wdgclass(self, min(values), max(values)) def infvalue(self): return self._cw.form.get('%s_inf' % self.__regid__) def supvalue(self): return self._cw.form.get('%s_sup' % self.__regid__) def formatvalue(self, value): """format `value` before in order to insert it in the RQL query""" return unicode(value) def add_rql_restrictions(self): infvalue = self.infvalue() if infvalue is None: # nothing sent return supvalue = self.supvalue() self.rqlst.add_constant_restriction(self.filtered_variable, self.rtype, self.formatvalue(infvalue), self.attrtype, '>=') self.rqlst.add_constant_restriction(self.filtered_variable, self.rtype, self.formatvalue(supvalue), self.attrtype, '<=') class DateRangeFacet(RangeFacet): attrtype = 'Date' # only date types are supported @property def wdgclass(self): return DateFacetRangeWidget def formatvalue(self, value): """format `value` before in order to insert it in the RQL query""" return '"%s"' % date.fromtimestamp(float(value) / 1000).strftime('%Y/%m/%d') class HasRelationFacet(AbstractFacet): rtype = None # override me in subclass role = 'subject' # role of filtered entity in the relation @property def title(self): return display_name(self._cw, self.rtype, self.role) def support_and(self): return False def get_widget(self): return CheckBoxFacetWidget(self._cw, self, '%s:%s' % (self.rtype, self), self._cw.form.get(self.__regid__)) def add_rql_restrictions(self): """add restriction for this facet into the rql syntax tree""" self.rqlst.set_distinct(True) # XXX value = self._cw.form.get(self.__regid__) if not value: # no value sent for this facet return var = self.rqlst.make_variable() if self.role == 'subject': self.rqlst.add_relation(self.filtered_variable, self.rtype, var) else: self.rqlst.add_relation(var, self.rtype, self.filtered_variable) ## html widets ################################################################ class FacetVocabularyWidget(HTMLWidget): def __init__(self, facet): self.facet = facet self.items = [] def append(self, item): self.items.append(item) def _render(self): title = xml_escape(self.facet.title) facetid = xml_escape(self.facet.__regid__) self.w(u'<div id="%s" class="facet">\n' % facetid) self.w(u'<div class="facetTitle" cubicweb:facetName="%s">%s</div>\n' % (xml_escape(facetid), title)) if self.facet.support_and(): _ = self.facet._cw._ self.w(u'''<select name="%s" class="radio facetOperator" title="%s"> <option value="OR">%s</option> <option value="AND">%s</option> </select>''' % (facetid + '_andor', _('and/or between different values'), _('OR'), _('AND'))) cssclass = '' if not self.facet.start_unfolded: cssclass += ' hidden' if len(self.items) > 6: cssclass += ' overflowed' self.w(u'<div class="facetBody%s">\n' % cssclass) for item in self.items: item.render(w=self.w) self.w(u'</div>\n') self.w(u'</div>\n') class FacetStringWidget(HTMLWidget): def __init__(self, facet): self.facet = facet self.value = None def _render(self): title = xml_escape(self.facet.title) facetid = xml_escape(self.facet.__regid__) self.w(u'<div id="%s" class="facet">\n' % facetid) self.w(u'<div class="facetTitle" cubicweb:facetName="%s">%s</div>\n' % (facetid, title)) self.w(u'<input name="%s" type="text" value="%s" />\n' % (facetid, self.value or u'')) self.w(u'</div>\n') class FacetRangeWidget(HTMLWidget): formatter = 'function (value) {return value;}' onload = u''' var _formatter = %(formatter)s; jQuery("#%(sliderid)s").slider({ range: true, min: %(minvalue)s, max: %(maxvalue)s, values: [%(minvalue)s, %(maxvalue)s], stop: function(event, ui) { // submit when the user stops sliding var form = $('#%(sliderid)s').closest('form'); buildRQL.apply(null, evalJSON(form.attr('cubicweb:facetargs'))); }, slide: function(event, ui) { jQuery('#%(sliderid)s_inf').html(_formatter(ui.values[0])); jQuery('#%(sliderid)s_sup').html(_formatter(ui.values[1])); jQuery('input[name=%(facetid)s_inf]').val(ui.values[0]); jQuery('input[name=%(facetid)s_sup]').val(ui.values[1]); } }); // use JS formatter to format value on page load jQuery('#%(sliderid)s_inf').html(_formatter(jQuery('input[name=%(facetid)s_inf]').val())); jQuery('#%(sliderid)s_sup').html(_formatter(jQuery('input[name=%(facetid)s_sup]').val())); ''' #'# make emacs happier def __init__(self, facet, minvalue, maxvalue): self.facet = facet self.minvalue = minvalue self.maxvalue = maxvalue def _render(self): facet = self.facet facet._cw.add_js('ui.slider.js') facet._cw.add_css('ui.all.css') sliderid = make_uid('theslider') facetid = xml_escape(self.facet.__regid__) facet._cw.html_headers.add_onload(self.onload % { 'sliderid': sliderid, 'facetid': facetid, 'minvalue': self.minvalue, 'maxvalue': self.maxvalue, 'formatter': self.formatter, }) title = xml_escape(self.facet.title) self.w(u'<div id="%s" class="facet">\n' % facetid) self.w(u'<div class="facetTitle" cubicweb:facetName="%s">%s</div>\n' % (facetid, title)) self.w(u'<span id="%s_inf"></span> - <span id="%s_sup"></span>' % (sliderid, sliderid)) self.w(u'<input type="hidden" name="%s_inf" value="%s" />' % (facetid, self.minvalue)) self.w(u'<input type="hidden" name="%s_sup" value="%s" />' % (facetid, self.maxvalue)) self.w(u'<div id="%s"></div>' % sliderid) self.w(u'</div>\n') class DateFacetRangeWidget(FacetRangeWidget): formatter = 'function (value) {return (new Date(parseFloat(value))).strftime(DATE_FMT);}' def round_max_value(self, d): 'round to upper value to avoid filtering out the max value' return datetime(d.year, d.month, d.day) + timedelta(days=1) def __init__(self, facet, minvalue, maxvalue): maxvalue = self.round_max_value(maxvalue) super(DateFacetRangeWidget, self).__init__(facet, datetime2ticks(minvalue), datetime2ticks(maxvalue)) fmt = facet._cw.property_value('ui.date-format') facet._cw.html_headers.define_var('DATE_FMT', fmt) class FacetItem(HTMLWidget): selected_img = "black-check.png" unselected_img = "no-check-no-border.png" def __init__(self, req, label, value, selected=False): self._cw = req self.label = label self.value = value self.selected = selected def _render(self): if self.selected: cssclass = ' facetValueSelected' imgsrc = self._cw.datadir_url + self.selected_img imgalt = self._cw._('selected') else: cssclass = '' imgsrc = self._cw.datadir_url + self.unselected_img imgalt = self._cw._('not selected') self.w(u'<div class="facetValue facetCheckBox%s" cubicweb:value="%s">\n' % (cssclass, xml_escape(unicode(self.value)))) self.w(u'<img src="%s" alt="%s"/> ' % (imgsrc, imgalt)) self.w(u'<a href="javascript: {}">%s</a>' % xml_escape(self.label)) self.w(u'</div>') class CheckBoxFacetWidget(HTMLWidget): selected_img = "black-check.png" unselected_img = "black-uncheck.png" def __init__(self, req, facet, value, selected): self._cw = req self.facet = facet self.value = value self.selected = selected def _render(self): title = xml_escape(self.facet.title) facetid = xml_escape(self.facet.__regid__) self.w(u'<div id="%s" class="facet">\n' % facetid) if self.selected: cssclass = ' facetValueSelected' imgsrc = self._cw.datadir_url + self.selected_img imgalt = self._cw._('selected') else: cssclass = '' imgsrc = self._cw.datadir_url + self.unselected_img imgalt = self._cw._('not selected') self.w(u'<div class="facetValue facetCheckBox%s" cubicweb:value="%s">\n' % (cssclass, xml_escape(unicode(self.value)))) self.w(u'<div class="facetCheckBoxWidget">') self.w(u'<img src="%s" alt="%s" cubicweb:unselimg="true" /> ' % (imgsrc, imgalt)) self.w(u'<label class="facetTitle" cubicweb:facetName="%s"><a href="javascript: {}">%s</a></label>' % (facetid, title)) self.w(u'</div>\n') self.w(u'</div>\n') self.w(u'</div>\n') class FacetSeparator(HTMLWidget): def __init__(self, label=None): self.label = label or u' ' def _render(self): pass