From ad6ac13d503337501edb8420a7105b9760113d60 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Feb 2026 18:36:26 -0600 Subject: [PATCH] feat: add basic CRUD for direct API views: animal types, animal assets --- src/wuttafarm/web/forms/schema.py | 59 +++++++- src/wuttafarm/web/forms/widgets.py | 64 ++++++++- src/wuttafarm/web/views/animals.py | 2 +- src/wuttafarm/web/views/farmos/animals.py | 101 +++++++++++--- src/wuttafarm/web/views/farmos/assets.py | 163 +++++++++++++--------- src/wuttafarm/web/views/farmos/master.py | 59 +++++++- 6 files changed, 351 insertions(+), 97 deletions(-) diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index df2a45c..469a466 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -58,10 +58,50 @@ class AnimalTypeRef(ObjectRef): class FarmOSRef(colander.SchemaType): def __init__(self, request, route_prefix, *args, **kwargs): + self.values = kwargs.pop("values", None) super().__init__(*args, **kwargs) self.request = request self.route_prefix = route_prefix + def get_values(self): + if callable(self.values): + self.values = self.values() + return self.values + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + # nb. keep a ref to this for later use + node.model_instance = appstruct + + # serialize to PK as string + return appstruct["uuid"] + + def deserialize(self, node, cstruct): + if not cstruct: + return colander.null + + # nb. deserialize to PK string, not dict + return cstruct + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSRefWidget + + if not kwargs.get("readonly"): + if "values" not in kwargs: + if values := self.get_values(): + kwargs["values"] = values + + return FarmOSRefWidget(self.request, self.route_prefix, **kwargs) + + +class FarmOSRefs(WuttaSet): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(request, *args, **kwargs) + self.route_prefix = route_prefix + def serialize(self, node, appstruct): if appstruct is colander.null: return colander.null @@ -69,10 +109,23 @@ class FarmOSRef(colander.SchemaType): return json.dumps(appstruct) def widget_maker(self, **kwargs): - """ """ - from wuttafarm.web.forms.widgets import FarmOSRefWidget + from wuttafarm.web.forms.widgets import FarmOSRefsWidget - return FarmOSRefWidget(self.request, self.route_prefix, **kwargs) + return FarmOSRefsWidget(self.request, self.route_prefix, **kwargs) + + +class FarmOSLocationRefs(WuttaSet): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSLocationRefsWidget + + return FarmOSLocationRefsWidget(self.request, **kwargs) class AnimalTypeType(colander.SchemaType): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 7c807fa..dfbaefe 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -26,7 +26,7 @@ Custom form widgets for WuttaFarm import json import colander -from deform.widget import Widget +from deform.widget import Widget, SelectWidget from webhelpers2.html import HTML, tags from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget @@ -54,7 +54,7 @@ class ImageWidget(Widget): return super().serialize(field, cstruct, **kw) -class FarmOSRefWidget(Widget): +class FarmOSRefWidget(SelectWidget): """ Generic widget to display "any reference field" - as a link to view the farmOS record it references. Only used by the farmOS @@ -72,7 +72,12 @@ class FarmOSRefWidget(Widget): if cstruct in (colander.null, None): return HTML.tag("span") - obj = json.loads(cstruct) + try: + obj = json.loads(cstruct) + except json.JSONDecodeError: + name = dict(self.values)[cstruct] + obj = {"uuid": cstruct, "name": name} + return tags.link_to( obj["name"], self.request.route_url(f"{self.route_prefix}.view", uuid=obj["uuid"]), @@ -81,6 +86,59 @@ class FarmOSRefWidget(Widget): return super().serialize(field, cstruct, **kw) +class FarmOSRefsWidget(Widget): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + links = [] + for obj in json.loads(cstruct): + url = self.request.route_url( + f"{self.route_prefix}.view", uuid=obj["uuid"] + ) + links.append(HTML.tag("li", c=tags.link_to(obj["name"], url))) + + return HTML.tag("ul", c=links) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSLocationRefsWidget(Widget): + """ + Widget to display a "Locations" field for an asset. + """ + + 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") + + locations = [] + for location in json.loads(cstruct): + asset_type = location["type"].split("--")[1] + url = self.request.route_url( + f"farmos_{asset_type}_assets.view", uuid=location["uuid"] + ) + locations.append(HTML.tag("li", c=tags.link_to(location["name"], url))) + + return HTML.tag("ul", c=locations) + + return super().serialize(field, cstruct, **kw) + + class AnimalTypeWidget(Widget): """ Widget to display an "animal type" field. diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 7fa6a09..76e0335 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -167,9 +167,9 @@ class AnimalAssetView(AssetMasterView): "asset_name", "animal_type", "birthdate", + "produces_eggs", "sex", "is_sterile", - "produces_eggs", "notes", "asset_type", "archived", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 5389d8f..690e7ee 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -28,7 +28,7 @@ import datetime import colander from webhelpers2.html import tags -from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos.assets import AssetMasterView @@ -39,7 +39,7 @@ from wuttafarm.web.grids import ( NullableBooleanFilter, DateTimeFilter, ) -from wuttafarm.web.forms.schema import AnimalTypeType +from wuttafarm.web.forms.schema import FarmOSRef class AnimalView(AssetMasterView): @@ -85,20 +85,23 @@ class AnimalView(AssetMasterView): "produces_eggs", "sex", "is_sterile", - "archived", + "notes", + "asset_type_name", "groups", "owners", - "location", - "notes", - "raw_image_url", - "large_image_url", - "thumbnail_image_url", + "locations", + "archived", + "thumbnail_url", + "image_url", + "thumbnail", "image", ] - def get_grid_data(self, **kwargs): - kwargs.setdefault("include", "image,animal_type,group,owner,location") - return super().get_grid_data(**kwargs) + def get_farmos_api_includes(self): + includes = super().get_farmos_api_includes() + includes.add("animal_type") + includes.add("group") + return includes def configure_grid(self, grid): g = grid @@ -188,19 +191,17 @@ class AnimalView(AssetMasterView): else: sterile = animal["attributes"]["is_castrated"] - animal_type = None - animal_type_name = None + animal_type_object = None group_objects = [] group_names = [] if relationships := animal.get("relationships"): if animal_type := relationships.get("animal_type"): if animal_type := included.get(animal_type["data"]["id"]): - animal_type = { + animal_type_object = { "uuid": animal_type["id"], "name": animal_type["attributes"]["name"], } - animal_type_name = animal_type["name"] if groups := relationships.get("group"): for group in groups["data"]: @@ -214,8 +215,9 @@ class AnimalView(AssetMasterView): normal.update( { - "animal_type": animal_type, - "animal_type_name": animal_type_name, + "animal_type": animal_type_object, + "animal_type_uuid": animal_type_object["uuid"], + "animal_type_name": animal_type_object["name"], "group_objects": group_objects, "group_names": group_names, "birthdate": birthdate, @@ -227,23 +229,78 @@ class AnimalView(AssetMasterView): return normal + def get_animal_types(self): + animal_types = [] + result = self.farmos_client.resource.get( + "taxonomy_term", "animal_type", params={"sort": "name"} + ) + for animal_type in result["data"]: + animal_types.append((animal_type["id"], animal_type["attributes"]["name"])) + return animal_types + def configure_form(self, form): f = form super().configure_form(f) + enum = self.app.enum + animal = f.model_instance # animal_type - f.set_node("animal_type", AnimalTypeType(self.request)) - - # birthdate - f.set_node("birthdate", WuttaDateTime()) - f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + f.set_node( + "animal_type", + FarmOSRef( + self.request, "farmos_animal_types", values=self.get_animal_types + ), + ) # produces_eggs f.set_node("produces_eggs", colander.Boolean()) + # birthdate + f.set_node("birthdate", WuttaDateTime()) + f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + f.set_required("birthdate", False) + + # sex + if not (self.creating or self.editing) and not animal["sex"]: + pass # TODO: dict enum widget does not handle null values well + else: + f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) + f.set_required("sex", False) + # is_sterile f.set_node("is_sterile", colander.Boolean()) + # groups + if self.creating or self.editing: + f.remove("groups") # TODO + + def get_api_payload(self, animal): + payload = super().get_api_payload(animal) + + birthdate = None + if animal["birthdate"]: + birthdate = self.app.localtime(animal["birthdate"]).timestamp() + + attrs = { + "sex": animal["sex"] or None, + "is_sterile": animal["is_sterile"], + "produces_eggs": animal["produces_eggs"], + "birthdate": birthdate, + } + + rels = { + "animal_type": { + "data": { + "id": animal["animal_type"], + "type": "taxonomy_term--animal_type", + } + } + } + + payload["attributes"].update(attrs) + payload.setdefault("relationships", {}).update(rels) + return payload + def get_xref_buttons(self, animal): buttons = super().get_xref_buttons(animal) diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index c662cf8..f985c6b 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -27,7 +27,7 @@ import colander from webhelpers2.html import tags from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType, StructureType +from wuttafarm.web.forms.schema import FarmOSRefs, FarmOSLocationRefs from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.grids import ( ResourceData, @@ -44,13 +44,19 @@ class AssetMasterView(FarmOSMasterView): """ farmos_asset_type = None + creatable = True + editable = True + deletable = True filterable = True sort_on_backend = True labels = { "name": "Asset Name", + "asset_type_name": "Asset Type", "owners": "Owner", "locations": "Location", + "thumbnail_url": "Thumbnail URL", + "image_url": "Image URL", } grid_columns = [ @@ -69,14 +75,13 @@ class AssetMasterView(FarmOSMasterView): "archived": {"active": True, "verb": "is_false"}, } - def get_grid_data(self, columns=None, session=None, **kwargs): - kwargs.setdefault("include", "image,owner,location") - kwargs.setdefault("normalizer", self.normalize_asset) + def get_grid_data(self, **kwargs): return ResourceData( self.config, self.farmos_client, f"asset--{self.farmos_asset_type}", - **kwargs, + include=",".join(self.get_farmos_api_includes()), + normalizer=self.normalize_asset, ) def configure_grid(self, grid): @@ -142,60 +147,19 @@ class AssetMasterView(FarmOSMasterView): return "has-background-warning" return None + def get_farmos_api_includes(self): + return {"asset_type", "location", "owner", "image"} + def get_instance(self): asset = self.farmos_client.resource.get_id( - "asset", self.farmos_asset_type, self.request.matchdict["uuid"] + "asset", + self.farmos_asset_type, + self.request.matchdict["uuid"], + params={"include": ",".join(self.get_farmos_api_includes())}, ) self.raw_json = asset - - # instance data - data = self.normalize_asset(asset["data"], {}) - - if relationships := asset["data"].get("relationships"): - - # add location - if location := relationships.get("location"): - if location["data"]: - location = self.farmos_client.resource.get_id( - "asset", "structure", location["data"][0]["id"] - ) - data["location"] = { - "uuid": location["data"]["id"], - "name": location["data"]["attributes"]["name"], - } - - # 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 + included = {obj["id"]: obj for obj in asset.get("included", [])} + return self.normalize_asset(asset["data"], included) def get_instance_title(self, asset): return asset["name"] @@ -210,13 +174,24 @@ class AssetMasterView(FarmOSMasterView): else: archived = asset["attributes"]["status"] == "archived" + asset_type_object = {} + asset_type_name = None owner_objects = [] owner_names = [] location_objects = [] location_names = [] thumbnail_url = None + image_url = None if relationships := asset.get("relationships"): + if asset_type := relationships.get("asset_type"): + if asset_type := included.get(asset_type["data"]["id"]): + asset_type_object = { + "uuid": asset_type["id"], + "name": asset_type["attributes"]["label"], + } + asset_type_name = asset_type_object["name"] + if owners := relationships.get("owner"): for user in owners["data"]: if user := included.get(user["id"]): @@ -244,42 +219,102 @@ class AssetMasterView(FarmOSMasterView): thumbnail_url = image["attributes"]["image_style_uri"][ "thumbnail" ] + image_url = image["attributes"]["image_style_uri"]["large"] return { "uuid": asset["id"], "drupal_id": asset["attributes"]["drupal_internal__id"], "name": asset["attributes"]["name"], - "location": colander.null, # TODO + "asset_type": asset_type_object, + "asset_type_name": asset_type_name, "notes": notes or colander.null, "owners": owner_objects, "owner_names": owner_names, "locations": location_objects, "location_names": location_names, "archived": archived, - "thumbnail_url": thumbnail_url, + "thumbnail_url": thumbnail_url or colander.null, + "image_url": image_url or colander.null, } def configure_form(self, form): f = form super().configure_form(f) - animal = f.model_instance + asset = f.model_instance - # location - f.set_node("location", StructureType(self.request)) + # asset_type_name + if self.creating or self.editing: + f.remove("asset_type_name") + + # locations + if self.creating or self.editing: + f.remove("locations") + else: + f.set_node("locations", FarmOSLocationRefs(self.request)) # owners - f.set_node("owners", UsersType(self.request)) + if self.creating or self.editing: + f.remove("owners") # TODO + else: + f.set_node("owners", FarmOSRefs(self.request, "farmos_users")) # notes f.set_widget("notes", "notes") + f.set_required("notes", False) # archived f.set_node("archived", colander.Boolean()) + # thumbnail_url + if self.creating or self.editing: + f.remove("thumbnail_url") + + # image_url + if self.creating or self.editing: + f.remove("image_url") + + # thumbnail + if self.creating or self.editing: + f.remove("thumbnail") + elif asset.get("thumbnail_url"): + f.set_widget("thumbnail", ImageWidget("asset thumbnail")) + f.set_default("thumbnail", asset["thumbnail_url"]) + # image - if url := animal.get("large_image_url"): - f.set_widget("image", ImageWidget("animal image")) - f.set_default("image", url) + if self.creating or self.editing: + f.remove("image") + elif asset.get("image_url"): + f.set_widget("image", ImageWidget("asset image")) + f.set_default("image", asset["image_url"]) + + def persist(self, asset, session=None): + payload = self.get_api_payload(asset) + if self.editing: + payload["id"] = asset["uuid"] + + result = self.farmos_client.asset.send(self.farmos_asset_type, payload) + + if self.creating: + asset["uuid"] = result["data"]["id"] + + def get_api_payload(self, asset): + + attrs = { + "name": asset["name"], + "notes": {"value": asset["notes"] or None}, + "archived": asset["archived"], + } + + if "is_location" in asset: + attrs["is_location"] = asset["is_location"] + + if "is_fixed" in asset: + attrs["is_fixed"] = asset["is_fixed"] + + return {"attributes": attrs} + + def delete_instance(self, asset): + self.farmos_client.asset.delete(self.farmos_asset_type, asset["uuid"]) def get_xref_buttons(self, asset): return [ diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index ae4b97e..5c4c635 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -31,9 +31,16 @@ import markdown from wuttaweb.views import MasterView from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.util import save_farmos_oauth2_token, use_farmos_style_grid_links -from wuttafarm.web.grids import ResourceData, StringFilter, SimpleSorter +from wuttafarm.web.grids import ( + ResourceData, + StringFilter, + NullableStringFilter, + DateTimeFilter, + SimpleSorter, +) class FarmOSMasterView(MasterView): @@ -114,6 +121,9 @@ class TaxonomyMasterView(FarmOSMasterView): """ farmos_taxonomy_type = None + creatable = True + editable = True + deletable = True filterable = True sort_on_backend = True @@ -143,7 +153,7 @@ class TaxonomyMasterView(FarmOSMasterView): normalizer=self.normalize_taxonomy_term, ) - def normalize_taxonomy_term(self, term): + def normalize_taxonomy_term(self, term, included): if changed := term["attributes"]["changed"]: changed = datetime.datetime.fromisoformat(changed) @@ -169,15 +179,21 @@ class TaxonomyMasterView(FarmOSMasterView): g.set_sorter("name", SimpleSorter("name")) g.set_filter("name", StringFilter) + # description + g.set_sorter("description", SimpleSorter("description.value")) + g.set_filter("description", NullableStringFilter, path="description.value") + # changed g.set_renderer("changed", "datetime") + g.set_sorter("changed", SimpleSorter("changed")) + g.set_filter("changed", DateTimeFilter) def get_instance(self): result = self.farmos_client.resource.get_id( "taxonomy_term", self.farmos_taxonomy_type, self.request.matchdict["uuid"] ) self.raw_json = result - return self.normalize_taxonomy_term(result["data"]) + return self.normalize_taxonomy_term(result["data"], {}) def get_instance_title(self, term): return term["name"] @@ -188,9 +204,44 @@ class TaxonomyMasterView(FarmOSMasterView): # description f.set_widget("description", "notes") + f.set_required("description", False) # changed - f.set_node("changed", WuttaDateTime()) + if self.creating or self.editing: + f.remove("changed") + else: + f.set_node("changed", WuttaDateTime()) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + def get_api_payload(self, term): + + attrs = { + "name": term["name"], + } + + if description := term["description"]: + attrs["description"] = {"value": description} + else: + attrs["description"] = None + + return {"attributes": attrs} + + def persist(self, term, session=None): + payload = self.get_api_payload(term) + if self.editing: + payload["id"] = term["uuid"] + + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + + if self.creating: + term["uuid"] = result["data"]["id"] + + def delete_instance(self, term): + self.farmos_client.resource.delete( + "taxonomy_term", self.farmos_taxonomy_type, term["uuid"] + ) def get_xref_buttons(self, term): return [