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:
parent
9cfa91e091
commit
bbb1207b27
4 changed files with 280 additions and 8 deletions
200
src/wuttafarm/web/grids.py
Normal file
200
src/wuttafarm/web/grids.py
Normal 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
|
||||
|
|
@ -31,6 +31,12 @@ from wuttaweb.forms.schema import WuttaDateTime
|
|||
from wuttaweb.forms.widgets import WuttaDateTimeWidget
|
||||
|
||||
from wuttafarm.web.views.farmos.assets import AssetMasterView
|
||||
from wuttafarm.web.grids import (
|
||||
SimpleSorter,
|
||||
StringFilter,
|
||||
BooleanFilter,
|
||||
NullableBooleanFilter,
|
||||
)
|
||||
from wuttafarm.web.forms.schema import AnimalTypeType
|
||||
|
||||
|
||||
|
|
@ -51,13 +57,16 @@ class AnimalView(AssetMasterView):
|
|||
|
||||
labels = {
|
||||
"animal_type": "Species / Breed",
|
||||
"is_sterile": "Sterile",
|
||||
}
|
||||
|
||||
grid_columns = [
|
||||
"drupal_id",
|
||||
"name",
|
||||
"produces_eggs",
|
||||
"birthdate",
|
||||
"sex",
|
||||
"is_sterile",
|
||||
"sex",
|
||||
"archived",
|
||||
]
|
||||
|
||||
|
|
@ -65,6 +74,7 @@ class AnimalView(AssetMasterView):
|
|||
"name",
|
||||
"animal_type",
|
||||
"birthdate",
|
||||
"produces_eggs",
|
||||
"sex",
|
||||
"is_sterile",
|
||||
"archived",
|
||||
|
|
@ -80,12 +90,26 @@ class AnimalView(AssetMasterView):
|
|||
def configure_grid(self, grid):
|
||||
g = grid
|
||||
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
|
||||
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
|
||||
g.set_renderer("is_sterile", "boolean")
|
||||
g.set_sorter("is_sterile", SimpleSorter("is_sterile"))
|
||||
g.set_filter("is_sterile", BooleanFilter)
|
||||
|
||||
def get_instance(self):
|
||||
|
||||
|
|
@ -126,6 +150,7 @@ class AnimalView(AssetMasterView):
|
|||
"birthdate": birthdate,
|
||||
"sex": animal["attributes"]["sex"] or colander.null,
|
||||
"is_sterile": sterile,
|
||||
"produces_eggs": animal["attributes"].get("produces_eggs"),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -142,6 +167,9 @@ class AnimalView(AssetMasterView):
|
|||
f.set_node("birthdate", WuttaDateTime())
|
||||
f.set_widget("birthdate", WuttaDateTimeWidget(self.request))
|
||||
|
||||
# produces_eggs
|
||||
f.set_node("produces_eggs", colander.Boolean())
|
||||
|
||||
# is_sterile
|
||||
f.set_node("is_sterile", colander.Boolean())
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ import colander
|
|||
from wuttafarm.web.views.farmos import FarmOSMasterView
|
||||
from wuttafarm.web.forms.schema import UsersType, StructureType
|
||||
from wuttafarm.web.forms.widgets import ImageWidget
|
||||
from wuttafarm.web.grids import (
|
||||
ResourceData,
|
||||
StringFilter,
|
||||
IntegerFilter,
|
||||
BooleanFilter,
|
||||
SimpleSorter,
|
||||
)
|
||||
|
||||
|
||||
class AssetMasterView(FarmOSMasterView):
|
||||
|
|
@ -36,6 +43,8 @@ class AssetMasterView(FarmOSMasterView):
|
|||
"""
|
||||
|
||||
farmos_asset_type = None
|
||||
filterable = True
|
||||
sort_on_backend = True
|
||||
|
||||
labels = {
|
||||
"name": "Asset Name",
|
||||
|
|
@ -43,26 +52,50 @@ class AssetMasterView(FarmOSMasterView):
|
|||
}
|
||||
|
||||
grid_columns = [
|
||||
"drupal_id",
|
||||
"name",
|
||||
"archived",
|
||||
]
|
||||
|
||||
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):
|
||||
result = self.farmos_client.asset.get(self.farmos_asset_type)
|
||||
return [self.normalize_asset(a) for a in result["data"]]
|
||||
return ResourceData(
|
||||
self.config,
|
||||
self.farmos_client,
|
||||
f"asset--{self.farmos_asset_type}",
|
||||
normalizer=self.normalize_asset,
|
||||
)
|
||||
|
||||
def configure_grid(self, grid):
|
||||
g = grid
|
||||
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
|
||||
g.set_link("name")
|
||||
g.set_searchable("name")
|
||||
g.set_sorter("name", SimpleSorter("name"))
|
||||
g.set_filter("name", StringFilter)
|
||||
|
||||
# archived
|
||||
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):
|
||||
asset = self.farmos_client.resource.get_id(
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from wuttaweb.views import MasterView
|
|||
from wuttaweb.forms.schema import WuttaDateTime
|
||||
|
||||
from wuttafarm.web.util import save_farmos_oauth2_token
|
||||
from wuttafarm.web.grids import ResourceData, StringFilter, SimpleSorter
|
||||
|
||||
|
||||
class FarmOSMasterView(MasterView):
|
||||
|
|
@ -53,6 +54,7 @@ class FarmOSMasterView(MasterView):
|
|||
farmos_refurl_path = None
|
||||
|
||||
labels = {
|
||||
"drupal_id": "Drupal ID",
|
||||
"raw_image_url": "Raw Image URL",
|
||||
"large_image_url": "Large Image URL",
|
||||
"thumbnail_image_url": "Thumbnail Image URL",
|
||||
|
|
@ -111,6 +113,8 @@ class TaxonomyMasterView(FarmOSMasterView):
|
|||
"""
|
||||
|
||||
farmos_taxonomy_type = None
|
||||
filterable = True
|
||||
sort_on_backend = True
|
||||
|
||||
grid_columns = [
|
||||
"name",
|
||||
|
|
@ -120,6 +124,10 @@ class TaxonomyMasterView(FarmOSMasterView):
|
|||
|
||||
sort_defaults = "name"
|
||||
|
||||
filter_defaults = {
|
||||
"name": {"active": True, "verb": "contains"},
|
||||
}
|
||||
|
||||
form_fields = [
|
||||
"name",
|
||||
"description",
|
||||
|
|
@ -127,10 +135,12 @@ class TaxonomyMasterView(FarmOSMasterView):
|
|||
]
|
||||
|
||||
def get_grid_data(self, columns=None, session=None):
|
||||
result = self.farmos_client.resource.get(
|
||||
"taxonomy_term", self.farmos_taxonomy_type
|
||||
return ResourceData(
|
||||
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):
|
||||
|
||||
|
|
@ -155,7 +165,8 @@ class TaxonomyMasterView(FarmOSMasterView):
|
|||
|
||||
# name
|
||||
g.set_link("name")
|
||||
g.set_searchable("name")
|
||||
g.set_sorter("name", SimpleSorter("name"))
|
||||
g.set_filter("name", StringFilter)
|
||||
|
||||
# changed
|
||||
g.set_renderer("changed", "datetime")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue