feat: add basic CRUD for direct API views: animal types, animal assets

This commit is contained in:
Lance Edgar 2026-02-21 18:36:26 -06:00
parent c976d94bdd
commit ad6ac13d50
6 changed files with 351 additions and 97 deletions

View file

@ -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):

View file

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

View file

@ -167,9 +167,9 @@ class AnimalAssetView(AssetMasterView):
"asset_name",
"animal_type",
"birthdate",
"produces_eggs",
"sex",
"is_sterile",
"produces_eggs",
"notes",
"asset_type",
"archived",

View file

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

View file

@ -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 [

View file

@ -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
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 [