navigation.py 14.8 KB
Newer Older
1
# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2
3
4
5
6
7
8
9
10
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
11
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
12
13
14
15
16
17
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
"""This module provides some generic components to navigate in the web
application.

Pagination
----------

Several implementations for large result set pagination are provided:

.. autoclass:: PageNavigation
.. autoclass:: PageNavigationSelect
.. autoclass:: SortedNavigation

Pagination will appear when needed according to the `page-size` ui property.

This module monkey-patch the :func:`paginate` function to the base :class:`View`
class, so that you can ask pagination explicitly on every result-set based views.

.. autofunction:: paginate


Previous / next navigation
--------------------------

An adapter and its related component for the somewhat usal "previous / next"
navigation are provided.

  .. autoclass:: IPrevNextAdapter
  .. autoclass:: NextPrevNavigationComponent
"""
Adrien Di Mascio's avatar
Adrien Di Mascio committed
47

Sylvain Thénault's avatar
Sylvain Thénault committed
48

49
from cubicweb import _
Adrien Di Mascio's avatar
Adrien Di Mascio committed
50

51
52
from datetime import datetime

Adrien Di Mascio's avatar
Adrien Di Mascio committed
53
54
from rql.nodes import VariableRef, Constant

Sylvain Thénault's avatar
Sylvain Thénault committed
55
from logilab.mtconverter import xml_escape
Adrien Di Mascio's avatar
Adrien Di Mascio committed
56

Aurelien Campeas's avatar
Aurelien Campeas committed
57
from cubicweb.predicates import paginated_rset, sorted_rset, adaptable
Sylvain Thénault's avatar
Sylvain Thénault committed
58
from cubicweb.uilib import cut
59
from cubicweb.entity import EntityAdapter
60
from cubicweb.web.component import EmptyComponent, EntityCtxComponent, NavigationComponent
Adrien Di Mascio's avatar
Adrien Di Mascio committed
61
62
63


class PageNavigation(NavigationComponent):
64
65
66
    """The default pagination component: display link to pages where each pages
    is identified by the item number of its first and last elements.
    """
Adrien Di Mascio's avatar
Adrien Di Mascio committed
67
68
    def call(self):
        """displays a resultset by page"""
69
70
71
72
        params = dict(self._cw.form)
        self.clean_params(params)
        basepath = self._cw.relative_path(includeparams=False)
        self.w(u'<div class="pagination">')
73
        self.w(self.previous_link(basepath, params))
74
75
        self.w(u'[&#160;%s&#160;]' %
               u'&#160;| '.join(self.iter_page_links(basepath, params)))
76
        self.w(u'&#160;&#160;%s' % self.next_link(basepath, params))
77
78
79
80
81
82
        self.w(u'</div>')

    def index_display(self, start, stop):
        return u'%s - %s' % (start+1, stop+1)

    def iter_page_links(self, basepath, params):
83
        rset = self.cw_rset
Adrien Di Mascio's avatar
Adrien Di Mascio committed
84
85
86
87
        page_size = self.page_size
        start = 0
        while start < rset.rowcount:
            stop = min(start + page_size - 1, rset.rowcount - 1)
88
89
            yield self.page_link(basepath, params, start, stop,
                                 self.index_display(start, stop))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
90
            start = stop + 1
91
92
93


class PageNavigationSelect(PageNavigation):
94
95
96
97
98
99
    """This pagination component displays a result-set by page as
    :class:`PageNavigation` but in a <select>, which is better when there are a
    lot of results.

    By default it will be selected when there are more than 4 pages to be
    displayed.
100
101
102
103
104
105
106
107
108
109
    """
    __select__ = paginated_rset(4)

    page_link_templ = u'<option value="%s" title="%s">%s</option>'
    selected_page_link_templ = u'<option value="%s" selected="selected" title="%s">%s</option>'
    def call(self):
        params = dict(self._cw.form)
        self.clean_params(params)
        basepath = self._cw.relative_path(includeparams=False)
        w = self.w
Adrien Di Mascio's avatar
Adrien Di Mascio committed
110
        w(u'<div class="pagination">')
111
        w(self.previous_link(basepath, params))
112
113
114
115
        w(u'<select onchange="javascript: document.location=this.options[this.selectedIndex].value">')
        for option in self.iter_page_links(basepath, params):
            w(option)
        w(u'</select>')
116
        w(u'&#160;&#160;%s' % self.next_link(basepath, params))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
117
        w(u'</div>')
118

119

Adrien Di Mascio's avatar
Adrien Di Mascio committed
120
class SortedNavigation(NavigationComponent):
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
    """This pagination component will be selected by default if there are less
    than 4 pages and if the result set is sorted.

    Displayed links to navigate accross pages of a result set are done according
    to the first variable on which the sort is done, and looks like:

        [ana - cro] | [cro - ghe] | ... | [tim - zou]

    You may want to override this component to customize display in some cases.

    .. automethod:: sort_on
    .. automethod:: display_func
    .. automethod:: format_link_content
    .. automethod:: write_links

    Below an example from the tracker cube:

    .. sourcecode:: python

      class TicketsNavigation(navigation.SortedNavigation):
          __select__ = (navigation.SortedNavigation.__select__
                        & ~paginated_rset(4) & is_instance('Ticket'))
          def sort_on(self):
              col, attrname = super(TicketsNavigation, self).sort_on()
              if col == 6:
                  # sort on state, we don't want that
                  return None, None
              return col, attrname

    The idea is that in trackers'ticket tables, result set is first ordered on
    ticket's state while this doesn't make any sense in the navigation. So we
    override :meth:`sort_on` so that if we detect such sorting, we disable the
    feature to go back to item number in the pagination.

    Also notice the `~paginated_rset(4)` in the selector so that if there are
    more than 4 pages to display, :class:`PageNavigationSelect` will still be
    selected.
Adrien Di Mascio's avatar
Adrien Di Mascio committed
158
    """
159
    __select__ = paginated_rset() & sorted_rset()
160

Adrien Di Mascio's avatar
Adrien Di Mascio committed
161
162
    # number of considered chars to build page links
    nb_chars = 5
163

164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
    def call(self):
        # attrname = the name of attribute according to which the sort
        # is done if any
        col, attrname = self.sort_on()
        index_display = self.display_func(self.cw_rset, col, attrname)
        basepath = self._cw.relative_path(includeparams=False)
        params = dict(self._cw.form)
        self.clean_params(params)
        blocklist = []
        start = 0
        total = self.cw_rset.rowcount
        while start < total:
            stop = min(start + self.page_size - 1, total - 1)
            cell = self.format_link_content(index_display(start), index_display(stop))
            blocklist.append(self.page_link(basepath, params, start, stop, cell))
            start = stop + 1
        self.write_links(basepath, params, blocklist)

Adrien Di Mascio's avatar
Adrien Di Mascio committed
182
    def display_func(self, rset, col, attrname):
183
184
185
        """Return a function that will be called with a row number as argument
        and should return a string to use as link for it.
        """
Adrien Di Mascio's avatar
Adrien Di Mascio committed
186
187
        if attrname is not None:
            def index_display(row):
Sylvain Thénault's avatar
Sylvain Thénault committed
188
189
                if not rset[row][col]: # outer join
                    return u''
Adrien Di Mascio's avatar
Adrien Di Mascio committed
190
191
                entity = rset.get_entity(row, col)
                return entity.printable_value(attrname, format='text/plain')
192
193
        elif col is None: # smart links disabled.
            def index_display(row):
Denis Laxalde's avatar
Denis Laxalde committed
194
                return str(row)
Sandrine Ribeau's avatar
Sandrine Ribeau committed
195
        elif self._cw.vreg.schema.eschema(rset.description[0][col]).final:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
196
            def index_display(row):
Denis Laxalde's avatar
Denis Laxalde committed
197
                return str(rset[row][col])
Adrien Di Mascio's avatar
Adrien Di Mascio committed
198
199
200
201
        else:
            def index_display(row):
                return rset.get_entity(row, col).view('text')
        return index_display
202

203
204
205
    def sort_on(self):
        """Return entity column number / attr name to use for nice display by
        inspecting the rset'syntax tree.
Adrien Di Mascio's avatar
Adrien Di Mascio committed
206
        """
Sandrine Ribeau's avatar
Sandrine Ribeau committed
207
        rschema = self._cw.vreg.schema.rschema
208
        for sorterm in self.cw_rset.syntax_tree().children[0].orderby:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
209
210
            if isinstance(sorterm.term, Constant):
                col = sorterm.term.value - 1
211
                return col, None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
212
213
214
215
216
217
218
            var = sorterm.term.get_nodes(VariableRef)[0].variable
            col = None
            for ref in var.references():
                rel = ref.relation()
                if rel is None:
                    continue
                attrname = rel.r_type
219
                if attrname in ('is', 'has_text'):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
220
                    continue
221
                if not rschema(attrname).final:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
222
223
224
225
226
227
228
229
230
231
                    col = var.selected_index()
                    attrname = None
                if col is None:
                    # final relation or not selected non final relation
                    if var is rel.children[0]:
                        relvar = rel.children[1].children[0].get_nodes(VariableRef)[0]
                    else:
                        relvar = rel.children[0].variable
                    col = relvar.selected_index()
                if col is not None:
232
233
234
235
236
                    break
            else:
                # no relation but maybe usable anyway if selected
                col = var.selected_index()
                attrname = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
237
            if col is not None:
238
239
240
241
                # if column type is date[time], set proper 'nb_chars'
                if var.stinfo['possibletypes'] & frozenset(('TZDatetime', 'Datetime',
                                                            'Date')):
                    self.nb_chars = len(self._cw.format_date(datetime.today()))
242
243
244
                return col, attrname
        # nothing usable found, use the first column
        return 0, None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
245
246

    def format_link_content(self, startstr, stopstr):
247
248
249
250
251
        """Return text for a page link, where `startstr` and `stopstr` are the
        text for the lower/upper boundaries of the page.

        By default text are stripped down to :attr:`nb_chars` characters.
        """
Adrien Di Mascio's avatar
Adrien Di Mascio committed
252
253
        text = u'%s - %s' % (startstr.lower()[:self.nb_chars],
                             stopstr.lower()[:self.nb_chars])
Sylvain Thénault's avatar
Sylvain Thénault committed
254
        return xml_escape(text)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
255

256
    def write_links(self, basepath, params, blocklist):
257
258
259
260
        """Return HTML for the whole navigation: `blocklist` is a list of HTML
        snippets for each page, `basepath` and `params` will be necessary to
        build previous/next links.
        """
Adrien Di Mascio's avatar
Adrien Di Mascio committed
261
        self.w(u'<div class="pagination">')
262
        self.w(u'%s&#160;' % self.previous_link(basepath, params))
263
        self.w(u'[&#160;%s&#160;]' % u'&#160;| '.join(blocklist))
264
        self.w(u'&#160;%s' % self.next_link(basepath, params))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
265
266
267
        self.w(u'</div>')


268
def do_paginate(view, rset=None, w=None, show_all_option=True, page_size=None):
269
270
271
272
    """write pages index in w stream (default to view.w) and then limit the
    result set (default to view.rset) to the currently displayed page if we're
    not explicitly told to display everything (by setting __force_display in
    req.form)
273
274
275
276
277
278
279
280
281
282
283
    """
    req = view._cw
    if rset is None:
        rset = view.cw_rset
    if w is None:
        w = view.w
    nav = req.vreg['components'].select_or_none(
        'navigation', req, rset=rset, page_size=page_size, view=view)
    if nav:
        if w is None:
            w = view.w
284
        if req.form.get('__force_display'):
285
            nav.render_link_back_to_pagination(w=w)
286
287
288
289
290
        else:
            # get boundaries before component rendering
            start, stop = nav.page_boundaries()
            nav.render(w=w)
            if show_all_option:
291
                nav.render_link_display_all(w=w)
292
            rset.limit(offset=start, limit=stop-start, inplace=True)
293
294
295


def paginate(view, show_all_option=True, w=None, page_size=None, rset=None):
296
    """paginate results if the view is paginable
297
    """
298
    if view.paginable:
299
300
301
302
303
304
305
306
307
308
309
        do_paginate(view, rset, w, show_all_option, page_size)

# monkey patch base View class to add a .paginate([...])
# method to be called to write pages index in the view and then limit the result
# set to the current page
from cubicweb.view import View
View.do_paginate = do_paginate
View.paginate = paginate
View.handle_pagination = False


310
311

class IPrevNextAdapter(EntityAdapter):
312
    """Interface for entities which can be linked to a previous and/or next
313
    entity
314
315
316

    .. automethod:: next_entity
    .. automethod:: previous_entity
317
    """
318
    __needs_bw_compat__ = True
319
    __regid__ = 'IPrevNext'
Aurelien Campeas's avatar
Aurelien Campeas committed
320
    __abstract__ = True
321
322
323
324
325
326
327
328
329
330

    def next_entity(self):
        """return the 'next' entity"""
        raise NotImplementedError

    def previous_entity(self):
        """return the 'previous' entity"""
        raise NotImplementedError


331
class NextPrevNavigationComponent(EntityCtxComponent):
332
333
334
335
336
    """Entities adaptable to the 'IPrevNext' should have this component
    automatically displayed. You may want to override this component to have a
    different look and feel.
    """

337
    __regid__ = 'prevnext'
Adrien Di Mascio's avatar
Adrien Di Mascio committed
338
339
    # register msg not generated since no entity implements IPrevNext in cubicweb
    # itself
340
341
    help = _('ctxcomponents_prevnext_description')
    __select__ = EntityCtxComponent.__select__ & adaptable('IPrevNext')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
342
343
    context = 'navbottom'
    order = 10
344

345
346
    @property
    def prev_icon(self):
347
348
        return '<img src="%s" alt="%s" />' % (
            xml_escape(self._cw.data_url('go_prev.png')), self._cw._('previous page'))
349
350
351

    @property
    def next_icon(self):
352
353
        return '<img src="%s" alt="%s" />' % (
            xml_escape(self._cw.data_url('go_next.png')), self._cw._('next page'))
354

355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
    def init_rendering(self):
        adapter = self.entity.cw_adapt_to('IPrevNext')
        self.previous = adapter.previous_entity()
        self.next = adapter.next_entity()
        if not (self.previous or self.next):
            raise EmptyComponent()

    def render_body(self, w):
        w(u'<div class="prevnext">')
        self.prevnext(w)
        w(u'</div>')
        w(u'<div class="clear"></div>')

    def prevnext(self, w):
        if self.previous:
            self.prevnext_entity(w, self.previous, 'prev')
        if self.next:
            self.prevnext_entity(w, self.next, 'next')

    def prevnext_entity(self, w, entity, type):
        textsize = self._cw.property_value('navigation.short-line-size')
376
        content = xml_escape(cut(entity.dc_title(), textsize))
377
378
        if type == 'prev':
            title = self._cw._('i18nprevnext_previous')
379
            icon = self.prev_icon
380
            cssclass = u'previousEntity left'
381
            content = icon + '&#160;&#160;' + content
382
383
        else:
            title = self._cw._('i18nprevnext_next')
384
            icon = self.next_icon
385
            cssclass = u'nextEntity right'
386
            content = content + '&#160;&#160;' + icon
387
        self.prevnext_div(w, type, cssclass, entity.absolute_url(),
388
                          title, content)
389
390
391
392
393
394
395
396
397

    def prevnext_div(self, w, type, cssclass, url, title, content):
        w(u'<div class="%s">' % cssclass)
        w(u'<a href="%s" title="%s">%s</a>' % (xml_escape(url),
                                               xml_escape(title),
                                               content))
        w(u'</div>')
        self._cw.html_headers.add_raw('<link rel="%s" href="%s" />' % (
              type, xml_escape(url)))