cubicweb/web/views/navigation.py
changeset 11057 0b59724cb3f2
parent 10727 3fb9111d521f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
       
     1 # copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    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/>.
       
    18 """This module provides some generic components to navigate in the web
       
    19 application.
       
    20 
       
    21 Pagination
       
    22 ----------
       
    23 
       
    24 Several implementations for large result set pagination are provided:
       
    25 
       
    26 .. autoclass:: PageNavigation
       
    27 .. autoclass:: PageNavigationSelect
       
    28 .. autoclass:: SortedNavigation
       
    29 
       
    30 Pagination will appear when needed according to the `page-size` ui property.
       
    31 
       
    32 This module monkey-patch the :func:`paginate` function to the base :class:`View`
       
    33 class, so that you can ask pagination explicitly on every result-set based views.
       
    34 
       
    35 .. autofunction:: paginate
       
    36 
       
    37 
       
    38 Previous / next navigation
       
    39 --------------------------
       
    40 
       
    41 An adapter and its related component for the somewhat usal "previous / next"
       
    42 navigation are provided.
       
    43 
       
    44   .. autoclass:: IPrevNextAdapter
       
    45   .. autoclass:: NextPrevNavigationComponent
       
    46 """
       
    47 
       
    48 __docformat__ = "restructuredtext en"
       
    49 from cubicweb import _
       
    50 
       
    51 from datetime import datetime
       
    52 
       
    53 from six import text_type
       
    54 
       
    55 from rql.nodes import VariableRef, Constant
       
    56 
       
    57 from logilab.mtconverter import xml_escape
       
    58 from logilab.common.deprecation import deprecated
       
    59 
       
    60 from cubicweb.predicates import paginated_rset, sorted_rset, adaptable
       
    61 from cubicweb.uilib import cut
       
    62 from cubicweb.view import EntityAdapter
       
    63 from cubicweb.web.component import EmptyComponent, EntityCtxComponent, NavigationComponent
       
    64 
       
    65 
       
    66 class PageNavigation(NavigationComponent):
       
    67     """The default pagination component: display link to pages where each pages
       
    68     is identified by the item number of its first and last elements.
       
    69     """
       
    70     def call(self):
       
    71         """displays a resultset by page"""
       
    72         params = dict(self._cw.form)
       
    73         self.clean_params(params)
       
    74         basepath = self._cw.relative_path(includeparams=False)
       
    75         self.w(u'<div class="pagination">')
       
    76         self.w(self.previous_link(basepath, params))
       
    77         self.w(u'[&#160;%s&#160;]' %
       
    78                u'&#160;| '.join(self.iter_page_links(basepath, params)))
       
    79         self.w(u'&#160;&#160;%s' % self.next_link(basepath, params))
       
    80         self.w(u'</div>')
       
    81 
       
    82     def index_display(self, start, stop):
       
    83         return u'%s - %s' % (start+1, stop+1)
       
    84 
       
    85     def iter_page_links(self, basepath, params):
       
    86         rset = self.cw_rset
       
    87         page_size = self.page_size
       
    88         start = 0
       
    89         while start < rset.rowcount:
       
    90             stop = min(start + page_size - 1, rset.rowcount - 1)
       
    91             yield self.page_link(basepath, params, start, stop,
       
    92                                  self.index_display(start, stop))
       
    93             start = stop + 1
       
    94 
       
    95 
       
    96 class PageNavigationSelect(PageNavigation):
       
    97     """This pagination component displays a result-set by page as
       
    98     :class:`PageNavigation` but in a <select>, which is better when there are a
       
    99     lot of results.
       
   100 
       
   101     By default it will be selected when there are more than 4 pages to be
       
   102     displayed.
       
   103     """
       
   104     __select__ = paginated_rset(4)
       
   105 
       
   106     page_link_templ = u'<option value="%s" title="%s">%s</option>'
       
   107     selected_page_link_templ = u'<option value="%s" selected="selected" title="%s">%s</option>'
       
   108     def call(self):
       
   109         params = dict(self._cw.form)
       
   110         self.clean_params(params)
       
   111         basepath = self._cw.relative_path(includeparams=False)
       
   112         w = self.w
       
   113         w(u'<div class="pagination">')
       
   114         w(self.previous_link(basepath, params))
       
   115         w(u'<select onchange="javascript: document.location=this.options[this.selectedIndex].value">')
       
   116         for option in self.iter_page_links(basepath, params):
       
   117             w(option)
       
   118         w(u'</select>')
       
   119         w(u'&#160;&#160;%s' % self.next_link(basepath, params))
       
   120         w(u'</div>')
       
   121 
       
   122 
       
   123 class SortedNavigation(NavigationComponent):
       
   124     """This pagination component will be selected by default if there are less
       
   125     than 4 pages and if the result set is sorted.
       
   126 
       
   127     Displayed links to navigate accross pages of a result set are done according
       
   128     to the first variable on which the sort is done, and looks like:
       
   129 
       
   130         [ana - cro] | [cro - ghe] | ... | [tim - zou]
       
   131 
       
   132     You may want to override this component to customize display in some cases.
       
   133 
       
   134     .. automethod:: sort_on
       
   135     .. automethod:: display_func
       
   136     .. automethod:: format_link_content
       
   137     .. automethod:: write_links
       
   138 
       
   139     Below an example from the tracker cube:
       
   140 
       
   141     .. sourcecode:: python
       
   142 
       
   143       class TicketsNavigation(navigation.SortedNavigation):
       
   144           __select__ = (navigation.SortedNavigation.__select__
       
   145                         & ~paginated_rset(4) & is_instance('Ticket'))
       
   146           def sort_on(self):
       
   147               col, attrname = super(TicketsNavigation, self).sort_on()
       
   148               if col == 6:
       
   149                   # sort on state, we don't want that
       
   150                   return None, None
       
   151               return col, attrname
       
   152 
       
   153     The idea is that in trackers'ticket tables, result set is first ordered on
       
   154     ticket's state while this doesn't make any sense in the navigation. So we
       
   155     override :meth:`sort_on` so that if we detect such sorting, we disable the
       
   156     feature to go back to item number in the pagination.
       
   157 
       
   158     Also notice the `~paginated_rset(4)` in the selector so that if there are
       
   159     more than 4 pages to display, :class:`PageNavigationSelect` will still be
       
   160     selected.
       
   161     """
       
   162     __select__ = paginated_rset() & sorted_rset()
       
   163 
       
   164     # number of considered chars to build page links
       
   165     nb_chars = 5
       
   166 
       
   167     def call(self):
       
   168         # attrname = the name of attribute according to which the sort
       
   169         # is done if any
       
   170         col, attrname = self.sort_on()
       
   171         index_display = self.display_func(self.cw_rset, col, attrname)
       
   172         basepath = self._cw.relative_path(includeparams=False)
       
   173         params = dict(self._cw.form)
       
   174         self.clean_params(params)
       
   175         blocklist = []
       
   176         start = 0
       
   177         total = self.cw_rset.rowcount
       
   178         while start < total:
       
   179             stop = min(start + self.page_size - 1, total - 1)
       
   180             cell = self.format_link_content(index_display(start), index_display(stop))
       
   181             blocklist.append(self.page_link(basepath, params, start, stop, cell))
       
   182             start = stop + 1
       
   183         self.write_links(basepath, params, blocklist)
       
   184 
       
   185     def display_func(self, rset, col, attrname):
       
   186         """Return a function that will be called with a row number as argument
       
   187         and should return a string to use as link for it.
       
   188         """
       
   189         if attrname is not None:
       
   190             def index_display(row):
       
   191                 if not rset[row][col]: # outer join
       
   192                     return u''
       
   193                 entity = rset.get_entity(row, col)
       
   194                 return entity.printable_value(attrname, format='text/plain')
       
   195         elif col is None: # smart links disabled.
       
   196             def index_display(row):
       
   197                 return text_type(row)
       
   198         elif self._cw.vreg.schema.eschema(rset.description[0][col]).final:
       
   199             def index_display(row):
       
   200                 return text_type(rset[row][col])
       
   201         else:
       
   202             def index_display(row):
       
   203                 return rset.get_entity(row, col).view('text')
       
   204         return index_display
       
   205 
       
   206     def sort_on(self):
       
   207         """Return entity column number / attr name to use for nice display by
       
   208         inspecting the rset'syntax tree.
       
   209         """
       
   210         rschema = self._cw.vreg.schema.rschema
       
   211         for sorterm in self.cw_rset.syntax_tree().children[0].orderby:
       
   212             if isinstance(sorterm.term, Constant):
       
   213                 col = sorterm.term.value - 1
       
   214                 return col, None
       
   215             var = sorterm.term.get_nodes(VariableRef)[0].variable
       
   216             col = None
       
   217             for ref in var.references():
       
   218                 rel = ref.relation()
       
   219                 if rel is None:
       
   220                     continue
       
   221                 attrname = rel.r_type
       
   222                 if attrname in ('is', 'has_text'):
       
   223                     continue
       
   224                 if not rschema(attrname).final:
       
   225                     col = var.selected_index()
       
   226                     attrname = None
       
   227                 if col is None:
       
   228                     # final relation or not selected non final relation
       
   229                     if var is rel.children[0]:
       
   230                         relvar = rel.children[1].children[0].get_nodes(VariableRef)[0]
       
   231                     else:
       
   232                         relvar = rel.children[0].variable
       
   233                     col = relvar.selected_index()
       
   234                 if col is not None:
       
   235                     break
       
   236             else:
       
   237                 # no relation but maybe usable anyway if selected
       
   238                 col = var.selected_index()
       
   239                 attrname = None
       
   240             if col is not None:
       
   241                 # if column type is date[time], set proper 'nb_chars'
       
   242                 if var.stinfo['possibletypes'] & frozenset(('TZDatetime', 'Datetime',
       
   243                                                             'Date')):
       
   244                     self.nb_chars = len(self._cw.format_date(datetime.today()))
       
   245                 return col, attrname
       
   246         # nothing usable found, use the first column
       
   247         return 0, None
       
   248 
       
   249     def format_link_content(self, startstr, stopstr):
       
   250         """Return text for a page link, where `startstr` and `stopstr` are the
       
   251         text for the lower/upper boundaries of the page.
       
   252 
       
   253         By default text are stripped down to :attr:`nb_chars` characters.
       
   254         """
       
   255         text = u'%s - %s' % (startstr.lower()[:self.nb_chars],
       
   256                              stopstr.lower()[:self.nb_chars])
       
   257         return xml_escape(text)
       
   258 
       
   259     def write_links(self, basepath, params, blocklist):
       
   260         """Return HTML for the whole navigation: `blocklist` is a list of HTML
       
   261         snippets for each page, `basepath` and `params` will be necessary to
       
   262         build previous/next links.
       
   263         """
       
   264         self.w(u'<div class="pagination">')
       
   265         self.w(u'%s&#160;' % self.previous_link(basepath, params))
       
   266         self.w(u'[&#160;%s&#160;]' % u'&#160;| '.join(blocklist))
       
   267         self.w(u'&#160;%s' % self.next_link(basepath, params))
       
   268         self.w(u'</div>')
       
   269 
       
   270 
       
   271 def do_paginate(view, rset=None, w=None, show_all_option=True, page_size=None):
       
   272     """write pages index in w stream (default to view.w) and then limit the
       
   273     result set (default to view.rset) to the currently displayed page if we're
       
   274     not explicitly told to display everything (by setting __force_display in
       
   275     req.form)
       
   276     """
       
   277     req = view._cw
       
   278     if rset is None:
       
   279         rset = view.cw_rset
       
   280     if w is None:
       
   281         w = view.w
       
   282     nav = req.vreg['components'].select_or_none(
       
   283         'navigation', req, rset=rset, page_size=page_size, view=view)
       
   284     if nav:
       
   285         if w is None:
       
   286             w = view.w
       
   287         if req.form.get('__force_display'):
       
   288             # allow to come back to the paginated view
       
   289             params = dict(req.form)
       
   290             basepath = req.relative_path(includeparams=False)
       
   291             del params['__force_display']
       
   292             url = nav.page_url(basepath, params)
       
   293             w(u'<div class="displayAllLink"><a href="%s">%s</a></div>\n'
       
   294               % (xml_escape(url), req._('back to pagination (%s results)')
       
   295                                   % nav.page_size))
       
   296         else:
       
   297             # get boundaries before component rendering
       
   298             start, stop = nav.page_boundaries()
       
   299             nav.render(w=w)
       
   300             params = dict(req.form)
       
   301             nav.clean_params(params)
       
   302             # make a link to see them all
       
   303             if show_all_option:
       
   304                 basepath = req.relative_path(includeparams=False)
       
   305                 params['__force_display'] = 1
       
   306                 params['__fromnavigation'] = 1
       
   307                 url = nav.page_url(basepath, params)
       
   308                 w(u'<div class="displayAllLink"><a href="%s">%s</a></div>\n'
       
   309                   % (xml_escape(url), req._('show %s results') % len(rset)))
       
   310             rset.limit(offset=start, limit=stop-start, inplace=True)
       
   311 
       
   312 
       
   313 def paginate(view, show_all_option=True, w=None, page_size=None, rset=None):
       
   314     """paginate results if the view is paginable
       
   315     """
       
   316     if view.paginable:
       
   317         do_paginate(view, rset, w, show_all_option, page_size)
       
   318 
       
   319 # monkey patch base View class to add a .paginate([...])
       
   320 # method to be called to write pages index in the view and then limit the result
       
   321 # set to the current page
       
   322 from cubicweb.view import View
       
   323 View.do_paginate = do_paginate
       
   324 View.paginate = paginate
       
   325 View.handle_pagination = False
       
   326 
       
   327 
       
   328 
       
   329 class IPrevNextAdapter(EntityAdapter):
       
   330     """Interface for entities which can be linked to a previous and/or next
       
   331     entity
       
   332 
       
   333     .. automethod:: next_entity
       
   334     .. automethod:: previous_entity
       
   335     """
       
   336     __needs_bw_compat__ = True
       
   337     __regid__ = 'IPrevNext'
       
   338     __abstract__ = True
       
   339 
       
   340     def next_entity(self):
       
   341         """return the 'next' entity"""
       
   342         raise NotImplementedError
       
   343 
       
   344     def previous_entity(self):
       
   345         """return the 'previous' entity"""
       
   346         raise NotImplementedError
       
   347 
       
   348 
       
   349 class NextPrevNavigationComponent(EntityCtxComponent):
       
   350     """Entities adaptable to the 'IPrevNext' should have this component
       
   351     automatically displayed. You may want to override this component to have a
       
   352     different look and feel.
       
   353     """
       
   354 
       
   355     __regid__ = 'prevnext'
       
   356     # register msg not generated since no entity implements IPrevNext in cubicweb
       
   357     # itself
       
   358     help = _('ctxcomponents_prevnext_description')
       
   359     __select__ = EntityCtxComponent.__select__ & adaptable('IPrevNext')
       
   360     context = 'navbottom'
       
   361     order = 10
       
   362 
       
   363     @property
       
   364     def prev_icon(self):
       
   365         return '<img src="%s" alt="%s" />' % (
       
   366             xml_escape(self._cw.data_url('go_prev.png')), self._cw._('previous page'))
       
   367 
       
   368     @property
       
   369     def next_icon(self):
       
   370         return '<img src="%s" alt="%s" />' % (
       
   371             xml_escape(self._cw.data_url('go_next.png')), self._cw._('next page'))
       
   372 
       
   373     def init_rendering(self):
       
   374         adapter = self.entity.cw_adapt_to('IPrevNext')
       
   375         self.previous = adapter.previous_entity()
       
   376         self.next = adapter.next_entity()
       
   377         if not (self.previous or self.next):
       
   378             raise EmptyComponent()
       
   379 
       
   380     def render_body(self, w):
       
   381         w(u'<div class="prevnext">')
       
   382         self.prevnext(w)
       
   383         w(u'</div>')
       
   384         w(u'<div class="clear"></div>')
       
   385 
       
   386     def prevnext(self, w):
       
   387         if self.previous:
       
   388             self.prevnext_entity(w, self.previous, 'prev')
       
   389         if self.next:
       
   390             self.prevnext_entity(w, self.next, 'next')
       
   391 
       
   392     def prevnext_entity(self, w, entity, type):
       
   393         textsize = self._cw.property_value('navigation.short-line-size')
       
   394         content = xml_escape(cut(entity.dc_title(), textsize))
       
   395         if type == 'prev':
       
   396             title = self._cw._('i18nprevnext_previous')
       
   397             icon = self.prev_icon
       
   398             cssclass = u'previousEntity left'
       
   399             content = icon + '&#160;&#160;' + content
       
   400         else:
       
   401             title = self._cw._('i18nprevnext_next')
       
   402             icon = self.next_icon
       
   403             cssclass = u'nextEntity right'
       
   404             content = content + '&#160;&#160;' + icon
       
   405         self.prevnext_div(w, type, cssclass, entity.absolute_url(),
       
   406                           title, content)
       
   407 
       
   408     def prevnext_div(self, w, type, cssclass, url, title, content):
       
   409         w(u'<div class="%s">' % cssclass)
       
   410         w(u'<a href="%s" title="%s">%s</a>' % (xml_escape(url),
       
   411                                                xml_escape(title),
       
   412                                                content))
       
   413         w(u'</div>')
       
   414         self._cw.html_headers.add_raw('<link rel="%s" href="%s" />' % (
       
   415               type, xml_escape(url)))