"""contains utility functions and some visual component to restrict results of
a search
:organization: Logilab
:copyright: 2008-2009 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 itertools import chain
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.compat import all
from rql import parse, nodes
from cubicweb import Unauthorized, typed_eid
from cubicweb.schema import display_name
from cubicweb.utils import datetime2ticks, make_uid, ustrftime
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.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):
"""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 retrieve only relevant attribute `attr` of `var`"""
_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)
_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 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'
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
def __init__(self, req, rset=None, rqlst=None, filtered_variable=None,
**kwargs):
super(AbstractFacet, self).__init__(req, rset, **kwargs)
assert rset is not None or rqlst is not None
assert filtered_variable
# facet retreived using `object_by_id` from an ajax call
if rset is None:
self.init_from_form(rqlst=rqlst)
# facet retreived from `select` using the result set to filter
else:
self.init_from_rset()
self.filtered_variable = filtered_variable
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(self.req, 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):
__select__ = partial_relation_possible() & match_context_prop()
# class attributes to configure the rel ation 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)
try:
rset = self.rqlexec(rqlst.as_string(), self.rset.args, self.rset.cachekey)
except:
self.exception('error while getting vocabulary for %s, rql: %s',
self, rqlst.as_string())
return ()
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)
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.rset.args, self.rset.cachekey)
except:
self.exception('error while getting vocabulary for %s, rql: %s',
self, rqlst.as_string())
return ()
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, self.comparator)
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
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)
return self.wdgclass(self, min(values), max(values))
def infvalue(self):
return self.req.form.get('%s_inf' % self.id)
def supvalue(self):
return self.req.form.get('%s_sup' % self.id)
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.req, self.rtype, self.role)
def support_and(self):
return False
def get_widget(self):
return CheckBoxFacetWidget(self.req, self,
'%s:%s' % (self.rtype, self),
self.req.form.get(self.id))
def add_rql_restrictions(self):
"""add restriction for this facet into the rql syntax tree"""
self.rqlst.set_distinct(True) # XXX
value = self.req.form.get(self.id)
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.id)
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.req._
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.id)
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.req.add_js('ui.slider.js')
facet.req.add_css('ui.all.css')
sliderid = make_uid('the slider')
facetid = xml_escape(self.facet.id)
facet.req.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.req.property_value('ui.date-format')
facet.req.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.req = req
self.label = label
self.value = value
self.selected = selected
def _render(self):
if self.selected:
cssclass = ' facetValueSelected'
imgsrc = self.req.datadir_url + self.selected_img
imgalt = self.req._('selected')
else:
cssclass = ''
imgsrc = self.req.datadir_url + self.unselected_img
imgalt = self.req._('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.req = req
self.facet = facet
self.value = value
self.selected = selected
def _render(self):
title = xml_escape(self.facet.title)
facetid = xml_escape(self.facet.id)
self.w(u'<div id="%s" class="facet">\n' % facetid)
if self.selected:
cssclass = ' facetValueSelected'
imgsrc = self.req.datadir_url + self.selected_img
imgalt = self.req._('selected')
else:
cssclass = ''
imgsrc = self.req.datadir_url + self.unselected_img
imgalt = self.req._('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