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