# -*- coding: utf-8; -*-

import datetime
import decimal
from collections import OrderedDict
from enum import Enum
from unittest import TestCase
from unittest.mock import patch, MagicMock

import sqlalchemy as sa
from sqlalchemy import orm
from paginate import Page
from paginate_sqlalchemy import SqlalchemyOrmPage
from pyramid import testing

from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base as mod
from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters
from wuttaweb.util import FieldList
from wuttaweb.forms import Form
from wuttaweb.testing import WebTestCase


class TestGrid(WebTestCase):

    def make_grid(self, request=None, **kwargs):
        return mod.Grid(request or self.request, **kwargs)

    def test_constructor(self):

        # empty
        grid = self.make_grid()
        self.assertIsNone(grid.key)
        self.assertEqual(grid.columns, [])
        self.assertIsNone(grid.data)

        # now with columns
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertIsInstance(grid.columns, FieldList)
        self.assertEqual(grid.columns, ['foo', 'bar'])

    def test_constructor_sorting(self):
        model = self.app.model

        # defaults, not sortable
        grid = self.make_grid()
        self.assertFalse(grid.sortable)
        self.assertTrue(grid.sort_on_backend)
        self.assertEqual(grid.sorters, {})
        self.assertEqual(grid.sort_defaults, [])

        # defaults, sortable
        grid = self.make_grid(sortable=True)
        self.assertTrue(grid.sortable)
        self.assertTrue(grid.sort_on_backend)
        self.assertEqual(grid.sorters, {})
        self.assertEqual(grid.sort_defaults, [])

        # sorters may be pre-populated
        grid = self.make_grid(model_class=model.Setting, sortable=True)
        self.assertEqual(len(grid.sorters), 2)
        self.assertIn('name', grid.sorters)
        self.assertIn('value', grid.sorters)
        self.assertEqual(grid.sort_defaults, [])

        # sort defaults as str
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_defaults='name')
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])

        # sort defaults as tuple
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_defaults=('name', 'desc'))
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])

        # sort defaults as list w/ single tuple
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_defaults=[('name', 'desc')])
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])

        # multi-column defaults
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_multiple=True,
                              sort_defaults=[('name', 'desc'), ('value', 'asc')])
        self.assertTrue(grid.sort_multiple)
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc'),
                                              mod.SortInfo('value', 'asc')])

        # multi-column sort disabled for oruga
        self.request.use_oruga = True
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_multiple=True)
        self.assertFalse(grid.sort_multiple)

    def test_constructor_filtering(self):
        model = self.app.model

        # defaults, not filterable
        grid = self.make_grid()
        self.assertFalse(grid.filterable)
        self.assertEqual(grid.filters, {})

        # defaults, filterable
        grid = self.make_grid(filterable=True)
        self.assertTrue(grid.filterable)
        self.assertEqual(grid.filters, {})

        # filters may be pre-populated
        with patch.object(mod.Grid, 'make_filter', return_value=42):
            grid = self.make_grid(model_class=model.Setting, filterable=True)
            self.assertEqual(len(grid.filters), 2)
            self.assertIn('name', grid.filters)
            self.assertIn('value', grid.filters)

        # can specify filters
        grid = self.make_grid(model_class=model.Setting, filterable=True,
                              filters={'name': 42})
        self.assertTrue(grid.filterable)
        self.assertEqual(grid.filters, {'name': 42})

    def test_vue_tagname(self):
        grid = self.make_grid()
        self.assertEqual(grid.vue_tagname, 'wutta-grid')

    def test_vue_component(self):
        grid = self.make_grid()
        self.assertEqual(grid.vue_component, 'WuttaGrid')

    def test_get_columns(self):
        model = self.app.model

        # empty
        grid = self.make_grid()
        self.assertEqual(grid.columns, [])
        self.assertEqual(grid.get_columns(), [])

        # explicit
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.columns, ['foo', 'bar'])
        self.assertEqual(grid.get_columns(), ['foo', 'bar'])

        # derived from model
        grid = self.make_grid(model_class=model.Setting)
        self.assertEqual(grid.columns, ['name', 'value'])
        self.assertEqual(grid.get_columns(), ['name', 'value'])

    def test_append(self):
        grid = self.make_grid(columns=['one', 'two'])
        self.assertEqual(grid.columns, ['one', 'two'])
        grid.append('one', 'two', 'three')
        self.assertEqual(grid.columns, ['one', 'two', 'three'])

    def test_remove(self):
        grid = self.make_grid(columns=['one', 'two', 'three', 'four'])
        self.assertEqual(grid.columns, ['one', 'two', 'three', 'four'])
        grid.remove('two', 'three')
        self.assertEqual(grid.columns, ['one', 'four'])

    def test_set_label(self):
        model = self.app.model
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.Setting, filterable=True)
        self.assertEqual(grid.labels, {})

        # basic
        grid.set_label('name', "NAME COL")
        self.assertEqual(grid.labels['name'], "NAME COL")

        # can replace label
        grid.set_label('name', "Different")
        self.assertEqual(grid.labels['name'], "Different")
        self.assertEqual(grid.get_label('name'), "Different")

        # can update only column, not filter
        self.assertEqual(grid.labels, {'name': "Different"})
        self.assertIn('name', grid.filters)
        self.assertEqual(grid.filters['name'].label, "Different")
        grid.set_label('name', "COLUMN ONLY", column_only=True)
        self.assertEqual(grid.get_label('name'), "COLUMN ONLY")
        self.assertEqual(grid.filters['name'].label, "Different")

    def test_get_label(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.labels, {})

        # default derived from key
        self.assertEqual(grid.get_label('foo'), "Foo")

        # can override
        grid.set_label('foo', "Different")
        self.assertEqual(grid.get_label('foo'), "Different")

    def test_set_renderer(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.renderers, {})

        def render1(record, key, value):
            pass

        # basic
        grid.set_renderer('foo', render1)
        self.assertIs(grid.renderers['foo'], render1)

        def render2(record, key, value, extra=None):
            return extra

        # can pass kwargs to get a partial
        grid.set_renderer('foo', render2, extra=42)
        self.assertIsNot(grid.renderers['foo'], render2)
        self.assertEqual(grid.renderers['foo'](None, None, None), 42)

        # can use built-in string shortcut
        grid.set_renderer('foo', 'quantity')
        obj = MagicMock(foo=42.00)
        self.assertEqual(grid.renderers['foo'](obj, 'foo', 42.00), '42')

    def test_set_default_renderers(self):
        model = self.app.model

        # no defaults for "plain" schema
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.renderers, {})

        # no defaults for "plain" mapped class
        grid = self.make_grid(model_class=model.Setting)
        self.assertEqual(grid.renderers, {})

        def myrender(obj, key, value):
            return value

        # renderer set for datetime mapped field
        grid = self.make_grid(model_class=model.Upgrade)
        self.assertIn('created', grid.renderers)
        self.assertIsNot(grid.renderers['created'], myrender)

        # renderer *not* set for datetime, if override present
        grid = self.make_grid(model_class=model.Upgrade,
                              renderers={'created': myrender})
        self.assertIn('created', grid.renderers)
        self.assertIs(grid.renderers['created'], myrender)

        # renderer set for boolean mapped field
        grid = self.make_grid(model_class=model.Upgrade)
        self.assertIn('executing', grid.renderers)
        self.assertIsNot(grid.renderers['executing'], myrender)

        # renderer *not* set for boolean, if override present
        grid = self.make_grid(model_class=model.Upgrade,
                              renderers={'executing': myrender})
        self.assertIn('executing', grid.renderers)
        self.assertIs(grid.renderers['executing'], myrender)

        # nb. as of writing we have no Date columns in default schema,
        # so must invent one to test that type
        class SomeFoolery(model.Base):
            __tablename__ = 'somefoolery'
            id = sa.Column(sa.Integer(), primary_key=True)
            created = sa.Column(sa.Date())

        # renderer set for date mapped field
        grid = self.make_grid(model_class=SomeFoolery)
        self.assertIn('created', grid.renderers)
        self.assertIsNot(grid.renderers['created'], myrender)

    def test_set_enum(self):
        model = self.app.model

        class MockEnum(Enum):
            FOO = 'foo'
            BAR = 'bar'

        # no enums by default
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.enums, {})

        # enum is set, but not filter choices
        grid = self.make_grid(columns=['foo', 'bar'],
                              filterable=False,
                              enums={'foo': MockEnum})
        self.assertIs(grid.enums['foo'], MockEnum)
        self.assertEqual(grid.filters, {})

        # both enum and filter choices are set
        grid = self.make_grid(model_class=model.Setting,
                              filterable=True,
                              enums={'name': MockEnum})
        self.assertIs(grid.enums['name'], MockEnum)
        self.assertIn('name', grid.filters)
        self.assertIn('value', grid.filters)
        self.assertEqual(grid.filters['name'].choices, OrderedDict([
            ('FOO', 'foo'),
            ('BAR', 'bar'),
        ]))

    def test_linked_columns(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.linked_columns, [])
        self.assertFalse(grid.is_linked('foo'))

        grid.set_link('foo')
        self.assertEqual(grid.linked_columns, ['foo'])
        self.assertTrue(grid.is_linked('foo'))
        self.assertFalse(grid.is_linked('bar'))

        grid.set_link('bar')
        self.assertEqual(grid.linked_columns, ['foo', 'bar'])
        self.assertTrue(grid.is_linked('foo'))
        self.assertTrue(grid.is_linked('bar'))

        grid.set_link('foo', False)
        self.assertEqual(grid.linked_columns, ['bar'])
        self.assertFalse(grid.is_linked('foo'))
        self.assertTrue(grid.is_linked('bar'))

    def test_hidden_columns(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.hidden_columns, [])
        self.assertFalse(grid.is_hidden('foo'))

        grid.set_hidden('foo')
        self.assertEqual(grid.hidden_columns, ['foo'])
        self.assertTrue(grid.is_hidden('foo'))
        self.assertFalse(grid.is_hidden('bar'))

        grid.set_hidden('bar')
        self.assertEqual(grid.hidden_columns, ['foo', 'bar'])
        self.assertTrue(grid.is_hidden('foo'))
        self.assertTrue(grid.is_hidden('bar'))

        grid.set_hidden('foo', False)
        self.assertEqual(grid.hidden_columns, ['bar'])
        self.assertFalse(grid.is_hidden('foo'))
        self.assertTrue(grid.is_hidden('bar'))

    def test_searchable_columns(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.searchable_columns, set())
        self.assertFalse(grid.is_searchable('foo'))

        grid.set_searchable('foo')
        self.assertEqual(grid.searchable_columns, {'foo'})
        self.assertTrue(grid.is_searchable('foo'))
        self.assertFalse(grid.is_searchable('bar'))

        grid.set_searchable('bar')
        self.assertEqual(grid.searchable_columns, {'foo', 'bar'})
        self.assertTrue(grid.is_searchable('foo'))
        self.assertTrue(grid.is_searchable('bar'))

        grid.set_searchable('foo', False)
        self.assertEqual(grid.searchable_columns, {'bar'})
        self.assertFalse(grid.is_searchable('foo'))
        self.assertTrue(grid.is_searchable('bar'))

    def test_add_action(self):
        grid = self.make_grid()
        self.assertEqual(len(grid.actions), 0)

        grid.add_action('view')
        self.assertEqual(len(grid.actions), 1)
        self.assertIsInstance(grid.actions[0], mod.GridAction)

    def test_set_tools(self):
        grid = self.make_grid()
        self.assertEqual(grid.tools, {})

        # null
        grid.set_tools(None)
        self.assertEqual(grid.tools, {})

        # empty
        grid.set_tools({})
        self.assertEqual(grid.tools, {})

        # full dict is replaced
        grid.tools = {'foo': 'bar'}
        self.assertEqual(grid.tools, {'foo': 'bar'})
        grid.set_tools({'bar': 'baz'})
        self.assertEqual(grid.tools, {'bar': 'baz'})

        # can specify as list of html elements
        grid.set_tools(['foo', 'bar'])
        self.assertEqual(len(grid.tools), 2)
        self.assertEqual(list(grid.tools.values()), ['foo', 'bar'])

    def test_add_tool(self):
        grid = self.make_grid()
        self.assertEqual(grid.tools, {})

        # with key
        grid.add_tool('foo', key='foo')
        self.assertEqual(grid.tools, {'foo': 'foo'})

        # without key
        grid.add_tool('bar')
        self.assertEqual(len(grid.tools), 2)
        self.assertEqual(list(grid.tools.values()), ['foo', 'bar'])

    def test_get_pagesize_options(self):
        grid = self.make_grid()

        # default
        options = grid.get_pagesize_options()
        self.assertEqual(options, [5, 10, 20, 50, 100, 200])

        # override default
        options = grid.get_pagesize_options(default=[42])
        self.assertEqual(options, [42])

        # from config
        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '1 2 3')
        options = grid.get_pagesize_options()
        self.assertEqual(options, [1, 2, 3])

    def test_get_pagesize(self):
        grid = self.make_grid()

        # default
        size = grid.get_pagesize()
        self.assertEqual(size, 20)

        # override default
        size = grid.get_pagesize(default=42)
        self.assertEqual(size, 42)

        # override default options
        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30')
        grid = self.make_grid()
        size = grid.get_pagesize()
        self.assertEqual(size, 10)

        # from config
        self.config.setdefault('wuttaweb.grids.default_pagesize', '15')
        size = grid.get_pagesize()
        self.assertEqual(size, 15)

    ##############################
    # configuration methods
    ##############################

    def test_load_settings(self):
        model = self.app.model

        # nb. first use a paging grid
        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True,
                              pagesize=20, page=1)

        # settings are loaded, applied, saved
        self.assertEqual(grid.page, 1)
        self.assertNotIn('grid.foo.page', self.request.session)
        self.request.GET = {'pagesize': '10', 'page': '2'}
        grid.load_settings()
        self.assertEqual(grid.page, 2)
        self.assertEqual(self.request.session['grid.foo.page'], 2)

        # can skip the saving step
        self.request.GET = {'pagesize': '10', 'page': '3'}
        grid.load_settings(persist=False)
        self.assertEqual(grid.page, 3)
        self.assertEqual(self.request.session['grid.foo.page'], 2)

        # no error for non-paginated grid
        grid = self.make_grid(key='foo', paginated=False)
        grid.load_settings()
        self.assertFalse(grid.paginated)

        # nb. next use a sorting grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True)

        # settings are loaded, applied, saved
        self.assertEqual(grid.sort_defaults, [])
        self.assertFalse(hasattr(grid, 'active_sorters'))
        self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'}
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')

        # can skip the saving step
        self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'}
        grid.load_settings(persist=False)
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')

        # no error for non-sortable grid
        grid = self.make_grid(key='foo', sortable=False)
        grid.load_settings()
        self.assertFalse(grid.sortable)

        # with sort defaults
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_on_backend=True, sort_defaults='name')
        self.assertFalse(hasattr(grid, 'active_sorters'))
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])

        # with multi-column sort defaults
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_on_backend=True)
        grid.sort_defaults = [
            mod.SortInfo('name', 'asc'),
            mod.SortInfo('value', 'desc'),
        ]
        self.assertFalse(hasattr(grid, 'active_sorters'))
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])

        # load settings from session when nothing is in request
        self.request.GET = {}
        self.request.session.invalidate()
        self.assertNotIn('grid.settings.sorters.length', self.request.session)
        self.request.session['grid.settings.sorters.length'] = 1
        self.request.session['grid.settings.sorters.1.key'] = 'name'
        self.request.session['grid.settings.sorters.1.dir'] = 'desc'
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True,
                              paginated=True, paginate_on_backend=True)
        self.assertFalse(hasattr(grid, 'active_sorters'))
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])

        # filter settings are loaded, applied, saved
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              filterable=True)
        self.assertEqual(len(grid.filters), 2)
        self.assertEqual(len(grid.active_filters), 0)
        self.assertNotIn('grid.settings.filter.name.active', self.request.session)
        self.assertNotIn('grid.settings.filter.value.active', self.request.session)
        self.request.GET = {'name': 'john', 'name.verb': 'contains'}
        grid.load_settings()
        self.assertTrue(grid.filters['name'].active)
        self.assertEqual(grid.filters['name'].verb, 'contains')
        self.assertEqual(grid.filters['name'].value, 'john')
        self.assertTrue(self.request.session['grid.settings.filter.name.active'])
        self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains')
        self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john')

        # filter + sort settings are loaded, applied, saved
        self.request.session.invalidate()
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, filterable=True)
        self.assertEqual(len(grid.filters), 2)
        self.assertEqual(len(grid.active_filters), 0)
        self.assertNotIn('grid.settings.filter.name.active', self.request.session)
        self.assertNotIn('grid.settings.filter.value.active', self.request.session)
        self.assertNotIn('grid.settings.sorters.length', self.request.session)
        self.request.GET = {'name': 'john', 'name.verb': 'contains',
                            'sort1key': 'name', 'sort1dir': 'asc'}
        grid.load_settings()
        self.assertTrue(grid.filters['name'].active)
        self.assertEqual(grid.filters['name'].verb, 'contains')
        self.assertEqual(grid.filters['name'].value, 'john')
        self.assertTrue(self.request.session['grid.settings.filter.name.active'])
        self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains')
        self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john')
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'asc')

        # can reset view to defaults
        self.request.GET = {'reset-view': 'true'}
        grid.load_settings()
        self.assertEqual(grid.active_filters, [])
        self.assertIsNone(grid.filters['name'].value)

    def test_request_has_settings(self):
        model = self.app.model
        grid = self.make_grid(key='settings', model_class=model.Setting)

        # paging
        self.assertFalse(grid.request_has_settings('page'))
        with patch.object(grid, 'paginated', new=True):
            with patch.object(self.request, 'GET', new={'pagesize': '20'}):
                self.assertTrue(grid.request_has_settings('page'))
            with patch.object(self.request, 'GET', new={'page': '1'}):
                self.assertTrue(grid.request_has_settings('page'))

        # sorting
        self.assertFalse(grid.request_has_settings('sort'))
        with patch.object(grid, 'sortable', new=True):
            with patch.object(self.request, 'GET', new={'sort1key': 'name'}):
                self.assertTrue(grid.request_has_settings('sort'))

        # filtering
        grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
        self.assertFalse(grid.request_has_settings('filter'))
        with patch.object(grid, 'filterable', new=True):
            with patch.object(self.request, 'GET', new={'name': 'john', 'name.verb': 'contains'}):
                self.assertTrue(grid.request_has_settings('filter'))
        with patch.object(self.request, 'GET', new={'filter': '1'}):
            self.assertTrue(grid.request_has_settings('filter'))

    def test_get_setting(self):
        grid = self.make_grid(key='foo')
        settings = {}

        # default is null
        value = grid.get_setting(settings, 'pagesize')
        self.assertIsNone(value)

        # can read value from user session
        self.request.session['grid.foo.pagesize'] = 15
        value = grid.get_setting(settings, 'pagesize', src='session')
        self.assertEqual(value, 15)

        # string value not normalized
        self.request.session['grid.foo.pagesize'] = '15'
        value = grid.get_setting(settings, 'pagesize', src='session')
        self.assertEqual(value, '15')
        self.assertNotEqual(value, 15)

        # but can be normalized
        self.request.session['grid.foo.pagesize'] = '15'
        value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
        self.assertEqual(value, 15)

        # can read value from request
        self.request.GET = {'pagesize': '25'}
        value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
        self.assertEqual(value, 25)

        # null when normalization fails
        self.request.GET = {'pagesize': 'invalid'}
        value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
        self.assertIsNone(value)

        # reset
        del self.request.session['grid.foo.pagesize']
        self.request.GET = {}

        # value can come from provided settings
        settings['pagesize'] = '35'
        value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
        self.assertEqual(value, 35)

    def test_update_filter_settings(self):
        model = self.app.model

        # nothing happens if not filterable
        grid = self.make_grid(key='settings', model_class=model.Setting)
        settings = {}
        self.request.session['grid.settings.filter.name.active'] = True
        self.request.session['grid.settings.filter.name.verb'] = 'contains'
        self.request.session['grid.settings.filter.name.value'] = 'john'
        grid.update_filter_settings(settings, src='session')
        self.assertEqual(settings, {})

        # nb. now use a filterable grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              filterable=True)

        # settings are updated from session
        settings = {}
        self.request.session['grid.settings.filter.name.active'] = True
        self.request.session['grid.settings.filter.name.verb'] = 'contains'
        self.request.session['grid.settings.filter.name.value'] = 'john'
        grid.update_filter_settings(settings, src='session')
        self.assertTrue(settings['filter.name.active'])
        self.assertEqual(settings['filter.name.verb'], 'contains')
        self.assertEqual(settings['filter.name.value'], 'john')

        # settings are updated from request
        self.request.GET = {'value': 'sally', 'value.verb': 'contains'}
        grid.update_filter_settings(settings, src='request')
        self.assertFalse(settings['filter.name.active'])
        self.assertTrue(settings['filter.value.active'])
        self.assertEqual(settings['filter.value.verb'], 'contains')
        self.assertEqual(settings['filter.value.value'], 'sally')

    def test_update_sort_settings(self):
        model = self.app.model

        # nothing happens if not sortable
        grid = self.make_grid(key='settings', model_class=model.Setting)
        settings = {'sorters.length': 0}
        self.request.session['grid.settings.sorters.length'] = 1
        self.request.session['grid.settings.sorters.1.key'] = 'name'
        self.request.session['grid.settings.sorters.1.dir'] = 'asc'
        grid.update_sort_settings(settings, src='session')
        self.assertEqual(settings['sorters.length'], 0)

        # nb. now use a sortable grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True)

        # settings are updated from session
        settings = {'sorters.length': 1, 'sorters.1.key': 'name', 'sorters.1.dir': 'asc'}
        self.request.session['grid.settings.sorters.length'] = 1
        self.request.session['grid.settings.sorters.1.key'] = 'name'
        self.request.session['grid.settings.sorters.1.dir'] = 'asc'
        grid.update_sort_settings(settings, src='session')
        self.assertEqual(settings['sorters.length'], 1)
        self.assertEqual(settings['sorters.1.key'], 'name')
        self.assertEqual(settings['sorters.1.dir'], 'asc')

        # settings are updated from request
        self.request.GET = {'sort1key': 'value', 'sort1dir': 'desc'}
        grid.update_sort_settings(settings, src='request')
        self.assertEqual(settings['sorters.length'], 1)
        self.assertEqual(settings['sorters.1.key'], 'value')
        self.assertEqual(settings['sorters.1.dir'], 'desc')

    def test_update_page_settings(self):

        # nothing happens if not paginated
        grid = self.make_grid(key='foo')
        settings = {'pagesize': 20, 'page': 1}
        self.request.session['grid.foo.pagesize'] = 10
        self.request.session['grid.foo.page'] = 2
        grid.update_page_settings(settings)
        self.assertEqual(settings['pagesize'], 20)
        self.assertEqual(settings['page'], 1)

        # nb. now use a paginated grid
        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)

        # settings are updated from session
        settings = {'pagesize': 20, 'page': 1}
        self.request.session['grid.foo.pagesize'] = 10
        self.request.session['grid.foo.page'] = 2
        grid.update_page_settings(settings)
        self.assertEqual(settings['pagesize'], 10)
        self.assertEqual(settings['page'], 2)

        # settings are updated from request
        self.request.GET = {'pagesize': '15', 'page': '4'}
        grid.update_page_settings(settings)
        self.assertEqual(settings['pagesize'], 15)
        self.assertEqual(settings['page'], 4)

    def test_persist_settings(self):
        model = self.app.model

        # nb. start out with paginated-only grid
        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)

        # invalid dest
        self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist')

        # nb. no error if empty settings, but it saves null values
        grid.persist_settings({}, dest='session')
        self.assertIsNone(self.request.session['grid.foo.page'])

        # provided values are saved
        grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session')
        self.assertEqual(self.request.session['grid.foo.page'], 3)

        # nb. now switch to sortable-only grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True)

        # no error if empty settings; does not save values
        grid.persist_settings({}, dest='session')
        self.assertNotIn('grid.settings.sorters.length', self.request.session)

        # provided values are saved
        grid.persist_settings({'sorters.length': 2,
                               'sorters.1.key': 'name',
                               'sorters.1.dir': 'desc',
                               'sorters.2.key': 'value',
                               'sorters.2.dir': 'asc'},
                              dest='session')
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 2)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
        self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value')
        self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc')

        # old values removed when new are saved
        grid.persist_settings({'sorters.length': 1,
                               'sorters.1.key': 'name',
                               'sorters.1.dir': 'desc'},
                              dest='session')
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
        self.assertNotIn('grid.settings.sorters.2.key', self.request.session)
        self.assertNotIn('grid.settings.sorters.2.dir', self.request.session)

        # nb. now switch to filterable-only grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              filterable=True)
        self.assertIn('name', grid.filters)
        self.assertEqual(grid.filters['name'].key, 'name')

        # no error if empty settings; does not save values
        grid.persist_settings({}, dest='session')
        self.assertNotIn('grid.settings.filters.name', self.request.session)

        # provided values are saved
        grid.persist_settings({'filter.name.active': True,
                               'filter.name.verb': 'contains',
                               'filter.name.value': 'john'},
                              dest='session')
        self.assertTrue(self.request.session['grid.settings.filter.name.active'])
        self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains')
        self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john')

    ##############################
    # sorting methods
    ##############################

    def test_make_backend_sorters(self):
        model = self.app.model

        # default is empty
        grid = self.make_grid()
        sorters = grid.make_backend_sorters()
        self.assertEqual(sorters, {})

        # makes sorters if model class
        grid = self.make_grid(model_class=model.Setting)
        sorters = grid.make_backend_sorters()
        self.assertEqual(len(sorters), 2)
        self.assertIn('name', sorters)
        self.assertIn('value', sorters)

        # does not replace supplied sorters
        grid = self.make_grid(model_class=model.Setting)
        mysorters = {'value': 42}
        sorters = grid.make_backend_sorters(mysorters)
        self.assertEqual(len(sorters), 2)
        self.assertIn('name', sorters)
        self.assertIn('value', sorters)
        self.assertEqual(sorters['value'], 42)
        self.assertEqual(mysorters['value'], 42)

    def test_make_sorter(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'three'},
            {'name': 'foo4', 'value': 'four'},
            {'name': 'foo5', 'value': 'five'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        # plain data
        grid = self.make_grid(columns=['name', 'value'])
        sorter = grid.make_sorter('name')
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})

        # model class, but still plain data
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('name')
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})

        # repeat previous test, w/ model property
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter(model.Setting.name)
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})

        # sqlalchemy query
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('name')
        sorted_query = sorter(sample_query, 'desc')
        sorted_data = sorted_query.all()
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
        sorted_query = sorter(sample_query, 'asc')
        sorted_data = sorted_query.all()
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})

        # repeat previous test, w/ model property
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter(model.Setting.name)
        sorted_query = sorter(sample_query, 'desc')
        sorted_data = sorted_query.all()
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
        sorted_query = sorter(sample_query, 'asc')
        sorted_data = sorted_query.all()
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})

        # sortfunc for "invalid" column will fail when called; however
        # it can work for manual sort w/ custom keyfunc
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('doesnotexist')
        self.assertRaises(TypeError, sorter, sample_query, 'desc')
        self.assertRaises(KeyError, sorter, sample_data, 'desc')
        sorter = grid.make_sorter('doesnotexist', keyfunc=lambda obj: obj['name'])
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(len(sorted_data), 9)
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(len(sorted_data), 9)

        # case folding is on by default
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('value')
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})

        # results are different with case folding off
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('value', foldcase=False)
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})

    def test_set_joiner(self):

        # basic
        grid = self.make_grid(columns=['foo', 'bar'], sortable=True, sort_on_backend=True)
        self.assertEqual(grid.joiners, {})
        grid.set_joiner('foo', 42)
        self.assertEqual(grid.joiners, {'foo': 42})

    def test_remove_joiner(self):

        # basic
        grid = self.make_grid(columns=['foo', 'bar'], sortable=True, sort_on_backend=True,
                              joiners={'foo': 42})
        self.assertEqual(grid.joiners, {'foo': 42})
        grid.remove_joiner('foo')
        self.assertEqual(grid.joiners, {})

    def test_set_sorter(self):
        model = self.app.model

        # explicit sortfunc
        grid = self.make_grid()
        self.assertEqual(grid.sorters, {})
        sortfunc = lambda data, direction: data
        grid.set_sorter('foo', sortfunc)
        self.assertIs(grid.sorters['foo'], sortfunc)

        # auto from model property
        grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
        self.assertEqual(grid.sorters, {})
        grid.set_sorter('name', model.Setting.name)
        self.assertTrue(callable(grid.sorters['name']))

        # auto from column name
        grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
        self.assertEqual(grid.sorters, {})
        grid.set_sorter('name', 'name')
        self.assertTrue(callable(grid.sorters['name']))

        # auto from key
        grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
        self.assertEqual(grid.sorters, {})
        grid.set_sorter('name')
        self.assertTrue(callable(grid.sorters['name']))

    def test_remove_sorter(self):
        model = self.app.model

        # basics
        grid = self.make_grid(model_class=model.Setting, sortable=True)
        self.assertEqual(len(grid.sorters), 2)
        self.assertIn('name', grid.sorters)
        self.assertIn('value', grid.sorters)
        grid.remove_sorter('value')
        self.assertNotIn('value', grid.sorters)

    def test_set_sort_defaults(self):
        model = self.app.model
        grid = self.make_grid(model_class=model.Setting, sortable=True)
        self.assertEqual(grid.sort_defaults, [])

        # can set just sortkey
        grid.set_sort_defaults('name')
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])

        # can set sortkey, sortdir
        grid.set_sort_defaults('name', 'desc')
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])

        # can set sortkey, sortdir as tuple
        grid.set_sort_defaults(('value', 'asc'))
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])

        # can set as list
        grid.sort_multiple = True
        grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')])
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc'),
                                              mod.SortInfo('name', 'desc')])

        # list is pruned if multi-sort disabled
        grid.sort_multiple = False
        grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')])
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])

        # error if any other single arg
        self.assertRaises(ValueError, grid.set_sort_defaults, 42)

        # error if more than 2 args
        self.assertRaises(ValueError, grid.set_sort_defaults, 'name', 'asc', 'value', 'desc')

    def test_is_sortable(self):
        model = self.app.model

        # basics, frontend sorting
        grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=False)
        self.assertTrue(grid.is_sortable('name'))
        self.assertTrue(grid.is_sortable('value'))
        grid.remove_sorter('value')
        # nb. columns are always sortable for frontend, despite remove_sorter()
        self.assertTrue(grid.is_sortable('value'))
        # nb. when grid is not sortable, no column is either
        grid.sortable = False
        self.assertFalse(grid.is_sortable('name'))

        # same test but with backend sorting
        grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=True)
        self.assertTrue(grid.is_sortable('name'))
        self.assertTrue(grid.is_sortable('value'))
        grid.remove_sorter('value')
        self.assertFalse(grid.is_sortable('value'))
        # nb. when grid is not sortable, no column is either
        grid.sortable = False
        self.assertFalse(grid.is_sortable('name'))

    def test_make_backend_filters(self):
        model = self.app.model

        # default is empty
        grid = self.make_grid()
        filters = grid.make_backend_filters()
        self.assertEqual(filters, {})

        # makes filters if model class
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.Setting)
            filters = grid.make_backend_filters()
        self.assertEqual(len(filters), 2)
        self.assertIn('name', filters)
        self.assertIn('value', filters)

        # does not replace supplied filters
        myfilters = {'value': 42}
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.Setting)
            filters = grid.make_backend_filters(myfilters)
        self.assertEqual(len(filters), 2)
        self.assertIn('name', filters)
        self.assertIn('value', filters)
        self.assertEqual(filters['value'], 42)
        self.assertEqual(myfilters['value'], 42)

        # filters for all *true* columns by default, despite grid.columns
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.User,
                                  columns=['username', 'person'])
            filters = grid.make_backend_filters()
            self.assertIn('username', filters)
            self.assertIn('active', filters)
            # nb. relationship not included by default
            self.assertNotIn('person', filters)
            # nb. uuid fields not included by default
            self.assertNotIn('uuid', filters)
            self.assertNotIn('person_uuid', filters)

    def test_make_filter(self):
        model = self.app.model

        # arg is column name
        grid = self.make_grid(model_class=model.Setting)
        filtr = grid.make_filter('name')
        self.assertIsInstance(filtr, StringAlchemyFilter)

        # arg is column name, but model class is invalid
        grid = self.make_grid(model_class=42)
        self.assertRaises(ValueError, grid.make_filter, 'name')

        # arg is model property
        grid = self.make_grid(model_class=model.Setting)
        filtr = grid.make_filter(model.Setting.name)
        self.assertIsInstance(filtr, StringAlchemyFilter)

        # model property as kwarg
        grid = self.make_grid(model_class=model.Setting)
        filtr = grid.make_filter(None, model_property=model.Setting.name)
        self.assertIsInstance(filtr, StringAlchemyFilter)

        # default factory
        grid = self.make_grid(model_class=model.Setting)
        with patch.dict(default_sqlalchemy_filters, {None: GridFilter}, clear=True):
            filtr = grid.make_filter(model.Setting.name)
        self.assertIsInstance(filtr, GridFilter)
        self.assertNotIsInstance(filtr, StringAlchemyFilter)

        # factory override
        grid = self.make_grid(model_class=model.Setting)
        filtr = grid.make_filter(model.Setting.name, factory=GridFilter)
        self.assertIsInstance(filtr, GridFilter)
        self.assertNotIsInstance(filtr, StringAlchemyFilter)

    def test_set_filter(self):
        model = self.app.model

        with patch.object(mod.Grid, 'make_filter', return_value=42):

            # auto from model property
            grid = self.make_grid(model_class=model.Setting)
            self.assertEqual(grid.filters, {})
            grid.set_filter('name', model.Setting.name)
            self.assertIn('name', grid.filters)

            # auto from column name
            grid = self.make_grid(model_class=model.Setting)
            self.assertEqual(grid.filters, {})
            grid.set_filter('name', 'name')
            self.assertIn('name', grid.filters)

            # auto from key
            grid = self.make_grid(model_class=model.Setting)
            self.assertEqual(grid.filters, {})
            grid.set_filter('name')
            self.assertIn('name', grid.filters)

            # explicit is not yet implemented
            grid = self.make_grid(model_class=model.Setting)
            self.assertEqual(grid.filters, {})
            self.assertRaises(NotImplementedError, grid.set_filter, 'name', lambda q: q)

    def test_remove_filter(self):
        model = self.app.model

        # basics
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.Setting, filterable=True)
        self.assertEqual(len(grid.filters), 2)
        self.assertIn('name', grid.filters)
        self.assertIn('value', grid.filters)
        grid.remove_filter('value')
        self.assertNotIn('value', grid.filters)

    def test_set_filter_defaults(self):
        model = self.app.model

        # empty by default
        grid = self.make_grid(model_class=model.Setting, filterable=True)
        self.assertEqual(grid.filter_defaults, {})

        # can specify via method call
        grid.set_filter_defaults(name={'active': True})
        self.assertEqual(grid.filter_defaults, {'name': {'active': True}})

        # can specify via constructor
        grid = self.make_grid(model_class=model.Setting, filterable=True,
                              filter_defaults={'name': {'active': True}})
        self.assertEqual(grid.filter_defaults, {'name': {'active': True}})

    ##############################
    # data methods
    ##############################

    def test_get_visible_data(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'three'},
            {'name': 'foo4', 'value': 'four'},
            {'name': 'foo5', 'value': 'five'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        # data is sorted and paginated
        grid = self.make_grid(model_class=model.Setting,
                              data=sample_query,
                              filterable=True,
                              sortable=True, sort_on_backend=True,
                              sort_defaults=('name', 'desc'),
                              paginated=True, paginate_on_backend=True,
                              pagesize=4, page=2)
        grid.load_settings()
        # nb. for now the filtering is mocked
        with patch.object(grid, 'filter_data') as filter_data:
            filter_data.side_effect = lambda q: q
            visible = grid.get_visible_data()
            filter_data.assert_called_once_with(sample_query)
        self.assertEqual([s.name for s in visible], ['foo5', 'foo4', 'foo3', 'foo2'])

    def test_filter_data(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'ggg'},
            {'name': 'foo4', 'value': 'ggg'},
            {'name': 'foo5', 'value': 'ggg'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
        self.assertEqual(list(grid.filters), ['name', 'value'])
        self.assertIsInstance(grid.filters['name'], StringAlchemyFilter)
        self.assertIsInstance(grid.filters['value'], StringAlchemyFilter)

        # not filtered by default
        grid.load_settings()
        self.assertEqual(grid.active_filters, [])
        filtered_query = grid.filter_data(sample_query)
        self.assertIs(filtered_query, sample_query)

        # can be filtered per session settings
        self.request.session['grid.settings.filter.value.active'] = True
        self.request.session['grid.settings.filter.value.verb'] = 'contains'
        self.request.session['grid.settings.filter.value.value'] = 'ggg'
        grid.load_settings()
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].key, 'value')
        filtered_query = grid.filter_data(sample_query)
        self.assertIsInstance(filtered_query, orm.Query)
        self.assertIsNot(filtered_query, sample_query)
        self.assertEqual(filtered_query.count(), 3)

        # can be filtered per request settings
        self.request.GET = {'value': 's', 'value.verb': 'contains'}
        grid.load_settings()
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].key, 'value')
        filtered_query = grid.filter_data(sample_query)
        self.assertIsInstance(filtered_query, orm.Query)
        self.assertEqual(filtered_query.count(), 2)

        # not filtered if verb is invalid
        self.request.GET = {'value': 'ggg', 'value.verb': 'doesnotexist'}
        grid.load_settings()
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].verb, 'doesnotexist')
        filtered_query = grid.filter_data(sample_query)
        self.assertIs(filtered_query, sample_query)
        self.assertEqual(filtered_query.count(), 9)

        # not filtered if error
        self.request.GET = {'value': 'ggg', 'value.verb': 'contains'}
        grid.load_settings()
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].verb, 'contains')
        filtered_query = grid.filter_data(sample_query)
        self.assertIsNot(filtered_query, sample_query)
        self.assertEqual(filtered_query.count(), 3)
        with patch.object(grid.active_filters[0], 'filter_contains', side_effect=RuntimeError):
            filtered_query = grid.filter_data(sample_query)
        self.assertIs(filtered_query, sample_query)
        self.assertEqual(filtered_query.count(), 9)

        # joiner is invoked
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].key, 'value')
        joiner = MagicMock(side_effect=lambda q: q)
        grid.joiners = {'value': joiner}
        grid.joined = set()
        filtered_query = grid.filter_data(sample_query)
        joiner.assert_called_once_with(sample_query)
        self.assertEqual(filtered_query.count(), 3)

    def test_sort_data(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'ggg'},
            {'name': 'foo4', 'value': 'ggg'},
            {'name': 'foo5', 'value': 'ggg'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        grid = self.make_grid(model_class=model.Setting,
                              sortable=True, sort_on_backend=True,
                              sort_defaults=('name', 'desc'))
        grid.load_settings()

        # can sort a simple list of data
        sorted_data = grid.sort_data(sample_data)
        self.assertIsInstance(sorted_data, list)
        self.assertEqual(len(sorted_data), 9)
        self.assertEqual(sorted_data[0]['name'], 'foo9')
        self.assertEqual(sorted_data[-1]['name'], 'foo1')

        # can also sort a data query
        sorted_query = grid.sort_data(sample_query)
        self.assertIsInstance(sorted_query, orm.Query)
        sorted_data = sorted_query.all()
        self.assertEqual(len(sorted_data), 9)
        self.assertEqual(sorted_data[0]['name'], 'foo9')
        self.assertEqual(sorted_data[-1]['name'], 'foo1')

        # cannot sort data if sorter missing in overrides
        sorted_data = grid.sort_data(sample_data, sorters=[])
        # nb. sorted data is in same order as original sample (not sorted)
        self.assertEqual(sorted_data[0]['name'], 'foo1')
        self.assertEqual(sorted_data[-1]['name'], 'foo9')

        # multi-column sorting for list data
        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
                                                           {'key': 'name', 'dir': 'asc'}])
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})

        # multi-column sorting for query
        sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'},
                                                             {'key': 'name', 'dir': 'asc'}])
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})

        # cannot sort data if sortfunc is missing for column
        grid.remove_sorter('name')
        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
                                                           {'key': 'name', 'dir': 'asc'}])
        # nb. sorted data is in same order as original sample (not sorted)
        self.assertEqual(sorted_data[0]['name'], 'foo1')
        self.assertEqual(sorted_data[-1]['name'], 'foo9')

        # now try with a joiner
        query = self.session.query(model.User)
        grid = self.make_grid(model_class=model.User,
                              data=query,
                              columns=['username', 'full_name'],
                              sortable=True, sort_on_backend=True,
                              sort_defaults='full_name',
                              joiners={
                                  'full_name': lambda q: q.join(model.Person),
                              })
        grid.set_sorter('full_name', model.Person.full_name)
        grid.load_settings()
        data = grid.get_visible_data()
        self.assertIsInstance(data, orm.Query)

    def test_paginate_data(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'three'},
            {'name': 'foo4', 'value': 'four'},
            {'name': 'foo5', 'value': 'five'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        # basic list pager
        grid = self.make_grid(paginated=True, paginate_on_backend=True)
        pager = grid.paginate_data(sample_data)
        self.assertIsInstance(pager, Page)

        # basic query pager
        grid = self.make_grid(paginated=True, paginate_on_backend=True)
        pager = grid.paginate_data(sample_query)
        self.assertIsInstance(pager, SqlalchemyOrmPage)

        # page is reset to 1 for empty data
        self.request.session['grid.foo.page'] = 2
        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
        grid.load_settings()
        self.assertEqual(grid.page, 2)
        self.assertEqual(self.request.session['grid.foo.page'], 2)
        pager = grid.paginate_data(sample_data)
        self.assertEqual(pager.page, 1)
        self.assertEqual(grid.page, 1)
        self.assertEqual(self.request.session['grid.foo.page'], 1)

    ##############################
    # rendering methods
    ##############################

    def test_render_batch_id(self):
        grid = self.make_grid(columns=['foo', 'bar'])

        # null
        obj = MagicMock(foo=None)
        self.assertEqual(grid.render_batch_id(obj, 'foo', None), "")

        # int
        obj = MagicMock(foo=42)
        self.assertEqual(grid.render_batch_id(obj, 'foo', 42), "00000042")

    def test_render_boolean(self):
        grid = self.make_grid(columns=['foo', 'bar'])

        # null
        obj = MagicMock(foo=None)
        self.assertEqual(grid.render_boolean(obj, 'foo', None), "")

        # true
        obj = MagicMock(foo=True)
        self.assertEqual(grid.render_boolean(obj, 'foo', True), "Yes")

        # false
        obj = MagicMock(foo=False)
        self.assertEqual(grid.render_boolean(obj, 'foo', False), "No")

    def test_render_currency(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        obj = MagicMock()

        # null
        self.assertEqual(grid.render_currency(obj, 'foo', None), '')

        # basic decimal example
        value = decimal.Decimal('42.00')
        self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.00')

        # basic float example
        value = 42.00
        self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.00')

        # decimal places will be rounded
        value = decimal.Decimal('42.12345')
        self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.12')

        # negative numbers get parens
        value = decimal.Decimal('-42.42')
        self.assertEqual(grid.render_currency(obj, 'foo', value), '($42.42)')

    def test_render_enum(self):
        enum = self.app.enum
        grid = self.make_grid(columns=['foo', 'bar'])
        obj = {'status': None}

        # null
        value = grid.render_enum(obj, 'status', None, enum=enum.UpgradeStatus)
        self.assertIsNone(value)

        # normal
        obj['status'] = enum.UpgradeStatus.SUCCESS
        value = grid.render_enum(obj, 'status', 'SUCCESS', enum=enum.UpgradeStatus)
        self.assertEqual(value, 'success')

    def test_render_percent(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        obj = MagicMock()

        # null
        self.assertEqual(grid.render_percent(obj, 'foo', None), "")

        # typical
        self.assertEqual(grid.render_percent(obj, 'foo', 12.3419), '12.34 %')

        # more decimal places
        self.assertEqual(grid.render_percent(obj, 'foo', 12.3419, decimals=3), '12.342 %')
        self.assertEqual(grid.render_percent(obj, 'foo', 12.3419, decimals=4), '12.3419 %')

        # negative
        self.assertEqual(grid.render_percent(obj, 'foo', -12.3419), '(12.34 %)')
        self.assertEqual(grid.render_percent(obj, 'foo', -12.3419, decimals=3), '(12.342 %)')

    def test_render_quantity(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        obj = MagicMock()

        # null
        self.assertEqual(grid.render_quantity(obj, 'foo', None), "")

        # integer decimals become integers
        value = decimal.Decimal('1.000')
        self.assertEqual(grid.render_quantity(obj, 'foo', value), "1")

        # but decimal places are preserved
        value = decimal.Decimal('1.234')
        self.assertEqual(grid.render_quantity(obj ,'foo', value), "1.234")

        # zero is *not* empty string (with this renderer)
        self.assertEqual(grid.render_quantity(obj, 'foo', 0), "0")

    def test_render_date(self):
        grid = self.make_grid(columns=['foo', 'bar'])

        # null
        obj = MagicMock(dt=None)
        result = grid.render_date(obj, 'dt', None)
        self.assertEqual(result, '')

        # typical
        dt = datetime.date(2025, 1, 13)
        obj = MagicMock(dt=dt)
        result = grid.render_date(obj, 'dt', str(dt))
        self.assertEqual(result, '2025-01-13')

    def test_render_datetime(self):
        grid = self.make_grid(columns=['foo', 'bar'])

        obj = MagicMock(dt=None)
        result = grid.render_datetime(obj, 'dt', None)
        self.assertEqual(result, '')

        dt = datetime.datetime(2024, 12, 12, 13, 44, tzinfo=datetime.timezone.utc)
        obj = MagicMock(dt=dt)
        result = grid.render_datetime(obj, 'dt', str(dt))
        self.assertEqual(result, '2024-12-12 13:44+0000')

    def test_render_vue_tag(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        html = grid.render_vue_tag()
        self.assertEqual(html, '<wutta-grid></wutta-grid>')

    def test_render_vue_template(self):
        self.pyramid_config.include('pyramid_mako')
        self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
                                           'pyramid.events.BeforeRender')

        grid = self.make_grid(columns=['foo', 'bar'])
        html = grid.render_vue_template()
        self.assertIn('<script type="text/x-template" id="wutta-grid-template">', html)

    def test_render_table_element(self):
        self.pyramid_config.include('pyramid_mako')
        self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
                                           'pyramid.events.BeforeRender')

        grid = self.make_grid(key='foobar', columns=['foo', 'bar'])

        # form not required
        html = grid.render_table_element()
        self.assertNotIn('<script ', html)
        self.assertIn('<b-table ', html)

        # form will register grid data
        form = Form(self.request)
        self.assertEqual(len(form.grid_vue_context), 0)
        html = grid.render_table_element(form)
        self.assertEqual(len(form.grid_vue_context), 1)
        self.assertIn('foobar', form.grid_vue_context)

    def test_render_vue_finalize(self):
        grid = self.make_grid()
        html = grid.render_vue_finalize()
        self.assertIn('<script>', html)
        self.assertIn("Vue.component('wutta-grid', WuttaGrid)", html)

    def test_get_vue_columns(self):

        # error if no columns are set
        grid = self.make_grid()
        self.assertRaises(ValueError, grid.get_vue_columns)

        # otherwise get back field/label dicts
        grid = self.make_grid(columns=['foo', 'bar'])
        columns = grid.get_vue_columns()
        first = columns[0]
        self.assertEqual(first['field'], 'foo')
        self.assertEqual(first['label'], 'Foo')

    def test_get_vue_active_sorters(self):
        model = self.app.model

        # empty
        grid = self.make_grid(key='foo', sortable=True, sort_on_backend=True)
        grid.load_settings()
        sorters = grid.get_vue_active_sorters()
        self.assertEqual(sorters, [])

        # format is different
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True,
                              sort_defaults='name')
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
        sorters = grid.get_vue_active_sorters()
        self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])

    def test_get_vue_first_sorter(self):

        # empty by default
        grid = self.make_grid(key='foo', sortable=True)
        sorter = grid.get_vue_first_sorter()
        self.assertIsNone(sorter)

        # will use first element from sort_defaults when applicable...

        # basic
        grid = self.make_grid(key='foo', sortable=True, sort_defaults='name')
        sorter = grid.get_vue_first_sorter()
        self.assertEqual(sorter, ['name', 'asc'])

        # descending
        grid = self.make_grid(key='foo', sortable=True, sort_defaults=('name', 'desc'))
        sorter = grid.get_vue_first_sorter()
        self.assertEqual(sorter, ['name', 'desc'])

        # multiple
        grid = self.make_grid(key='foo', sortable=True, sort_defaults=[('key', 'asc'), ('name', 'asc')])
        sorter = grid.get_vue_first_sorter()
        self.assertEqual(sorter, ['key', 'asc'])

        # will use first element from active_sorters when applicable...

        # basic
        grid = self.make_grid(key='foo', sortable=True)
        grid.active_sorters = [{'key': 'name', 'dir': 'asc'}]
        sorter = grid.get_vue_first_sorter()
        self.assertEqual(sorter, ['name', 'asc'])

        # descending
        grid = self.make_grid(key='foo', sortable=True)
        grid.active_sorters = [{'key': 'name', 'dir': 'desc'}]
        sorter = grid.get_vue_first_sorter()
        self.assertEqual(sorter, ['name', 'desc'])

        # multiple
        grid = self.make_grid(key='foo', sortable=True)
        grid.active_sorters = [{'key': 'key', 'dir': 'asc'}, {'key': 'name', 'dir': 'asc'}]
        sorter = grid.get_vue_first_sorter()
        self.assertEqual(sorter, ['key', 'asc'])

    def test_get_vue_filters(self):
        model = self.app.model

        # basic
        grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
        grid.load_settings()
        filters = grid.get_vue_filters()
        self.assertEqual(len(filters), 2)
        name, value = filters
        self.assertEqual(name['choices'], [])
        self.assertEqual(name['choice_labels'], {})
        self.assertEqual(value['choices'], [])
        self.assertEqual(value['choice_labels'], {})

        class MockEnum(Enum):
            FOO = 'foo'
            BAR = 'bar'

        # with filter choices
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              filterable=True, enums={'name': MockEnum})
        grid.load_settings()
        filters = grid.get_vue_filters()
        self.assertEqual(len(filters), 2)
        name, value = filters
        self.assertEqual(name['choices'], ['FOO', 'BAR'])
        self.assertEqual(name['choice_labels'], {'FOO': 'foo', 'BAR': 'bar'})
        self.assertEqual(value['choices'], [])
        self.assertEqual(value['choice_labels'], {})

    def test_object_to_dict(self):
        grid = self.make_grid()
        setting = {'name': 'foo', 'value': 'bar'}

        # new dict but with same values
        dct = grid.object_to_dict(setting)
        self.assertIsInstance(dct, dict)
        self.assertIsNot(dct, setting)
        self.assertEqual(dct, setting)

        # random object, not iterable
        class MockSetting:
            def __init__(self, **kw):
                self.__dict__.update(kw)
        mock = MockSetting(**setting)
        dct = grid.object_to_dict(mock)
        self.assertIsInstance(dct, dict)
        self.assertEqual(dct, setting)

    def test_get_vue_context(self):

        # empty if no columns defined
        grid = self.make_grid()
        context = grid.get_vue_context()
        self.assertEqual(context, {'data': [], 'row_classes': {}})

        # typical data is a list
        mydata = [
            {'foo': 'bar'},
        ]
        grid = self.make_grid(columns=['foo'], data=mydata)
        context = grid.get_vue_context()
        self.assertEqual(context, {'data': [{'foo': 'bar'}], 'row_classes': {}})

        # if grid has actions, that list may be supplemented
        grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
        context = grid.get_vue_context()
        self.assertIsNot(context['data'], mydata)
        self.assertEqual(context, {'data': [{'foo': 'bar', '_action_url_view': '/blarg'}],
                                   'row_classes': {}})

        # can override value rendering
        grid.set_renderer('foo', lambda record, key, value: "blah blah")
        context = grid.get_vue_context()
        self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
                                   'row_classes': {}})

        # can set row class
        grid.row_class = 'whatever'
        context = grid.get_vue_context()
        self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
                                   'row_classes': {'0': 'whatever'}})

    def test_get_vue_data(self):

        # empty if no columns defined
        grid = self.make_grid()
        data = grid.get_vue_data()
        self.assertEqual(data, [])

        # typical data is a list
        mydata = [
            {'foo': 'bar'},
        ]
        grid = self.make_grid(columns=['foo'], data=mydata)
        data = grid.get_vue_data()
        self.assertEqual(data, [{'foo': 'bar'}])

        # if grid has actions, that list may be supplemented
        grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
        data = grid.get_vue_data()
        self.assertIsNot(data, mydata)
        self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])

        # can override value rendering
        grid.set_renderer('foo', lambda record, key, value: "blah blah")
        data = grid.get_vue_data()
        self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}])

    def test_get_row_class(self):
        model = self.app.model
        user = model.User(username='barney', active=True)
        self.session.add(user)
        self.session.commit()
        data = dict(user)

        # null by default
        grid = self.make_grid()
        self.assertIsNone(grid.get_row_class(user, data, 1))

        # can use static class
        grid.row_class = 'foo'
        self.assertEqual(grid.get_row_class(user, data, 1), 'foo')

        # can use callable
        def status(u, d, i):
            if not u.active:
                return 'inactive'
        grid.row_class = status
        self.assertIsNone(grid.get_row_class(user, data, 1))
        user.active = False
        self.assertEqual(grid.get_row_class(user, data, 1), 'inactive')

    def test_get_vue_pager_stats(self):
        data = [
            {'foo': 1, 'bar': 1},
            {'foo': 2, 'bar': 2},
            {'foo': 3, 'bar': 3},
            {'foo': 4, 'bar': 4},
            {'foo': 5, 'bar': 5},
            {'foo': 6, 'bar': 6},
            {'foo': 7, 'bar': 7},
            {'foo': 8, 'bar': 8},
            {'foo': 9, 'bar': 9},
        ]

        grid = self.make_grid(columns=['foo', 'bar'], pagesize=4, page=2)
        grid.pager = grid.paginate_data(data)
        stats = grid.get_vue_pager_stats()
        self.assertEqual(stats['item_count'], 9)
        self.assertEqual(stats['items_per_page'], 4)
        self.assertEqual(stats['page'], 2)
        self.assertEqual(stats['first_item'], 5)
        self.assertEqual(stats['last_item'], 8)


class TestGridAction(TestCase):

    def setUp(self):
        self.config = WuttaConfig()
        self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)

    def make_action(self, key, **kwargs):
        return mod.GridAction(self.request, key, **kwargs)

    def test_render_icon(self):

        # icon is derived from key by default
        action = self.make_action('blarg')
        html = action.render_icon()
        self.assertIn('<i class="fas fa-blarg">', html)

        # oruga has different output
        self.request.use_oruga = True
        html = action.render_icon()
        self.assertIn('<o-icon icon="blarg">', html)

    def test_render_label(self):

        # label is derived from key by default
        action = self.make_action('blarg')
        label = action.render_label()
        self.assertEqual(label, "Blarg")

        # otherwise use what caller provides
        action = self.make_action('foo', label="Bar")
        label = action.render_label()
        self.assertEqual(label, "Bar")

    def test_render_icon_and_label(self):
        action = self.make_action('blarg')
        with patch.multiple(action,
                            render_icon=lambda: 'ICON',
                            render_label=lambda: 'LABEL'):
            html = action.render_icon_and_label()
            self.assertEqual('ICON LABEL', html)

    def test_get_url(self):
        obj = {'foo': 'bar'}

        # null by default
        action = self.make_action('blarg')
        url = action.get_url(obj)
        self.assertIsNone(url)

        # or can be "static"
        action = self.make_action('blarg', url='/foo')
        url = action.get_url(obj)
        self.assertEqual(url, '/foo')

        # or can be "dynamic"
        action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
        url = action.get_url(obj)
        self.assertEqual(url, '/yeehaw')
