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

View file

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

View file

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

View file

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