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): class FarmOSRef(colander.SchemaType):
def __init__(self, request, route_prefix, *args, **kwargs): def __init__(self, request, route_prefix, *args, **kwargs):
self.values = kwargs.pop("values", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.request = request self.request = request
self.route_prefix = route_prefix 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): def serialize(self, node, appstruct):
if appstruct is colander.null: if appstruct is colander.null:
return colander.null return colander.null
@ -69,10 +109,23 @@ class FarmOSRef(colander.SchemaType):
return json.dumps(appstruct) return json.dumps(appstruct)
def widget_maker(self, **kwargs): def widget_maker(self, **kwargs):
""" """ from wuttafarm.web.forms.widgets import FarmOSRefsWidget
from wuttafarm.web.forms.widgets import FarmOSRefWidget
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): class AnimalTypeType(colander.SchemaType):

View file

@ -26,7 +26,7 @@ Custom form widgets for WuttaFarm
import json import json
import colander import colander
from deform.widget import Widget from deform.widget import Widget, SelectWidget
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget
@ -54,7 +54,7 @@ class ImageWidget(Widget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
class FarmOSRefWidget(Widget): class FarmOSRefWidget(SelectWidget):
""" """
Generic widget to display "any reference field" - as a link to Generic widget to display "any reference field" - as a link to
view the farmOS record it references. Only used by the farmOS view the farmOS record it references. Only used by the farmOS
@ -72,7 +72,12 @@ class FarmOSRefWidget(Widget):
if cstruct in (colander.null, None): if cstruct in (colander.null, None):
return HTML.tag("span") 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( return tags.link_to(
obj["name"], obj["name"],
self.request.route_url(f"{self.route_prefix}.view", uuid=obj["uuid"]), 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) 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): class AnimalTypeWidget(Widget):
""" """
Widget to display an "animal type" field. Widget to display an "animal type" field.

View file

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

View file

@ -28,7 +28,7 @@ import datetime
import colander import colander
from webhelpers2.html import tags 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 wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos.assets import AssetMasterView from wuttafarm.web.views.farmos.assets import AssetMasterView
@ -39,7 +39,7 @@ from wuttafarm.web.grids import (
NullableBooleanFilter, NullableBooleanFilter,
DateTimeFilter, DateTimeFilter,
) )
from wuttafarm.web.forms.schema import AnimalTypeType from wuttafarm.web.forms.schema import FarmOSRef
class AnimalView(AssetMasterView): class AnimalView(AssetMasterView):
@ -85,20 +85,23 @@ class AnimalView(AssetMasterView):
"produces_eggs", "produces_eggs",
"sex", "sex",
"is_sterile", "is_sterile",
"archived", "notes",
"asset_type_name",
"groups", "groups",
"owners", "owners",
"location", "locations",
"notes", "archived",
"raw_image_url", "thumbnail_url",
"large_image_url", "image_url",
"thumbnail_image_url", "thumbnail",
"image", "image",
] ]
def get_grid_data(self, **kwargs): def get_farmos_api_includes(self):
kwargs.setdefault("include", "image,animal_type,group,owner,location") includes = super().get_farmos_api_includes()
return super().get_grid_data(**kwargs) includes.add("animal_type")
includes.add("group")
return includes
def configure_grid(self, grid): def configure_grid(self, grid):
g = grid g = grid
@ -188,19 +191,17 @@ class AnimalView(AssetMasterView):
else: else:
sterile = animal["attributes"]["is_castrated"] sterile = animal["attributes"]["is_castrated"]
animal_type = None animal_type_object = None
animal_type_name = None
group_objects = [] group_objects = []
group_names = [] group_names = []
if relationships := animal.get("relationships"): if relationships := animal.get("relationships"):
if animal_type := relationships.get("animal_type"): if animal_type := relationships.get("animal_type"):
if animal_type := included.get(animal_type["data"]["id"]): if animal_type := included.get(animal_type["data"]["id"]):
animal_type = { animal_type_object = {
"uuid": animal_type["id"], "uuid": animal_type["id"],
"name": animal_type["attributes"]["name"], "name": animal_type["attributes"]["name"],
} }
animal_type_name = animal_type["name"]
if groups := relationships.get("group"): if groups := relationships.get("group"):
for group in groups["data"]: for group in groups["data"]:
@ -214,8 +215,9 @@ class AnimalView(AssetMasterView):
normal.update( normal.update(
{ {
"animal_type": animal_type, "animal_type": animal_type_object,
"animal_type_name": animal_type_name, "animal_type_uuid": animal_type_object["uuid"],
"animal_type_name": animal_type_object["name"],
"group_objects": group_objects, "group_objects": group_objects,
"group_names": group_names, "group_names": group_names,
"birthdate": birthdate, "birthdate": birthdate,
@ -227,23 +229,78 @@ class AnimalView(AssetMasterView):
return normal 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): def configure_form(self, form):
f = form f = form
super().configure_form(f) super().configure_form(f)
enum = self.app.enum
animal = f.model_instance
# animal_type # animal_type
f.set_node("animal_type", AnimalTypeType(self.request)) f.set_node(
"animal_type",
# birthdate FarmOSRef(
f.set_node("birthdate", WuttaDateTime()) self.request, "farmos_animal_types", values=self.get_animal_types
f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) ),
)
# produces_eggs # produces_eggs
f.set_node("produces_eggs", colander.Boolean()) 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 # is_sterile
f.set_node("is_sterile", colander.Boolean()) 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): def get_xref_buttons(self, animal):
buttons = super().get_xref_buttons(animal) buttons = super().get_xref_buttons(animal)

View file

@ -27,7 +27,7 @@ import colander
from webhelpers2.html import tags from webhelpers2.html import tags
from wuttafarm.web.views.farmos import FarmOSMasterView 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.forms.widgets import ImageWidget
from wuttafarm.web.grids import ( from wuttafarm.web.grids import (
ResourceData, ResourceData,
@ -44,13 +44,19 @@ class AssetMasterView(FarmOSMasterView):
""" """
farmos_asset_type = None farmos_asset_type = None
creatable = True
editable = True
deletable = True
filterable = True filterable = True
sort_on_backend = True sort_on_backend = True
labels = { labels = {
"name": "Asset Name", "name": "Asset Name",
"asset_type_name": "Asset Type",
"owners": "Owner", "owners": "Owner",
"locations": "Location", "locations": "Location",
"thumbnail_url": "Thumbnail URL",
"image_url": "Image URL",
} }
grid_columns = [ grid_columns = [
@ -69,14 +75,13 @@ class AssetMasterView(FarmOSMasterView):
"archived": {"active": True, "verb": "is_false"}, "archived": {"active": True, "verb": "is_false"},
} }
def get_grid_data(self, columns=None, session=None, **kwargs): def get_grid_data(self, **kwargs):
kwargs.setdefault("include", "image,owner,location")
kwargs.setdefault("normalizer", self.normalize_asset)
return ResourceData( return ResourceData(
self.config, self.config,
self.farmos_client, self.farmos_client,
f"asset--{self.farmos_asset_type}", f"asset--{self.farmos_asset_type}",
**kwargs, include=",".join(self.get_farmos_api_includes()),
normalizer=self.normalize_asset,
) )
def configure_grid(self, grid): def configure_grid(self, grid):
@ -142,60 +147,19 @@ class AssetMasterView(FarmOSMasterView):
return "has-background-warning" return "has-background-warning"
return None return None
def get_farmos_api_includes(self):
return {"asset_type", "location", "owner", "image"}
def get_instance(self): def get_instance(self):
asset = self.farmos_client.resource.get_id( 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 self.raw_json = asset
included = {obj["id"]: obj for obj in asset.get("included", [])}
# instance data return self.normalize_asset(asset["data"], included)
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
def get_instance_title(self, asset): def get_instance_title(self, asset):
return asset["name"] return asset["name"]
@ -210,13 +174,24 @@ class AssetMasterView(FarmOSMasterView):
else: else:
archived = asset["attributes"]["status"] == "archived" archived = asset["attributes"]["status"] == "archived"
asset_type_object = {}
asset_type_name = None
owner_objects = [] owner_objects = []
owner_names = [] owner_names = []
location_objects = [] location_objects = []
location_names = [] location_names = []
thumbnail_url = None thumbnail_url = None
image_url = None
if relationships := asset.get("relationships"): 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"): if owners := relationships.get("owner"):
for user in owners["data"]: for user in owners["data"]:
if user := included.get(user["id"]): if user := included.get(user["id"]):
@ -244,42 +219,102 @@ class AssetMasterView(FarmOSMasterView):
thumbnail_url = image["attributes"]["image_style_uri"][ thumbnail_url = image["attributes"]["image_style_uri"][
"thumbnail" "thumbnail"
] ]
image_url = image["attributes"]["image_style_uri"]["large"]
return { return {
"uuid": asset["id"], "uuid": asset["id"],
"drupal_id": asset["attributes"]["drupal_internal__id"], "drupal_id": asset["attributes"]["drupal_internal__id"],
"name": asset["attributes"]["name"], "name": asset["attributes"]["name"],
"location": colander.null, # TODO "asset_type": asset_type_object,
"asset_type_name": asset_type_name,
"notes": notes or colander.null, "notes": notes or colander.null,
"owners": owner_objects, "owners": owner_objects,
"owner_names": owner_names, "owner_names": owner_names,
"locations": location_objects, "locations": location_objects,
"location_names": location_names, "location_names": location_names,
"archived": archived, "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): def configure_form(self, form):
f = form f = form
super().configure_form(f) super().configure_form(f)
animal = f.model_instance asset = f.model_instance
# location # asset_type_name
f.set_node("location", StructureType(self.request)) 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 # 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 # notes
f.set_widget("notes", "notes") f.set_widget("notes", "notes")
f.set_required("notes", False)
# archived # archived
f.set_node("archived", colander.Boolean()) 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 # image
if url := animal.get("large_image_url"): if self.creating or self.editing:
f.set_widget("image", ImageWidget("animal image")) f.remove("image")
f.set_default("image", url) 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): def get_xref_buttons(self, asset):
return [ return [

View file

@ -31,9 +31,16 @@ import markdown
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.forms.schema import WuttaDateTime 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.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): class FarmOSMasterView(MasterView):
@ -114,6 +121,9 @@ class TaxonomyMasterView(FarmOSMasterView):
""" """
farmos_taxonomy_type = None farmos_taxonomy_type = None
creatable = True
editable = True
deletable = True
filterable = True filterable = True
sort_on_backend = True sort_on_backend = True
@ -143,7 +153,7 @@ class TaxonomyMasterView(FarmOSMasterView):
normalizer=self.normalize_taxonomy_term, normalizer=self.normalize_taxonomy_term,
) )
def normalize_taxonomy_term(self, term): def normalize_taxonomy_term(self, term, included):
if changed := term["attributes"]["changed"]: if changed := term["attributes"]["changed"]:
changed = datetime.datetime.fromisoformat(changed) changed = datetime.datetime.fromisoformat(changed)
@ -169,15 +179,21 @@ class TaxonomyMasterView(FarmOSMasterView):
g.set_sorter("name", SimpleSorter("name")) g.set_sorter("name", SimpleSorter("name"))
g.set_filter("name", StringFilter) g.set_filter("name", StringFilter)
# description
g.set_sorter("description", SimpleSorter("description.value"))
g.set_filter("description", NullableStringFilter, path="description.value")
# changed # changed
g.set_renderer("changed", "datetime") g.set_renderer("changed", "datetime")
g.set_sorter("changed", SimpleSorter("changed"))
g.set_filter("changed", DateTimeFilter)
def get_instance(self): def get_instance(self):
result = self.farmos_client.resource.get_id( result = self.farmos_client.resource.get_id(
"taxonomy_term", self.farmos_taxonomy_type, self.request.matchdict["uuid"] "taxonomy_term", self.farmos_taxonomy_type, self.request.matchdict["uuid"]
) )
self.raw_json = result self.raw_json = result
return self.normalize_taxonomy_term(result["data"]) return self.normalize_taxonomy_term(result["data"], {})
def get_instance_title(self, term): def get_instance_title(self, term):
return term["name"] return term["name"]
@ -188,9 +204,44 @@ class TaxonomyMasterView(FarmOSMasterView):
# description # description
f.set_widget("description", "notes") f.set_widget("description", "notes")
f.set_required("description", False)
# changed # 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): def get_xref_buttons(self, term):
return [ return [