From d9ef55010043dbc2228f718b47cde8f3b90b51af Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 16:42:32 -0600 Subject: [PATCH] feat: add view for farmOS structures --- src/wuttafarm/web/forms/schema.py | 19 ++ src/wuttafarm/web/forms/widgets.py | 39 +++- src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + src/wuttafarm/web/views/farmos/animals.py | 21 +- src/wuttafarm/web/views/farmos/master.py | 6 + src/wuttafarm/web/views/farmos/structures.py | 209 +++++++++++++++++++ 8 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 src/wuttafarm/web/views/farmos/structures.py diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 7a9878e..a38588a 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -47,6 +47,25 @@ class AnimalTypeType(colander.SchemaType): return AnimalTypeWidget(self.request, **kwargs) +class StructureType(colander.SchemaType): + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): # pylint: disable=empty-docstring + """ """ + from wuttafarm.web.forms.widgets import StructureWidget + + return StructureWidget(self.request, **kwargs) + + class UsersType(colander.SchemaType): def __init__(self, request, *args, **kwargs): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 008c295..0ffb055 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -30,13 +30,14 @@ from deform.widget import Widget from webhelpers2.html import HTML, tags -class AnimalImage(Widget): +class ImageWidget(Widget): + """ + Widget to display an image URL for a record. """ - Widget to display an image URL for an animal. - TODO: this should be refactored to a more general name, once more - types of images need to be supported. - """ + def __init__(self, alt_text, *args, **kwargs): + super().__init__(*args, **kwargs) + self.alt_text = alt_text def serialize(self, field, cstruct, **kw): readonly = kw.get("readonly", self.readonly) @@ -44,7 +45,7 @@ class AnimalImage(Widget): if cstruct in (colander.null, None): return HTML.tag("span") - return tags.image(cstruct, "animal image", **kw) + return tags.image(cstruct, self.alt_text, **kw) return super().serialize(field, cstruct, **kw) @@ -75,6 +76,32 @@ class AnimalTypeWidget(Widget): return super().serialize(field, cstruct, **kw) +class StructureWidget(Widget): + """ + Widget to display a "structure" field. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + structure = json.loads(cstruct) + return tags.link_to( + structure["name"], + self.request.route_url( + "farmos_structures.view", uuid=structure["uuid"] + ), + ) + + return super().serialize(field, cstruct, **kw) + + class UsersWidget(Widget): """ Widget to display the list of owners for an asset etc. diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index a715019..dbeba44 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -52,6 +52,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Structures", + "route": "farmos_structures", + "perm": "farmos_structures.list", + }, {"type": "sep"}, { "title": "Users", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 26d5be3..a238983 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -52,6 +52,8 @@ class CommonView(base.CommonView): "farmos_animal_types.view", "farmos_animals.list", "farmos_animals.view", + "farmos_structures.list", + "farmos_structures.view", "farmos_users.list", "farmos_users.view", ] diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index 2f6e764..52652b8 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,5 +28,6 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") + config.include("wuttafarm.web.views.farmos.structures") config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animals") diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 96ae67d..8eca5af 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -29,8 +29,8 @@ import colander from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType, AnimalTypeType -from wuttafarm.web.forms.widgets import AnimalImage +from wuttafarm.web.forms.schema import UsersType, AnimalTypeType, StructureType +from wuttafarm.web.forms.widgets import ImageWidget class AnimalView(FarmOSMasterView): @@ -50,10 +50,7 @@ class AnimalView(FarmOSMasterView): labels = { "animal_type": "Species / Breed", "is_castrated": "Castrated", - "location_name": "Current Location", - "raw_image_url": "Raw Image URL", - "large_image_url": "Large Image URL", - "thumbnail_image_url": "Thumbnail Image URL", + "location": "Current Location", } grid_columns = [ @@ -74,7 +71,7 @@ class AnimalView(FarmOSMasterView): "is_castrated", "status", "owners", - "location_name", + "location", "notes", "raw_image_url", "large_image_url", @@ -125,7 +122,10 @@ class AnimalView(FarmOSMasterView): location = self.farmos_client.resource.get_id( "asset", "structure", location["data"][0]["id"] ) - data["location_name"] = location["data"]["attributes"]["name"] + data["location"] = { + "uuid": location["data"]["id"], + "name": location["data"]["attributes"]["name"], + } # add owners if owner := relationships.get("owner"): @@ -193,6 +193,9 @@ class AnimalView(FarmOSMasterView): # is_castrated f.set_node("is_castrated", colander.Boolean()) + # location + f.set_node("location", StructureType(self.request)) + # owners f.set_node("owners", UsersType(self.request)) @@ -201,7 +204,7 @@ class AnimalView(FarmOSMasterView): # image if url := animal.get("large_image_url"): - f.set_widget("image", AnimalImage()) + f.set_widget("image", ImageWidget("animal image")) f.set_default("image", url) def get_xref_buttons(self, animal): diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index eed04d1..59003d0 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -45,6 +45,12 @@ class FarmOSMasterView(MasterView): farmos_refurl_path = None + labels = { + "raw_image_url": "Raw Image URL", + "large_image_url": "Large Image URL", + "thumbnail_image_url": "Thumbnail Image URL", + } + def __init__(self, request, context=None): super().__init__(request, context=context) self.farmos_client = self.get_farmos_client() diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py new file mode 100644 index 0000000..bbc4f1f --- /dev/null +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -0,0 +1,209 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Structures +""" + +import datetime + +import colander + +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.forms.widgets import ImageWidget + + +class StructureView(FarmOSMasterView): + """ + View for farmOS Structures + """ + + model_name = "farmos_structure" + model_title = "farmOS Structure" + model_title_plural = "farmOS Structures" + + route_prefix = "farmos_structures" + url_prefix = "/farmOS/structures" + + farmos_refurl_path = "/assets/structure" + + grid_columns = [ + "name", + "status", + "created", + "changed", + ] + + sort_defaults = "name" + + form_fields = [ + "name", + "status", + "structure_type", + "is_location", + "is_fixed", + "notes", + "created", + "changed", + "raw_image_url", + "large_image_url", + "thumbnail_image_url", + "image", + ] + + def get_grid_data(self, columns=None, session=None): + structures = self.farmos_client.resource.get("asset", "structure") + return [self.normalize_structure(s) for s in structures["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # created + g.set_renderer("created", "datetime") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + structure = self.farmos_client.resource.get_id( + "asset", "structure", self.request.matchdict["uuid"] + ) + + data = self.normalize_structure(structure["data"]) + + if relationships := structure["data"].get("relationships"): + + # add owners + if owner := relationships.get("owner"): + data["owners"] = [] + for owner_data in owner["data"]: + owner = self.farmos_client.resource.get_id( + "user", "user", owner_data["id"] + ) + data["owners"].append( + { + "uuid": owner["data"]["id"], + "display_name": owner["data"]["attributes"]["display_name"], + } + ) + + # add image urls + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + data["raw_image_url"] = self.app.get_farmos_url( + image["data"]["attributes"]["uri"]["url"] + ) + # nb. other styles available: medium, wide + data["large_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["large"] + data["thumbnail_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["thumbnail"] + + return data + + def get_instance_title(self, structure): + return structure["name"] + + def normalize_structure(self, structure): + + if created := structure["attributes"].get("created"): + created = datetime.datetime.fromisoformat(created) + created = self.app.localtime(created) + + if changed := structure["attributes"].get("changed"): + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + return { + "uuid": structure["id"], + "drupal_internal_id": structure["attributes"]["drupal_internal__id"], + "name": structure["attributes"]["name"], + "structure_type": structure["attributes"]["structure_type"], + "is_fixed": structure["attributes"]["is_fixed"], + "is_location": structure["attributes"]["is_location"], + "notes": structure["attributes"]["notes"] or colander.null, + "status": structure["attributes"]["status"], + "created": created, + "changed": changed, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + structure = f.model_instance + + # is_fixed + f.set_node("is_fixed", colander.Boolean()) + + # is_location + f.set_node("is_location", colander.Boolean()) + + # notes + f.set_widget("notes", "notes") + + # created + f.set_node("created", WuttaDateTime()) + f.set_widget("created", WuttaDateTimeWidget(self.request)) + + # changed + f.set_node("changed", WuttaDateTime()) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + # image + if url := structure.get("large_image_url"): + f.set_widget("image", ImageWidget("structure image")) + f.set_default("image", url) + + def get_xref_buttons(self, structure): + drupal_id = structure["drupal_internal_id"] + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/asset/{drupal_id}"), + target="_blank", + icon_left="external-link-alt", + ), + ] + + +def defaults(config, **kwargs): + base = globals() + + StructureView = kwargs.get("StructureView", base["StructureView"]) + StructureView.defaults(config) + + +def includeme(config): + defaults(config)