fix: add basic support for grid filters for Date fields
This commit is contained in:
parent
5493dfeae8
commit
abec06c63c
|
@ -1976,6 +1976,7 @@ class Grid:
|
||||||
for filtr in self.filters.values():
|
for filtr in self.filters.values():
|
||||||
filters.append({
|
filters.append({
|
||||||
'key': filtr.key,
|
'key': filtr.key,
|
||||||
|
'data_type': filtr.data_type,
|
||||||
'active': filtr.active,
|
'active': filtr.active,
|
||||||
'visible': filtr.active,
|
'visible': filtr.active,
|
||||||
'verbs': filtr.get_verbs(),
|
'verbs': filtr.get_verbs(),
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
Grid Filters
|
Grid Filters
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
@ -69,6 +70,18 @@ class GridFilter:
|
||||||
|
|
||||||
Display label for the filter field.
|
Display label for the filter field.
|
||||||
|
|
||||||
|
.. attribute:: data_type
|
||||||
|
|
||||||
|
Simplistic "data type" which the filter supports. So far this
|
||||||
|
will be one of:
|
||||||
|
|
||||||
|
* ``'string'``
|
||||||
|
* ``'date'``
|
||||||
|
|
||||||
|
Note that this mainly applies to the "value input" used by the
|
||||||
|
filter. There is no data type for boolean since it does not
|
||||||
|
need a value input; the verb is enough.
|
||||||
|
|
||||||
.. attribute:: active
|
.. attribute:: active
|
||||||
|
|
||||||
Boolean indicating whether the filter is currently active.
|
Boolean indicating whether the filter is currently active.
|
||||||
|
@ -110,12 +123,18 @@ class GridFilter:
|
||||||
|
|
||||||
See also :attr:`default_verb`.
|
See also :attr:`default_verb`.
|
||||||
"""
|
"""
|
||||||
|
data_type = 'string'
|
||||||
default_verbs = ['equal', 'not_equal']
|
default_verbs = ['equal', 'not_equal']
|
||||||
|
|
||||||
default_verb_labels = {
|
default_verb_labels = {
|
||||||
'is_any': "is any",
|
'is_any': "is any",
|
||||||
'equal': "equal to",
|
'equal': "equal to",
|
||||||
'not_equal': "not equal to",
|
'not_equal': "not equal to",
|
||||||
|
'greater_than': "greater than",
|
||||||
|
'greater_equal': "greater than or equal to",
|
||||||
|
'less_than': "less than",
|
||||||
|
'less_equal': "less than or equal to",
|
||||||
|
# 'between': "between",
|
||||||
'is_true': "is true",
|
'is_true': "is true",
|
||||||
'is_false': "is false",
|
'is_false': "is false",
|
||||||
'is_false_null': "is false or null",
|
'is_false_null': "is false or null",
|
||||||
|
@ -343,6 +362,42 @@ class AlchemyFilter(GridFilter):
|
||||||
self.model_property != value,
|
self.model_property != value,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
def filter_greater_than(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with a greater than (``>``) condition.
|
||||||
|
"""
|
||||||
|
value = self.coerce_value(value)
|
||||||
|
if value is None:
|
||||||
|
return query
|
||||||
|
return query.filter(self.model_property > value)
|
||||||
|
|
||||||
|
def filter_greater_equal(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with a greater than or equal (``>=``) condition.
|
||||||
|
"""
|
||||||
|
value = self.coerce_value(value)
|
||||||
|
if value is None:
|
||||||
|
return query
|
||||||
|
return query.filter(self.model_property >= value)
|
||||||
|
|
||||||
|
def filter_less_than(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with a less than (``<``) condition.
|
||||||
|
"""
|
||||||
|
value = self.coerce_value(value)
|
||||||
|
if value is None:
|
||||||
|
return query
|
||||||
|
return query.filter(self.model_property < value)
|
||||||
|
|
||||||
|
def filter_less_equal(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with a less than or equal (``<=``) condition.
|
||||||
|
"""
|
||||||
|
value = self.coerce_value(value)
|
||||||
|
if value is None:
|
||||||
|
return query
|
||||||
|
return query.filter(self.model_property <= value)
|
||||||
|
|
||||||
def filter_is_null(self, query, value):
|
def filter_is_null(self, query, value):
|
||||||
"""
|
"""
|
||||||
Filter data with an ``IS NULL`` query. The value is ignored.
|
Filter data with an ``IS NULL`` query. The value is ignored.
|
||||||
|
@ -467,9 +522,52 @@ class BooleanAlchemyFilter(AlchemyFilter):
|
||||||
self.model_property == None))
|
self.model_property == None))
|
||||||
|
|
||||||
|
|
||||||
|
class DateAlchemyFilter(AlchemyFilter):
|
||||||
|
"""
|
||||||
|
SQLAlchemy filter option for a
|
||||||
|
:class:`sqlalchemy:sqlalchemy.types.Date` column.
|
||||||
|
|
||||||
|
Subclass of :class:`AlchemyFilter`.
|
||||||
|
"""
|
||||||
|
data_type = 'date'
|
||||||
|
default_verbs = [
|
||||||
|
'equal',
|
||||||
|
'not_equal',
|
||||||
|
'greater_than',
|
||||||
|
'greater_equal',
|
||||||
|
'less_than',
|
||||||
|
'less_equal',
|
||||||
|
# 'between',
|
||||||
|
]
|
||||||
|
|
||||||
|
default_verb_labels = {
|
||||||
|
'equal': "on",
|
||||||
|
'not_equal': "not on",
|
||||||
|
'greater_than': "after",
|
||||||
|
'greater_equal': "on or after",
|
||||||
|
'less_than': "before",
|
||||||
|
'less_equal': "on or before",
|
||||||
|
# 'between': "between",
|
||||||
|
}
|
||||||
|
|
||||||
|
def coerce_value(self, value):
|
||||||
|
""" """
|
||||||
|
if value:
|
||||||
|
if isinstance(value, datetime.date):
|
||||||
|
return value
|
||||||
|
|
||||||
|
try:
|
||||||
|
dt = datetime.datetime.strptime(value, '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
log.warning("invalid date value: %s", value)
|
||||||
|
else:
|
||||||
|
return dt.date()
|
||||||
|
|
||||||
|
|
||||||
default_sqlalchemy_filters = {
|
default_sqlalchemy_filters = {
|
||||||
None: AlchemyFilter,
|
None: AlchemyFilter,
|
||||||
sa.String: StringAlchemyFilter,
|
sa.String: StringAlchemyFilter,
|
||||||
sa.Text: StringAlchemyFilter,
|
sa.Text: StringAlchemyFilter,
|
||||||
sa.Boolean: BooleanAlchemyFilter,
|
sa.Boolean: BooleanAlchemyFilter,
|
||||||
|
sa.Date: DateAlchemyFilter,
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
${self.make_wutta_timepicker_component()}
|
${self.make_wutta_timepicker_component()}
|
||||||
${self.make_wutta_filter_component()}
|
${self.make_wutta_filter_component()}
|
||||||
${self.make_wutta_filter_value_component()}
|
${self.make_wutta_filter_value_component()}
|
||||||
|
${self.make_wutta_filter_date_value_component()}
|
||||||
${self.make_wutta_tool_panel_component()}
|
${self.make_wutta_tool_panel_component()}
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
@ -155,11 +156,14 @@
|
||||||
<%def name="make_wutta_datepicker_component()">
|
<%def name="make_wutta_datepicker_component()">
|
||||||
<script type="text/x-template" id="wutta-datepicker-template">
|
<script type="text/x-template" id="wutta-datepicker-template">
|
||||||
<b-datepicker :name="name"
|
<b-datepicker :name="name"
|
||||||
|
ref="datepicker"
|
||||||
:editable="editable"
|
:editable="editable"
|
||||||
icon-pack="fas"
|
icon-pack="fas"
|
||||||
icon="calendar-alt"
|
icon="calendar-alt"
|
||||||
:date-formatter="formatDate"
|
:date-formatter="formatDate"
|
||||||
:value="buefyValue" />
|
:value="buefyValue"
|
||||||
|
:size="size"
|
||||||
|
@input="val => $emit('input', val)" />
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
const WuttaDatepicker = {
|
const WuttaDatepicker = {
|
||||||
|
@ -167,6 +171,7 @@
|
||||||
props: {
|
props: {
|
||||||
name: String,
|
name: String,
|
||||||
value: String,
|
value: String,
|
||||||
|
size: String,
|
||||||
editable: {
|
editable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
|
@ -179,6 +184,10 @@
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this.$refs.datepicker.focus()
|
||||||
|
},
|
||||||
|
|
||||||
formatDate(date) {
|
formatDate(date) {
|
||||||
if (date === null) {
|
if (date === null) {
|
||||||
return null
|
return null
|
||||||
|
@ -324,7 +333,16 @@
|
||||||
icon-pack="fas"
|
icon-pack="fas"
|
||||||
:size="isSmall ? 'is-small' : null" />
|
:size="isSmall ? 'is-small' : null" />
|
||||||
|
|
||||||
<wutta-filter-value v-model="filter.value"
|
## nb. only *ONE* of the following is used, per filter data type
|
||||||
|
|
||||||
|
<wutta-filter-date-value v-if="filter.data_type == 'date'"
|
||||||
|
v-model="filter.value"
|
||||||
|
ref="filterValue"
|
||||||
|
v-show="valuedVerb()"
|
||||||
|
:is-small="isSmall" />
|
||||||
|
|
||||||
|
<wutta-filter-value v-else
|
||||||
|
v-model="filter.value"
|
||||||
ref="filterValue"
|
ref="filterValue"
|
||||||
v-show="valuedVerb()"
|
v-show="valuedVerb()"
|
||||||
:is-small="isSmall" />
|
:is-small="isSmall" />
|
||||||
|
@ -479,6 +497,53 @@
|
||||||
</script>
|
</script>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
<%def name="make_wutta_filter_date_value_component()">
|
||||||
|
<script type="text/x-template" id="wutta-filter-date-value-template">
|
||||||
|
<div class="wutta-filter-value">
|
||||||
|
|
||||||
|
<wutta-datepicker v-model="inputValue"
|
||||||
|
ref="valueInput"
|
||||||
|
:size="isSmall ? 'is-small' : null"
|
||||||
|
@input="valueChanged" >
|
||||||
|
</wutta-datepicker>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
const WuttaFilterDateValue = {
|
||||||
|
template: '#wutta-filter-date-value-template',
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
isSmall: Boolean,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
inputValue: this.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this.$refs.valueInput.focus()
|
||||||
|
},
|
||||||
|
|
||||||
|
valueChanged(value) {
|
||||||
|
if (value) {
|
||||||
|
value = this.$refs.valueInput.formatDate(value)
|
||||||
|
}
|
||||||
|
this.$emit('input', value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.component('wutta-filter-date-value', WuttaFilterDateValue)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</%def>
|
||||||
|
|
||||||
<%def name="make_wutta_tool_panel_component()">
|
<%def name="make_wutta_tool_panel_component()">
|
||||||
<script type="text/x-template" id="wutta-tool-panel-template">
|
<script type="text/x-template" id="wutta-tool-panel-template">
|
||||||
<nav class="panel tool-panel">
|
<nav class="panel tool-panel">
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from wuttjamaican.db.model import Base
|
||||||
|
|
||||||
from wuttaweb.grids import filters as mod
|
from wuttaweb.grids import filters as mod
|
||||||
from tests.util import WebTestCase
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
@ -422,6 +427,157 @@ class TestBooleanAlchemyFilter(WebTestCase):
|
||||||
self.assertEqual(filtered_query.count(), 2)
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class TheLocalThing(Base):
|
||||||
|
__tablename__ = 'the_local_thing'
|
||||||
|
id = sa.Column(sa.Integer(), primary_key=True, autoincrement=False)
|
||||||
|
date = sa.Column(sa.DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateAlchemyFilter(WebTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_web()
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
# nb. create table for TheLocalThing
|
||||||
|
model.Base.metadata.create_all(bind=self.session.bind)
|
||||||
|
|
||||||
|
self.sample_data = [
|
||||||
|
{'id': 1, 'date': datetime.date(2024, 1, 1)},
|
||||||
|
{'id': 2, 'date': datetime.date(2024, 1, 1)},
|
||||||
|
{'id': 3, 'date': datetime.date(2024, 3, 1)},
|
||||||
|
{'id': 4, 'date': datetime.date(2024, 3, 1)},
|
||||||
|
{'id': 5, 'date': None},
|
||||||
|
{'id': 6, 'date': None},
|
||||||
|
]
|
||||||
|
|
||||||
|
for thing in self.sample_data:
|
||||||
|
thing = TheLocalThing(**thing)
|
||||||
|
self.session.add(thing)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.sample_query = self.session.query(TheLocalThing)
|
||||||
|
|
||||||
|
def make_filter(self, model_property, **kwargs):
|
||||||
|
factory = kwargs.pop('factory', mod.DateAlchemyFilter)
|
||||||
|
kwargs['model_property'] = model_property
|
||||||
|
return factory(self.request, model_property.key, **kwargs)
|
||||||
|
|
||||||
|
def test_coerce_value(self):
|
||||||
|
filtr = self.make_filter(TheLocalThing.date)
|
||||||
|
|
||||||
|
# null value
|
||||||
|
self.assertIsNone(filtr.coerce_value(None))
|
||||||
|
|
||||||
|
# value as datetime
|
||||||
|
value = datetime.date(2024, 1, 1)
|
||||||
|
result = filtr.coerce_value(value)
|
||||||
|
self.assertIs(value, result)
|
||||||
|
|
||||||
|
# value as string
|
||||||
|
result = filtr.coerce_value('2024-04-01')
|
||||||
|
self.assertIsInstance(result, datetime.date)
|
||||||
|
self.assertEqual(result, datetime.date(2024, 4, 1))
|
||||||
|
|
||||||
|
# invalid
|
||||||
|
result = filtr.coerce_value('thisinputisbad')
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_greater_than(self):
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
filtr = self.make_filter(TheLocalThing.date)
|
||||||
|
self.assertEqual(self.sample_query.count(), 6)
|
||||||
|
|
||||||
|
# null value ignored
|
||||||
|
filtered_query = filtr.filter_greater_than(self.sample_query, None)
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 6)
|
||||||
|
|
||||||
|
# value as date
|
||||||
|
filtered_query = filtr.filter_greater_than(self.sample_query, datetime.date(2024, 2, 1))
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
# value as string
|
||||||
|
filtered_query = filtr.filter_greater_than(self.sample_query, '2024-02-01')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
def test_greater_equal(self):
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
filtr = self.make_filter(TheLocalThing.date)
|
||||||
|
self.assertEqual(self.sample_query.count(), 6)
|
||||||
|
|
||||||
|
# null value ignored
|
||||||
|
filtered_query = filtr.filter_greater_equal(self.sample_query, None)
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 6)
|
||||||
|
|
||||||
|
# value as date (clear of boundary)
|
||||||
|
filtered_query = filtr.filter_greater_equal(self.sample_query, datetime.date(2024, 2, 1))
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
# value as date (at boundary)
|
||||||
|
filtered_query = filtr.filter_greater_equal(self.sample_query, datetime.date(2024, 3, 1))
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
# value as string
|
||||||
|
filtered_query = filtr.filter_greater_equal(self.sample_query, '2024-01-01')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 4)
|
||||||
|
|
||||||
|
def test_less_than(self):
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
filtr = self.make_filter(TheLocalThing.date)
|
||||||
|
self.assertEqual(self.sample_query.count(), 6)
|
||||||
|
|
||||||
|
# null value ignored
|
||||||
|
filtered_query = filtr.filter_less_than(self.sample_query, None)
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 6)
|
||||||
|
|
||||||
|
# value as date
|
||||||
|
filtered_query = filtr.filter_less_than(self.sample_query, datetime.date(2024, 2, 1))
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
# value as string
|
||||||
|
filtered_query = filtr.filter_less_than(self.sample_query, '2024-04-01')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 4)
|
||||||
|
|
||||||
|
def test_less_equal(self):
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
filtr = self.make_filter(TheLocalThing.date)
|
||||||
|
self.assertEqual(self.sample_query.count(), 6)
|
||||||
|
|
||||||
|
# null value ignored
|
||||||
|
filtered_query = filtr.filter_less_equal(self.sample_query, None)
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 6)
|
||||||
|
|
||||||
|
# value as date (clear of boundary)
|
||||||
|
filtered_query = filtr.filter_less_equal(self.sample_query, datetime.date(2024, 2, 1))
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
# value as date (at boundary)
|
||||||
|
filtered_query = filtr.filter_less_equal(self.sample_query, datetime.date(2024, 3, 1))
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
# value as string
|
||||||
|
filtered_query = filtr.filter_less_equal(self.sample_query, '2024-04-01')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 4)
|
||||||
|
|
||||||
|
|
||||||
class TestVerbNotSupported(TestCase):
|
class TestVerbNotSupported(TestCase):
|
||||||
|
|
||||||
def test_basic(self):
|
def test_basic(self):
|
||||||
|
|
Loading…
Reference in a new issue