web/facet.py
branchstable
changeset 6120 c000e41316ec
parent 6119 b217635d3b28
child 6152 6824f8b61098
equal deleted inserted replaced
6119:b217635d3b28 6120:c000e41316ec
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
    14 # details.
    14 # details.
    15 #
    15 #
    16 # You should have received a copy of the GNU Lesser General Public License along
    16 # You should have received a copy of the GNU Lesser General Public License along
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
    18 """contains utility functions and some visual component to restrict results of
    18 """
    19 a search
    19 The :mod:`cubicweb.web.facet` module contains a set of abstract classes to use
       
    20 as bases to build your own facets
       
    21 
       
    22 All facet classes inherits from the :class:`AbstractFacet` class, though you'll
       
    23 usually find some more handy class that do what you want.
       
    24 
       
    25 Let's see available classes.
       
    26 
       
    27 Classes you'll want to use
       
    28 --------------------------
       
    29 .. autoclass:: cubicweb.web.facet.RelationFacet
       
    30 .. autoclass:: cubicweb.web.facet.RelationAttributeFacet
       
    31 .. autoclass:: cubicweb.web.facet.HasRelationFacet
       
    32 .. autoclass:: cubicweb.web.facet.AttributeFacet
       
    33 .. autoclass:: cubicweb.web.facet.RangeFacet
       
    34 .. autoclass:: cubicweb.web.facet.DateRangeFacet
       
    35 
       
    36 Classes for facets implementor
       
    37 ------------------------------
       
    38 Unless you didn't find the class that does the job you want above, you may want
       
    39 to skip those classes...
       
    40 
       
    41 .. autoclass:: cubicweb.web.facet.AbstractFacet
       
    42 .. autoclass:: cubicweb.web.facet.VocabularyFacet
       
    43 
       
    44 .. comment: XXX widgets
    20 """
    45 """
    21 
    46 
    22 __docformat__ = "restructuredtext en"
    47 __docformat__ = "restructuredtext en"
    23 
    48 
    24 from copy import deepcopy
    49 from copy import deepcopy
   266                 continue
   291                 continue
   267             if not has_path(vargraph, ovarname, mainvar.name):
   292             if not has_path(vargraph, ovarname, mainvar.name):
   268                 toremove.add(rqlst.defined_vars[ovarname])
   293                 toremove.add(rqlst.defined_vars[ovarname])
   269 
   294 
   270 
   295 
   271 
   296 ## base facet classes ##########################################################
   272 ## base facet classes #########################################################
   297 
   273 class AbstractFacet(AppObject):
   298 class AbstractFacet(AppObject):
       
   299     """Abstract base class for all facets. Facets are stored in their own
       
   300     'facets' registry. They are similar to contextual components since the use
       
   301     the following configurable properties:
       
   302 
       
   303     * `visible`, boolean flag telling if a facet should be displayed or not
       
   304 
       
   305     * `order`, integer to control facets display order
       
   306 
       
   307     * `context`, telling if a facet should be displayed in the table form filter
       
   308       (context = 'tablefilter') or in the facet box (context = 'facetbox') or in
       
   309       both (context = '')
       
   310 
       
   311     The following methods define the facet API:
       
   312 
       
   313     .. automethod:: cubicweb.web.facet.AbstractFacet.get_widget
       
   314     .. automethod:: cubicweb.web.facet.AbstractFacet.add_rql_restrictions
       
   315 
       
   316     Facets will have the following attributes set (beside the standard
       
   317     :class:`~cubicweb.appobject.AppObject` ones):
       
   318 
       
   319     * `rqlst`, the rql syntax tree being facetted
       
   320 
       
   321     * `filtered_variable`, the variable node in this rql syntax tree that we're
       
   322       interested in filtering
       
   323 
       
   324     Facets implementors may also be interested in the following properties /
       
   325     methods:
       
   326 
       
   327     .. automethod:: cubicweb.web.facet.AbstractFacet.operator
       
   328     .. automethod:: cubicweb.web.facet.AbstractFacet.rqlexec
       
   329     """
   274     __abstract__ = True
   330     __abstract__ = True
   275     __registry__ = 'facets'
   331     __registry__ = 'facets'
   276     cw_property_defs = {
   332     cw_property_defs = {
   277         _('visible'): dict(type='Boolean', default=True,
   333         _('visible'): dict(type='Boolean', default=True,
   278                            help=_('display the facet or not')),
   334                            help=_('display the facet or not')),
   300         self.rqlst = rqlst
   356         self.rqlst = rqlst
   301         self.filtered_variable = filtered_variable
   357         self.filtered_variable = filtered_variable
   302 
   358 
   303     @property
   359     @property
   304     def operator(self):
   360     def operator(self):
       
   361         """Return the operator (AND or OR) to use for this facet when multiple
       
   362         values are selected.
       
   363         """
   305         # OR between selected values by default
   364         # OR between selected values by default
   306         return self._cw.form.get(self.__regid__ + '_andor', 'OR')
   365         return self._cw.form.get(self.__regid__ + '_andor', 'OR')
   307 
   366 
       
   367     def rqlexec(self, rql, args=None):
       
   368         """Utility method to execute some rql queries, and simply returning an
       
   369         empty list if :exc:`Unauthorized` is raised.
       
   370         """
       
   371         try:
       
   372             return self._cw.execute(rql, args)
       
   373         except Unauthorized:
       
   374             return []
       
   375 
   308     def get_widget(self):
   376     def get_widget(self):
   309         """return the widget instance to use to display this facet
   377         """Return the widget instance to use to display this facet, or None if
       
   378         the facet can't do anything valuable (only one value in the vocabulary
       
   379         for instance).
   310         """
   380         """
   311         raise NotImplementedError
   381         raise NotImplementedError
   312 
   382 
   313     def add_rql_restrictions(self):
   383     def add_rql_restrictions(self):
   314         """add restriction for this facet into the rql syntax tree"""
   384         """When some facet criteria has been updated, this method is called to
       
   385         add restriction for this facet into the rql syntax tree. It should get
       
   386         back its value in form parameters, and modify the syntax tree
       
   387         (`self.rqlst`) accordingly.
       
   388         """
   315         raise NotImplementedError
   389         raise NotImplementedError
   316 
   390 
   317 
   391 
   318 class VocabularyFacet(AbstractFacet):
   392 class VocabularyFacet(AbstractFacet):
       
   393     """This abstract class extend :class:`AbstractFacet` to use the
       
   394     :class:`FacetVocabularyWidget` as widget, suitable for facets that may
       
   395     restrict values according to a (usually computed) vocabulary.
       
   396 
       
   397     A class which inherits from VocabularyFacet must define at least these methods:
       
   398 
       
   399     .. automethod:: cubicweb.web.facet.VocabularyFacet.vocabulary
       
   400     .. automethod:: cubicweb.web.facet.VocabularyFacet.possible_values
       
   401     """
   319     needs_update = True
   402     needs_update = True
   320 
   403 
   321     def get_widget(self):
   404     def get_widget(self):
   322         """return the widget instance to use to display this facet
   405         """Return the widget instance to use to display this facet.
   323 
   406 
   324         default implentation expects a .vocabulary method on the facet and
   407         This implementation expects a .vocabulary method on the facet and
   325         return a combobox displaying this vocabulary
   408         return a combobox displaying this vocabulary.
   326         """
   409         """
   327         vocab = self.vocabulary()
   410         vocab = self.vocabulary()
   328         if len(vocab) <= 1:
   411         if len(vocab) <= 1:
   329             return None
   412             return None
   330         wdg = FacetVocabularyWidget(self)
   413         wdg = FacetVocabularyWidget(self)
   335             else:
   418             else:
   336                 wdg.append(FacetItem(self._cw, label, value, value in selected))
   419                 wdg.append(FacetItem(self._cw, label, value, value in selected))
   337         return wdg
   420         return wdg
   338 
   421 
   339     def vocabulary(self):
   422     def vocabulary(self):
   340         """return vocabulary for this facet, eg a list of 2-uple (label, value)
   423         """Return vocabulary for this facet, eg a list of 2-uple (label, value).
   341         """
   424         """
   342         raise NotImplementedError
   425         raise NotImplementedError
   343 
   426 
   344     def possible_values(self):
   427     def possible_values(self):
   345         """return a list of possible values (as string since it's used to
   428         """Return a list of possible values (as string since it's used to
   346         compare to a form value in javascript) for this facet
   429         compare to a form value in javascript) for this facet.
   347         """
   430         """
   348         raise NotImplementedError
   431         raise NotImplementedError
   349 
   432 
   350     def support_and(self):
   433     def support_and(self):
   351         return False
   434         return False
   352 
       
   353     def rqlexec(self, rql, args=None):
       
   354         try:
       
   355             return self._cw.execute(rql, args)
       
   356         except Unauthorized:
       
   357             return []
       
   358 
   435 
   359 
   436 
   360 class RelationFacet(VocabularyFacet):
   437 class RelationFacet(VocabularyFacet):
   361     """Base facet to filter some entities according to other entities to which
   438     """Base facet to filter some entities according to other entities to which
   362     they are related. Create concret facet by inheriting from this class an then
   439     they are related. Create concret facet by inheriting from this class an then
   380 
   457 
   381     * `sortfunc`: set this to a stored procedure name if you want to sort on the
   458     * `sortfunc`: set this to a stored procedure name if you want to sort on the
   382       result of this function's result instead of direct value
   459       result of this function's result instead of direct value
   383 
   460 
   384     * `sortasc`: boolean flag to control ascendant/descendant sorting
   461     * `sortasc`: boolean flag to control ascendant/descendant sorting
       
   462 
       
   463     To illustrate this facet, let's take for example an *excerpt* of the schema
       
   464     of an office location search application:
       
   465 
       
   466     .. sourcecode:: python
       
   467 
       
   468       class Office(WorkflowableEntityType):
       
   469           price = Int(description='euros / m2 / HC / HT')
       
   470           surface = Int(description='m2')
       
   471           has_address = SubjectRelation('PostalAddress',
       
   472                                         cardinality='1?',
       
   473                                         composite='subject')
       
   474           proposed_by = SubjectRelation('Agency')
       
   475 
       
   476 
       
   477     We can simply define a facet to filter offices according to the agency
       
   478     proposing it:
       
   479 
       
   480     .. sourcecode:: python
       
   481 
       
   482       class AgencyFacet(RelationFacet):
       
   483           __regid__ = 'agency'
       
   484           # this facet should only be selected when visualizing offices
       
   485           __select__ = RelationFacet.__select__ & implements('Office')
       
   486           # this facet is a filter on the 'Agency' entities linked to the office
       
   487           # through the 'proposed_by' relation, where the office is the subject
       
   488           # of the relation
       
   489           rtype = 'has_address'
       
   490           # 'subject' is the default but setting it explicitly doesn't hurt...
       
   491           role = 'subject'
       
   492           # we want to display the agency's name
       
   493           target_attr = 'name'
   385     """
   494     """
   386     __select__ = partial_relation_possible() & match_context_prop()
   495     __select__ = partial_relation_possible() & match_context_prop()
   387     # class attributes to configure the relation facet
   496     # class attributes to configure the relation facet
   388     rtype = None
   497     rtype = None
   389     role = 'subject'
   498     role = 'subject'
   530 
   639 
   531     * you should specify the attribute type using `attrtype` if it's not a
   640     * you should specify the attribute type using `attrtype` if it's not a
   532       String
   641       String
   533 
   642 
   534     * you can specify a comparison operator using `comparator`
   643     * you can specify a comparison operator using `comparator`
       
   644 
       
   645 
       
   646     Back to our example... if you want to search office by postal code and that
       
   647     you use a :class:`RelationFacet` for that, you won't get the expected
       
   648     behaviour: if two offices have the same postal code, they've however two
       
   649     different addresses.  So you'll see in the facet the same postal code twice,
       
   650     though linked to a different address entity. There is a great chance your
       
   651     users won't understand that...
       
   652 
       
   653     That's where this class come in ! It's used to said that you want to filter
       
   654     according to the *attribute value* of a relatied entity, not to the entity
       
   655     itself. Now here is the source code for the facet:
       
   656 
       
   657     .. sourcecode:: python
       
   658 
       
   659       class PostalCodeFacet(RelationAttributeFacet):
       
   660           __regid__ = 'postalcode'
       
   661           # this facet should only be selected when visualizing offices
       
   662           __select__ = RelationAttributeFacet.__select__ & implements('Office')
       
   663           # this facet is a filter on the PostalAddress entities linked to the
       
   664           # office through the 'has_address' relation, where the office is the
       
   665           # subject of the relation
       
   666           rtype = 'has_address'
       
   667           role = 'subject'
       
   668           # we want to search according to address 'postal_code' attribute
       
   669           target_attr = 'postalcode'
   535     """
   670     """
   536     _select_target_entity = False
   671     _select_target_entity = False
   537     # attribute type
   672     # attribute type
   538     attrtype = 'String'
   673     attrtype = 'String'
   539     # type of comparison: default is an exact match on the attribute value
   674     # type of comparison: default is an exact match on the attribute value
   577     """Base facet to filter some entities according one of their attribute.
   712     """Base facet to filter some entities according one of their attribute.
   578     Configuration is mostly similarly as :class:`RelationAttributeFacet`, except that:
   713     Configuration is mostly similarly as :class:`RelationAttributeFacet`, except that:
   579 
   714 
   580     * `target_attr` doesn't make sense here (you specify the attribute using `rtype`
   715     * `target_attr` doesn't make sense here (you specify the attribute using `rtype`
   581     * `role` neither, it's systematically 'subject'
   716     * `role` neither, it's systematically 'subject'
   582     """
   717 
       
   718     So, suppose that in our office search example you want to refine search according
       
   719     to the office's surface. Here is a code snippet achieving this:
       
   720 
       
   721     .. sourcecode:: python
       
   722 
       
   723       class SurfaceFacet(AttributeFacet):
       
   724           __regid__ = 'surface'
       
   725           __select__ = AttributeFacet.__select__ & implements('Office')
       
   726           # this facet is a filter on the office'surface
       
   727           rtype = 'surface'
       
   728           # override the default value of operator since we want to filter
       
   729           # according to a minimal value, not an exact one
       
   730           comparator = '>='
       
   731 
       
   732           def vocabulary(self):
       
   733               '''override the default vocabulary method since we want to
       
   734               hard-code our threshold values.
       
   735 
       
   736               Not overriding would generate a filter containing all existing
       
   737               surfaces defined in the database.
       
   738               '''
       
   739               return [('> 200', '200'), ('> 250', '250'),
       
   740                       ('> 275', '275'), ('> 300', '300')]
       
   741     """
       
   742 
   583     _select_target_entity = True
   743     _select_target_entity = True
   584 
   744 
   585     def vocabulary(self):
   745     def vocabulary(self):
   586         """return vocabulary for this facet, eg a list of 2-uple (label, value)
   746         """return vocabulary for this facet, eg a list of 2-uple (label, value)
   587         """
   747         """
   615         mainvar = self.filtered_variable
   775         mainvar = self.filtered_variable
   616         self.rqlst.add_constant_restriction(mainvar, self.rtype, value,
   776         self.rqlst.add_constant_restriction(mainvar, self.rtype, value,
   617                                             self.attrtype, self.comparator)
   777                                             self.attrtype, self.comparator)
   618 
   778 
   619 
   779 
   620 class FilterRQLBuilder(object):
       
   621     """called by javascript to get a rql string from filter form"""
       
   622 
       
   623     def __init__(self, req):
       
   624         self._cw = req
       
   625 
       
   626     def build_rql(self):#, tablefilter=False):
       
   627         form = self._cw.form
       
   628         facetids = form['facets'].split(',')
       
   629         select = self._cw.vreg.parse(self._cw, form['baserql']).children[0] # XXX Union unsupported yet
       
   630         mainvar = filtered_variable(select)
       
   631         toupdate = []
       
   632         for facetid in facetids:
       
   633             facet = get_facet(self._cw, facetid, select, mainvar)
       
   634             facet.add_rql_restrictions()
       
   635             if facet.needs_update:
       
   636                 toupdate.append(facetid)
       
   637         return select.as_string(), toupdate
       
   638 
       
   639 
       
   640 class RangeFacet(AttributeFacet):
   780 class RangeFacet(AttributeFacet):
       
   781     """This class allows to filter entities according to an attribute of
       
   782     numerical type.
       
   783 
       
   784     It displays a slider using `jquery`_ to choose a lower bound and an upper
       
   785     bound.
       
   786 
       
   787     The example below provides an alternative to the surface facet seen earlier,
       
   788     in a more powerful way since
       
   789 
       
   790     * lower/upper boundaries are computed according to entities to filter
       
   791     * user can specify lower/upper boundaries, not only the lower one
       
   792 
       
   793     .. sourcecode:: python
       
   794 
       
   795       class SurfaceFacet(RangeFacet):
       
   796           __regid__ = 'surface'
       
   797           __select__ = RangeFacet.__select__ & implements('Office')
       
   798           # this facet is a filter on the office'surface
       
   799           rtype = 'surface'
       
   800 
       
   801     All this with even less code!
       
   802 
       
   803     The image below display the rendering of the slider:
       
   804 
       
   805     .. image:: ../images/facet_range.png
       
   806 
       
   807     .. _jquery: http://www.jqueryui.com/
       
   808     """
   641     attrtype = 'Float' # only numerical types are supported
   809     attrtype = 'Float' # only numerical types are supported
   642 
   810 
   643     @property
   811     @property
   644     def wdgclass(self):
   812     def wdgclass(self):
   645         return FacetRangeWidget
   813         return FacetRangeWidget
   676                                             self.formatvalue(supvalue),
   844                                             self.formatvalue(supvalue),
   677                                             self.attrtype, '<=')
   845                                             self.attrtype, '<=')
   678 
   846 
   679 
   847 
   680 class DateRangeFacet(RangeFacet):
   848 class DateRangeFacet(RangeFacet):
       
   849     """This class works similarly as the :class:`RangeFacet` but for attribute
       
   850     of date type.
       
   851 
       
   852     The image below display the rendering of the slider for a date range:
       
   853 
       
   854     .. image:: ../images/facet_date_range.png
       
   855     """
   681     attrtype = 'Date' # only date types are supported
   856     attrtype = 'Date' # only date types are supported
   682 
   857 
   683     @property
   858     @property
   684     def wdgclass(self):
   859     def wdgclass(self):
   685         return DateFacetRangeWidget
   860         return DateFacetRangeWidget
   688         """format `value` before in order to insert it in the RQL query"""
   863         """format `value` before in order to insert it in the RQL query"""
   689         return '"%s"' % date.fromtimestamp(float(value) / 1000).strftime('%Y/%m/%d')
   864         return '"%s"' % date.fromtimestamp(float(value) / 1000).strftime('%Y/%m/%d')
   690 
   865 
   691 
   866 
   692 class HasRelationFacet(AbstractFacet):
   867 class HasRelationFacet(AbstractFacet):
       
   868     """This class simply filter according to the presence of a relation
       
   869     (whatever the entity at the other end). It display a simple checkbox that
       
   870     lets you refine your selection in order to get only entities that actually
       
   871     have this relation. You simply have to define which relation using the
       
   872     `rtype` and `role` attributes.
       
   873 
       
   874     Here is an example of the rendering of thos facet to filter book with image
       
   875     and the corresponding code:
       
   876 
       
   877     .. image:: ../images/facet_has_image.png
       
   878 
       
   879     .. sourcecode:: python
       
   880 
       
   881       class HasImageFacet(HasRelationFacet):
       
   882           __regid__ = 'hasimage'
       
   883           __select__ = HasRelationFacet.__select__ & implements('Book')
       
   884           rtype = 'has_image'
       
   885           role = 'subject'
       
   886     """
   693     rtype = None # override me in subclass
   887     rtype = None # override me in subclass
   694     role = 'subject' # role of filtered entity in the relation
   888     role = 'subject' # role of filtered entity in the relation
   695 
   889 
   696     @property
   890     @property
   697     def title(self):
   891     def title(self):
   906     def __init__(self, label=None):
  1100     def __init__(self, label=None):
   907         self.label = label or u'&#160;'
  1101         self.label = label or u'&#160;'
   908 
  1102 
   909     def _render(self):
  1103     def _render(self):
   910         pass
  1104         pass
       
  1105 
       
  1106 # other classes ################################################################
       
  1107 
       
  1108 class FilterRQLBuilder(object):
       
  1109     """called by javascript to get a rql string from filter form"""
       
  1110 
       
  1111     def __init__(self, req):
       
  1112         self._cw = req
       
  1113 
       
  1114     def build_rql(self):#, tablefilter=False):
       
  1115         form = self._cw.form
       
  1116         facetids = form['facets'].split(',')
       
  1117         # XXX Union unsupported yet
       
  1118         select = self._cw.vreg.parse(self._cw, form['baserql']).children[0]
       
  1119         mainvar = filtered_variable(select)
       
  1120         toupdate = []
       
  1121         for facetid in facetids:
       
  1122             facet = get_facet(self._cw, facetid, select, mainvar)
       
  1123             facet.add_rql_restrictions()
       
  1124             if facet.needs_update:
       
  1125                 toupdate.append(facetid)
       
  1126         return select.as_string(), toupdate