web/facet.py
branchstable
changeset 6120 c000e41316ec
parent 6119 b217635d3b28
child 6152 6824f8b61098
--- a/web/facet.py	Wed Aug 18 13:58:12 2010 +0200
+++ b/web/facet.py	Wed Aug 18 16:53:05 2010 +0200
@@ -15,8 +15,33 @@
 #
 # You should have received a copy of the GNU Lesser General Public License along
 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
-"""contains utility functions and some visual component to restrict results of
-a search
+"""
+The :mod:`cubicweb.web.facet` module contains a set of abstract classes to use
+as bases to build your own facets
+
+All facet classes inherits from the :class:`AbstractFacet` class, though you'll
+usually find some more handy class that do what you want.
+
+Let's see available classes.
+
+Classes you'll want to use
+--------------------------
+.. autoclass:: cubicweb.web.facet.RelationFacet
+.. autoclass:: cubicweb.web.facet.RelationAttributeFacet
+.. autoclass:: cubicweb.web.facet.HasRelationFacet
+.. autoclass:: cubicweb.web.facet.AttributeFacet
+.. autoclass:: cubicweb.web.facet.RangeFacet
+.. autoclass:: cubicweb.web.facet.DateRangeFacet
+
+Classes for facets implementor
+------------------------------
+Unless you didn't find the class that does the job you want above, you may want
+to skip those classes...
+
+.. autoclass:: cubicweb.web.facet.AbstractFacet
+.. autoclass:: cubicweb.web.facet.VocabularyFacet
+
+.. comment: XXX widgets
 """
 
 __docformat__ = "restructuredtext en"
@@ -268,9 +293,40 @@
                 toremove.add(rqlst.defined_vars[ovarname])
 
 
+## base facet classes ##########################################################
 
-## base facet classes #########################################################
 class AbstractFacet(AppObject):
+    """Abstract base class for all facets. Facets are stored in their own
+    'facets' registry. They are similar to contextual components since the use
+    the following configurable properties:
+
+    * `visible`, boolean flag telling if a facet should be displayed or not
+
+    * `order`, integer to control facets display order
+
+    * `context`, telling if a facet should be displayed in the table form filter
+      (context = 'tablefilter') or in the facet box (context = 'facetbox') or in
+      both (context = '')
+
+    The following methods define the facet API:
+
+    .. automethod:: cubicweb.web.facet.AbstractFacet.get_widget
+    .. automethod:: cubicweb.web.facet.AbstractFacet.add_rql_restrictions
+
+    Facets will have the following attributes set (beside the standard
+    :class:`~cubicweb.appobject.AppObject` ones):
+
+    * `rqlst`, the rql syntax tree being facetted
+
+    * `filtered_variable`, the variable node in this rql syntax tree that we're
+      interested in filtering
+
+    Facets implementors may also be interested in the following properties /
+    methods:
+
+    .. automethod:: cubicweb.web.facet.AbstractFacet.operator
+    .. automethod:: cubicweb.web.facet.AbstractFacet.rqlexec
+    """
     __abstract__ = True
     __registry__ = 'facets'
     cw_property_defs = {
@@ -302,27 +358,54 @@
 
     @property
     def operator(self):
+        """Return the operator (AND or OR) to use for this facet when multiple
+        values are selected.
+        """
         # OR between selected values by default
         return self._cw.form.get(self.__regid__ + '_andor', 'OR')
 
+    def rqlexec(self, rql, args=None):
+        """Utility method to execute some rql queries, and simply returning an
+        empty list if :exc:`Unauthorized` is raised.
+        """
+        try:
+            return self._cw.execute(rql, args)
+        except Unauthorized:
+            return []
+
     def get_widget(self):
-        """return the widget instance to use to display this facet
+        """Return the widget instance to use to display this facet, or None if
+        the facet can't do anything valuable (only one value in the vocabulary
+        for instance).
         """
         raise NotImplementedError
 
     def add_rql_restrictions(self):
-        """add restriction for this facet into the rql syntax tree"""
+        """When some facet criteria has been updated, this method is called to
+        add restriction for this facet into the rql syntax tree. It should get
+        back its value in form parameters, and modify the syntax tree
+        (`self.rqlst`) accordingly.
+        """
         raise NotImplementedError
 
 
 class VocabularyFacet(AbstractFacet):
+    """This abstract class extend :class:`AbstractFacet` to use the
+    :class:`FacetVocabularyWidget` as widget, suitable for facets that may
+    restrict values according to a (usually computed) vocabulary.
+
+    A class which inherits from VocabularyFacet must define at least these methods:
+
+    .. automethod:: cubicweb.web.facet.VocabularyFacet.vocabulary
+    .. automethod:: cubicweb.web.facet.VocabularyFacet.possible_values
+    """
     needs_update = True
 
     def get_widget(self):
-        """return the widget instance to use to display this facet
+        """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
+        This implementation expects a .vocabulary method on the facet and
+        return a combobox displaying this vocabulary.
         """
         vocab = self.vocabulary()
         if len(vocab) <= 1:
@@ -337,25 +420,19 @@
         return wdg
 
     def vocabulary(self):
-        """return vocabulary for this facet, eg a list of 2-uple (label, value)
+        """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
+        """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):
     """Base facet to filter some entities according to other entities to which
@@ -382,6 +459,38 @@
       result of this function's result instead of direct value
 
     * `sortasc`: boolean flag to control ascendant/descendant sorting
+
+    To illustrate this facet, let's take for example an *excerpt* of the schema
+    of an office location search application:
+
+    .. sourcecode:: python
+
+      class Office(WorkflowableEntityType):
+          price = Int(description='euros / m2 / HC / HT')
+          surface = Int(description='m2')
+          has_address = SubjectRelation('PostalAddress',
+                                        cardinality='1?',
+                                        composite='subject')
+          proposed_by = SubjectRelation('Agency')
+
+
+    We can simply define a facet to filter offices according to the agency
+    proposing it:
+
+    .. sourcecode:: python
+
+      class AgencyFacet(RelationFacet):
+          __regid__ = 'agency'
+          # this facet should only be selected when visualizing offices
+          __select__ = RelationFacet.__select__ & implements('Office')
+          # this facet is a filter on the 'Agency' entities linked to the office
+          # through the 'proposed_by' relation, where the office is the subject
+          # of the relation
+          rtype = 'has_address'
+          # 'subject' is the default but setting it explicitly doesn't hurt...
+          role = 'subject'
+          # we want to display the agency's name
+          target_attr = 'name'
     """
     __select__ = partial_relation_possible() & match_context_prop()
     # class attributes to configure the relation facet
@@ -532,6 +641,32 @@
       String
 
     * you can specify a comparison operator using `comparator`
+
+
+    Back to our example... if you want to search office by postal code and that
+    you use a :class:`RelationFacet` for that, you won't get the expected
+    behaviour: if two offices have the same postal code, they've however two
+    different addresses.  So you'll see in the facet the same postal code twice,
+    though linked to a different address entity. There is a great chance your
+    users won't understand that...
+
+    That's where this class come in ! It's used to said that you want to filter
+    according to the *attribute value* of a relatied entity, not to the entity
+    itself. Now here is the source code for the facet:
+
+    .. sourcecode:: python
+
+      class PostalCodeFacet(RelationAttributeFacet):
+          __regid__ = 'postalcode'
+          # this facet should only be selected when visualizing offices
+          __select__ = RelationAttributeFacet.__select__ & implements('Office')
+          # this facet is a filter on the PostalAddress entities linked to the
+          # office through the 'has_address' relation, where the office is the
+          # subject of the relation
+          rtype = 'has_address'
+          role = 'subject'
+          # we want to search according to address 'postal_code' attribute
+          target_attr = 'postalcode'
     """
     _select_target_entity = False
     # attribute type
@@ -579,7 +714,32 @@
 
     * `target_attr` doesn't make sense here (you specify the attribute using `rtype`
     * `role` neither, it's systematically 'subject'
+
+    So, suppose that in our office search example you want to refine search according
+    to the office's surface. Here is a code snippet achieving this:
+
+    .. sourcecode:: python
+
+      class SurfaceFacet(AttributeFacet):
+          __regid__ = 'surface'
+          __select__ = AttributeFacet.__select__ & implements('Office')
+          # this facet is a filter on the office'surface
+          rtype = 'surface'
+          # override the default value of operator since we want to filter
+          # according to a minimal value, not an exact one
+          comparator = '>='
+
+          def vocabulary(self):
+              '''override the default vocabulary method since we want to
+              hard-code our threshold values.
+
+              Not overriding would generate a filter containing all existing
+              surfaces defined in the database.
+              '''
+              return [('> 200', '200'), ('> 250', '250'),
+                      ('> 275', '275'), ('> 300', '300')]
     """
+
     _select_target_entity = True
 
     def vocabulary(self):
@@ -617,27 +777,35 @@
                                             self.attrtype, self.comparator)
 
 
-class FilterRQLBuilder(object):
-    """called by javascript to get a rql string from filter form"""
+class RangeFacet(AttributeFacet):
+    """This class allows to filter entities according to an attribute of
+    numerical type.
+
+    It displays a slider using `jquery`_ to choose a lower bound and an upper
+    bound.
 
-    def __init__(self, req):
-        self._cw = req
+    The example below provides an alternative to the surface facet seen earlier,
+    in a more powerful way since
+
+    * lower/upper boundaries are computed according to entities to filter
+    * user can specify lower/upper boundaries, not only the lower one
+
+    .. sourcecode:: python
 
-    def build_rql(self):#, tablefilter=False):
-        form = self._cw.form
-        facetids = form['facets'].split(',')
-        select = self._cw.vreg.parse(self._cw, 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 SurfaceFacet(RangeFacet):
+          __regid__ = 'surface'
+          __select__ = RangeFacet.__select__ & implements('Office')
+          # this facet is a filter on the office'surface
+          rtype = 'surface'
 
+    All this with even less code!
 
-class RangeFacet(AttributeFacet):
+    The image below display the rendering of the slider:
+
+    .. image:: ../images/facet_range.png
+
+    .. _jquery: http://www.jqueryui.com/
+    """
     attrtype = 'Float' # only numerical types are supported
 
     @property
@@ -678,6 +846,13 @@
 
 
 class DateRangeFacet(RangeFacet):
+    """This class works similarly as the :class:`RangeFacet` but for attribute
+    of date type.
+
+    The image below display the rendering of the slider for a date range:
+
+    .. image:: ../images/facet_date_range.png
+    """
     attrtype = 'Date' # only date types are supported
 
     @property
@@ -690,6 +865,25 @@
 
 
 class HasRelationFacet(AbstractFacet):
+    """This class simply filter according to the presence of a relation
+    (whatever the entity at the other end). It display a simple checkbox that
+    lets you refine your selection in order to get only entities that actually
+    have this relation. You simply have to define which relation using the
+    `rtype` and `role` attributes.
+
+    Here is an example of the rendering of thos facet to filter book with image
+    and the corresponding code:
+
+    .. image:: ../images/facet_has_image.png
+
+    .. sourcecode:: python
+
+      class HasImageFacet(HasRelationFacet):
+          __regid__ = 'hasimage'
+          __select__ = HasRelationFacet.__select__ & implements('Book')
+          rtype = 'has_image'
+          role = 'subject'
+    """
     rtype = None # override me in subclass
     role = 'subject' # role of filtered entity in the relation
 
@@ -908,3 +1102,25 @@
 
     def _render(self):
         pass
+
+# other classes ################################################################
+
+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(',')
+        # XXX Union unsupported yet
+        select = self._cw.vreg.parse(self._cw, form['baserql']).children[0]
+        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