diff -r 000000000000 -r b97547f5f1fa web/facet.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/facet.py Wed Nov 05 15:52:50 2008 +0100 @@ -0,0 +1,563 @@ +"""contains utility functions and some visual component to restrict results of +a search + +:organization: Logilab +:copyright: 2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr +""" +__docformat__ = "restructuredtext en" + +from itertools import chain +from copy import deepcopy + +from logilab.mtconverter import html_escape + +from logilab.common.graph import has_path +from logilab.common.decorators import cached +from logilab.common.compat import all + +from rql import parse, nodes + +from cubicweb import Unauthorized, typed_eid +from cubicweb.common.selectors import contextprop_selector, one_has_relation_selector +from cubicweb.common.registerers import priority_registerer +from cubicweb.common.appobject import AppRsetObject +from cubicweb.common.utils import AcceptMixIn +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.object_by_id('facets', facetid, req, rqlst=rqlst, + filtered_variable=mainvar) + + +def filter_hiddens(w, **kwargs): + for key, val in kwargs.items(): + w(u'' % ( + key, html_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.is_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(rschema.rproperty(s, o, 'cardinality')[cardidx] in '1+' + for s,o in rschema.iter_rdefs()): + # 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): + newvar = rqlst.make_variable() + if role == 'object': + rel = rqlst.add_relation(newvar, rtype, mainvar) + else: + rel = rqlst.add_relation(mainvar, rtype, newvar) + return newvar, rel + +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, rel = _add_rtype_relation(rqlst, mainvar, rtype, role) + if rqlst.groupby: + rqlst.add_group_var(newvar) + rqlst.add_selected(newvar) + return newvar, rel + +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 retrieve only relevant attribute `attr` of `var`""" + _cleanup_rqlst(rqlst, mainvar) + var, mainrel = _prepare_vocabulary_rqlst(rqlst, mainvar, rtype, role) + # not found, create one + attrvar = rqlst.make_variable() + attrrel = 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) + _set_orderby(rqlst, attrvar, sortasc, sortfuncname) + # add attribute variable to selection + rqlst.add_selected(attrvar) + # add is restriction if necessary + if not mainvar.stinfo['typerels']: + 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 chain(trvar.stinfo['relations'], trvar.stinfo['typerels']): + if rel in removed: + # already removed + continue + 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 not has_path(vargraph, ovarname, mainvar.name): + toremove.add(rqlst.defined_vars[ovarname]) + + + +## base facet classes ######################################################### +class AbstractFacet(AcceptMixIn, AppRsetObject): + __registerer__ = priority_registerer + __abstract__ = True + __registry__ = 'facets' + 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, + # None <-> both + vocabulary=(_('tablefilter'), _('facetbox'), None), + help=_('context where this box should be displayed')), + } + visible = True + context = None + needs_update = False + start_unfolded = True + + @classmethod + def selected(cls, req, rset=None, rqlst=None, context=None, + filtered_variable=None): + assert rset is not None or rqlst is not None + assert filtered_variable + instance = super(AbstractFacet, cls).selected(req, rset) + #instance = AppRsetObject.selected(req, rset) + #instance.__class__ = cls + # facet retreived using `object_by_id` from an ajax call + if rset is None: + instance.init_from_form(rqlst=rqlst) + # facet retreived from `select` using the result set to filter + else: + instance.init_from_rset() + instance.filtered_variable = filtered_variable + return instance + + def init_from_rset(self): + self.rqlst = self.rset.syntax_tree().children[0] + + def init_from_form(self, rqlst): + self.rqlst = rqlst + + @property + def operator(self): + # OR between selected values by default + return self.req.form.get(self.id + '_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.req.list_form_param(self.id)) + for label, value in vocab: + if value is None: + wdg.append(FacetSeparator(label)) + else: + wdg.append(FacetItem(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, cachekey=None): + try: + return self.req.execute(rql, args, cachekey) + except Unauthorized: + return [] + + +class RelationFacet(VocabularyFacet): + __selectors__ = (one_has_relation_selector, contextprop_selector) + # class attributes to configure the relation facet + rtype = None + role = 'subject' + target_attr = 'eid' + # 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 + + @property + def title(self): + return display_name(self.req, 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() + try: + mainvar = self.filtered_variable + insert_attr_select_relation(rqlst, mainvar, self.rtype, self.role, + self.target_attr, self.sortfunc, self.sortasc) + rset = self.rqlexec(rqlst.as_string(), self.rset.args, self.rset.cachekey) + finally: + rqlst.recover() + return 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): + _ = self.req._ + return [(_(label), eid) for eid, label in rset] + + @cached + def support_and(self): + rschema = self.schema.rschema(self.rtype) + if self.role == 'subject': + cardidx = 0 + else: + cardidx = 1 + # XXX when called via ajax, no rset to compute possible types + possibletypes = self.rset and self.rset.column_types(0) + for subjtype, objtype in rschema.iter_rdefs(): + if possibletypes is not None: + if self.role == 'subject': + if not subjtype in possibletypes: + continue + elif not objtype in possibletypes: + continue + if rschema.rproperty(subjtype, objtype, 'cardinality')[cardidx] in '+*': + return True + return False + + def add_rql_restrictions(self): + """add restriction for this facet into the rql syntax tree""" + value = self.req.form.get(self.id) + if not value: + return + mainvar = self.filtered_variable + restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype, self.role)[0] + 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)[0] + self.rqlst.add_eid_restriction(restrvar, value.pop()) + + +class AttributeFacet(RelationFacet): + # attribute type + attrtype = 'String' + + 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, rel = _prepare_vocabulary_rqlst(rqlst, mainvar, self.rtype, self.role) + _set_orderby(rqlst, newvar, self.sortasc, self.sortfunc) + rset = self.rqlexec(rqlst.as_string(), self.rset.args, + self.rset.cachekey) + finally: + rqlst.recover() + return self.rset_vocabulary(rset) + + def rset_vocabulary(self, rset): + _ = self.req._ + 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.req.form.get(self.id) + if not value: + return + mainvar = self.filtered_variable + self.rqlst.add_constant_restriction(mainvar, self.rtype, value, + self.attrtype) + + + +class FilterRQLBuilder(object): + """called by javascript to get a rql string from filter form""" + + def __init__(self, req): + self.req = req + + def build_rql(self):#, tablefilter=False): + form = self.req.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.req, facetid, select, mainvar) + facet.add_rql_restrictions() + if facet.needs_update: + toupdate.append(facetid) + return select.as_string(), toupdate + + +## 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 = html_escape(self.facet.title) + facetid = html_escape(self.facet.id) + self.w(u'
\n' % facetid) + self.w(u'
%s
\n' % + (html_escape(facetid), title)) + if self.facet.support_and(): + _ = self.facet.req._ + self.w(u'''''' % (facetid + '_andor', _('and/or between different values'), + _('OR'), _('AND'))) + if self.facet.start_unfolded: + cssclass = '' + else: + cssclass = ' hidden' + self.w(u'
\n' % cssclass) + for item in self.items: + item.render(self.w) + self.w(u'
\n') + self.w(u'
\n') + + +class FacetStringWidget(HTMLWidget): + def __init__(self, facet): + self.facet = facet + self.value = None + + def _render(self): + title = html_escape(self.facet.title) + facetid = html_escape(self.facet.id) + self.w(u'
\n' % facetid) + self.w(u'
%s
\n' % + (facetid, title)) + self.w(u'\n' % (facetid, self.value or u'')) + self.w(u'
\n') + + +class FacetItem(HTMLWidget): + + selected_img = "http://static.simile.mit.edu/exhibit/api-2.0/images/black-check.png" + unselected_img = "http://static.simile.mit.edu/exhibit/api-2.0/images/no-check-no-border.png" + + def __init__(self, label, value, selected=False): + self.label = label + self.value = value + self.selected = selected + + def _render(self): + if self.selected: + cssclass = ' facetValueSelected' + imgsrc = self.selected_img + else: + cssclass = '' + imgsrc = self.unselected_img + self.w(u'
\n' + % (cssclass, html_escape(unicode(self.value)))) + self.w(u' ' % imgsrc) + self.w(u'%s' % html_escape(self.label)) + self.w(u'
') + + +class FacetSeparator(HTMLWidget): + def __init__(self, label=None): + self.label = label or u' ' + + def _render(self): + pass +