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():
|
||||
filters.append({
|
||||
'key': filtr.key,
|
||||
'data_type': filtr.data_type,
|
||||
'active': filtr.active,
|
||||
'visible': filtr.active,
|
||||
'verbs': filtr.get_verbs(),
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
Grid Filters
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
@ -69,6 +70,18 @@ class GridFilter:
|
|||
|
||||
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
|
||||
|
||||
Boolean indicating whether the filter is currently active.
|
||||
|
@ -110,12 +123,18 @@ class GridFilter:
|
|||
|
||||
See also :attr:`default_verb`.
|
||||
"""
|
||||
data_type = 'string'
|
||||
default_verbs = ['equal', 'not_equal']
|
||||
|
||||
default_verb_labels = {
|
||||
'is_any': "is any",
|
||||
'equal': "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_false': "is false",
|
||||
'is_false_null': "is false or null",
|
||||
|
@ -343,6 +362,42 @@ class AlchemyFilter(GridFilter):
|
|||
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):
|
||||
"""
|
||||
Filter data with an ``IS NULL`` query. The value is ignored.
|
||||
|
@ -467,9 +522,52 @@ class BooleanAlchemyFilter(AlchemyFilter):
|
|||
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 = {
|
||||
None: AlchemyFilter,
|
||||
sa.String: StringAlchemyFilter,
|
||||
sa.Text: StringAlchemyFilter,
|
||||
sa.Boolean: BooleanAlchemyFilter,
|
||||
sa.Date: DateAlchemyFilter,
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
${self.make_wutta_timepicker_component()}
|
||||
${self.make_wutta_filter_component()}
|
||||
${self.make_wutta_filter_value_component()}
|
||||
${self.make_wutta_filter_date_value_component()}
|
||||
${self.make_wutta_tool_panel_component()}
|
||||
</%def>
|
||||
|
||||
|
@ -155,11 +156,14 @@
|
|||
<%def name="make_wutta_datepicker_component()">
|
||||
<script type="text/x-template" id="wutta-datepicker-template">
|
||||
<b-datepicker :name="name"
|
||||
ref="datepicker"
|
||||
:editable="editable"
|
||||
icon-pack="fas"
|
||||
icon="calendar-alt"
|
||||
:date-formatter="formatDate"
|
||||
:value="buefyValue" />
|
||||
:value="buefyValue"
|
||||
:size="size"
|
||||
@input="val => $emit('input', val)" />
|
||||
</script>
|
||||
<script>
|
||||
const WuttaDatepicker = {
|
||||
|
@ -167,6 +171,7 @@
|
|||
props: {
|
||||
name: String,
|
||||
value: String,
|
||||
size: String,
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
@ -179,6 +184,10 @@
|
|||
},
|
||||
methods: {
|
||||
|
||||
focus() {
|
||||
this.$refs.datepicker.focus()
|
||||
},
|
||||
|
||||
formatDate(date) {
|
||||
if (date === null) {
|
||||
return null
|
||||
|
@ -324,7 +333,16 @@
|
|||
icon-pack="fas"
|
||||
: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"
|
||||
v-show="valuedVerb()"
|
||||
:is-small="isSmall" />
|
||||
|
@ -479,6 +497,53 @@
|
|||
</script>
|
||||
</%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()">
|
||||
<script type="text/x-template" id="wutta-tool-panel-template">
|
||||
<nav class="panel tool-panel">
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
import datetime
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from wuttjamaican.db.model import Base
|
||||
|
||||
from wuttaweb.grids import filters as mod
|
||||
from tests.util import WebTestCase
|
||||
|
||||
|
@ -422,6 +427,157 @@ class TestBooleanAlchemyFilter(WebTestCase):
|
|||
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):
|
||||
|
||||
def test_basic(self):
|
||||
|
|
Loading…
Reference in a new issue