From bbb1207b271070f6b6c77c9567f1997a2b1c39fe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 13:23:20 -0600 Subject: [PATCH] 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 --- src/wuttafarm/web/grids.py | 200 ++++++++++++++++++++++ src/wuttafarm/web/views/farmos/animals.py | 30 +++- src/wuttafarm/web/views/farmos/assets.py | 39 ++++- src/wuttafarm/web/views/farmos/master.py | 19 +- 4 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 src/wuttafarm/web/grids.py diff --git a/src/wuttafarm/web/grids.py b/src/wuttafarm/web/grids.py new file mode 100644 index 0000000..198d591 --- /dev/null +++ b/src/wuttafarm/web/grids.py @@ -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 . +# +################################################################################ +""" +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 diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 3a79c8c..ce5cd40 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -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()) diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 31f21c9..06f9563 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -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( diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 56d70b6..90e8549 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -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")