source: tailbone/tailbone/grids/core.py @ 40c7e34

Last change on this file since 40c7e34 was 40c7e34, checked in by Lance Edgar <ledgar@…>, 5 months ago

Show grid link even when value is "false-ish"

saw a value of '0' get rendered with no link; this fixes

  • Property mode set to 100644
File size: 41.2 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2018 Lance Edgar
6#
7#  This file is part of Rattail.
8#
9#  Rattail is free software: you can redistribute it and/or modify it under the
10#  terms of the GNU General Public License as published by the Free Software
11#  Foundation, either version 3 of the License, or (at your option) any later
12#  version.
13#
14#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
15#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
17#  details.
18#
19#  You should have received a copy of the GNU General Public License along with
20#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Core Grid Classes
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import datetime
30from six.moves import urllib
31
32import six
33import sqlalchemy as sa
34from sqlalchemy import orm
35
36from rattail.db import api
37from rattail.db.types import GPCType
38from rattail.util import prettify, pretty_boolean, pretty_quantity, pretty_hours
39from rattail.time import localtime
40
41import webhelpers2_grid
42from pyramid.renderers import render
43from webhelpers2.html import HTML, tags
44from paginate_sqlalchemy import SqlalchemyOrmPage
45
46from . import filters as gridfilters
47from tailbone.db import Session
48from tailbone.util import raw_datetime
49
50
51class FieldList(list):
52    """
53    Convenience wrapper for a field list.
54    """
55
56    def insert_before(self, field, newfield):
57        i = self.index(field)
58        self.insert(i, newfield)
59
60    def insert_after(self, field, newfield):
61        i = self.index(field)
62        self.insert(i + 1, newfield)
63
64
65class Grid(object):
66    """
67    Core grid class.  In sore need of documentation.
68    """
69
70    def __init__(self, key, data, columns=None, width='auto', request=None, mobile=False, model_class=None,
71                 enums={}, labels={}, renderers={}, extra_row_class=None, linked_columns=[], url='#',
72                 joiners={}, filterable=False, filters={}, use_byte_string_filters=False,
73                 sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
74                 pageable=False, default_pagesize=20, default_page=1,
75                 checkboxes=False, checked=None, main_actions=[], more_actions=[], delete_speedbump=False,
76                 **kwargs):
77
78        self.key = key
79        self.data = data
80        self.columns = FieldList(columns) if columns is not None else None
81        self.width = width
82        self.request = request
83        self.mobile = mobile
84        self.model_class = model_class
85        if self.model_class and self.columns is None:
86            self.columns = self.make_columns()
87        self.enums = enums or {}
88
89        self.labels = labels or {}
90        self.renderers = self.make_default_renderers(renderers or {})
91        self.extra_row_class = extra_row_class
92        self.linked_columns = linked_columns or []
93        self.url = url
94        self.joiners = joiners or {}
95
96        self.filterable = filterable
97        self.use_byte_string_filters = use_byte_string_filters
98        self.filters = self.make_filters(filters)
99
100        self.sortable = sortable
101        self.sorters = self.make_sorters(sorters)
102        self.default_sortkey = default_sortkey
103        self.default_sortdir = default_sortdir
104
105        self.pageable = pageable
106        self.default_pagesize = default_pagesize
107        self.default_page = default_page
108
109        self.checkboxes = checkboxes
110        self.checked = checked
111        if self.checked is None:
112            self.checked = lambda item: False
113        self.main_actions = main_actions or []
114        self.more_actions = more_actions or []
115        self.delete_speedbump = delete_speedbump
116
117        self._whgrid_kwargs = kwargs
118
119    def make_columns(self):
120        """
121        Return a default list of columns, based on :attr:`model_class`.
122        """
123        if not self.model_class:
124            raise ValueError("Must define model_class to use make_columns()")
125
126        mapper = orm.class_mapper(self.model_class)
127        return [prop.key for prop in mapper.iterate_properties]
128
129    def hide_column(self, key):
130        if key in self.columns:
131            self.columns.remove(key)
132
133    def hide_columns(self, *keys):
134        for key in keys:
135            self.hide_column(key)
136
137    def append(self, field):
138        self.columns.append(field)
139
140    def insert_before(self, field, newfield):
141        self.columns.insert_before(field, newfield)
142
143    def insert_after(self, field, newfield):
144        self.columns.insert_after(field, newfield)
145
146    def replace(self, oldfield, newfield):
147        self.insert_after(oldfield, newfield)
148        self.hide_column(oldfield)
149
150    def set_joiner(self, key, joiner):
151        if joiner is None:
152            self.joiners.pop(key, None)
153        else:
154            self.joiners[key] = joiner
155
156    def set_sorter(self, key, *args, **kwargs):
157        self.sorters[key] = self.make_sorter(*args, **kwargs)
158
159    def set_sort_defaults(self, sortkey, sortdir='asc'):
160        self.default_sortkey = sortkey
161        self.default_sortdir = sortdir
162
163    def set_filter(self, key, *args, **kwargs):
164        if len(args) == 1 and args[0] is None:
165            self.remove_filter(key)
166        else:
167            if 'label' not in kwargs and key in self.labels:
168                kwargs['label'] = self.labels[key]
169            self.filters[key] = self.make_filter(key, *args, **kwargs)
170
171    def remove_filter(self, key):
172        self.filters.pop(key, None)
173
174    def set_label(self, key, label):
175        self.labels[key] = label
176        if key in self.filters:
177            self.filters[key].label = label
178
179    def set_link(self, key, link=True):
180        if link:
181            if key not in self.linked_columns:
182                self.linked_columns.append(key)
183        else: # unlink
184            if self.linked_columns and key in self.linked_columns:
185                self.linked_columns.remove(key)
186
187    def set_renderer(self, key, renderer):
188        self.renderers[key] = renderer
189
190    def set_type(self, key, type_):
191        if type_ == 'boolean':
192            self.set_renderer(key, self.render_boolean)
193        elif type_ == 'currency':
194            self.set_renderer(key, self.render_currency)
195        elif type_ == 'datetime':
196            self.set_renderer(key, self.render_datetime)
197        elif type_ == 'datetime_local':
198            self.set_renderer(key, self.render_datetime_local)
199        elif type_ == 'enum':
200            self.set_renderer(key, self.render_enum)
201        elif type_ == 'gpc':
202            self.set_renderer(key, self.render_gpc)
203        elif type_ == 'percent':
204            self.set_renderer(key, self.render_percent)
205        elif type_ == 'quantity':
206            self.set_renderer(key, self.render_quantity)
207        elif type_ == 'duration':
208            self.set_renderer(key, self.render_duration)
209        else:
210            raise ValueError("Unsupported type for column '{}': {}".format(key, type_))
211
212    def set_enum(self, key, enum):
213        if enum:
214            self.enums[key] = enum
215            self.set_type(key, 'enum')
216            if key in self.filters:
217                self.filters[key].set_value_renderer(gridfilters.EnumValueRenderer(enum))
218        else:
219            self.enums.pop(key, None)
220
221    def render_generic(self, obj, column_name):
222        return self.obtain_value(obj, column_name)
223
224    def render_boolean(self, obj, column_name):
225        value = self.obtain_value(obj, column_name)
226        return pretty_boolean(value)
227
228    def obtain_value(self, obj, column_name):
229        try:
230            return obj[column_name]
231        except TypeError:
232            return getattr(obj, column_name)
233
234    def render_currency(self, obj, column_name):
235        value = self.obtain_value(obj, column_name)
236        if value is None:
237            return ""
238        if value < 0:
239            return "(${:0,.2f})".format(0 - value)
240        return "${:0,.2f}".format(value)
241
242    def render_datetime(self, obj, column_name):
243        value = self.obtain_value(obj, column_name)
244        if value is None:
245            return ""
246        return raw_datetime(self.request.rattail_config, value)
247
248    def render_datetime_local(self, obj, column_name):
249        value = self.obtain_value(obj, column_name)
250        if value is None:
251            return ""
252        value = localtime(self.request.rattail_config, value)
253        return raw_datetime(self.request.rattail_config, value)
254
255    def render_enum(self, obj, column_name):
256        value = self.obtain_value(obj, column_name)
257        if value is None:
258            return ""
259        enum = self.enums.get(column_name)
260        if enum and value in enum:
261            return six.text_type(enum[value])
262        return six.text_type(value)
263
264    def render_gpc(self, obj, column_name):
265        value = self.obtain_value(obj, column_name)
266        if value is None:
267            return ""
268        return value.pretty()
269
270    def render_percent(self, obj, column_name):
271        value = self.obtain_value(obj, column_name)
272        if value is None:
273            return ""
274        return "{:0.3f} %".format(value * 100)
275
276    def render_quantity(self, obj, column_name):
277        value = self.obtain_value(obj, column_name)
278        return pretty_quantity(value)
279
280    def render_duration(self, obj, column_name):
281        value = self.obtain_value(obj, column_name)
282        if value is None:
283            return ""
284        return pretty_hours(datetime.timedelta(seconds=value))
285
286    def set_url(self, url):
287        self.url = url
288
289    def make_url(self, obj, i=None):
290        if callable(self.url):
291            return self.url(obj)
292        return self.url
293
294    def make_webhelpers_grid(self):
295        kwargs = dict(self._whgrid_kwargs)
296        kwargs['request'] = self.request
297        kwargs['mobile'] = self.mobile
298        kwargs['url'] = self.make_url
299
300        columns = list(self.columns)
301        column_labels = kwargs.setdefault('column_labels', {})
302        column_formats = kwargs.setdefault('column_formats', {})
303
304        for key, value in self.labels.items():
305            column_labels.setdefault(key, value)
306
307        if self.checkboxes:
308            columns.insert(0, 'checkbox')
309            column_labels['checkbox'] = tags.checkbox('check-all')
310            column_formats['checkbox'] = self.checkbox_column_format
311
312        if self.renderers:
313            kwargs['renderers'] = self.renderers
314        if self.extra_row_class:
315            kwargs['extra_record_class'] = self.extra_row_class
316        if self.linked_columns:
317            kwargs['linked_columns'] = list(self.linked_columns)
318
319        if self.main_actions or self.more_actions:
320            columns.append('actions')
321            column_formats['actions'] = self.actions_column_format
322
323        # TODO: pretty sure this factory doesn't serve all use cases yet?
324        factory = CustomWebhelpersGrid
325        # factory = webhelpers2_grid.Grid
326        if self.sortable:
327            # factory = CustomWebhelpersGrid
328            kwargs['order_column'] = self.sortkey
329            kwargs['order_direction'] = 'dsc' if self.sortdir == 'desc' else 'asc'
330
331        grid = factory(self.make_visible_data(), columns, **kwargs)
332        if self.sortable:
333            grid.exclude_ordering = list([key for key in grid.exclude_ordering
334                                          if key not in self.sorters])
335        return grid
336
337    def make_default_renderers(self, renderers):
338        """
339        Make the default set of column renderers for the grid.
340
341        We honor any existing renderers which have already been set, but then
342        we also try to supplement that by auto-assigning renderers based on
343        underlying column type.  Note that this special logic only applies to
344        grids with a valid :attr:`model_class`.
345        """
346        if self.model_class:
347            mapper = orm.class_mapper(self.model_class)
348            for prop in mapper.iterate_properties:
349                if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
350                    if prop.key in self.columns and prop.key not in renderers:
351                        if len(prop.columns) == 1:
352                            coltype = prop.columns[0].type
353                            renderers[prop.key] = self.get_renderer_for_column_type(coltype)
354
355        return renderers
356
357    def get_renderer_for_column_type(self, coltype):
358        """
359        Returns an appropriate renderer according to the given SA column type.
360        """
361        if isinstance(coltype, sa.Boolean):
362            return self.render_boolean
363
364        if isinstance(coltype, sa.DateTime):
365            return self.render_datetime
366
367        if isinstance(coltype, GPCType):
368            return self.render_gpc
369
370        return self.render_generic
371
372    def checkbox_column_format(self, column_number, row_number, item):
373        return HTML.td(self.render_checkbox(item), class_='checkbox')
374
375    def actions_column_format(self, column_number, row_number, item):
376        return HTML.td(self.render_actions(item, row_number), class_='actions')
377
378    def render_grid(self, template='/grids/grid.mako', **kwargs):
379        context = kwargs
380        context['grid'] = self
381        grid_class = ''
382        if self.width == 'full':
383            grid_class = 'full'
384        elif self.width == 'half':
385            grid_class = 'half'
386        context['grid_class'] = '{} {}'.format(grid_class, context.get('grid_class', ''))
387        context.setdefault('grid_attrs', {})
388        return render(template, context)
389
390    def get_default_filters(self):
391        """
392        Returns the default set of filters provided by the grid.
393        """
394        if hasattr(self, 'default_filters'):
395            if callable(self.default_filters):
396                return self.default_filters()
397            return self.default_filters
398        filters = gridfilters.GridFilterSet()
399        if self.model_class:
400            mapper = orm.class_mapper(self.model_class)
401            for prop in mapper.iterate_properties:
402                if not isinstance(prop, orm.ColumnProperty):
403                    continue
404                if prop.key.endswith('uuid'):
405                    continue
406                if len(prop.columns) != 1:
407                    continue
408                column = prop.columns[0]
409                if isinstance(column.type, sa.LargeBinary):
410                    continue
411                filters[prop.key] = self.make_filter(prop.key, column)
412        return filters
413
414    def make_filters(self, filters=None):
415        """
416        Returns an initial set of filters which will be available to the grid.
417        The grid itself may or may not provide some default filters, and the
418        ``filters`` kwarg may contain additions and/or overrides.
419        """
420        if filters:
421            return filters
422        return self.get_default_filters()
423
424    def make_filter(self, key, column, **kwargs):
425        """
426        Make a filter suitable for use with the given column.
427        """
428        factory = kwargs.pop('factory', None)
429        if not factory:
430            factory = gridfilters.AlchemyGridFilter
431            if isinstance(column.type, sa.String):
432                factory = gridfilters.AlchemyStringFilter
433            elif isinstance(column.type, sa.Numeric):
434                factory = gridfilters.AlchemyNumericFilter
435            elif isinstance(column.type, sa.Integer):
436                factory = gridfilters.AlchemyIntegerFilter
437            elif isinstance(column.type, sa.Boolean):
438                # TODO: check column for nullable here?
439                factory = gridfilters.AlchemyNullableBooleanFilter
440            elif isinstance(column.type, sa.Date):
441                factory = gridfilters.AlchemyDateFilter
442            elif isinstance(column.type, sa.DateTime):
443                factory = gridfilters.AlchemyDateTimeFilter
444            elif isinstance(column.type, GPCType):
445                factory = gridfilters.AlchemyGPCFilter
446        kwargs.setdefault('encode_values', self.use_byte_string_filters)
447        return factory(key, column=column, config=self.request.rattail_config, **kwargs)
448
449    def iter_filters(self):
450        """
451        Iterate over all filters available to the grid.
452        """
453        return six.itervalues(self.filters)
454
455    def iter_active_filters(self):
456        """
457        Iterate over all *active* filters for the grid.  Whether a filter is
458        active is determined by current grid settings.
459        """
460        for filtr in self.iter_filters():
461            if filtr.active:
462                yield filtr
463
464    def make_sorters(self, sorters=None):
465        """
466        Returns an initial set of sorters which will be available to the grid.
467        The grid itself may or may not provide some default sorters, and the
468        ``sorters`` kwarg may contain additions and/or overrides.
469        """
470        sorters, updates = {}, sorters
471        if self.model_class:
472            mapper = orm.class_mapper(self.model_class)
473            for prop in mapper.iterate_properties:
474                if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
475                    sorters[prop.key] = self.make_sorter(prop)
476        if updates:
477            sorters.update(updates)
478        return sorters
479
480    def make_sorter(self, model_property):
481        """
482        Returns a function suitable for a sort map callable, with typical logic
483        built in for sorting applied to ``field``.
484        """
485        class_ = getattr(model_property, 'class_', self.model_class)
486        column = getattr(class_, model_property.key)
487        return lambda q, d: q.order_by(getattr(column, d)())
488
489    def make_simple_sorter(self, key, foldcase=False):
490        """
491        Returns a function suitable for a sort map callable, with typical logic
492        built in for sorting a data set comprised of dicts, on the given key.
493        """
494        if foldcase:
495            keyfunc = lambda v: v[key].lower()
496        else:
497            keyfunc = lambda v: v[key]
498        return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc')
499
500    def load_settings(self, store=True):
501        """
502        Load current/effective settings for the grid, from the request query
503        string and/or session storage.  If ``store`` is true, then once
504        settings have been fully read, they are stored in current session for
505        next time.  Finally, various instance attributes of the grid and its
506        filters are updated in-place to reflect the settings; this is so code
507        needn't access the settings dict directly, but the more Pythonic
508        instance attributes.
509        """
510
511        # initial default settings
512        settings = {}
513        if self.sortable:
514            settings['sortkey'] = self.default_sortkey
515            settings['sortdir'] = self.default_sortdir
516        if self.pageable:
517            settings['pagesize'] = self.default_pagesize
518            settings['page'] = self.default_page
519        if self.filterable:
520            for filtr in self.iter_filters():
521                settings['filter.{}.active'.format(filtr.key)] = filtr.default_active
522                settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb
523                settings['filter.{}.value'.format(filtr.key)] = filtr.default_value
524
525        # If user has default settings on file, apply those first.
526        if self.user_has_defaults():
527            self.apply_user_defaults(settings)
528
529        # If request contains instruction to reset to default filters, then we
530        # can skip the rest of the request/session checks.
531        if self.request.GET.get('reset-to-default-filters') == 'true':
532            pass
533
534        # If request has filter settings, grab those, then grab sort/pager
535        # settings from request or session.
536        elif self.filterable and self.request_has_settings('filter'):
537            self.update_filter_settings(settings, 'request')
538            if self.request_has_settings('sort'):
539                self.update_sort_settings(settings, 'request')
540            else:
541                self.update_sort_settings(settings, 'session')
542            self.update_page_settings(settings)
543
544        # If request has no filter settings but does have sort settings, grab
545        # those, then grab filter settings from session, then grab pager
546        # settings from request or session.
547        elif self.request_has_settings('sort'):
548            self.update_sort_settings(settings, 'request')
549            self.update_filter_settings(settings, 'session')
550            self.update_page_settings(settings)
551
552        # NOTE: These next two are functionally equivalent, but are kept
553        # separate to maintain the narrative...
554
555        # If request has no filter/sort settings but does have pager settings,
556        # grab those, then grab filter/sort settings from session.
557        elif self.request_has_settings('page'):
558            self.update_page_settings(settings)
559            self.update_filter_settings(settings, 'session')
560            self.update_sort_settings(settings, 'session')
561
562        # If request has no settings, grab all from session.
563        elif self.session_has_settings():
564            self.update_filter_settings(settings, 'session')
565            self.update_sort_settings(settings, 'session')
566            self.update_page_settings(settings)
567
568        # If no settings were found in request or session, don't store result.
569        else:
570            store = False
571           
572        # Maybe store settings for next time.
573        if store:
574            self.persist_settings(settings, 'session')
575
576        # If request contained instruction to save current settings as defaults
577        # for the current user, then do that.
578        if self.request.GET.get('save-current-filters-as-defaults') == 'true':
579            self.persist_settings(settings, 'defaults')
580
581        # update ourself to reflect settings
582        if self.filterable:
583            for filtr in self.iter_filters():
584                filtr.active = settings['filter.{}.active'.format(filtr.key)]
585                filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
586                filtr.value = settings['filter.{}.value'.format(filtr.key)]
587        if self.sortable:
588            self.sortkey = settings['sortkey']
589            self.sortdir = settings['sortdir']
590        if self.pageable:
591            self.pagesize = settings['pagesize']
592            self.page = settings['page']
593
594    def user_has_defaults(self):
595        """
596        Check to see if the current user has default settings on file for this grid.
597        """
598        user = self.request.user
599        if not user:
600            return False
601
602        # NOTE: we used to leverage `self.session` here, but sometimes we might
603        # be showing a grid of data from another system...so always use
604        # Tailbone Session now, for the settings.  hopefully that didn't break
605        # anything...
606        session = Session()
607        if user not in session:
608            user = session.merge(user)
609
610        # User defaults should have all or nothing, so just check one key.
611        key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key)
612        return api.get_setting(session, key) is not None
613
614    def apply_user_defaults(self, settings):
615        """
616        Update the given settings dict with user defaults, if any exist.
617        """
618        def merge(key, normalize=lambda v: v):
619            skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
620            value = api.get_setting(Session(), skey)
621            settings[key] = normalize(value)
622
623        if self.filterable:
624            for filtr in self.iter_filters():
625                merge('filter.{}.active'.format(filtr.key), lambda v: v == 'true')
626                merge('filter.{}.verb'.format(filtr.key))
627                merge('filter.{}.value'.format(filtr.key))
628
629        if self.sortable:
630            merge('sortkey')
631            merge('sortdir')
632
633        if self.pageable:
634            merge('pagesize', int)
635            merge('page', int)
636
637    def request_has_settings(self, type_):
638        """
639        Determine if the current request (GET query string) contains any
640        filter/sort settings for the grid.
641        """
642        if type_ == 'filter':
643            for filtr in self.iter_filters():
644                if filtr.key in self.request.GET:
645                    return True
646            if 'filter' in self.request.GET: # user may be applying empty filters
647                return True
648
649        elif type_ == 'sort':
650            for key in ['sortkey', 'sortdir']:
651                if key in self.request.GET:
652                    return True
653
654        elif type_ == 'page':
655            for key in ['pagesize', 'page']:
656                if key in self.request.GET:
657                    return True
658
659        return False
660
661    def session_has_settings(self):
662        """
663        Determine if the current session contains any settings for the grid.
664        """
665        # session should have all or nothing, so just check a few keys which
666        # should be guaranteed present if anything has been stashed
667        for key in ['page', 'sortkey']:
668            if 'grid.{}.{}'.format(self.key, key) in self.request.session:
669                return True
670        return any([key.startswith('grid.{}.filter'.format(self.key)) for key in self.request.session])
671
672    def get_setting(self, source, settings, key, normalize=lambda v: v, default=None):
673        """
674        Get the effective value for a particular setting, preferring ``source``
675        but falling back to existing ``settings`` and finally the ``default``.
676        """
677        if source not in ('request', 'session'):
678            raise ValueError("Invalid source identifier: {}".format(source))
679
680        # If source is query string, try that first.
681        if source == 'request':
682            value = self.request.GET.get(key)
683            if value is not None:
684                try:
685                    value = normalize(value)
686                except ValueError:
687                    pass
688                else:
689                    return value
690
691        # Or, if source is session, try that first.
692        else:
693            value = self.request.session.get('grid.{}.{}'.format(self.key, key))
694            if value is not None:
695                return normalize(value)
696
697        # If source had nothing, try default/existing settings.
698        value = settings.get(key)
699        if value is not None:
700            try:
701                value = normalize(value)
702            except ValueError:
703                pass
704            else:
705                return value
706
707        # Okay then, default it is.
708        return default
709
710    def update_filter_settings(self, settings, source):
711        """
712        Updates a settings dictionary according to filter settings data found
713        in either the GET query string, or session storage.
714
715        :param settings: Dictionary of initial settings, which is to be updated.
716
717        :param source: String identifying the source to consult for settings
718           data.  Must be one of: ``('request', 'session')``.
719        """
720        if not self.filterable:
721            return
722
723        for filtr in self.iter_filters():
724            prefix = 'filter.{}'.format(filtr.key)
725
726            if source == 'request':
727                # consider filter active if query string contains a value for it
728                settings['{}.active'.format(prefix)] = filtr.key in self.request.GET
729                settings['{}.verb'.format(prefix)] = self.get_setting(
730                    source, settings, '{}.verb'.format(filtr.key), default='')
731                settings['{}.value'.format(prefix)] = self.get_setting(
732                    source, settings, filtr.key, default='')
733
734            else: # source = session
735                settings['{}.active'.format(prefix)] = self.get_setting(
736                    source, settings, '{}.active'.format(prefix),
737                    normalize=lambda v: six.text_type(v).lower() == 'true', default=False)
738                settings['{}.verb'.format(prefix)] = self.get_setting(
739                    source, settings, '{}.verb'.format(prefix), default='')
740                settings['{}.value'.format(prefix)] = self.get_setting(
741                    source, settings, '{}.value'.format(prefix), default='')
742
743    def update_sort_settings(self, settings, source):
744        """
745        Updates a settings dictionary according to sort settings data found in
746        either the GET query string, or session storage.
747
748        :param settings: Dictionary of initial settings, which is to be updated.
749
750        :param source: String identifying the source to consult for settings
751           data.  Must be one of: ``('request', 'session')``.
752        """
753        if not self.sortable:
754            return
755        settings['sortkey'] = self.get_setting(source, settings, 'sortkey')
756        settings['sortdir'] = self.get_setting(source, settings, 'sortdir')
757
758    def update_page_settings(self, settings):
759        """
760        Updates a settings dictionary according to pager settings data found in
761        either the GET query string, or session storage.
762
763        Note that due to how the actual pager functions, the effective settings
764        will often come from *both* the request and session.  This is so that
765        e.g. the page size will remain constant (coming from the session) while
766        the user jumps between pages (which only provides the single setting).
767
768        :param settings: Dictionary of initial settings, which is to be updated.
769        """
770        if not self.pageable:
771            return
772
773        pagesize = self.request.GET.get('pagesize')
774        if pagesize is not None:
775            if pagesize.isdigit():
776                settings['pagesize'] = int(pagesize)
777        else:
778            pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key))
779            if pagesize is not None:
780                settings['pagesize'] = pagesize
781
782        page = self.request.GET.get('page')
783        if page is not None:
784            if page.isdigit():
785                settings['page'] = int(page)
786        else:
787            page = self.request.session.get('grid.{}.page'.format(self.key))
788            if page is not None:
789                settings['page'] = int(page)
790
791    def persist_settings(self, settings, to='session'):
792        """
793        Persist the given settings in some way, as defined by ``func``.
794        """
795        def persist(key, value=lambda k: settings[k]):
796            if to == 'defaults':
797                skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
798                api.save_setting(Session(), skey, value(key))
799            else: # to == session
800                skey = 'grid.{}.{}'.format(self.key, key)
801                self.request.session[skey] = value(key)
802
803        if self.filterable:
804            for filtr in self.iter_filters():
805                persist('filter.{}.active'.format(filtr.key), value=lambda k: six.text_type(settings[k]).lower())
806                persist('filter.{}.verb'.format(filtr.key))
807                persist('filter.{}.value'.format(filtr.key))
808
809        if self.sortable:
810            persist('sortkey')
811            persist('sortdir')
812
813        if self.pageable:
814            persist('pagesize')
815            persist('page')
816
817    def filter_data(self, data):
818        """
819        Filter and return the given data set, according to current settings.
820        """
821        for filtr in self.iter_active_filters():
822
823            # apply filter to data but save reference to original; if data is a
824            # SQLAlchemy query and wasn't modified, we don't need to bother
825            # with the underlying join (if there is one)
826            original = data
827            data = filtr.filter(data)
828            if filtr.key in self.joiners and filtr.key not in self.joined and (
829                    not isinstance(data, orm.Query) or data is not original):
830
831                # this filter requires a join; apply that
832                data = self.joiners[filtr.key](data)
833                self.joined.add(filtr.key)
834
835        return data
836
837    def sort_data(self, data):
838        """
839        Sort the given query according to current settings, and return the result.
840        """
841        # Cannot sort unless we know which column to sort by.
842        if not self.sortkey:
843            return data
844
845        # Cannot sort unless we have a sort function.
846        sortfunc = self.sorters.get(self.sortkey)
847        if not sortfunc:
848            return data
849
850        # We can provide a default sort direction though.
851        sortdir = getattr(self, 'sortdir', 'asc')
852        if self.sortkey in self.joiners and self.sortkey not in self.joined:
853            data = self.joiners[self.sortkey](data)
854            self.joined.add(self.sortkey)
855        return sortfunc(data, sortdir)
856
857    def paginate_data(self, data):
858        """
859        Paginate the given data set according to current settings, and return
860        the result.
861        """
862        # we of course assume our current page is correct, at first
863        pager = self.make_pager(data)
864
865        # if pager has detected that our current page is outside the valid
866        # range, we must re-orient ourself around the "new" (valid) page
867        if pager.page != self.page:
868            self.page = pager.page
869            self.request.session['grid.{}.page'.format(self.key)] = self.page
870            pager = self.make_pager(data)
871
872        return pager
873
874    def make_pager(self, data):
875        return SqlalchemyOrmPage(data,
876                                 items_per_page=self.pagesize,
877                                 page=self.page,
878                                 url_maker=URLMaker(self.request))
879
880    def make_visible_data(self):
881        """
882        Apply various settings to the raw data set, to produce a final data
883        set.  This will page / sort / filter as necessary, according to the
884        grid's defaults and the current request etc.
885        """
886        self.joined = set()
887        data = self.data
888        if self.filterable:
889            data = self.filter_data(data)
890        if self.sortable:
891            data = self.sort_data(data)
892        if self.pageable:
893            self.pager = self.paginate_data(data)
894            data = self.pager
895        return data
896
897    def render_complete(self, template='/grids/complete.mako', **kwargs):
898        """
899        Render the complete grid, including filters.
900        """
901        context = kwargs
902        context['grid'] = self
903        context.setdefault('allow_save_defaults', True)
904        return render(template, context)
905
906    def render_filters(self, template='/grids/filters.mako', **kwargs):
907        """
908        Render the filters to a Unicode string, using the specified template.
909        Additional kwargs are passed along as context to the template.
910        """
911        # Provide default data to filters form, so renderer can do some of the
912        # work for us.
913        data = {}
914        for filtr in self.iter_active_filters():
915            data['{}.active'.format(filtr.key)] = filtr.active
916            data['{}.verb'.format(filtr.key)] = filtr.verb
917            data[filtr.key] = filtr.value
918
919        form = gridfilters.GridFiltersForm(self.filters,
920                                           request=self.request,
921                                           defaults=data)
922
923        kwargs['request'] = self.request
924        kwargs['grid'] = self
925        kwargs['form'] = form
926        return render(template, kwargs)
927
928    def render_actions(self, row, i):
929        """
930        Returns the rendered contents of the 'actions' column for a given row.
931        """
932        main_actions = [self.render_action(a, row, i)
933                        for a in self.main_actions]
934        main_actions = [a for a in main_actions if a]
935        more_actions = [self.render_action(a, row, i)
936                        for a in self.more_actions]
937        more_actions = [a for a in more_actions if a]
938        if more_actions:
939            icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e')
940            link = tags.link_to("More" + icon, '#', class_='more')
941            main_actions.append(HTML.literal('&nbsp; ') + link + HTML.tag('div', class_='more', c=more_actions))
942        return HTML.literal('').join(main_actions)
943
944    def render_action(self, action, row, i):
945        """
946        Renders an action menu item (link) for the given row.
947        """
948        url = action.get_url(row, i)
949        if url:
950            kwargs = {'class_': action.key, 'target': action.target}
951            if action.icon:
952                icon = HTML.tag('span', class_='ui-icon ui-icon-{}'.format(action.icon))
953                return tags.link_to(icon + action.label, url, **kwargs)
954            return tags.link_to(action.label, url, **kwargs)
955
956    def get_row_key(self, item):
957        """
958        Must return a unique key for the given data item's row.
959        """
960        mapper = orm.object_mapper(item)
961        if len(mapper.primary_key) == 1:
962            return getattr(item, mapper.primary_key[0].key)
963        raise NotImplementedError
964
965    def checkbox(self, item):
966        """
967        Returns boolean indicating whether a checkbox should be rendererd for
968        the given data item's row.
969        """
970        return True
971
972    def render_checkbox(self, item):
973        """
974        Renders a checkbox cell for the given item, if applicable.
975        """
976        if not self.checkbox(item):
977            return ''
978        return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)),
979                             checked=self.checked(item))
980
981    def get_pagesize_options(self):
982        # TODO: Make configurable or something...
983        return [5, 10, 20, 50, 100, 200]
984
985
986class CustomWebhelpersGrid(webhelpers2_grid.Grid):
987    """
988    Implement column sorting links etc. for webhelpers2_grid
989    """
990
991    def __init__(self, itemlist, columns, **kwargs):
992        self.mobile = kwargs.pop('mobile', False)
993        self.renderers = kwargs.pop('renderers', {})
994        self.linked_columns = kwargs.pop('linked_columns', [])
995        self.extra_record_class = kwargs.pop('extra_record_class', None)
996        super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs)
997
998    def default_header_record_format(self, headers):
999        if self.mobile:
1000            return HTML('')
1001        return super(CustomWebhelpersGrid, self).default_header_record_format(headers)
1002
1003    def generate_header_link(self, column_number, column, label_text):
1004
1005        # display column header as simple no-op link; client-side JS takes care
1006        # of the rest for us
1007        label_text = tags.link_to(label_text, '#', data_sortkey=column)
1008
1009        # Is the current column the one we're ordering on?
1010        if (column == self.order_column):
1011            return self.default_header_ordered_column_format(column_number,
1012                                                             column,
1013                                                             label_text)
1014        else:
1015            return self.default_header_column_format(column_number, column,
1016                                                     label_text)           
1017
1018    def default_record_format(self, i, record, columns):
1019        if self.mobile:
1020            return columns
1021        kwargs = {
1022            'class_': self.get_record_class(i, record, columns),
1023        }
1024        if hasattr(record, 'uuid'):
1025            kwargs['data_uuid'] = record.uuid
1026        return HTML.tag('tr', columns, **kwargs)
1027
1028    def get_record_class(self, i, record, columns):
1029        if i % 2 == 0:
1030            cls = 'even r{}'.format(i)
1031        else:
1032            cls = 'odd r{}'.format(i)
1033        if self.extra_record_class:
1034            extra = self.extra_record_class(record, i)
1035            if extra:
1036                cls = '{} {}'.format(cls, extra)
1037        return cls
1038
1039    def get_column_value(self, column_number, i, record, column_name):
1040        if self.renderers and column_name in self.renderers:
1041            return self.renderers[column_name](record, column_name)
1042        try:
1043            return record[column_name]
1044        except TypeError:
1045            return getattr(record, column_name)
1046
1047    def default_column_format(self, column_number, i, record, column_name):
1048        value = self.get_column_value(column_number, i, record, column_name)
1049        if self.mobile:
1050            url = self.url_generator(record, i)
1051            attrs = {}
1052            if hasattr(record, 'uuid'):
1053                attrs['data_uuid'] = record.uuid
1054            return HTML.tag('li', tags.link_to(value, url), **attrs)
1055        if self.linked_columns and column_name in self.linked_columns and value != '':
1056            url = self.url_generator(record, i)
1057            value = tags.link_to(value, url)
1058        class_name = 'c{} {}'.format(column_number, column_name)
1059        return HTML.tag('td', value, class_=class_name)
1060
1061
1062class GridAction(object):
1063    """
1064    Represents an action available to a grid.  This is used to construct the
1065    'actions' column when rendering the grid.
1066    """
1067
1068    def __init__(self, key, label=None, url='#', icon=None, target=None):
1069        self.key = key
1070        self.label = label or prettify(key)
1071        self.icon = icon
1072        self.url = url
1073        self.target = target
1074
1075    def get_url(self, row, i):
1076        """
1077        Returns an action URL for the given row.
1078        """
1079        if callable(self.url):
1080            return self.url(row, i)
1081        return self.url
1082
1083
1084class URLMaker(object):
1085    """
1086    URL constructor for use with SQLAlchemy grid pagers.  Logic for this was
1087    basically copied from the old `webhelpers.paginate` module
1088    """
1089
1090    def __init__(self, request):
1091        self.request = request
1092
1093    def __call__(self, page):
1094        params = self.request.GET.copy()
1095        params["page"] = page
1096        params["partial"] = "1"
1097        qs = urllib.parse.urlencode(params, True)
1098        return '{}?{}'.format(self.request.path, qs)
Note: See TracBrowser for help on using the repository browser.