source: tailbone/tailbone/grids/core.py

Last change on this file was 1bb0330, checked in by Lance Edgar <ledgar@…>, 2 weeks ago

Refactory Buefy templates to use WholePage? and ThisPage? components

plus add GridFilter.set_choices() method

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