source: tailbone/tailbone/grids/core.py

v0.8.77
Last change on this file was bcfb4f2, checked in by Lance Edgar <lance@…>, 6 weeks ago

Improve checkbox click handling support for grids

i.e. let custom use define click handlers

  • Property mode set to 100644
File size: 50.9 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2019 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,
71                 model_class=None, model_title=None, model_title_plural=None,
72                 enums={}, labels={}, renderers={}, extra_row_class=None, linked_columns=[], url='#',
73                 joiners={}, filterable=False, filters={}, use_byte_string_filters=False,
74                 sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
75                 pageable=False, default_pagesize=20, default_page=1,
76                 checkboxes=False, checked=None, check_handler=None, check_all_handler=None,
77                 main_actions=[], more_actions=[], delete_speedbump=False,
78                 ajax_data_url=None,
79                 **kwargs):
80
81        self.key = key
82        self.data = data
83        self.columns = FieldList(columns) if columns is not None else None
84        self.width = width
85        self.request = request
86        self.mobile = mobile
87        self.model_class = model_class
88        if self.model_class and self.columns is None:
89            self.columns = self.make_columns()
90
91        self.model_title = model_title
92        if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'):
93            self.model_title = self.model_class.get_model_title()
94
95        self.model_title_plural = model_title_plural
96        if not self.model_title_plural:
97            if self.model_class and hasattr(self.model_class, 'get_model_title_plural'):
98                self.model_title_plural = self.model_class.get_model_title_plural()
99            if not self.model_title_plural:
100                self.model_title_plural = '{}s'.format(self.model_title)
101
102        self.enums = enums or {}
103
104        self.labels = labels or {}
105        self.renderers = self.make_default_renderers(renderers or {})
106        self.extra_row_class = extra_row_class
107        self.linked_columns = linked_columns or []
108        self.url = url
109        self.joiners = joiners or {}
110
111        self.filterable = filterable
112        self.use_byte_string_filters = use_byte_string_filters
113        self.filters = self.make_filters(filters)
114
115        self.sortable = sortable
116        self.sorters = self.make_sorters(sorters)
117        self.default_sortkey = default_sortkey
118        self.default_sortdir = default_sortdir
119
120        self.pageable = pageable
121        self.default_pagesize = default_pagesize
122        self.default_page = default_page
123
124        self.checkboxes = checkboxes
125        self.checked = checked
126        if self.checked is None:
127            self.checked = lambda item: False
128        self.check_handler = check_handler
129        self.check_all_handler = check_all_handler
130
131        self.main_actions = main_actions or []
132        self.more_actions = more_actions or []
133        self.delete_speedbump = delete_speedbump
134
135        if ajax_data_url:
136            self.ajax_data_url = ajax_data_url
137        elif self.request:
138            self.ajax_data_url = self.request.current_route_url()
139        else:
140            self.ajax_data_url = ''
141
142        self._whgrid_kwargs = kwargs
143
144    def make_columns(self):
145        """
146        Return a default list of columns, based on :attr:`model_class`.
147        """
148        if not self.model_class:
149            raise ValueError("Must define model_class to use make_columns()")
150
151        mapper = orm.class_mapper(self.model_class)
152        return [prop.key for prop in mapper.iterate_properties]
153
154    def hide_column(self, key):
155        if key in self.columns:
156            self.columns.remove(key)
157
158    def hide_columns(self, *keys):
159        for key in keys:
160            self.hide_column(key)
161
162    def append(self, field):
163        self.columns.append(field)
164
165    def insert_before(self, field, newfield):
166        self.columns.insert_before(field, newfield)
167
168    def insert_after(self, field, newfield):
169        self.columns.insert_after(field, newfield)
170
171    def replace(self, oldfield, newfield):
172        self.insert_after(oldfield, newfield)
173        self.hide_column(oldfield)
174
175    def set_joiner(self, key, joiner):
176        if joiner is None:
177            self.joiners.pop(key, None)
178        else:
179            self.joiners[key] = joiner
180
181    def set_sorter(self, key, *args, **kwargs):
182        self.sorters[key] = self.make_sorter(*args, **kwargs)
183
184    def set_sort_defaults(self, sortkey, sortdir='asc'):
185        self.default_sortkey = sortkey
186        self.default_sortdir = sortdir
187
188    def set_filter(self, key, *args, **kwargs):
189        if len(args) == 1 and args[0] is None:
190            self.remove_filter(key)
191        else:
192            if 'label' not in kwargs and key in self.labels:
193                kwargs['label'] = self.labels[key]
194            self.filters[key] = self.make_filter(key, *args, **kwargs)
195
196    def remove_filter(self, key):
197        self.filters.pop(key, None)
198
199    def set_label(self, key, label):
200        self.labels[key] = label
201        if key in self.filters:
202            self.filters[key].label = label
203
204    def get_label(self, key):
205        """
206        Returns the label text for given field key.
207        """
208        return self.labels.get(key, prettify(key))
209
210    def set_link(self, key, link=True):
211        if link:
212            if key not in self.linked_columns:
213                self.linked_columns.append(key)
214        else: # unlink
215            if self.linked_columns and key in self.linked_columns:
216                self.linked_columns.remove(key)
217
218    def set_renderer(self, key, renderer):
219        self.renderers[key] = renderer
220
221    def set_type(self, key, type_):
222        if type_ == 'boolean':
223            self.set_renderer(key, self.render_boolean)
224        elif type_ == 'currency':
225            self.set_renderer(key, self.render_currency)
226        elif type_ == 'datetime':
227            self.set_renderer(key, self.render_datetime)
228        elif type_ == 'datetime_local':
229            self.set_renderer(key, self.render_datetime_local)
230        elif type_ == 'enum':
231            self.set_renderer(key, self.render_enum)
232        elif type_ == 'gpc':
233            self.set_renderer(key, self.render_gpc)
234        elif type_ == 'percent':
235            self.set_renderer(key, self.render_percent)
236        elif type_ == 'quantity':
237            self.set_renderer(key, self.render_quantity)
238        elif type_ == 'duration':
239            self.set_renderer(key, self.render_duration)
240        elif type_ == 'duration_hours':
241            self.set_renderer(key, self.render_duration_hours)
242        else:
243            raise ValueError("Unsupported type for column '{}': {}".format(key, type_))
244
245    def set_enum(self, key, enum):
246        if enum:
247            self.enums[key] = enum
248            self.set_type(key, 'enum')
249            if key in self.filters:
250                self.filters[key].set_value_renderer(gridfilters.EnumValueRenderer(enum))
251        else:
252            self.enums.pop(key, None)
253
254    def render_generic(self, obj, column_name):
255        return self.obtain_value(obj, column_name)
256
257    def render_boolean(self, obj, column_name):
258        value = self.obtain_value(obj, column_name)
259        return pretty_boolean(value)
260
261    def obtain_value(self, obj, column_name):
262        try:
263            return obj[column_name]
264        except TypeError:
265            return getattr(obj, column_name)
266
267    def render_currency(self, obj, column_name):
268        value = self.obtain_value(obj, column_name)
269        if value is None:
270            return ""
271        if value < 0:
272            return "(${:0,.2f})".format(0 - value)
273        return "${:0,.2f}".format(value)
274
275    def render_datetime(self, obj, column_name):
276        value = self.obtain_value(obj, column_name)
277        if value is None:
278            return ""
279        return raw_datetime(self.request.rattail_config, value)
280
281    def render_datetime_local(self, obj, column_name):
282        value = self.obtain_value(obj, column_name)
283        if value is None:
284            return ""
285        value = localtime(self.request.rattail_config, value)
286        return raw_datetime(self.request.rattail_config, value)
287
288    def render_enum(self, obj, column_name):
289        value = self.obtain_value(obj, column_name)
290        if value is None:
291            return ""
292        enum = self.enums.get(column_name)
293        if enum and value in enum:
294            return six.text_type(enum[value])
295        return six.text_type(value)
296
297    def render_gpc(self, obj, column_name):
298        value = self.obtain_value(obj, column_name)
299        if value is None:
300            return ""
301        return value.pretty()
302
303    def render_percent(self, obj, column_name):
304        value = self.obtain_value(obj, column_name)
305        if value is None:
306            return ""
307        return "{:0.3f} %".format(value * 100)
308
309    def render_quantity(self, obj, column_name):
310        value = self.obtain_value(obj, column_name)
311        return pretty_quantity(value)
312
313    def render_duration(self, obj, column_name):
314        value = self.obtain_value(obj, column_name)
315        if value is None:
316            return ""
317        return pretty_hours(datetime.timedelta(seconds=value))
318
319    def render_duration_hours(self, obj, field):
320        value = self.obtain_value(obj, field)
321        if value is None:
322            return ""
323        return pretty_hours(hours=value)
324
325    def set_url(self, url):
326        self.url = url
327
328    def make_url(self, obj, i=None):
329        if callable(self.url):
330            return self.url(obj)
331        return self.url
332
333    def make_webhelpers_grid(self):
334        kwargs = dict(self._whgrid_kwargs)
335        kwargs['request'] = self.request
336        kwargs['mobile'] = self.mobile
337        kwargs['url'] = self.make_url
338
339        columns = list(self.columns)
340        column_labels = kwargs.setdefault('column_labels', {})
341        column_formats = kwargs.setdefault('column_formats', {})
342
343        for key, value in self.labels.items():
344            column_labels.setdefault(key, value)
345
346        if self.checkboxes:
347            columns.insert(0, 'checkbox')
348            column_labels['checkbox'] = tags.checkbox('check-all')
349            column_formats['checkbox'] = self.checkbox_column_format
350
351        if self.renderers:
352            kwargs['renderers'] = self.renderers
353        if self.extra_row_class:
354            kwargs['extra_record_class'] = self.extra_row_class
355        if self.linked_columns:
356            kwargs['linked_columns'] = list(self.linked_columns)
357
358        if self.main_actions or self.more_actions:
359            columns.append('actions')
360            column_formats['actions'] = self.actions_column_format
361
362        # TODO: pretty sure this factory doesn't serve all use cases yet?
363        factory = CustomWebhelpersGrid
364        # factory = webhelpers2_grid.Grid
365        if self.sortable:
366            # factory = CustomWebhelpersGrid
367            kwargs['order_column'] = self.sortkey
368            kwargs['order_direction'] = 'dsc' if self.sortdir == 'desc' else 'asc'
369
370        grid = factory(self.make_visible_data(), columns, **kwargs)
371        if self.sortable:
372            grid.exclude_ordering = list([key for key in grid.exclude_ordering
373                                          if key not in self.sorters])
374        return grid
375
376    def make_default_renderers(self, renderers):
377        """
378        Make the default set of column renderers for the grid.
379
380        We honor any existing renderers which have already been set, but then
381        we also try to supplement that by auto-assigning renderers based on
382        underlying column type.  Note that this special logic only applies to
383        grids with a valid :attr:`model_class`.
384        """
385        if self.model_class:
386            mapper = orm.class_mapper(self.model_class)
387            for prop in mapper.iterate_properties:
388                if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
389                    if prop.key in self.columns and prop.key not in renderers:
390                        if len(prop.columns) == 1:
391                            coltype = prop.columns[0].type
392                            renderers[prop.key] = self.get_renderer_for_column_type(coltype)
393
394        return renderers
395
396    def get_renderer_for_column_type(self, coltype):
397        """
398        Returns an appropriate renderer according to the given SA column type.
399        """
400        if isinstance(coltype, sa.Boolean):
401            return self.render_boolean
402
403        if isinstance(coltype, sa.DateTime):
404            return self.render_datetime
405
406        if isinstance(coltype, GPCType):
407            return self.render_gpc
408
409        return self.render_generic
410
411    def checkbox_column_format(self, column_number, row_number, item):
412        return HTML.td(self.render_checkbox(item), class_='checkbox')
413
414    def actions_column_format(self, column_number, row_number, item):
415        return HTML.td(self.render_actions(item, row_number), class_='actions')
416
417    def render_grid(self, template='/grids/grid.mako', **kwargs):
418        context = kwargs
419        context['grid'] = self
420        context['request'] = self.request
421        grid_class = ''
422        if self.width == 'full':
423            grid_class = 'full'
424        elif self.width == 'half':
425            grid_class = 'half'
426        context['grid_class'] = '{} {}'.format(grid_class, context.get('grid_class', ''))
427        context.setdefault('grid_attrs', {})
428        return render(template, context)
429
430    def get_default_filters(self):
431        """
432        Returns the default set of filters provided by the grid.
433        """
434        if hasattr(self, 'default_filters'):
435            if callable(self.default_filters):
436                return self.default_filters()
437            return self.default_filters
438        filters = gridfilters.GridFilterSet()
439        if self.model_class:
440            mapper = orm.class_mapper(self.model_class)
441            for prop in mapper.iterate_properties:
442                if not isinstance(prop, orm.ColumnProperty):
443                    continue
444                if prop.key.endswith('uuid'):
445                    continue
446                if len(prop.columns) != 1:
447                    continue
448                column = prop.columns[0]
449                if isinstance(column.type, sa.LargeBinary):
450                    continue
451                filters[prop.key] = self.make_filter(prop.key, column)
452        return filters
453
454    def make_filters(self, filters=None):
455        """
456        Returns an initial set of filters which will be available to the grid.
457        The grid itself may or may not provide some default filters, and the
458        ``filters`` kwarg may contain additions and/or overrides.
459        """
460        if filters:
461            return filters
462        return self.get_default_filters()
463
464    def make_filter(self, key, column, **kwargs):
465        """
466        Make a filter suitable for use with the given column.
467        """
468        factory = kwargs.pop('factory', None)
469        if not factory:
470            factory = gridfilters.AlchemyGridFilter
471            if isinstance(column.type, sa.String):
472                factory = gridfilters.AlchemyStringFilter
473            elif isinstance(column.type, sa.Numeric):
474                factory = gridfilters.AlchemyNumericFilter
475            elif isinstance(column.type, sa.Integer):
476                factory = gridfilters.AlchemyIntegerFilter
477            elif isinstance(column.type, sa.Boolean):
478                # TODO: check column for nullable here?
479                factory = gridfilters.AlchemyNullableBooleanFilter
480            elif isinstance(column.type, sa.Date):
481                factory = gridfilters.AlchemyDateFilter
482            elif isinstance(column.type, sa.DateTime):
483                factory = gridfilters.AlchemyDateTimeFilter
484            elif isinstance(column.type, GPCType):
485                factory = gridfilters.AlchemyGPCFilter
486        kwargs.setdefault('encode_values', self.use_byte_string_filters)
487        return factory(key, column=column, config=self.request.rattail_config, **kwargs)
488
489    def iter_filters(self):
490        """
491        Iterate over all filters available to the grid.
492        """
493        return six.itervalues(self.filters)
494
495    def iter_active_filters(self):
496        """
497        Iterate over all *active* filters for the grid.  Whether a filter is
498        active is determined by current grid settings.
499        """
500        for filtr in self.iter_filters():
501            if filtr.active:
502                yield filtr
503
504    def make_sorters(self, sorters=None):
505        """
506        Returns an initial set of sorters which will be available to the grid.
507        The grid itself may or may not provide some default sorters, and the
508        ``sorters`` kwarg may contain additions and/or overrides.
509        """
510        sorters, updates = {}, sorters
511        if self.model_class:
512            mapper = orm.class_mapper(self.model_class)
513            for prop in mapper.iterate_properties:
514                if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
515                    sorters[prop.key] = self.make_sorter(prop)
516        if updates:
517            sorters.update(updates)
518        return sorters
519
520    def make_sorter(self, model_property):
521        """
522        Returns a function suitable for a sort map callable, with typical logic
523        built in for sorting applied to ``field``.
524        """
525        class_ = getattr(model_property, 'class_', self.model_class)
526        column = getattr(class_, model_property.key)
527        return lambda q, d: q.order_by(getattr(column, d)())
528
529    def make_simple_sorter(self, key, foldcase=False):
530        """
531        Returns a function suitable for a sort map callable, with typical logic
532        built in for sorting a data set comprised of dicts, on the given key.
533        """
534        if foldcase:
535            keyfunc = lambda v: v[key].lower()
536        else:
537            keyfunc = lambda v: v[key]
538        return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc')
539
540    def load_settings(self, store=True):
541        """
542        Load current/effective settings for the grid, from the request query
543        string and/or session storage.  If ``store`` is true, then once
544        settings have been fully read, they are stored in current session for
545        next time.  Finally, various instance attributes of the grid and its
546        filters are updated in-place to reflect the settings; this is so code
547        needn't access the settings dict directly, but the more Pythonic
548        instance attributes.
549        """
550
551        # initial default settings
552        settings = {}
553        if self.sortable:
554            settings['sortkey'] = self.default_sortkey
555            settings['sortdir'] = self.default_sortdir
556        if self.pageable:
557            settings['pagesize'] = self.default_pagesize
558            settings['page'] = self.default_page
559        if self.filterable:
560            for filtr in self.iter_filters():
561                settings['filter.{}.active'.format(filtr.key)] = filtr.default_active
562                settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb
563                settings['filter.{}.value'.format(filtr.key)] = filtr.default_value
564
565        # If user has default settings on file, apply those first.
566        if self.user_has_defaults():
567            self.apply_user_defaults(settings)
568
569        # If request contains instruction to reset to default filters, then we
570        # can skip the rest of the request/session checks.
571        if self.request.GET.get('reset-to-default-filters') == 'true':
572            pass
573
574        # If request has filter settings, grab those, then grab sort/pager
575        # settings from request or session.
576        elif self.filterable and self.request_has_settings('filter'):
577            self.update_filter_settings(settings, 'request')
578            if self.request_has_settings('sort'):
579                self.update_sort_settings(settings, 'request')
580            else:
581                self.update_sort_settings(settings, 'session')
582            self.update_page_settings(settings)
583
584        # If request has no filter settings but does have sort settings, grab
585        # those, then grab filter settings from session, then grab pager
586        # settings from request or session.
587        elif self.request_has_settings('sort'):
588            self.update_sort_settings(settings, 'request')
589            self.update_filter_settings(settings, 'session')
590            self.update_page_settings(settings)
591
592        # NOTE: These next two are functionally equivalent, but are kept
593        # separate to maintain the narrative...
594
595        # If request has no filter/sort settings but does have pager settings,
596        # grab those, then grab filter/sort settings from session.
597        elif self.request_has_settings('page'):
598            self.update_page_settings(settings)
599            self.update_filter_settings(settings, 'session')
600            self.update_sort_settings(settings, 'session')
601
602        # If request has no settings, grab all from session.
603        elif self.session_has_settings():
604            self.update_filter_settings(settings, 'session')
605            self.update_sort_settings(settings, 'session')
606            self.update_page_settings(settings)
607
608        # If no settings were found in request or session, don't store result.
609        else:
610            store = False
611           
612        # Maybe store settings for next time.
613        if store:
614            self.persist_settings(settings, 'session')
615
616        # If request contained instruction to save current settings as defaults
617        # for the current user, then do that.
618        if self.request.GET.get('save-current-filters-as-defaults') == 'true':
619            self.persist_settings(settings, 'defaults')
620
621        # update ourself to reflect settings
622        if self.filterable:
623            for filtr in self.iter_filters():
624                filtr.active = settings['filter.{}.active'.format(filtr.key)]
625                filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
626                filtr.value = settings['filter.{}.value'.format(filtr.key)]
627        if self.sortable:
628            self.sortkey = settings['sortkey']
629            self.sortdir = settings['sortdir']
630        if self.pageable:
631            self.pagesize = settings['pagesize']
632            self.page = settings['page']
633
634    def user_has_defaults(self):
635        """
636        Check to see if the current user has default settings on file for this grid.
637        """
638        user = self.request.user
639        if not user:
640            return False
641
642        # NOTE: we used to leverage `self.session` here, but sometimes we might
643        # be showing a grid of data from another system...so always use
644        # Tailbone Session now, for the settings.  hopefully that didn't break
645        # anything...
646        session = Session()
647        if user not in session:
648            user = session.merge(user)
649
650        # User defaults should have all or nothing, so just check one key.
651        key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key)
652        return api.get_setting(session, key) is not None
653
654    def apply_user_defaults(self, settings):
655        """
656        Update the given settings dict with user defaults, if any exist.
657        """
658        def merge(key, normalize=lambda v: v):
659            skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
660            value = api.get_setting(Session(), skey)
661            settings[key] = normalize(value)
662
663        if self.filterable:
664            for filtr in self.iter_filters():
665                merge('filter.{}.active'.format(filtr.key), lambda v: v == 'true')
666                merge('filter.{}.verb'.format(filtr.key))
667                merge('filter.{}.value'.format(filtr.key))
668
669        if self.sortable:
670            merge('sortkey')
671            merge('sortdir')
672
673        if self.pageable:
674            merge('pagesize', int)
675            merge('page', int)
676
677    def request_has_settings(self, type_):
678        """
679        Determine if the current request (GET query string) contains any
680        filter/sort settings for the grid.
681        """
682        if type_ == 'filter':
683            for filtr in self.iter_filters():
684                if filtr.key in self.request.GET:
685                    return True
686            if 'filter' in self.request.GET: # user may be applying empty filters
687                return True
688
689        elif type_ == 'sort':
690            for key in ['sortkey', 'sortdir']:
691                if key in self.request.GET:
692                    return True
693
694        elif type_ == 'page':
695            for key in ['pagesize', 'page']:
696                if key in self.request.GET:
697                    return True
698
699        return False
700
701    def session_has_settings(self):
702        """
703        Determine if the current session contains any settings for the grid.
704        """
705        # session should have all or nothing, so just check a few keys which
706        # should be guaranteed present if anything has been stashed
707        for key in ['page', 'sortkey']:
708            if 'grid.{}.{}'.format(self.key, key) in self.request.session:
709                return True
710        return any([key.startswith('grid.{}.filter'.format(self.key)) for key in self.request.session])
711
712    def get_setting(self, source, settings, key, normalize=lambda v: v, default=None):
713        """
714        Get the effective value for a particular setting, preferring ``source``
715        but falling back to existing ``settings`` and finally the ``default``.
716        """
717        if source not in ('request', 'session'):
718            raise ValueError("Invalid source identifier: {}".format(source))
719
720        # If source is query string, try that first.
721        if source == 'request':
722            value = self.request.GET.get(key)
723            if value is not None:
724                try:
725                    value = normalize(value)
726                except ValueError:
727                    pass
728                else:
729                    return value
730
731        # Or, if source is session, try that first.
732        else:
733            value = self.request.session.get('grid.{}.{}'.format(self.key, key))
734            if value is not None:
735                return normalize(value)
736
737        # If source had nothing, try default/existing settings.
738        value = settings.get(key)
739        if value is not None:
740            try:
741                value = normalize(value)
742            except ValueError:
743                pass
744            else:
745                return value
746
747        # Okay then, default it is.
748        return default
749
750    def update_filter_settings(self, settings, source):
751        """
752        Updates a settings dictionary according to filter settings data found
753        in either the GET query string, or session storage.
754
755        :param settings: Dictionary of initial settings, which is to be updated.
756
757        :param source: String identifying the source to consult for settings
758           data.  Must be one of: ``('request', 'session')``.
759        """
760        if not self.filterable:
761            return
762
763        for filtr in self.iter_filters():
764            prefix = 'filter.{}'.format(filtr.key)
765
766            if source == 'request':
767                # consider filter active if query string contains a value for it
768                settings['{}.active'.format(prefix)] = filtr.key in self.request.GET
769                settings['{}.verb'.format(prefix)] = self.get_setting(
770                    source, settings, '{}.verb'.format(filtr.key), default='')
771                settings['{}.value'.format(prefix)] = self.get_setting(
772                    source, settings, filtr.key, default='')
773
774            else: # source = session
775                settings['{}.active'.format(prefix)] = self.get_setting(
776                    source, settings, '{}.active'.format(prefix),
777                    normalize=lambda v: six.text_type(v).lower() == 'true', default=False)
778                settings['{}.verb'.format(prefix)] = self.get_setting(
779                    source, settings, '{}.verb'.format(prefix), default='')
780                settings['{}.value'.format(prefix)] = self.get_setting(
781                    source, settings, '{}.value'.format(prefix), default='')
782
783    def update_sort_settings(self, settings, source):
784        """
785        Updates a settings dictionary according to sort settings data found in
786        either the GET query string, or session storage.
787
788        :param settings: Dictionary of initial settings, which is to be updated.
789
790        :param source: String identifying the source to consult for settings
791           data.  Must be one of: ``('request', 'session')``.
792        """
793        if not self.sortable:
794            return
795        settings['sortkey'] = self.get_setting(source, settings, 'sortkey')
796        settings['sortdir'] = self.get_setting(source, settings, 'sortdir')
797
798    def update_page_settings(self, settings):
799        """
800        Updates a settings dictionary according to pager settings data found in
801        either the GET query string, or session storage.
802
803        Note that due to how the actual pager functions, the effective settings
804        will often come from *both* the request and session.  This is so that
805        e.g. the page size will remain constant (coming from the session) while
806        the user jumps between pages (which only provides the single setting).
807
808        :param settings: Dictionary of initial settings, which is to be updated.
809        """
810        if not self.pageable:
811            return
812
813        pagesize = self.request.GET.get('pagesize')
814        if pagesize is not None:
815            if pagesize.isdigit():
816                settings['pagesize'] = int(pagesize)
817        else:
818            pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key))
819            if pagesize is not None:
820                settings['pagesize'] = pagesize
821
822        page = self.request.GET.get('page')
823        if page is not None:
824            if page.isdigit():
825                settings['page'] = int(page)
826        else:
827            page = self.request.session.get('grid.{}.page'.format(self.key))
828            if page is not None:
829                settings['page'] = int(page)
830
831    def persist_settings(self, settings, to='session'):
832        """
833        Persist the given settings in some way, as defined by ``func``.
834        """
835        def persist(key, value=lambda k: settings[k]):
836            if to == 'defaults':
837                skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
838                api.save_setting(Session(), skey, value(key))
839            else: # to == session
840                skey = 'grid.{}.{}'.format(self.key, key)
841                self.request.session[skey] = value(key)
842
843        if self.filterable:
844            for filtr in self.iter_filters():
845                persist('filter.{}.active'.format(filtr.key), value=lambda k: six.text_type(settings[k]).lower())
846                persist('filter.{}.verb'.format(filtr.key))
847                persist('filter.{}.value'.format(filtr.key))
848
849        if self.sortable:
850            persist('sortkey')
851            persist('sortdir')
852
853        if self.pageable:
854            persist('pagesize')
855            persist('page')
856
857    def filter_data(self, data):
858        """
859        Filter and return the given data set, according to current settings.
860        """
861        for filtr in self.iter_active_filters():
862
863            # apply filter to data but save reference to original; if data is a
864            # SQLAlchemy query and wasn't modified, we don't need to bother
865            # with the underlying join (if there is one)
866            original = data
867            data = filtr.filter(data)
868            if filtr.key in self.joiners and filtr.key not in self.joined and (
869                    not isinstance(data, orm.Query) or data is not original):
870
871                # this filter requires a join; apply that
872                data = self.joiners[filtr.key](data)
873                self.joined.add(filtr.key)
874
875        return data
876
877    def sort_data(self, data):
878        """
879        Sort the given query according to current settings, and return the result.
880        """
881        # Cannot sort unless we know which column to sort by.
882        if not self.sortkey:
883            return data
884
885        # Cannot sort unless we have a sort function.
886        sortfunc = self.sorters.get(self.sortkey)
887        if not sortfunc:
888            return data
889
890        # We can provide a default sort direction though.
891        sortdir = getattr(self, 'sortdir', 'asc')
892        if self.sortkey in self.joiners and self.sortkey not in self.joined:
893            data = self.joiners[self.sortkey](data)
894            self.joined.add(self.sortkey)
895        return sortfunc(data, sortdir)
896
897    def paginate_data(self, data):
898        """
899        Paginate the given data set according to current settings, and return
900        the result.
901        """
902        # we of course assume our current page is correct, at first
903        pager = self.make_pager(data)
904
905        # if pager has detected that our current page is outside the valid
906        # range, we must re-orient ourself around the "new" (valid) page
907        if pager.page != self.page:
908            self.page = pager.page
909            self.request.session['grid.{}.page'.format(self.key)] = self.page
910            pager = self.make_pager(data)
911
912        return pager
913
914    def make_pager(self, data):
915        return SqlalchemyOrmPage(data,
916                                 items_per_page=self.pagesize,
917                                 page=self.page,
918                                 url_maker=URLMaker(self.request))
919
920    def make_visible_data(self):
921        """
922        Apply various settings to the raw data set, to produce a final data
923        set.  This will page / sort / filter as necessary, according to the
924        grid's defaults and the current request etc.
925        """
926        self.joined = set()
927        data = self.data
928        if self.filterable:
929            data = self.filter_data(data)
930        if self.sortable:
931            data = self.sort_data(data)
932        if self.pageable:
933            self.pager = self.paginate_data(data)
934            data = self.pager
935        return data
936
937    def render_complete(self, template='/grids/complete.mako', **kwargs):
938        """
939        Render the complete grid, including filters.
940        """
941        context = kwargs
942        context['grid'] = self
943        context['request'] = self.request
944        context.setdefault('allow_save_defaults', True)
945        return render(template, context)
946
947    def render_buefy(self, template='/grids/buefy.mako', **kwargs):
948        """
949        Render the Buefy grid, complete with filters.  Note that this also
950        includes the context menu items and grid tools.
951        """
952        if 'grid_columns' not in kwargs:
953            kwargs['grid_columns'] = self.get_buefy_columns()
954
955        if 'grid_data' not in kwargs:
956            kwargs['grid_data'] = self.get_buefy_data()
957
958        if 'static_data' not in kwargs:
959            kwargs['static_data'] = self.has_static_data()
960
961        if self.filterable and 'filters_data' not in kwargs:
962            kwargs['filters_data'] = self.get_filters_data()
963
964        if self.filterable and 'filters_sequence' not in kwargs:
965            kwargs['filters_sequence'] = self.get_filters_sequence()
966
967        return self.render_complete(template=template, **kwargs)
968
969    def render_buefy_table_element(self, template='/grids/b-table.mako', data_prop='gridData', **kwargs):
970        """
971        This is intended for ad-hoc "small" grids with static data.  Renders
972        just a ``<b-table>`` element instead of the typical "full" grid.
973        """
974        context = dict(kwargs)
975        context['grid'] = self
976        context['data_prop'] = data_prop
977        if 'grid_columns' not in context:
978            context['grid_columns'] = self.get_buefy_columns()
979
980        # locate the 'view' action
981        # TODO: this should be easier, and/or moved elsewhere?
982        view = None
983        for action in self.main_actions:
984            if action.key == 'view':
985                view = action
986                break
987        if not view:
988            for action in self.more_actions:
989                if action.key == 'view':
990                    view = action
991                    break
992
993        context['view_click_handler'] = None
994        if view and view.click_handler:
995            context['view_click_handler'] = view.click_handler
996
997        return render(template, context)
998
999    def get_filters_sequence(self):
1000        """
1001        Returns a list of filter keys (strings) in the sequence with which they
1002        should be displayed in the UI.
1003        """
1004        return list(self.filters)
1005
1006    def get_filters_data(self):
1007        """
1008        Returns a dict of current filters data, for use with Buefy grid view.
1009        """
1010        data = {}
1011        for filtr in self.filters.values():
1012
1013            valueless = [v for v in filtr.valueless_verbs
1014                         if v in filtr.verbs]
1015
1016            choices = []
1017            choice_labels = {}
1018            if filtr.choices:
1019                choices = list(filtr.choices)
1020                choice_labels = dict(filtr.choices)
1021            elif self.enums and filtr.key in self.enums:
1022                choices = list(self.enums[filtr.key])
1023                choice_labels = self.enums[filtr.key]
1024
1025            data[filtr.key] = {
1026                'key': filtr.key,
1027                'label': filtr.label,
1028                'active': filtr.active,
1029                'visible': filtr.active,
1030                'verbs': filtr.verbs,
1031                'valueless_verbs': valueless,
1032                'verb_labels': filtr.verb_labels,
1033                'verb': filtr.verb or filtr.default_verb or filtr.verbs[0],
1034                'value': six.text_type(filtr.value) if filtr.value is not None else "",
1035                'data_type': filtr.data_type,
1036                'choices': choices,
1037                'choice_labels': choice_labels,
1038            }
1039
1040        return data
1041
1042    def render_filters(self, template='/grids/filters.mako', **kwargs):
1043        """
1044        Render the filters to a Unicode string, using the specified template.
1045        Additional kwargs are passed along as context to the template.
1046        """
1047        # Provide default data to filters form, so renderer can do some of the
1048        # work for us.
1049        data = {}
1050        for filtr in self.iter_active_filters():
1051            data['{}.active'.format(filtr.key)] = filtr.active
1052            data['{}.verb'.format(filtr.key)] = filtr.verb
1053            data[filtr.key] = filtr.value
1054
1055        form = gridfilters.GridFiltersForm(self.filters,
1056                                           request=self.request,
1057                                           defaults=data)
1058
1059        kwargs['request'] = self.request
1060        kwargs['grid'] = self
1061        kwargs['form'] = form
1062        return render(template, kwargs)
1063
1064    def render_actions(self, row, i):
1065        """
1066        Returns the rendered contents of the 'actions' column for a given row.
1067        """
1068        main_actions = [self.render_action(a, row, i)
1069                        for a in self.main_actions]
1070        main_actions = [a for a in main_actions if a]
1071        more_actions = [self.render_action(a, row, i)
1072                        for a in self.more_actions]
1073        more_actions = [a for a in more_actions if a]
1074        if more_actions:
1075            icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e')
1076            link = tags.link_to("More" + icon, '#', class_='more')
1077            main_actions.append(HTML.literal('&nbsp; ') + link + HTML.tag('div', class_='more', c=more_actions))
1078        return HTML.literal('').join(main_actions)
1079
1080    def render_action(self, action, row, i):
1081        """
1082        Renders an action menu item (link) for the given row.
1083        """
1084        url = action.get_url(row, i)
1085        if url:
1086            kwargs = {'class_': action.key, 'target': action.target}
1087            if action.icon:
1088                icon = HTML.tag('span', class_='ui-icon ui-icon-{}'.format(action.icon))
1089                return tags.link_to(icon + action.label, url, **kwargs)
1090            return tags.link_to(action.label, url, **kwargs)
1091
1092    def get_row_key(self, item):
1093        """
1094        Must return a unique key for the given data item's row.
1095        """
1096        mapper = orm.object_mapper(item)
1097        if len(mapper.primary_key) == 1:
1098            return getattr(item, mapper.primary_key[0].key)
1099        raise NotImplementedError
1100
1101    def checkbox(self, item):
1102        """
1103        Returns boolean indicating whether a checkbox should be rendererd for
1104        the given data item's row.
1105        """
1106        return True
1107
1108    def render_checkbox(self, item):
1109        """
1110        Renders a checkbox cell for the given item, if applicable.
1111        """
1112        if not self.checkbox(item):
1113            return ''
1114        return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)),
1115                             checked=self.checked(item))
1116
1117    def get_pagesize_options(self):
1118
1119        # use values from config, if defined
1120        options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options')
1121        if options:
1122            options = [int(size) for size in options
1123                       if size.isdigit()]
1124            if options:
1125                return options
1126
1127        return [5, 10, 20, 50, 100, 200]
1128
1129    def has_static_data(self):
1130        """
1131        Should return ``True`` if the grid data can be considered "static"
1132        (i.e. a list of values).  Will return ``False`` otherwise, e.g. if the
1133        data is represented as a SQLAlchemy query.
1134        """
1135        # TODO: should make this smarter?
1136        if isinstance(self.data, list):
1137            return True
1138        return False
1139
1140    def get_buefy_columns(self):
1141        """
1142        Return a list of dicts representing all grid columns.  Meant for use
1143        with Buefy table.
1144        """
1145        columns = []
1146        for name in self.columns:
1147            columns.append({
1148                'field': name,
1149                'label': self.get_label(name),
1150                'sortable': self.sortable and name in self.sorters,
1151            })
1152        return columns
1153
1154    def get_buefy_data(self):
1155        """
1156        Returns a list of data rows for the grid, for use with Buefy table.
1157        """
1158        # filter / sort / paginate to get "visible" data
1159        raw_data = self.make_visible_data()
1160        data = []
1161        status_map = {}
1162        checked = []
1163
1164        # iterate over data rows
1165        for i in range(len(raw_data)):
1166            rowobj = raw_data[i]
1167            row = {}
1168
1169            # sometimes we need to include some "raw" data columns in our
1170            # result set, even though the column is not displayed as part of
1171            # the grid.  this can be used for front-end editing of row data for
1172            # instance, when the "display" version is different than raw data.
1173            # here is the hack we use for that.
1174            columns = list(self.columns)
1175            if hasattr(self, 'buefy_data_columns'):
1176                columns.extend(self.buefy_data_columns)
1177
1178            # iterate over data fields
1179            for name in columns:
1180
1181                # leverage configured rendering logic where applicable;
1182                # otherwise use "raw" data value as string
1183                if self.renderers and name in self.renderers:
1184                    value = self.renderers[name](rowobj, name)
1185                else:
1186                    value = self.obtain_value(rowobj, name)
1187                if value is None:
1188                    value = ""
1189                row[name] = six.text_type(value)
1190
1191            # maybe add UUID for convenience
1192            if 'uuid' not in self.columns:
1193                if hasattr(rowobj, 'uuid'):
1194                    row['uuid'] = rowobj.uuid
1195
1196            # set action URL(s) for row, as needed
1197            self.set_action_urls(row, rowobj, i)
1198
1199            # set extra row class if applicable
1200            if self.extra_row_class:
1201                status = self.extra_row_class(rowobj, i)
1202                if status:
1203                    status_map[i] = status
1204
1205            # set checked flag if applicable
1206            if self.checkboxes:
1207                if self.checked(rowobj):
1208                    checked.append(i)
1209
1210            data.append(row)
1211
1212        results = {
1213            'data': data,
1214            'row_status_map': status_map,
1215        }
1216
1217        if self.checkboxes:
1218            results['checked_rows'] = checked
1219            # TODO: this seems a bit hacky, but is required for now to
1220            # initialize things on the client side...
1221            results['checked_rows_code'] = '[{}]'.format(
1222                ', '.join(['TailboneGridCurrentData[{}]'.format(i) for i in checked]))
1223
1224        if self.pageable and self.pager is not None:
1225            results['total_items'] = self.pager.item_count
1226            results['per_page'] = self.pager.items_per_page
1227            results['page'] = self.pager.page
1228            results['pages'] = self.pager.page_count
1229            results['first_item'] = self.pager.first_item
1230            results['last_item'] = self.pager.last_item
1231
1232        return results
1233
1234    def set_action_urls(self, row, rowobj, i):
1235        """
1236        Pre-generate all action URLs for the given data row.  Meant for use
1237        with Buefy table, since we can't generate URLs from JS.
1238        """
1239        for action in (self.main_actions + self.more_actions):
1240            url = action.get_url(rowobj, i)
1241            row['_action_url_{}'.format(action.key)] = url
1242
1243    def is_linked(self, name):
1244        """
1245        Should return ``True`` if the given column name is configured to be
1246        "linked" (i.e. table cell should contain a link to "view object"),
1247        otherwise ``False``.
1248        """
1249        if self.linked_columns:
1250            if name in self.linked_columns:
1251                return True
1252        return False
1253
1254
1255class CustomWebhelpersGrid(webhelpers2_grid.Grid):
1256    """
1257    Implement column sorting links etc. for webhelpers2_grid
1258    """
1259
1260    def __init__(self, itemlist, columns, **kwargs):
1261        self.mobile = kwargs.pop('mobile', False)
1262        self.renderers = kwargs.pop('renderers', {})
1263        self.linked_columns = kwargs.pop('linked_columns', [])
1264        self.extra_record_class = kwargs.pop('extra_record_class', None)
1265        super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs)
1266
1267    def default_header_record_format(self, headers):
1268        if self.mobile:
1269            return HTML('')
1270        return super(CustomWebhelpersGrid, self).default_header_record_format(headers)
1271
1272    def generate_header_link(self, column_number, column, label_text):
1273
1274        # display column header as simple no-op link; client-side JS takes care
1275        # of the rest for us
1276        label_text = tags.link_to(label_text, '#', data_sortkey=column)
1277
1278        # Is the current column the one we're ordering on?
1279        if (column == self.order_column):
1280            return self.default_header_ordered_column_format(column_number,
1281                                                             column,
1282                                                             label_text)
1283        else:
1284            return self.default_header_column_format(column_number, column,
1285                                                     label_text)           
1286
1287    def default_record_format(self, i, record, columns):
1288        if self.mobile:
1289            return columns
1290        kwargs = {
1291            'class_': self.get_record_class(i, record, columns),
1292        }
1293        if hasattr(record, 'uuid'):
1294            kwargs['data_uuid'] = record.uuid
1295        return HTML.tag('tr', columns, **kwargs)
1296
1297    def get_record_class(self, i, record, columns):
1298        if i % 2 == 0:
1299            cls = 'even r{}'.format(i)
1300        else:
1301            cls = 'odd r{}'.format(i)
1302        if self.extra_record_class:
1303            extra = self.extra_record_class(record, i)
1304            if extra:
1305                cls = '{} {}'.format(cls, extra)
1306        return cls
1307
1308    def get_column_value(self, column_number, i, record, column_name):
1309        if self.renderers and column_name in self.renderers:
1310            return self.renderers[column_name](record, column_name)
1311        try:
1312            return record[column_name]
1313        except TypeError:
1314            return getattr(record, column_name)
1315
1316    def default_column_format(self, column_number, i, record, column_name):
1317        value = self.get_column_value(column_number, i, record, column_name)
1318        if self.mobile:
1319            url = self.url_generator(record, i)
1320            attrs = {}
1321            if hasattr(record, 'uuid'):
1322                attrs['data_uuid'] = record.uuid
1323            return HTML.tag('li', tags.link_to(value, url), **attrs)
1324        if self.linked_columns and column_name in self.linked_columns and (
1325                value is not None and value != ''):
1326            url = self.url_generator(record, i)
1327            value = tags.link_to(value, url)
1328        class_name = 'c{} {}'.format(column_number, column_name)
1329        return HTML.tag('td', value, class_=class_name)
1330
1331
1332class GridAction(object):
1333    """
1334    Represents an action available to a grid.  This is used to construct the
1335    'actions' column when rendering the grid.
1336    """
1337
1338    def __init__(self, key, label=None, url='#', icon=None, target=None,
1339                 click_handler=None):
1340        self.key = key
1341        self.label = label or prettify(key)
1342        self.icon = icon
1343        self.url = url
1344        self.target = target
1345        self.click_handler = click_handler
1346
1347    def get_url(self, row, i):
1348        """
1349        Returns an action URL for the given row.
1350        """
1351        if callable(self.url):
1352            return self.url(row, i)
1353        return self.url
1354
1355
1356class URLMaker(object):
1357    """
1358    URL constructor for use with SQLAlchemy grid pagers.  Logic for this was
1359    basically copied from the old `webhelpers.paginate` module
1360    """
1361
1362    def __init__(self, request):
1363        self.request = request
1364
1365    def __call__(self, page):
1366        params = self.request.GET.copy()
1367        params["page"] = page
1368        params["partial"] = "1"
1369        qs = urllib.parse.urlencode(params, True)
1370        return '{}?{}'.format(self.request.path, qs)
Note: See TracBrowser for help on using the repository browser.