1
2 import types
3 from math import ceil
4 import logging
5
6 import cherrypy
7 try:
8 import sqlobject
9 except ImportError:
10 sqlobject = None
11
12 try:
13 import sqlalchemy
14 try:
15 from sqlalchemy.exc import InvalidRequestError
16 sqlalchemy_before_0_5 = False
17 except ImportError:
18 from sqlalchemy.exceptions import InvalidRequestError
19 sqlalchemy_before_0_5 = True
20 except ImportError:
21 sqlalchemy = None
22
23 import turbogears
24 from turbogears.controllers import redirect
25 from turbogears.decorator import weak_signature_decorator
26 from turbogears.view import variable_providers
27 from formencode.variabledecode import variable_encode
28 from turbogears.util import add_tg_args
29
30 log = logging.getLogger('turbogears.paginate')
31
32
33
34
35 _so_no_offset = 'mssql maxdb sybase'.split()
36 _sa_no_offset = 'mssql maxdb access'.split()
37
38
39 _simulate_offset = None
40
41
43 """Helper class for accessing object attributes."""
47 for name in self.name.split('.'):
48 obj = getattr(obj, name)
49 return obj
50
52 """Helper class for dictionary access."""
57
58
59 -def paginate(var_name, default_order='', limit=10,
60 max_limit=0, max_pages=5, max_sort=1000, dynamic_limit=None):
61 """The famous TurboGears paginate decorator.
62
63 @param var_name: the variable name that the paginate decorator will try
64 to control. This key must be present in the dictionary returned from your
65 controller in order for the paginate decorator to be able to handle it.
66 @type var_name: string
67
68 @param default_order: The column name(s) that will be used to order
69 pagination results. Due to the way pagination is implemented specifying a
70 default_order will override any result ordering performed in the controller.
71 @type default_order: string or a list of strings. Any string starting with
72 "-" (minus sign) indicates a reverse order for that field/column.
73
74 @param limit: The hard-coded limit that the paginate decorator will impose
75 on the number of "var_name" to display at the same time. This value can be
76 overridden by the use of the dynamic_limit keyword argument.
77 @type limit: integer
78
79 @param max_limit: The maximum number to which the imposed limit can be
80 increased using the "var_name"_tgp_limit keyword argument in the URL.
81 If this is set to 0, no dynamic change at all will be allowed;
82 if it is set to None, any change will be allowed.
83 @type max_limit: int
84
85 @param max_pages: Used to generate the tg.paginate.pages variable. If the
86 page count is larger than max_pages, tg.paginate.pages will only contain
87 the page numbers surrounding the current page at a distance of max_pages/2.
88 A zero value means that all pages will be shown, no matter how much.
89 @type max_pages: integer
90
91 @param max_sort: The maximum number of records that will be sorted in
92 memory if the data cannot be sorted using SQL. If set to 0, sorting in
93 memory will never be performed; if set to None, no limit will be imposed.
94 @type max_sort: integer
95
96 @param dynamic_limit: If specified, this parameter must be the name
97 of a key present in the dictionary returned by your decorated
98 controller. The value found for this key will be used as the limit
99 for our pagination and will override the other settings, the hard-coded
100 one declared in the decorator itself AND the URL parameter one.
101 This enables the programmer to store a limit settings inside the
102 application preferences and then let the user manage it.
103 @type dynamic_limit: string
104
105 """
106
107 def entangle(func):
108
109 get = turbogears.config.get
110
111 def decorated(func, *args, **kw):
112
113 def kwpop(name, default=None):
114 return kw.pop(var_name + '_tgp_' + name, default)
115
116 page = kwpop('no')
117 if page is None:
118 page = 1
119 elif page == 'last':
120 page = None
121 else:
122 try:
123 page = int(page)
124 if page < 1:
125 raise ValueError
126 except (TypeError, ValueError):
127 page = 1
128 if get('paginate.redirect_on_out_of_range'):
129 cherrypy.request.params[var_name + '_tgp_no'] = page
130 redirect(cherrypy.request.path_info, cherrypy.request.params)
131
132 try:
133 limit_ = int(kwpop('limit'))
134 if max_limit is not None:
135 if max_limit <= 0:
136 raise ValueError
137 limit_ = min(limit_, max_limit)
138 except (TypeError, ValueError):
139 limit_ = limit
140 order = kwpop('order')
141 ordering = kwpop('ordering')
142
143 log.debug("paginate params: page=%s, limit=%s, order=%s",
144 page, limit_, order)
145
146
147 output = func(*args, **kw)
148 if not isinstance(output, dict):
149 return output
150
151 try:
152 var_data = output[var_name]
153 except KeyError:
154 raise KeyError("paginate: var_name"
155 " (%s) not found in output dict" % var_name)
156 if not hasattr(var_data, '__getitem__') and callable(var_data):
157
158 var_data = var_data()
159 if not hasattr(var_data, '__getitem__'):
160 raise TypeError('Paginate variable is not a sequence')
161
162 if dynamic_limit:
163 try:
164 dyn_limit = output[dynamic_limit]
165 except KeyError:
166 raise KeyError("paginate: dynamic_limit"
167 " (%s) not found in output dict" % dynamic_limit)
168 limit_ = dyn_limit
169
170 if ordering:
171 ordering = str(ordering).split(',')
172 else:
173 ordering = default_order or []
174 if isinstance(ordering, basestring):
175 ordering = [ordering]
176
177 if order:
178 order = str(order)
179 log.debug('paginate: ordering was %s, sort is %s',
180 ordering, order)
181 sort_ordering(ordering, order)
182 log.debug('paginate: ordering is %s', ordering)
183
184 try:
185 row_count = len(var_data)
186 except TypeError:
187 try:
188 row_count = var_data.count() or 0
189 except AttributeError:
190 var_data = list(var_data)
191 row_count = len(var_data)
192
193 if ordering:
194 var_data = sort_data(var_data, ordering,
195 max_sort is None or 0 < row_count <= max_sort)
196
197
198 if not limit_:
199 limit_ = row_count or 1
200
201 page_count = int(ceil(float(row_count)/limit_))
202
203 if page is None:
204 page = max(page_count, 1)
205 if get('paginate.redirect_on_last_page'):
206 cherrypy.request.params[var_name + '_tgp_no'] = page
207 redirect(cherrypy.request.path_info, cherrypy.request.params)
208 elif page > page_count:
209 page = max(page_count, 1)
210 if get('paginate.redirect_on_out_of_range'):
211 cherrypy.request.params[var_name + '_tgp_no'] = page
212 redirect(cherrypy.request.path_info, cherrypy.request.params)
213
214 offset = (page-1) * limit_
215
216 pages_to_show = _select_pages_to_show(page, page_count, max_pages)
217
218
219 input_values = variable_encode(cherrypy.request.params.copy())
220 input_values.pop('self', None)
221 for input_key in input_values.keys():
222 if input_key.startswith(var_name + '_tgp_'):
223 del input_values[input_key]
224
225 paginate_instance = Paginate(
226 current_page=page,
227 limit=limit_,
228 pages=pages_to_show,
229 page_count=page_count,
230 input_values=input_values,
231 order=order,
232 ordering=ordering,
233 row_count=row_count,
234 var_name=var_name)
235
236 cherrypy.request.paginate = paginate_instance
237 if not hasattr(cherrypy.request, 'paginates'):
238 cherrypy.request.paginates = dict()
239 cherrypy.request.paginates[var_name] = paginate_instance
240
241
242 endpoint = offset + limit_
243 log.debug("paginate: slicing data between %d and %d",
244 offset, endpoint)
245
246 global _simulate_offset
247 if _simulate_offset is None:
248 _simulate_offset = get('paginate.simulate_offset', None)
249 if _simulate_offset is None:
250 _simulate_offset = False
251 so_db = get('sqlobject.dburi', 'NOMATCH:').split(':', 1)[0]
252 sa_db = get('sqlalchemy.dburi', 'NOMATCH:').split(':', 1)[0]
253 if so_db in _so_no_offset or sa_db in _sa_no_offset:
254 _simulate_offset = True
255 log.warning("paginate: simulating OFFSET,"
256 " paginate may be slow"
257 " (disable with paginate.simulate_offset=False)")
258
259 if _simulate_offset:
260 var_data = iter(var_data[:endpoint])
261
262 for i in xrange(offset):
263 var_data.next()
264
265 output[var_name] = list(var_data)
266 else:
267 try:
268 output[var_name] = var_data[offset:endpoint]
269 except TypeError:
270 for i in xrange(offset):
271 var_data.next()
272 output[var_name] = [var_data.next()
273 for i in xrange(offset, endpoint)]
274
275 return output
276
277 if not get('tg.strict_parameters', False):
278
279 add_tg_args(func, (var_name + '_tgp_' + arg
280 for arg in ('no', 'limit', 'order', 'ordering')))
281 return decorated
282
283 return weak_signature_decorator(entangle)
284
285
287 """Auxiliary function for providing the paginate variable."""
288 paginate = getattr(cherrypy.request, 'paginate', None)
289 if paginate:
290 d.update(dict(paginate=paginate))
291 paginates = getattr(cherrypy.request, 'paginates', None)
292 if paginates:
293 d.update(dict(paginates=paginates))
294 variable_providers.append(_paginate_var_provider)
295
296
298 """Class for paginate variable provider."""
299
300 - def __init__(self, current_page, pages, page_count, input_values,
301 limit, order, ordering, row_count, var_name):
302
303 self.var_name = var_name
304 self.pages = pages
305 self.limit = limit
306 self.page_count = page_count
307 self.current_page = current_page
308 self.input_values = input_values
309 self.order = order
310 self.ordering = ordering
311 self.row_count = row_count
312 self.first_item = page_count and ((current_page - 1) * limit + 1) or 0
313 self.last_item = min(current_page * limit, row_count)
314
315 self.reversed = ordering and ordering[0][0] == '-'
316
317
318 input_values = {var_name + '_tgp_limit': limit}
319 if ordering:
320 input_values[var_name + '_tgp_ordering'] = ','.join(ordering)
321 self.input_values.update(input_values)
322
323 if current_page < page_count:
324 self.input_values.update({
325 var_name + '_tgp_no': current_page + 1,
326 var_name + '_tgp_limit': limit
327 })
328 self.href_next = turbogears.url(
329 cherrypy.request.path_info, self.input_values)
330 self.input_values.update({
331 var_name + '_tgp_no': 'last',
332 var_name + '_tgp_limit': limit
333 })
334 self.href_last = turbogears.url(
335 cherrypy.request.path_info, self.input_values)
336 else:
337 self.href_next = None
338 self.href_last = None
339
340 if current_page > 1:
341 self.input_values.update({
342 var_name + '_tgp_no': current_page - 1,
343 var_name + '_tgp_limit': limit
344 })
345 self.href_prev = turbogears.url(
346 cherrypy.request.path_info, self.input_values)
347 self.input_values.update({
348 var_name + '_tgp_no': 1,
349 var_name + '_tgp_limit': limit
350 })
351 self.href_first = turbogears.url(
352 cherrypy.request.path_info, self.input_values)
353 else:
354 self.href_prev = None
355 self.href_first = None
356
357 - def get_href(self, page, order=None, reverse_order=None):
358
359
360
361 order = order or None
362 input_values = self.input_values.copy()
363 input_values[self.var_name + '_tgp_no'] = page
364 if order:
365 input_values[ self.var_name + '_tgp_order'] = order
366 return turbogears.url('', input_values)
367
368
369 -def _select_pages_to_show(current_page, page_count, max_pages=None):
370 """Auxiliary function for getting the range of pages to show."""
371 if max_pages is not None and max_pages > 0:
372 start = current_page - (max_pages // 2) - (max_pages % 2) + 1
373 end = start + max_pages - 1
374 if start < 1:
375 start, end = 1, min(page_count, max_pages)
376 elif end > page_count:
377 start, end = max(1, page_count - max_pages + 1), page_count
378 else:
379 start, end = 1, page_count
380 return xrange(start, end + 1)
381
382
383
384
386 """Rearrange ordering based on sort_name."""
387 try:
388 index = ordering.index(sort_name)
389 except ValueError:
390 try:
391 index = ordering.index('-' + sort_name)
392 except ValueError:
393 ordering.insert(0, sort_name)
394 else:
395 del ordering[index]
396 ordering.insert(0, (index and '-' or '') + sort_name)
397 else:
398 del ordering[index]
399 ordering.insert(0, (not index and '-' or '') + sort_name)
400
402 """Return a column from SQLAlchemy var_data based on colname."""
403 if sqlalchemy_before_0_5:
404 mapper = var_data.mapper
405 else:
406 mapper = var_data._mapper_zero()
407 propnames = colname.split('.')
408 colname = propnames.pop()
409 props = []
410 for propname in propnames:
411 try:
412 prop = mapper.get_property(propname)
413 except InvalidRequestError:
414 prop = None
415 if not prop:
416 break
417 if join_props is not None:
418 props.append(prop)
419 mapper = prop.mapper
420 col = getattr(mapper.c, colname, None)
421 if col is not None and props:
422 join_props.extend(props)
423 return col
424
426 """Return a column from SQLObject var_data based on colname."""
427 return getattr(var_data.sourceClass.q, colname, None)
428
430 """Return a column from var_data based on colname."""
431 if sqlalchemy:
432 try:
433 return sqlalchemy_get_column(colname, var_data)
434 except AttributeError:
435 pass
436 if sqlobject:
437 try:
438 return sqlobject_get_column(colname, var_data)
439 except AttributeError:
440 pass
441 raise TypeError('Cannot find columns of paginate variable')
442
444 """Return an SQLAlchemy ordered column for col."""
445 if descending:
446 return sqlalchemy.sql.desc(col)
447 else:
448 return sqlalchemy.sql.asc(col)
449
451 """Return an SQLObject ordered column for col."""
452 if descending:
453 return sqlobject.DESC(col)
454 else:
455 return col
456
465
467 """Return a query where all tables for properties are joined."""
468 if join_props:
469 attrs = []
470 for prop in join_props:
471 try:
472 attrs.append(prop.class_attribute)
473 except AttributeError:
474 try:
475 attrs.append(getattr(prop.parent.class_, prop.key))
476 except AttributeError:
477 pass
478 if attrs:
479 log.debug("paginate: need to join some attributes")
480 if sqlalchemy_before_0_5:
481 var_data = var_data.outerjoin(attrs)
482 else:
483 var_data = var_data.outerjoin(*attrs)
484 return var_data
485
486 -def sort_data(data, ordering, in_memory=True):
487 """Sort data based on ordering.
488
489 Tries to sort the data using SQL whenever possible,
490 otherwise sorts the data as list in memory unless in_memory is false.
491
492 """
493 try:
494 order_by = data.order_by
495 if sqlalchemy_before_0_5:
496 old_order_by = order_by
497 order_by = lambda *cols: old_order_by(cols)
498 get_column, order_col = sqlalchemy_get_column, sqlalchemy_order_col
499 except AttributeError:
500 try:
501 orderBy = data.orderBy
502 order_by = lambda *cols: orderBy(cols)
503 get_column, order_col = sqlobject_get_column, sqlobject_order_col
504 except AttributeError:
505 order_by = None
506 order_cols = []
507 key_cols = []
508 num_ascending = num_descending = 0
509 join_props = []
510 for order in ordering:
511 if order[0] == '-':
512 order = order[1:]
513 descending = True
514 else:
515 descending = False
516 if order_by:
517 col = get_column(order, data, join_props)
518 if col is not None:
519 order_cols.append(order_col(col, descending))
520 continue
521 if not order_cols:
522 key_cols.append((order, descending))
523 if descending:
524 num_descending += 1
525 else:
526 num_ascending += 1
527 if order_by and order_cols:
528 data = order_by(*order_cols)
529 if join_props:
530 data = sqlalchemy_join_props(data, join_props)
531 if key_cols:
532 if in_memory:
533 data = list(data)
534 if not data:
535 return data
536 wrapper = isinstance(data[0], dict) and itemwrapper or attrwrapper
537 keys = [(wrapper(col[0]), col[1]) for col in key_cols]
538 if num_ascending == 0 or num_descending == 0:
539 reverse = num_ascending == 0
540 keys = [key[0] for key in keys]
541 if len(key_cols) == 1:
542 key = keys[0]
543 else:
544 key = lambda row: [key(row) for key in keys]
545 else:
546 reverse = num_descending > num_ascending
547 def reverse_key(key, descending):
548 if reverse == descending:
549 return key
550 else:
551 keys = map(key, data)
552 try:
553 keys = list(set(keys))
554 except TypeError:
555 keys.sort()
556 return lambda row: -keys.index(key(row))
557 else:
558 keys.sort()
559 keys = dict((k, -n) for n, k in enumerate(keys))
560 return lambda row: keys[key(row)]
561 keys = [reverse_key(*key) for key in keys]
562 key = lambda row: [key(row) for key in keys]
563 log.debug("paginate: sorting in memory")
564 data.sort(key=key, reverse=reverse)
565 else:
566 log.debug("paginate: sorting in memory not allowed")
567 return data
568