3
0
Fork 0

fix: add basic support for grid filters for Date fields

This commit is contained in:
Lance Edgar 2024-12-17 16:31:33 -06:00
parent 5493dfeae8
commit abec06c63c
4 changed files with 322 additions and 2 deletions

View file

@ -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(),

View file

@ -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,
}

View file

@ -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">

View file

@ -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):