feat: add backend filters, sorting for farmOS animal types, assets

could not add pagination due to quirks with how Drupal JSONAPI works
for that.  but so far it looks like we can add filter/sort to all of
the farmOS grids..now just need to do it
This commit is contained in:
Lance Edgar 2026-02-20 13:23:20 -06:00
parent 9cfa91e091
commit bbb1207b27
4 changed files with 280 additions and 8 deletions

200
src/wuttafarm/web/grids.py Normal file
View file

@ -0,0 +1,200 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Custom grid stuff for use with farmOS / JSONAPI
"""
from wuttaweb.grids.filters import GridFilter
class SimpleFilter(GridFilter):
default_verbs = ["equal", "not_equal"]
def __init__(self, request, key, path=None, **kwargs):
super().__init__(request, key, **kwargs)
self.path = path or key
def filter_equal(self, data, value):
if value:
data.add_filter(self.path, "=", value)
return data
def filter_not_equal(self, data, value):
if value:
data.add_filter(self.path, "<>", value)
return data
def filter_is_null(self, data, value):
data.add_filter(self.path, "IS NULL", None)
return data
def filter_is_not_null(self, data, value):
data.add_filter(self.path, "IS NOT NULL", None)
return data
class StringFilter(SimpleFilter):
default_verbs = ["contains", "equal", "not_equal"]
def filter_contains(self, data, value):
if value:
data.add_filter(self.path, "CONTAINS", value)
return data
class NullableStringFilter(StringFilter):
default_verbs = ["contains", "equal", "not_equal", "is_null", "is_not_null"]
class IntegerFilter(SimpleFilter):
default_verbs = [
"equal",
"not_equal",
"less_than",
"less_equal",
"greater_than",
"greater_equal",
]
def filter_less_than(self, data, value):
if value:
data.add_filter(self.path, "<", value)
return data
def filter_less_equal(self, data, value):
if value:
data.add_filter(self.path, "<=", value)
return data
def filter_greater_than(self, data, value):
if value:
data.add_filter(self.path, ">", value)
return data
def filter_greater_equal(self, data, value):
if value:
data.add_filter(self.path, ">=", value)
return data
class NullableIntegerFilter(IntegerFilter):
default_verbs = ["equal", "not_equal", "is_null", "is_not_null"]
class BooleanFilter(SimpleFilter):
default_verbs = ["is_true", "is_false"]
def filter_is_true(self, data, value):
data.add_filter(self.path, "=", 1)
return data
def filter_is_false(self, data, value):
data.add_filter(self.path, "=", 0)
return data
class NullableBooleanFilter(BooleanFilter):
default_verbs = ["is_true", "is_false", "is_null", "is_not_null"]
class SimpleSorter:
def __init__(self, key):
self.key = key
def __call__(self, data, sortdir):
data.add_sorter(self.key, sortdir)
return data
class ResourceData:
def __init__(self, config, farmos_client, content_type, normalizer=None):
self.config = config
self.farmos_client = farmos_client
self.entity, self.bundle = content_type.split("--")
self.filters = []
self.sorters = []
self.normalizer = normalizer
self._data = None
def __bool__(self):
return True
def __getitem__(self, subscript):
return self.get_data()[subscript]
def __len__(self):
return len(self._data)
def add_filter(self, path, operator, value):
self.filters.append((path, operator, value))
def add_sorter(self, path, sortdir):
self.sorters.append((path, sortdir))
def get_data(self):
if self._data is None:
params = {}
for path, operator, value in self.filters:
params[f"filter[{path}][condition][path]"] = path
params[f"filter[{path}][condition][operator]"] = operator
params[f"filter[{path}][condition][value]"] = value
sorters = []
for path, sortdir in self.sorters:
prefix = "-" if sortdir == "desc" else ""
sorters.append(f"{prefix}{path}")
if sorters:
params["sort"] = ",".join(sorters)
# nb. while the API allows for pagination, it does not
# tell me how many total records there are (IIUC). also
# if i ask for e.g. items 21-40 (page 2 @ 20/page) i am
# not guaranteed to get 20 items even if there are plenty
# in the DB, since Drupal may filter some out based on
# permissions. (granted that may not be an issue in
# practice, but can't rule it out.) so the punchline is,
# we fetch "all" (sic) data and send it to the frontend,
# and pagination happens there.
# TODO: if we ever try again, this sort of works...
# params["page[offset]"] = start
# params["page[limit]"] = stop - start
result = self.farmos_client.resource.get(
self.entity, self.bundle, params=params
)
data = result["data"]
if self.normalizer:
data = [self.normalizer(d) for d in data]
self._data = data
return self._data

View file

@ -31,6 +31,12 @@ from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos.assets import AssetMasterView from wuttafarm.web.views.farmos.assets import AssetMasterView
from wuttafarm.web.grids import (
SimpleSorter,
StringFilter,
BooleanFilter,
NullableBooleanFilter,
)
from wuttafarm.web.forms.schema import AnimalTypeType from wuttafarm.web.forms.schema import AnimalTypeType
@ -51,13 +57,16 @@ class AnimalView(AssetMasterView):
labels = { labels = {
"animal_type": "Species / Breed", "animal_type": "Species / Breed",
"is_sterile": "Sterile",
} }
grid_columns = [ grid_columns = [
"drupal_id",
"name", "name",
"produces_eggs",
"birthdate", "birthdate",
"sex",
"is_sterile", "is_sterile",
"sex",
"archived", "archived",
] ]
@ -65,6 +74,7 @@ class AnimalView(AssetMasterView):
"name", "name",
"animal_type", "animal_type",
"birthdate", "birthdate",
"produces_eggs",
"sex", "sex",
"is_sterile", "is_sterile",
"archived", "archived",
@ -80,12 +90,26 @@ class AnimalView(AssetMasterView):
def configure_grid(self, grid): def configure_grid(self, grid):
g = grid g = grid
super().configure_grid(g) super().configure_grid(g)
enum = self.app.enum
# produces_eggs
g.set_renderer("produces_eggs", "boolean")
g.set_sorter("produces_eggs", SimpleSorter("produces_eggs"))
g.set_filter("produces_eggs", NullableBooleanFilter)
# birthdate # birthdate
g.set_renderer("birthdate", "date") g.set_renderer("birthdate", "date")
g.set_sorter("birthdate", SimpleSorter("birthdate"))
# sex
g.set_enum("sex", enum.ANIMAL_SEX)
g.set_sorter("sex", SimpleSorter("sex"))
g.set_filter("sex", StringFilter)
# is_sterile # is_sterile
g.set_renderer("is_sterile", "boolean") g.set_renderer("is_sterile", "boolean")
g.set_sorter("is_sterile", SimpleSorter("is_sterile"))
g.set_filter("is_sterile", BooleanFilter)
def get_instance(self): def get_instance(self):
@ -126,6 +150,7 @@ class AnimalView(AssetMasterView):
"birthdate": birthdate, "birthdate": birthdate,
"sex": animal["attributes"]["sex"] or colander.null, "sex": animal["attributes"]["sex"] or colander.null,
"is_sterile": sterile, "is_sterile": sterile,
"produces_eggs": animal["attributes"].get("produces_eggs"),
} }
) )
@ -142,6 +167,9 @@ class AnimalView(AssetMasterView):
f.set_node("birthdate", WuttaDateTime()) f.set_node("birthdate", WuttaDateTime())
f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) f.set_widget("birthdate", WuttaDateTimeWidget(self.request))
# produces_eggs
f.set_node("produces_eggs", colander.Boolean())
# is_sterile # is_sterile
f.set_node("is_sterile", colander.Boolean()) f.set_node("is_sterile", colander.Boolean())

View file

@ -28,6 +28,13 @@ import colander
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.schema import UsersType, StructureType from wuttafarm.web.forms.schema import UsersType, StructureType
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.grids import (
ResourceData,
StringFilter,
IntegerFilter,
BooleanFilter,
SimpleSorter,
)
class AssetMasterView(FarmOSMasterView): class AssetMasterView(FarmOSMasterView):
@ -36,6 +43,8 @@ class AssetMasterView(FarmOSMasterView):
""" """
farmos_asset_type = None farmos_asset_type = None
filterable = True
sort_on_backend = True
labels = { labels = {
"name": "Asset Name", "name": "Asset Name",
@ -43,26 +52,50 @@ class AssetMasterView(FarmOSMasterView):
} }
grid_columns = [ grid_columns = [
"drupal_id",
"name", "name",
"archived", "archived",
] ]
sort_defaults = "name" sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
"archived": {"active": True, "verb": "is_false"},
}
def get_grid_data(self, columns=None, session=None): def get_grid_data(self, columns=None, session=None):
result = self.farmos_client.asset.get(self.farmos_asset_type) return ResourceData(
return [self.normalize_asset(a) for a in result["data"]] self.config,
self.farmos_client,
f"asset--{self.farmos_asset_type}",
normalizer=self.normalize_asset,
)
def configure_grid(self, grid): def configure_grid(self, grid):
g = grid g = grid
super().configure_grid(g) super().configure_grid(g)
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
g.set_sorter("drupal_id", SimpleSorter("drupal_internal__id"))
g.set_filter("drupal_id", IntegerFilter, path="drupal_internal__id")
# name # name
g.set_link("name") g.set_link("name")
g.set_searchable("name") g.set_sorter("name", SimpleSorter("name"))
g.set_filter("name", StringFilter)
# archived # archived
g.set_renderer("archived", "boolean") g.set_renderer("archived", "boolean")
g.set_sorter("archived", SimpleSorter("archived"))
g.set_filter("archived", BooleanFilter)
def grid_row_class(self, asset, data, i):
""" """
if asset["archived"]:
return "has-background-warning"
return None
def get_instance(self): def get_instance(self):
asset = self.farmos_client.resource.get_id( asset = self.farmos_client.resource.get_id(

View file

@ -33,6 +33,7 @@ from wuttaweb.views import MasterView
from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.schema import WuttaDateTime
from wuttafarm.web.util import save_farmos_oauth2_token from wuttafarm.web.util import save_farmos_oauth2_token
from wuttafarm.web.grids import ResourceData, StringFilter, SimpleSorter
class FarmOSMasterView(MasterView): class FarmOSMasterView(MasterView):
@ -53,6 +54,7 @@ class FarmOSMasterView(MasterView):
farmos_refurl_path = None farmos_refurl_path = None
labels = { labels = {
"drupal_id": "Drupal ID",
"raw_image_url": "Raw Image URL", "raw_image_url": "Raw Image URL",
"large_image_url": "Large Image URL", "large_image_url": "Large Image URL",
"thumbnail_image_url": "Thumbnail Image URL", "thumbnail_image_url": "Thumbnail Image URL",
@ -111,6 +113,8 @@ class TaxonomyMasterView(FarmOSMasterView):
""" """
farmos_taxonomy_type = None farmos_taxonomy_type = None
filterable = True
sort_on_backend = True
grid_columns = [ grid_columns = [
"name", "name",
@ -120,6 +124,10 @@ class TaxonomyMasterView(FarmOSMasterView):
sort_defaults = "name" sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [ form_fields = [
"name", "name",
"description", "description",
@ -127,10 +135,12 @@ class TaxonomyMasterView(FarmOSMasterView):
] ]
def get_grid_data(self, columns=None, session=None): def get_grid_data(self, columns=None, session=None):
result = self.farmos_client.resource.get( return ResourceData(
"taxonomy_term", self.farmos_taxonomy_type self.config,
self.farmos_client,
f"taxonomy_term--{self.farmos_taxonomy_type}",
normalizer=self.normalize_taxonomy_term,
) )
return [self.normalize_taxonomy_term(t) for t in result["data"]]
def normalize_taxonomy_term(self, term): def normalize_taxonomy_term(self, term):
@ -155,7 +165,8 @@ class TaxonomyMasterView(FarmOSMasterView):
# name # name
g.set_link("name") g.set_link("name")
g.set_searchable("name") g.set_sorter("name", SimpleSorter("name"))
g.set_filter("name", StringFilter)
# changed # changed
g.set_renderer("changed", "datetime") g.set_renderer("changed", "datetime")