diff --git a/tailbone/grids/__init__.py b/tailbone/grids/__init__.py new file mode 100644 index 00000000..030c0da7 --- /dev/null +++ b/tailbone/grids/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2016 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ +""" +Grids +""" + +from __future__ import unicode_literals, absolute_import + +from .core import * +from .alchemy import AlchemyGrid +from . import util +from . import search diff --git a/tailbone/grids/alchemy.py b/tailbone/grids/alchemy.py new file mode 100644 index 00000000..c7e13bbb --- /dev/null +++ b/tailbone/grids/alchemy.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2016 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ +""" +FormAlchemy Grid Classes +""" + +from __future__ import unicode_literals, absolute_import + +from sqlalchemy.orm import object_session + +try: + from sqlalchemy.inspection import inspect +except ImportError: + inspect = None + from sqlalchemy.orm import class_mapper + +from rattail.util import prettify + +import formalchemy as fa +from webhelpers.html import tags +from webhelpers.html import HTML + +from tailbone.db import Session +from tailbone.grids.core import Grid + + +class AlchemyGrid(Grid): + + sort_map = {} + + pager = None + pager_format = '$link_first $link_previous ~1~ $link_next $link_last' + + def __init__(self, request, cls, instances, **kwargs): + super(AlchemyGrid, self).__init__(request, **kwargs) + self._formalchemy_grid = fa.Grid(cls, instances, session=Session(), + request=request) + self._formalchemy_grid.prettify = prettify + + def __delattr__(self, attr): + delattr(self._formalchemy_grid, attr) + + def __getattr__(self, attr): + return getattr(self._formalchemy_grid, attr) + + def cell_class(self, field): + classes = [field.name] + return ' '.join(classes) + + def checkbox(self, row): + return tags.checkbox('check-'+row.uuid) + + def column_header(self, field): + class_ = None + label = field.label() + if field.key in self.sort_map: + class_ = 'sortable' + if field.key == self.config['sort']: + class_ += ' sorted ' + self.config['dir'] + label = tags.link_to(label, '#') + return HTML.tag('th', class_=class_, field=field.key, + title=self.column_titles.get(field.key), c=label) + + def crud_route_kwargs(self, row): + if inspect: + mapper = inspect(row.__class__) + else: + mapper = class_mapper(row.__class__) + keys = [k.key for k in mapper.primary_key] + values = [getattr(row, k) for k in keys] + return dict(zip(keys, values)) + + view_route_kwargs = crud_route_kwargs + edit_route_kwargs = crud_route_kwargs + delete_route_kwargs = crud_route_kwargs + + def iter_fields(self): + return self._formalchemy_grid.render_fields.itervalues() + + def iter_rows(self): + for row in self._formalchemy_grid.rows: + self._formalchemy_grid._set_active(row, object_session(row)) + yield row + + def page_count_options(self): + return [5, 10, 20, 50, 100] + + def page_links(self): + return self.pager.pager(self.pager_format, + symbol_next='next', + symbol_previous='prev', + onclick="grid_navigate_page(this, '$partial_url'); return false;") + + def render_field(self, field): + if self._formalchemy_grid.readonly: + return field.render_readonly() + return field.render() + + def row_attrs(self, row, i): + attrs = super(AlchemyGrid, self).row_attrs(row, i) + if hasattr(row, 'uuid'): + attrs['uuid'] = row.uuid + return attrs diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py new file mode 100644 index 00000000..911441ae --- /dev/null +++ b/tailbone/grids/core.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +Core Grid Classes +""" + +try: + from collections import OrderedDict +except ImportError: + from ordereddict import OrderedDict + +from webhelpers.html import HTML +from webhelpers.html.builder import format_attrs + +from pyramid.renderers import render + +from rattail.core import Object + + +__all__ = ['Grid'] + + +class Grid(Object): + + full = False + hoverable = True + checkboxes = False + partial_only = False + + viewable = False + view_route_name = None + view_route_kwargs = None + + editable = False + edit_route_name = None + edit_route_kwargs = None + + deletable = False + delete_route_name = None + delete_route_kwargs = None + + # Set this to a callable to allow ad-hoc row class additions. + extra_row_class = None + + def __init__(self, request, **kwargs): + kwargs.setdefault('fields', OrderedDict()) + kwargs.setdefault('column_titles', {}) + kwargs.setdefault('extra_columns', []) + super(Grid, self).__init__(**kwargs) + self.request = request + + def add_column(self, name, label, callback): + self.extra_columns.append( + Object(name=name, label=label, callback=callback)) + + def column_header(self, field): + return HTML.tag('th', field=field.name, + title=self.column_titles.get(field.name), + c=field.label) + + def div_attrs(self): + classes = ['grid'] + if self.full: + classes.append('full') + if self.hoverable: + classes.append('hoverable') + return format_attrs( + class_=' '.join(classes), + url=self.request.current_route_url(_query=None)) + + def get_view_url(self, row): + kwargs = {} + if self.view_route_kwargs: + if callable(self.view_route_kwargs): + kwargs = self.view_route_kwargs(row) + else: + kwargs = self.view_route_kwargs + return self.request.route_url(self.view_route_name, **kwargs) + + def get_edit_url(self, row): + kwargs = {} + if self.edit_route_kwargs: + if callable(self.edit_route_kwargs): + kwargs = self.edit_route_kwargs(row) + else: + kwargs = self.edit_route_kwargs + return self.request.route_url(self.edit_route_name, **kwargs) + + def get_delete_url(self, row): + kwargs = {} + if self.delete_route_kwargs: + if callable(self.delete_route_kwargs): + kwargs = self.delete_route_kwargs(row) + else: + kwargs = self.delete_route_kwargs + return self.request.route_url(self.delete_route_name, **kwargs) + + def get_row_attrs(self, row, i): + attrs = self.row_attrs(row, i) + return format_attrs(**attrs) + + def row_attrs(self, row, i): + return {'class_': self.get_row_class(row, i)} + + def get_row_class(self, row, i): + class_ = self.default_row_class(row, i) + if callable(self.extra_row_class): + extra = self.extra_row_class(row, i) + if extra: + class_ = '{0} {1}'.format(class_, extra) + return class_ + + def default_row_class(self, row, i): + return 'odd' if i % 2 else 'even' + + def iter_fields(self): + return self.fields.itervalues() + + def iter_rows(self): + """ + Iterate over the grid rows. The default implementation simply returns + an iterator over ``self.rows``; note however that by default there is + no such attribute. You must either populate that, or overrirde this + method. + """ + return iter(self.rows) + + def render(self, template='/grids/grid.mako', **kwargs): + kwargs.setdefault('grid', self) + return render(template, kwargs) + + def render_field(self, field): + raise NotImplementedError diff --git a/tailbone/grids/search.py b/tailbone/grids/search.py new file mode 100644 index 00000000..2bc2861f --- /dev/null +++ b/tailbone/grids/search.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2016 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ +""" +Grid Search Filters +""" + +from __future__ import unicode_literals, absolute_import + +import re + +from sqlalchemy import func, or_ + +from webhelpers.html import tags +from webhelpers.html import literal + +from pyramid.renderers import render +from pyramid_simpleform import Form +from pyramid_simpleform.renderers import FormRenderer + +from rattail.core import Object +from rattail.gpc import GPC +from rattail.util import prettify + + +class SearchFilter(Object): + """ + Base class and default implementation for search filters. + """ + + def __init__(self, name, label=None, **kwargs): + Object.__init__(self, **kwargs) + self.name = name + self.label = label or prettify(name) + + def types_select(self): + types = [ + ('is', "is"), + ('nt', "is not"), + ('lk', "contains"), + ('nl', "doesn't contain"), + (u'sx', u"sounds like"), + (u'nx', u"doesn't sound like"), + ] + options = [] + filter_map = self.search.filter_map[self.name] + for value, label in types: + if value in filter_map: + options.append((value, label)) + return tags.select('filter_type_'+self.name, + self.search.config.get('filter_type_'+self.name), + options, class_='filter-type') + + def value_control(self): + return tags.text(self.name, self.search.config.get(self.name)) + + +class BooleanSearchFilter(SearchFilter): + """ + Boolean search filter. + """ + + def value_control(self): + return tags.select(self.name, self.search.config.get(self.name), + ["True", "False"]) + + +class ChoiceSearchFilter(SearchFilter): + """ + Generic search filter where the user may only select among a specific set + of choices. + """ + + def __init__(self, choices): + self.choices = choices + + def __call__(self, name, label=None, **kwargs): + super(ChoiceSearchFilter, self).__init__(name, label=label, **kwargs) + return self + + def value_control(self): + return tags.select(self.name, self.search.config.get(self.name), self.choices) + + +def EnumSearchFilter(enum): + options = enum.items() + + class EnumSearchFilter(SearchFilter): + def value_control(self): + return tags.select(self.name, self.search.config.get(self.name), options) + + return EnumSearchFilter + + +class SearchForm(Form): + """ + Generic form class which aggregates :class:`SearchFilter` instances. + """ + + def __init__(self, request, filter_map, config, *args, **kwargs): + super(SearchForm, self).__init__(request, *args, **kwargs) + self.filter_map = filter_map + self.config = config + self.filters = {} + + def add_filter(self, filter_): + filter_.search = self + self.filters[filter_.name] = filter_ + + +class SearchFormRenderer(FormRenderer): + """ + Renderer for :class:`SearchForm` instances. + """ + + def __init__(self, form, *args, **kwargs): + super(SearchFormRenderer, self).__init__(form, *args, **kwargs) + self.request = form.request + self.filters = form.filters + self.config = form.config + + def add_filter(self, visible): + options = ['add a filter'] + for f in self.sorted_filters(): + options.append((f.name, f.label)) + return self.select('add-filter', options, + style='display: none;' if len(visible) == len(self.filters) else None) + + def checkbox(self, name, checked=None, **kwargs): + if name.startswith('include_filter_'): + if checked is None: + checked = self.config[name] + return tags.checkbox(name, checked=checked, **kwargs) + if checked is None: + checked = False + return super(SearchFormRenderer, self).checkbox(name, checked=checked, **kwargs) + + def render(self, **kwargs): + kwargs['search'] = self + return literal(render('/grids/search.mako', kwargs)) + + def sorted_filters(self): + return sorted(self.filters.values(), key=lambda x: x.label) + + def text(self, name, **kwargs): + return tags.text(name, value=self.config.get(name), **kwargs) + + +def filter_exact(field): + """ + Convenience function which returns a filter map entry, with typical logic + built in for "exact match" queries applied to ``field``. + """ + + return { + 'is': + lambda q, v: q.filter(field == v) if v else q, + 'nt': + lambda q, v: q.filter(field != v) if v else q, + } + + +def filter_ilike(field): + """ + Convenience function which returns a filter map entry, with typical logic + built in for "ILIKE" queries applied to ``field``. + """ + + def ilike(query, value): + if value: + query = query.filter(field.ilike('%%%s%%' % value)) + return query + + def not_ilike(query, value): + if value: + query = query.filter(or_( + field == None, + ~field.ilike('%%%s%%' % value), + )) + return query + + return {'lk': ilike, 'nl': not_ilike} + + +def filter_int(field): + """ + Returns a filter map entry for an integer field. This provides exact + matching but also strips out non-numeric characters to avoid type errors. + """ + + def filter_is(q, v): + v = re.sub(r'\D', '', v or '') + return q.filter(field == int(v)) if v else q + + def filter_nt(q, v): + v = re.sub(r'\D', '', v or '') + return q.filter(field != int(v)) if v else q + + return {'is': filter_is, 'nt': filter_nt} + + +def filter_soundex(field): + """ + Returns a filter map entry which leverages the `soundex()` SQL function. + """ + + def soundex(query, value): + if value: + query = query.filter(func.soundex(field) == func.soundex(value)) + return query + + def not_soundex(query, value): + if value: + query = query.filter(func.soundex(field) != func.soundex(value)) + return query + + return {u'sx': soundex, u'nx': not_soundex} + + +def filter_ilike_and_soundex(field): + """ + Returns a filter map which provides both the `ilike` and `soundex` + features. + """ + filters = filter_ilike(field) + filters.update(filter_soundex(field)) + return filters + + +def filter_gpc(field): + """ + Returns a filter suitable for a GPC field. + """ + + def filter_is(q, v): + if not v: + return q + try: + return q.filter(field.in_(( + GPC(v), GPC(v, calc_check_digit='upc')))) + except ValueError: + return q + + def filter_not(q, v): + if not v: + return q + try: + return q.filter(~field.in_(( + GPC(v), GPC(v, calc_check_digit='upc')))) + except ValueError: + return q + + return {'is': filter_is, 'nt': filter_not} + + +def get_filter_config(prefix, request, filter_map, **kwargs): + """ + Returns a configuration dictionary for a search form. + """ + + config = {} + + def update_config(dict_, prefix='', exclude_by_default=False): + """ + Updates the ``config`` dictionary based on the contents of ``dict_``. + """ + + for field in filter_map: + if prefix+'include_filter_'+field in dict_: + include = dict_[prefix+'include_filter_'+field] + include = bool(include) and include != '0' + config['include_filter_'+field] = include + elif exclude_by_default: + config['include_filter_'+field] = False + if prefix+'filter_type_'+field in dict_: + config['filter_type_'+field] = dict_[prefix+'filter_type_'+field] + if prefix+field in dict_: + config[field] = dict_[prefix+field] + + # Update config to exclude all filters by default. + for field in filter_map: + config['include_filter_'+field] = False + + # Update config to honor default settings. + config.update(kwargs) + + # Update config with data cached in session. + update_config(request.session, prefix=prefix+'.') + + # Update config with data from GET/POST request. + if request.params.get('filters') == 'true': + update_config(request.params, exclude_by_default=True) + + # Cache filter data in session. + for key in config: + if (not key.startswith('filter_factory_') + and not key.startswith('filter_label_')): + request.session[prefix+'.'+key] = config[key] + + return config + + +def get_filter_map(cls, exact=[], ilike=[], int_=[], **kwargs): + """ + Convenience function which returns a "filter map" for ``cls``. + + ``exact``, if provided, should be a list of field names for which "exact" + filtering is to be allowed. + + ``ilike``, if provided, should be a list of field names for which "ILIKE" + filtering is to be allowed. + + ``int_``, if provided, should be a list of field names for which "integer" + filtering is to be allowed. + + Any remaining ``kwargs`` are assumed to be filter map entries themselves, + and are added directly to the map. + """ + + fmap = {} + for name in exact: + fmap[name] = filter_exact(getattr(cls, name)) + for name in ilike: + fmap[name] = filter_ilike(getattr(cls, name)) + for name in int_: + fmap[name] = filter_int(getattr(cls, name)) + fmap.update(kwargs) + return fmap + + +def get_search_form(request, filter_map, config): + """ + Returns a :class:`SearchForm` instance with a :class:`SearchFilter` for + each filter in ``filter_map``, using configuration from ``config``. + """ + + search = SearchForm(request, filter_map, config) + for field in filter_map: + factory = config.get('filter_factory_%s' % field, SearchFilter) + label = config.get('filter_label_%s' % field) + search.add_filter(factory(field, label=label)) + return search + + +def filter_query(query, config, filter_map, join_map): + """ + Filters the given query according to filter and sorting hints found within + the config dictionary, using the filter and join maps as needed. The + filtered query is returned. + """ + joins = config.setdefault('joins', []) + for key in config: + if key.startswith('include_filter_') and config[key]: + field = key[15:] + value = config.get(field) + if value != '': + if field in join_map and field not in joins: + query = join_map[field](query) + joins.append(field) + fmap = filter_map[field] + filt = fmap[config['filter_type_'+field]] + query = filt(query, value) + return query diff --git a/tailbone/grids/util.py b/tailbone/grids/util.py new file mode 100644 index 00000000..6ecfd3f8 --- /dev/null +++ b/tailbone/grids/util.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +Grid Utilities +""" + +from sqlalchemy.orm.attributes import InstrumentedAttribute + +from webhelpers.html import literal + +from pyramid.response import Response + +from .search import SearchFormRenderer + + +def get_sort_config(name, request, **kwargs): + """ + Returns a configuration dictionary for grid sorting. + """ + + # Initial config uses some default values. + config = { + 'dir': 'asc', + 'per_page': 20, + 'page': 1, + } + + # Override with defaults provided by caller. + config.update(kwargs) + + # Override with values from GET/POST request and/or session. + for key in config: + full_key = name+'_'+key + if request.params.get(key): + value = request.params[key] + config[key] = value + request.session[full_key] = value + elif request.session.get(full_key): + value = request.session[full_key] + config[key] = value + + return config + + +def get_sort_map(cls, names=None, **kwargs): + """ + Convenience function which returns a sort map for ``cls``. + + If ``names`` is not specified, the map will include all "standard" fields + present on the mapped class. Otherwise, the map will be limited to only + the fields which are named. + + All remaining ``kwargs`` are assumed to be sort map entries, and will be + added to the map directly. + """ + + smap = {} + if names is None: + names = [] + for attr in cls.__dict__: + obj = getattr(cls, attr) + if isinstance(obj, InstrumentedAttribute): + if obj.key != 'uuid': + names.append(obj.key) + for name in names: + smap[name] = sorter(getattr(cls, name)) + smap.update(kwargs) + return smap + + +def render_grid(grid, search_form=None, **kwargs): + """ + Convenience function to render ``grid`` (which should be a + :class:`tailbone.grids.Grid` instance). + + This "usually" will return a dictionary to be used as context for rendering + the final view template. + + However, if a partial grid is requested (or mandated), then the grid body + will be rendered and a :class:`pyramid.response.Response` object will be + returned instead. + """ + + if grid.partial_only or grid.request.params.get('partial'): + return Response(body=grid.render(), content_type='text/html') + kwargs['grid'] = literal(grid.render()) + if search_form: + kwargs['search'] = SearchFormRenderer(search_form) + return kwargs + + +def sort_query(query, config, sort_map, join_map={}): + """ + Sorts ``query`` according to ``config`` and ``sort_map``. ``join_map`` is + used, if necessary, to join additional tables to the base query. The + sorted query is returned. + """ + + field = config.get('sort') + if not field: + return query + joins = config.setdefault('joins', []) + if field in join_map and field not in joins: + query = join_map[field](query) + joins.append(field) + sort = sort_map[field] + return sort(query, config['dir']) + + +def sorter(field): + """ + Returns a function suitable for a sort map callable, with typical logic + built in for sorting applied to ``field``. + """ + + return lambda q, d: q.order_by(getattr(field, d)()) diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 5719965f..aa07933f 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -32,6 +32,9 @@ from .master import MasterView # TODO: deprecate / remove some of this from .autocomplete import AutocompleteView from .crud import CrudView +from .grids import ( + GridView, AlchemyGridView, SortableAlchemyGridView, + PagedAlchemyGridView, SearchableAlchemyGridView) def includeme(config): diff --git a/tailbone/views/grids/__init__.py b/tailbone/views/grids/__init__.py new file mode 100644 index 00000000..ba3106ed --- /dev/null +++ b/tailbone/views/grids/__init__.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +Grid Views +""" + +from tailbone.views.grids.core import GridView +from tailbone.views.grids.alchemy import ( + AlchemyGridView, SortableAlchemyGridView, + PagedAlchemyGridView, SearchableAlchemyGridView) diff --git a/tailbone/views/grids/alchemy.py b/tailbone/views/grids/alchemy.py new file mode 100644 index 00000000..ee564b8d --- /dev/null +++ b/tailbone/views/grids/alchemy.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2014 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +FormAlchemy Grid Views +""" + +from webhelpers import paginate + +from .core import GridView +from ... import grids +from ...db import Session + + +__all__ = ['AlchemyGridView', 'SortableAlchemyGridView', + 'PagedAlchemyGridView', 'SearchableAlchemyGridView'] + + +class AlchemyGridView(GridView): + + def make_query(self, session=Session): + query = session.query(self.mapped_class) + return self.modify_query(query) + + def modify_query(self, query): + return query + + def query(self): + return self.make_query() + + def make_grid(self, **kwargs): + self.update_grid_kwargs(kwargs) + return grids.AlchemyGrid( + self.request, self.mapped_class, self._data, **kwargs) + + def grid(self): + return self.make_grid() + + def __call__(self): + self._data = self.query() + grid = self.grid() + return self.render_grid(grid) + + +class SortableAlchemyGridView(AlchemyGridView): + + sort = None + full = True + + @property + def config_prefix(self): + raise NotImplementedError + + def join_map(self): + return {} + + def make_sort_map(self, *args, **kwargs): + return grids.util.get_sort_map( + self.mapped_class, names=args or None, **kwargs) + + def sorter(self, field): + return grids.util.sorter(field) + + def sort_map(self): + return self.make_sort_map() + + def make_sort_config(self, **kwargs): + return grids.util.get_sort_config( + self.config_prefix, self.request, **kwargs) + + def sort_config(self): + return self.make_sort_config(sort=self.sort) + + def modify_query(self, query): + return grids.util.sort_query( + query, self._sort_config, self.sort_map(), self.join_map()) + + def make_grid(self, **kwargs): + self.update_grid_kwargs(kwargs) + return grids.AlchemyGrid( + self.request, self.mapped_class, self._data, + sort_map=self.sort_map(), config=self._sort_config, **kwargs) + + def grid(self): + return self.make_grid() + + def __call__(self): + self._sort_config = self.sort_config() + self._data = self.query() + grid = self.grid() + return self.render_grid(grid) + + +class PagedAlchemyGridView(SortableAlchemyGridView): + + def make_pager(self): + config = self._sort_config + query = self.query() + return paginate.Page( + query, item_count=query.count(), + items_per_page=int(config['per_page']), + page=int(config['page']), + url=paginate.PageURL_WebOb(self.request)) + + def __call__(self): + self._sort_config = self.sort_config() + self._data = self.make_pager() + grid = self.grid() + grid.pager = self._data + return self.render_grid(grid) + + +class SearchableAlchemyGridView(PagedAlchemyGridView): + + def filter_exact(self, field): + return grids.search.filter_exact(field) + + def filter_ilike(self, field): + return grids.search.filter_ilike(field) + + def filter_int(self, field): + return grids.search.filter_int(field) + + def filter_soundex(self, field): + return grids.search.filter_soundex(field) + + def filter_ilike_and_soundex(self, field): + return grids.search.filter_ilike_and_soundex(field) + + def filter_gpc(self, field): + return grids.search.filter_gpc(field) + + def make_filter_map(self, **kwargs): + return grids.search.get_filter_map(self.mapped_class, **kwargs) + + def filter_map(self): + return self.make_filter_map() + + def make_filter_config(self, **kwargs): + return grids.search.get_filter_config( + self.config_prefix, self.request, self.filter_map(), **kwargs) + + def filter_config(self): + return self.make_filter_config() + + def make_search_form(self): + return grids.search.get_search_form( + self.request, self.filter_map(), self._filter_config) + + def search_form(self): + return self.make_search_form() + + def modify_query(self, query): + join_map = self.join_map() + if not hasattr(self, '_filter_config'): + self._filter_config = self.filter_config() + query = grids.search.filter_query( + query, self._filter_config, self.filter_map(), join_map) + if hasattr(self, '_sort_config'): + self._sort_config['joins'] = self._filter_config['joins'] + query = grids.util.sort_query( + query, self._sort_config, self.sort_map(), join_map) + return query + + def __call__(self): + self._filter_config = self.filter_config() + search = self.search_form() + self._sort_config = self.sort_config() + self._data = self.make_pager() + grid = self.grid() + grid.pager = self._data + return self.render_grid(grid, search) diff --git a/tailbone/views/grids/core.py b/tailbone/views/grids/core.py new file mode 100644 index 00000000..a2cfd48d --- /dev/null +++ b/tailbone/views/grids/core.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +Core Grid View +""" + +from .. import View +from ... import grids + + +__all__ = ['GridView'] + + +class GridView(View): + + route_name = None + route_url = None + renderer = None + permission = None + + full = False + checkboxes = False + deletable = False + + partial_only = False + + def update_grid_kwargs(self, kwargs): + kwargs.setdefault('full', self.full) + kwargs.setdefault('checkboxes', self.checkboxes) + kwargs.setdefault('deletable', self.deletable) + kwargs.setdefault('partial_only', self.partial_only) + + def make_grid(self, **kwargs): + self.update_grid_kwargs(kwargs) + return grids.Grid(self.request, **kwargs) + + def grid(self): + return self.make_grid() + + def render_kwargs(self): + return {} + + def render_grid(self, grid, search=None, **kwargs): + kwargs = self.render_kwargs() + kwargs['search_form'] = search + return grids.util.render_grid(grid, **kwargs) + + def __call__(self): + grid = self.grid() + return self.render_grid(grid)