source: tailbone/tailbone/grids/core.py

Last change on this file was 8947a4d, checked in by Lance Edgar <lance@…>, 4 weeks ago

Add Grid.set_filters_sequence() convenience method

sometimes a properly-ordered filter sequence can really help

  • Property mode set to 100644
File size: 51.5 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 set_filters_sequence(self, filters):
1000        """
1001        Explicitly set the sequence for grid filters, using the sequence
1002        provided.  If the grid currently has more filters than are mentioned in
1003        the given sequence, the sequence will come first and all others will be
1004        tacked on at the end.
1005
1006        :param filters: Sequence of filter keys, i.e. field names.
1007        """
1008        new_filters = gridfilters.GridFilterSet()
1009        for field in filters:
1010            new_filters[field] = self.filters.pop(field)
1011        for field in self.filters:
1012            new_filters[field] = self.filters[field]
1013        self.filters = new_filters
1014
1015    def get_filters_sequence(self):
1016        """
1017        Returns a list of filter keys (strings) in the sequence with which they
1018        should be displayed in the UI.
1019        """
1020        return list(self.filters)
1021
1022    def get_filters_data(self):
1023        """
1024        Returns a dict of current filters data, for use with Buefy grid view.
1025        """
1026        data = {}
1027        for filtr in self.filters.values():
1028
1029            valueless = [v for v in filtr.valueless_verbs
1030                         if v in filtr.verbs]
1031
1032            choices = []
1033            choice_labels = {}
1034            if filtr.choices:
1035                choices = list(filtr.choices)
1036                choice_labels = dict(filtr.choices)
1037            elif self.enums and filtr.key in self.enums:
1038                choices = list(self.enums[filtr.key])
1039                choice_labels = self.enums[filtr.key]
1040
1041            data[filtr.key] = {
1042                'key': filtr.key,
1043                'label': filtr.label,
1044                'active': filtr.active,
1045                'visible': filtr.active,
1046                'verbs': filtr.verbs,
1047                'valueless_verbs': valueless,
1048                'verb_labels': filtr.verb_labels,
1049                'verb': filtr.verb or filtr.default_verb or filtr.verbs[0],
1050                'value': six.text_type(filtr.value) if filtr.value is not None else "",
1051                'data_type': filtr.data_type,
1052                'choices': choices,
1053                'choice_labels': choice_labels,
1054            }
1055
1056        return data
1057
1058    def render_filters(self, template='/grids/filters.mako', **kwargs):
1059        """
1060        Render the filters to a Unicode string, using the specified template.
1061        Additional kwargs are passed along as context to the template.
1062        """
1063        # Provide default data to filters form, so renderer can do some of the
1064        # work for us.
1065        data = {}
1066        for filtr in self.iter_active_filters():
1067            data['{}.active'.format(filtr.key)] = filtr.active
1068            data['{}.verb'.format(filtr.key)] = filtr.verb
1069            data[filtr.key] = filtr.value
1070
1071        form = gridfilters.GridFiltersForm(self.filters,
1072                                           request=self.request,
1073                                           defaults=data)
1074
1075        kwargs['request'] = self.request
1076        kwargs['grid'] = self
1077        kwargs['form'] = form
1078        return render(template, kwargs)
1079
1080    def render_actions(self, row, i):
1081        """
1082        Returns the rendered contents of the 'actions' column for a given row.
1083        """
1084        main_actions = [self.render_action(a, row, i)
1085                        for a in self.main_actions]
1086        main_actions = [a for a in main_actions if a]
1087        more_actions = [self.render_action(a, row, i)
1088                        for a in self.more_actions]
1089        more_actions = [a for a in more_actions if a]
1090        if more_actions:
1091            icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e')
1092            link = tags.link_to("More" + icon, '#', class_='more')
1093            main_actions.append(HTML.literal('&nbsp; ') + link + HTML.tag('div', class_='more', c=more_actions))
1094        return HTML.literal('').join(main_actions)
1095
1096    def render_action(self, action, row, i):
1097        """
1098        Renders an action menu item (link) for the given row.
1099        """
1100        url = action.get_url(row, i)
1101        if url:
1102            kwargs = {'class_': action.key, 'target': action.target}
1103            if action.icon:
1104                icon = HTML.tag('span', class_='ui-icon ui-icon-{}'.format(action.icon))
1105                return tags.link_to(icon + action.label, url, **kwargs)
1106            return tags.link_to(action.label, url, **kwargs)
1107
1108    def get_row_key(self, item):
1109        """
1110        Must return a unique key for the given data item's row.
1111        """
1112        mapper = orm.object_mapper(item)
1113        if len(mapper.primary_key) == 1:
1114            return getattr(item, mapper.primary_key[0].key)
1115        raise NotImplementedError
1116
1117    def checkbox(self, item):
1118        """
1119        Returns boolean indicating whether a checkbox should be rendererd for
1120        the given data item's row.
1121        """
1122        return True
1123
1124    def render_checkbox(self, item):
1125        """
1126        Renders a checkbox cell for the given item, if applicable.
1127        """
1128        if not self.checkbox(item):
1129            return ''
1130        return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)),
1131                             checked=self.checked(item))
1132
1133    def get_pagesize_options(self):
1134
1135        # use values from config, if defined
1136        options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options')
1137        if options:
1138            options = [int(size) for size in options
1139                       if size.isdigit()]
1140            if options:
1141                return options
1142
1143        return [5, 10, 20, 50, 100, 200]
1144
1145    def has_static_data(self):
1146        """
1147        Should return ``True`` if the grid data can be considered "static"
1148        (i.e. a list of values).  Will return ``False`` otherwise, e.g. if the
1149        data is represented as a SQLAlchemy query.
1150        """
1151        # TODO: should make this smarter?
1152        if isinstance(self.data, list):
1153            return True
1154        return False
1155
1156    def get_buefy_columns(self):
1157        """
1158        Return a list of dicts representing all grid columns.  Meant for use
1159        with Buefy table.
1160        """
1161        columns = []
1162        for name in self.columns:
1163            columns.append({
1164                'field': name,
1165                'label': self.get_label(name),
1166                'sortable': self.sortable and name in self.sorters,
1167            })
1168        return columns
1169
1170    def get_buefy_data(self):
1171        """
1172        Returns a list of data rows for the grid, for use with Buefy table.
1173        """
1174        # filter / sort / paginate to get "visible" data
1175        raw_data = self.make_visible_data()
1176        data = []
1177        status_map = {}
1178        checked = []
1179
1180        # iterate over data rows
1181        for i in range(len(raw_data)):
1182            rowobj = raw_data[i]
1183            row = {}
1184
1185            # sometimes we need to include some "raw" data columns in our
1186            # result set, even though the column is not displayed as part of
1187            # the grid.  this can be used for front-end editing of row data for
1188            # instance, when the "display" version is different than raw data.
1189            # here is the hack we use for that.
1190            columns = list(self.columns)
1191            if hasattr(self, 'buefy_data_columns'):
1192                columns.extend(self.buefy_data_columns)
1193
1194            # iterate over data fields
1195            for name in columns:
1196
1197                # leverage configured rendering logic where applicable;
1198                # otherwise use "raw" data value as string
1199                if self.renderers and name in self.renderers:
1200                    value = self.renderers[name](rowobj, name)
1201                else:
1202                    value = self.obtain_value(rowobj, name)
1203                if value is None:
1204                    value = ""
1205                row[name] = six.text_type(value)
1206
1207            # maybe add UUID for convenience
1208            if 'uuid' not in self.columns:
1209                if hasattr(rowobj, 'uuid'):
1210                    row['uuid'] = rowobj.uuid
1211
1212            # set action URL(s) for row, as needed
1213            self.set_action_urls(row, rowobj, i)
1214
1215            # set extra row class if applicable
1216            if self.extra_row_class:
1217                status = self.extra_row_class(rowobj, i)
1218                if status:
1219                    status_map[i] = status
1220
1221            # set checked flag if applicable
1222            if self.checkboxes:
1223                if self.checked(rowobj):
1224                    checked.append(i)
1225
1226            data.append(row)
1227
1228        results = {
1229            'data': data,
1230            'row_status_map': status_map,
1231        }
1232
1233        if self.checkboxes:
1234            results['checked_rows'] = checked
1235            # TODO: this seems a bit hacky, but is required for now to
1236            # initialize things on the client side...
1237            results['checked_rows_code'] = '[{}]'.format(
1238                ', '.join(['TailboneGridCurrentData[{}]'.format(i) for i in checked]))
1239
1240        if self.pageable and self.pager is not None:
1241            results['total_items'] = self.pager.item_count
1242            results['per_page'] = self.pager.items_per_page
1243            results['page'] = self.pager.page
1244            results['pages'] = self.pager.page_count
1245            results['first_item'] = self.pager.first_item
1246            results['last_item'] = self.pager.last_item
1247
1248        return results
1249
1250    def set_action_urls(self, row, rowobj, i):
1251        """
1252        Pre-generate all action URLs for the given data row.  Meant for use
1253        with Buefy table, since we can't generate URLs from JS.
1254        """
1255        for action in (self.main_actions + self.more_actions):
1256            url = action.get_url(rowobj, i)
1257            row['_action_url_{}'.format(action.key)] = url
1258
1259    def is_linked(self, name):
1260        """
1261        Should return ``True`` if the given column name is configured to be
1262        "linked" (i.e. table cell should contain a link to "view object"),
1263        otherwise ``False``.
1264        """
1265        if self.linked_columns:
1266            if name in self.linked_columns:
1267                return True
1268        return False
1269
1270
1271class CustomWebhelpersGrid(webhelpers2_grid.Grid):
1272    """
1273    Implement column sorting links etc. for webhelpers2_grid
1274    """
1275
1276    def __init__(self, itemlist, columns, **kwargs):
1277        self.mobile = kwargs.pop('mobile', False)
1278        self.renderers = kwargs.pop('renderers', {})
1279        self.linked_columns = kwargs.pop('linked_columns', [])
1280        self.extra_record_class = kwargs.pop('extra_record_class', None)
1281        super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs)
1282
1283    def default_header_record_format(self, headers):
1284        if self.mobile:
1285            return HTML('')
1286        return super(CustomWebhelpersGrid, self).default_header_record_format(headers)
1287
1288    def generate_header_link(self, column_number, column, label_text):
1289
1290        # display column header as simple no-op link; client-side JS takes care
1291        # of the rest for us
1292        label_text = tags.link_to(label_text, '#', data_sortkey=column)
1293
1294        # Is the current column the one we're ordering on?
1295        if (column == self.order_column):
1296            return self.default_header_ordered_column_format(column_number,
1297                                                             column,
1298                                                             label_text)
1299        else:
1300            return self.default_header_column_format(column_number, column,
1301                                                     label_text)           
1302
1303    def default_record_format(self, i, record, columns):
1304        if self.mobile:
1305            return columns
1306        kwargs = {
1307            'class_': self.get_record_class(i, record, columns),
1308        }
1309        if hasattr(record, 'uuid'):
1310            kwargs['data_uuid'] = record.uuid
1311        return HTML.tag('tr', columns, **kwargs)
1312
1313    def get_record_class(self, i, record, columns):
1314        if i % 2 == 0:
1315            cls = 'even r{}'.format(i)
1316        else:
1317            cls = 'odd r{}'.format(i)
1318        if self.extra_record_class:
1319            extra = self.extra_record_class(record, i)
1320            if extra:
1321                cls = '{} {}'.format(cls, extra)
1322        return cls
1323
1324    def get_column_value(self, column_number, i, record, column_name):
1325        if self.renderers and column_name in self.renderers:
1326            return self.renderers[column_name](record, column_name)
1327        try:
1328            return record[column_name]
1329        except TypeError:
1330            return getattr(record, column_name)
1331
1332    def default_column_format(self, column_number, i, record, column_name):
1333        value = self.get_column_value(column_number, i, record, column_name)
1334        if self.mobile:
1335            url = self.url_generator(record, i)
1336            attrs = {}
1337            if hasattr(record, 'uuid'):
1338                attrs['data_uuid'] = record.uuid
1339            return HTML.tag('li', tags.link_to(value, url), **attrs)
1340        if self.linked_columns and column_name in self.linked_columns and (
1341                value is not None and value != ''):
1342            url = self.url_generator(record, i)
1343            value = tags.link_to(value, url)
1344        class_name = 'c{} {}'.format(column_number, column_name)
1345        return HTML.tag('td', value, class_=class_name)
1346
1347
1348class GridAction(object):
1349    """
1350    Represents an action available to a grid.  This is used to construct the
1351    'actions' column when rendering the grid.
1352    """
1353
1354    def __init__(self, key, label=None, url='#', icon=None, target=None,
1355                 click_handler=None):
1356        self.key = key
1357        self.label = label or prettify(key)
1358        self.icon = icon
1359        self.url = url
1360        self.target = target
1361        self.click_handler = click_handler
1362
1363    def get_url(self, row, i):
1364        """
1365        Returns an action URL for the given row.
1366        """
1367        if callable(self.url):
1368            return self.url(row, i)
1369        return self.url
1370
1371
1372class URLMaker(object):
1373    """
1374    URL constructor for use with SQLAlchemy grid pagers.  Logic for this was
1375    basically copied from the old `webhelpers.paginate` module
1376    """
1377
1378    def __init__(self, request):
1379        self.request = request
1380
1381    def __call__(self, page):
1382        params = self.request.GET.copy()
1383        params["page"] = page
1384        params["partial"] = "1"
1385        qs = urllib.parse.urlencode(params, True)
1386        return '{}?{}'.format(self.request.path, qs)
Note: See TracBrowser for help on using the repository browser.