From aecbfc6c024af435655151da16059facda38943a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 5 Mar 2026 20:06:17 -0600 Subject: [PATCH 01/20] fix: fix Assets column for All Logs subgrid when viewing asset --- src/wuttafarm/web/views/assets.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b4e4d31..923108c 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -373,6 +373,23 @@ class AssetMasterView(WuttaFarmMasterView): g.set_filter("log_type", model.Log.log_type) g.set_enum("log_type", get_log_type_enum(self.config, session=session)) + # assets + g.set_renderer("assets", self.render_assets_for_grid) + + def render_assets_for_grid(self, log, field, value): + assets = getattr(log, field) + + if self.farmos_style_grid_links: + links = [] + for asset in assets: + url = self.request.route_url( + f"{asset.asset_type}_assets.view", uuid=asset.uuid + ) + links.append(tags.link_to(str(asset), url)) + return ", ".join(links) + + return ", ".join([str(a) for a in assets]) + def get_row_action_url_view(self, log, i): return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) From 3336294b3b8490f0dfa4ee82bc0b4ea1e292f2df Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 5 Mar 2026 20:30:39 -0600 Subject: [PATCH 02/20] fix: allow "N/A" option for animal sex --- src/wuttafarm/web/views/animals.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index f4c97e2..d8e4edf 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -23,6 +23,8 @@ Master view for Animals """ +from collections import OrderedDict + from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum @@ -294,7 +296,9 @@ class AnimalAssetView(AssetMasterView): if not (self.creating or self.editing) and animal.sex is None: pass # TODO: dict enum widget does not handle null values well else: - f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) + # nb. ensure empty option appears like we want + sex_enum = OrderedDict([("", "N/A")] + list(enum.ANIMAL_SEX.items())) + f.set_node("sex", WuttaDictEnum(self.request, sex_enum)) f.set_required("sex", False) From d46ba43d117405ad93c59ed2ee45aee23f39be55 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 5 Mar 2026 20:43:44 -0600 Subject: [PATCH 03/20] fix: expose `is_location` and `is_fixed` for editing on Animal Asset --- src/wuttafarm/farmos/importing/model.py | 2 ++ src/wuttafarm/farmos/importing/wuttafarm.py | 4 +++ src/wuttafarm/web/views/animals.py | 37 +++++++++------------ src/wuttafarm/web/views/assets.py | 18 ++++++++++ 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index ad1cb38..54e67ac 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -245,6 +245,8 @@ class AnimalAssetImporter(ToFarmOSAsset): "is_sterile", "produces_eggs", "birthdate", + "is_location", + "is_fixed", "notes", "archived", ] diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 8394e4c..8cf0e92 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -149,6 +149,8 @@ class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImpor "is_sterile", "produces_eggs", "birthdate", + "is_location", + "is_fixed", "notes", "archived", ] @@ -162,6 +164,8 @@ class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImpor "is_sterile": animal.is_sterile, "produces_eggs": animal.produces_eggs, "birthdate": animal.birthdate, + "is_location": animal.is_location, + "is_fixed": animal.is_fixed, "notes": animal.notes, "archived": animal.archived, "_src_object": animal, diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index d8e4edf..ad9f060 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -28,6 +28,7 @@ from collections import OrderedDict from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum +from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttaweb.util import get_form_data from wuttafarm.db.model import AnimalType, AnimalAsset @@ -236,27 +237,6 @@ class AnimalAssetView(AssetMasterView): "archived", ] - form_fields = [ - "asset_name", - "animal_type", - "birthdate", - "produces_eggs", - "sex", - "is_sterile", - "notes", - "asset_type", - "owners", - "locations", - "groups", - "archived", - "drupal_id", - "farmos_uuid", - "thumbnail_url", - "image_url", - "thumbnail", - "image", - ] - def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -290,9 +270,21 @@ class AnimalAssetView(AssetMasterView): animal = f.model_instance # animal_type + f.fields.insert_after("asset_name", "animal_type") f.set_node("animal_type", AnimalTypeRef(self.request)) + # birthdate + f.fields.insert_after("animal_type", "birthdate") + # TODO: why must we assign the widget here? pretty sure that + # was not needed when we declared form_fields directly, i.e. + # instead of adding birthdate field in this method + f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + + # produces_eggs + f.fields.insert_after("birthdate", "produces_eggs") + # sex + f.fields.insert_after("produces_eggs", "sex") if not (self.creating or self.editing) and animal.sex is None: pass # TODO: dict enum widget does not handle null values well else: @@ -301,6 +293,9 @@ class AnimalAssetView(AssetMasterView): f.set_node("sex", WuttaDictEnum(self.request, sex_enum)) f.set_required("sex", False) + # is_sterile + f.fields.insert_after("sex", "is_sterile") + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 923108c..4345372 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -77,6 +77,24 @@ class AssetMasterView(WuttaFarmMasterView): "archived": {"active": True, "verb": "is_false"}, } + form_fields = [ + "asset_name", + "notes", + "asset_type", + "owners", + "locations", + "is_location", + "is_fixed", + "groups", + "archived", + "drupal_id", + "farmos_uuid", + "thumbnail_url", + "image_url", + "thumbnail", + "image", + ] + has_rows = True row_model_class = Log rows_viewable = True From e61043b9d9fa0ddc66b2e70fdff7f9bdcfa93f57 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Mar 2026 19:52:20 -0600 Subject: [PATCH 04/20] feat: add edit/sync support for asset parents plus several changes to API calls, use iterate() instead of get() also some changes to better share code for asset importers --- src/wuttafarm/farmos/importing/model.py | 54 +++-- src/wuttafarm/farmos/importing/wuttafarm.py | 220 ++++++++++-------- src/wuttafarm/importing/farmos.py | 143 +++++------- src/wuttafarm/normal.py | 23 ++ src/wuttafarm/web/forms/schema.py | 34 ++- src/wuttafarm/web/forms/widgets.py | 65 +++--- .../web/templates/deform/assetrefs.pt | 11 + .../web/templates/wuttafarm-components.mako | 115 ++++++++- src/wuttafarm/web/views/assets.py | 31 ++- src/wuttafarm/web/views/groups.py | 14 +- src/wuttafarm/web/views/plants.py | 20 +- 11 files changed, 437 insertions(+), 293 deletions(-) create mode 100644 src/wuttafarm/web/templates/deform/assetrefs.pt diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 54e67ac..ac4dc86 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -74,10 +74,11 @@ class ToFarmOSTaxonomy(ToFarmOS): ] def get_target_objects(self, **kwargs): - result = self.farmos_client.resource.get( - "taxonomy_term", self.farmos_taxonomy_type + return list( + self.farmos_client.resource.iterate( + "taxonomy_term", self.farmos_taxonomy_type + ) ) - return result["data"] def get_target_object(self, key): @@ -127,9 +128,9 @@ class ToFarmOSTaxonomy(ToFarmOS): normal["_new_object"] = result["data"] return normal - def update_target_object(self, asset, source_data, target_data=None): + def update_target_object(self, term, source_data, target_data=None): if self.dry_run: - return asset + return term payload = self.get_term_payload(source_data) payload["id"] = str(source_data["uuid"]) @@ -146,9 +147,12 @@ class ToFarmOSAsset(ToFarmOS): farmos_asset_type = None + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.normal = self.app.get_normalizer(self.farmos_client) + def get_target_objects(self, **kwargs): - assets = self.farmos_client.asset.get(self.farmos_asset_type) - return assets["data"] + return list(self.farmos_client.asset.iterate(self.farmos_asset_type)) def get_target_object(self, key): @@ -191,18 +195,17 @@ class ToFarmOSAsset(ToFarmOS): return self.normalize_target_object(result["data"]) def normalize_target_object(self, asset): - - if notes := asset["attributes"]["notes"]: - notes = notes["value"] - + normal = self.normal.normalize_farmos_asset(asset) return { - "uuid": UUID(asset["id"]), - "asset_name": asset["attributes"]["name"], - "is_location": asset["attributes"]["is_location"], - "is_fixed": asset["attributes"]["is_fixed"], - "produces_eggs": asset["attributes"].get("produces_eggs"), - "notes": notes, - "archived": asset["attributes"]["archived"], + "uuid": UUID(normal["uuid"]), + "asset_name": normal["asset_name"], + "is_location": normal["is_location"], + "is_fixed": normal["is_fixed"], + # nb. this is only used for certain asset types + "produces_eggs": normal["produces_eggs"], + "parents": [(p["asset_type"], UUID(p["uuid"])) for p in normal["parents"]], + "notes": normal["notes"], + "archived": normal["archived"], } def get_asset_payload(self, source_data): @@ -221,8 +224,18 @@ class ToFarmOSAsset(ToFarmOS): if "archived" in self.fields: attrs["archived"] = source_data["archived"] - payload = {"attributes": attrs} + rels = {} + if "parents" in self.fields: + rels["parent"] = {"data": []} + for asset_type, uuid in source_data["parents"]: + rels["parent"]["data"].append( + { + "id": str(uuid), + "type": f"asset--{asset_type}", + } + ) + payload = {"attributes": attrs, "relationships": rels} return payload @@ -607,8 +620,7 @@ class ToFarmOSLog(ToFarmOS): self.normal = self.app.get_normalizer(self.farmos_client) def get_target_objects(self, **kwargs): - result = self.farmos_client.log.get(self.farmos_log_type) - return result["data"] + return list(self.farmos_client.log.iterate(self.farmos_log_type)) def get_target_object(self, key): diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 8cf0e92..f4f4948 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -134,42 +134,68 @@ class FromWuttaFarm(FromWutta): return obj -class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImporter): +class FromWuttaFarmAsset(FromWuttaFarm): + """ + Base class for WuttaFarm → farmOS API asset exporters + """ + + supported_fields = [ + "uuid", + "asset_name", + "is_location", + "is_fixed", + "parents", + "notes", + "archived", + ] + + def normalize_source_object(self, asset): + return { + "uuid": asset.farmos_uuid or self.app.make_true_uuid(), + "asset_name": asset.asset_name, + "is_location": asset.is_location, + "is_fixed": asset.is_fixed, + "parents": [(p.asset_type, p.farmos_uuid) for p in asset.parents], + "notes": asset.notes, + "archived": asset.archived, + "_src_object": asset, + } + + +class AnimalAssetImporter( + FromWuttaFarmAsset, farmos_importing.model.AnimalAssetImporter +): """ WuttaFarm → farmOS API exporter for Animal Assets """ source_model_class = model.AnimalAsset - supported_fields = [ - "uuid", - "asset_name", - "animal_type_uuid", - "sex", - "is_sterile", - "produces_eggs", - "birthdate", - "is_location", - "is_fixed", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "animal_type_uuid", + "sex", + "is_sterile", + "produces_eggs", + "birthdate", + ] + ) + return fields def normalize_source_object(self, animal): - return { - "uuid": animal.farmos_uuid or self.app.make_true_uuid(), - "asset_name": animal.asset_name, - "animal_type_uuid": animal.animal_type.farmos_uuid, - "sex": animal.sex, - "is_sterile": animal.is_sterile, - "produces_eggs": animal.produces_eggs, - "birthdate": animal.birthdate, - "is_location": animal.is_location, - "is_fixed": animal.is_fixed, - "notes": animal.notes, - "archived": animal.archived, - "_src_object": animal, - } + data = super().normalize_source_object(animal) + data.update( + { + "animal_type_uuid": animal.animal_type.farmos_uuid, + "sex": animal.sex, + "is_sterile": animal.is_sterile, + "produces_eggs": animal.produces_eggs, + "birthdate": animal.birthdate, + } + ) + return data class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporter): @@ -216,60 +242,56 @@ class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter): } -class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter): +class GroupAssetImporter(FromWuttaFarmAsset, farmos_importing.model.GroupAssetImporter): """ WuttaFarm → farmOS API exporter for Group Assets """ source_model_class = model.GroupAsset - supported_fields = [ - "uuid", - "asset_name", - "produces_eggs", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "produces_eggs", + ] + ) + return fields def normalize_source_object(self, group): - return { - "uuid": group.farmos_uuid or self.app.make_true_uuid(), - "asset_name": group.asset_name, - "produces_eggs": group.produces_eggs, - "notes": group.notes, - "archived": group.archived, - "_src_object": group, - } + data = super().normalize_source_object(group) + data.update( + { + "produces_eggs": group.produces_eggs, + } + ) + return data -class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter): +class LandAssetImporter(FromWuttaFarmAsset, farmos_importing.model.LandAssetImporter): """ WuttaFarm → farmOS API exporter for Land Assets """ source_model_class = model.LandAsset - supported_fields = [ - "uuid", - "asset_name", - "land_type_id", - "is_location", - "is_fixed", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "land_type_id", + ] + ) + return fields def normalize_source_object(self, land): - return { - "uuid": land.farmos_uuid or self.app.make_true_uuid(), - "asset_name": land.asset_name, - "land_type_id": land.land_type.drupal_id, - "is_location": land.is_location, - "is_fixed": land.is_fixed, - "notes": land.notes, - "archived": land.archived, - "_src_object": land, - } + data = super().normalize_source_object(land) + data.update( + { + "land_type_id": land.land_type.drupal_id, + } + ) + return data class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter): @@ -294,34 +316,36 @@ class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter) } -class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter): +class PlantAssetImporter(FromWuttaFarmAsset, farmos_importing.model.PlantAssetImporter): """ WuttaFarm → farmOS API exporter for Plant Assets """ source_model_class = model.PlantAsset - supported_fields = [ - "uuid", - "asset_name", - "plant_type_uuids", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "plant_type_uuids", + ] + ) + return fields def normalize_source_object(self, plant): - return { - "uuid": plant.farmos_uuid or self.app.make_true_uuid(), - "asset_name": plant.asset_name, - "plant_type_uuids": [t.plant_type.farmos_uuid for t in plant._plant_types], - "notes": plant.notes, - "archived": plant.archived, - "_src_object": plant, - } + data = super().normalize_source_object(plant) + data.update( + { + "plant_type_uuids": [ + t.plant_type.farmos_uuid for t in plant._plant_types + ], + } + ) + return data class StructureAssetImporter( - FromWuttaFarm, farmos_importing.model.StructureAssetImporter + FromWuttaFarmAsset, farmos_importing.model.StructureAssetImporter ): """ WuttaFarm → farmOS API exporter for Structure Assets @@ -329,27 +353,23 @@ class StructureAssetImporter( source_model_class = model.StructureAsset - supported_fields = [ - "uuid", - "asset_name", - "structure_type_id", - "is_location", - "is_fixed", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "structure_type_id", + ] + ) + return fields def normalize_source_object(self, structure): - return { - "uuid": structure.farmos_uuid or self.app.make_true_uuid(), - "asset_name": structure.asset_name, - "structure_type_id": structure.structure_type.drupal_id, - "is_location": structure.is_location, - "is_fixed": structure.is_fixed, - "notes": structure.notes, - "archived": structure.archived, - "_src_object": structure, - } + data = super().normalize_source_object(structure) + data.update( + { + "structure_type_id": structure.structure_type.drupal_id, + } + ) + return data ############################## diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 6b21090..ea1fd4b 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -330,21 +330,18 @@ class AnimalAssetImporter(AssetImporterBase): model_class = model.AnimalAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "animal_type_uuid", - "sex", - "is_sterile", - "produces_eggs", - "birthdate", - "notes", - "archived", - "image_url", - "thumbnail_url", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "animal_type_uuid", + "sex", + "is_sterile", + "produces_eggs", + "birthdate", + ] + ) + return fields def setup(self): super().setup() @@ -415,8 +412,7 @@ class AnimalTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type") - return animal_types["data"] + return list(self.farmos_client.resource.iterate("taxonomy_term", "animal_type")) def normalize_source_object(self, animal_type): """ """ @@ -444,8 +440,7 @@ class AssetTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - asset_types = self.farmos_client.resource.get("asset_type") - return asset_types["data"] + return list(self.farmos_client.resource.iterate("asset_type")) def normalize_source_object(self, asset_type): """ """ @@ -464,20 +459,14 @@ class GroupAssetImporter(AssetImporterBase): model_class = model.GroupAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "is_location", - "is_fixed", - "produces_eggs", - "notes", - "archived", - "image_url", - "thumbnail_url", - "parents", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "produces_eggs", + ] + ) + return fields def normalize_source_object(self, group): """ """ @@ -497,18 +486,14 @@ class LandAssetImporter(AssetImporterBase): model_class = model.LandAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "land_type_uuid", - "is_location", - "is_fixed", - "notes", - "archived", - "parents", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "land_type_uuid", + ] + ) + return fields def setup(self): """ """ @@ -553,8 +538,7 @@ class LandTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - land_types = self.farmos_client.resource.get("land_type") - return land_types["data"] + return list(self.farmos_client.resource.iterate("land_type")) def normalize_source_object(self, land_type): """ """ @@ -581,8 +565,7 @@ class PlantTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - result = self.farmos_client.resource.get("taxonomy_term", "plant_type") - return result["data"] + return list(self.farmos_client.resource.iterate("taxonomy_term", "plant_type")) def normalize_source_object(self, plant_type): """ """ @@ -601,17 +584,14 @@ class PlantAssetImporter(AssetImporterBase): model_class = model.PlantAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "plant_types", - "notes", - "archived", - "image_url", - "thumbnail_url", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "plant_types", + ] + ) + return fields def setup(self): super().setup() @@ -624,6 +604,8 @@ class PlantAssetImporter(AssetImporterBase): def normalize_source_object(self, plant): """ """ + data = super().normalize_source_object(plant) + plant_types = [] if relationships := plant.get("relationships"): @@ -637,7 +619,6 @@ class PlantAssetImporter(AssetImporterBase): else: log.warning("plant type not found: %s", plant_type["id"]) - data = super().normalize_source_object(plant) data.update( { "plant_types": set(plant_types), @@ -693,20 +674,14 @@ class StructureAssetImporter(AssetImporterBase): model_class = model.StructureAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "structure_type_uuid", - "is_location", - "is_fixed", - "notes", - "archived", - "image_url", - "thumbnail_url", - "parents", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "structure_type_uuid", + ] + ) + return fields def setup(self): super().setup() @@ -752,8 +727,7 @@ class StructureTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - structure_types = self.farmos_client.resource.get("structure_type") - return structure_types["data"] + return list(self.farmos_client.resource.iterate("structure_type")) def normalize_source_object(self, structure_type): """ """ @@ -791,8 +765,7 @@ class UserImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - users = self.farmos_client.resource.get("user") - return users["data"] + return list(self.farmos_client.resource.iterate("user")) def normalize_source_object(self, user): """ """ @@ -869,8 +842,7 @@ class UnitImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - result = self.farmos_client.resource.get("taxonomy_term", "unit") - return result["data"] + return list(self.farmos_client.resource.iterate("taxonomy_term", "unit")) def normalize_source_object(self, unit): """ """ @@ -898,8 +870,7 @@ class QuantityTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - result = self.farmos_client.resource.get("quantity_type") - return result["data"] + return list(self.farmos_client.resource.iterate("quantity_type")) def normalize_source_object(self, quantity_type): """ """ @@ -927,8 +898,7 @@ class LogTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - log_types = self.farmos_client.resource.get("log_type") - return log_types["data"] + return list(self.farmos_client.resource.iterate("log_type")) def normalize_source_object(self, log_type): """ """ @@ -1271,8 +1241,7 @@ class QuantityImporterBase(FromFarmOS, ToWutta): def get_source_objects(self): """ """ quantity_type = self.get_farmos_quantity_type() - result = self.farmos_client.resource.get("quantity", quantity_type) - return result["data"] + return list(self.farmos_client.resource.iterate("quantity", quantity_type)) def get_quantity_type_by_farmos_uuid(self, uuid): if hasattr(self, "quantity_types_by_farmos_uuid"): diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index 4fc8796..2e38f49 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -90,10 +90,29 @@ class Normalizer(GenericHandler): if notes := asset["attributes"]["notes"]: notes = notes["value"] + parent_objects = [] + parent_uuids = [] owner_objects = [] owner_uuids = [] if relationships := asset.get("relationships"): + if parents := relationships.get("parent"): + for parent in parents["data"]: + parent_uuid = parent["id"] + parent_uuids.append(parent_uuid) + parent_object = { + "uuid": parent_uuid, + "type": parent["type"], + "asset_type": parent["type"].split("--")[1], + } + if parent := included.get(parent_uuid): + parent_object.update( + { + "name": parent["attributes"]["name"], + } + ) + parent_objects.append(parent_object) + if owners := relationships.get("owner"): for user in owners["data"]: user_uuid = user["id"] @@ -114,6 +133,10 @@ class Normalizer(GenericHandler): "is_fixed": asset["attributes"]["is_fixed"], "archived": asset["attributes"]["archived"], "notes": notes, + # nb. this is only used for certain asset types + "produces_eggs": asset["attributes"].get("produces_eggs"), + "parents": parent_objects, + "parent_uuids": parent_uuids, "owners": owner_objects, "owner_uuids": owner_uuids, } diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 6bf434e..bad5670 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -372,37 +372,35 @@ class UsersType(colander.SchemaType): return UsersWidget(self.request, **kwargs) -class AssetParentRefs(WuttaSet): - """ - Schema type for Parents field which references assets. - """ - - def serialize(self, node, appstruct): - if not appstruct: - appstruct = [] - uuids = [u.hex for u in appstruct] - return json.dumps(uuids) - - def widget_maker(self, **kwargs): - from wuttafarm.web.forms.widgets import AssetParentRefsWidget - - return AssetParentRefsWidget(self.request, **kwargs) - - class AssetRefs(WuttaSet): """ Schema type for Assets field (on a Log record) """ + def __init__(self, request, for_asset=None, **kwargs): + super().__init__(request, **kwargs) + self.for_asset = for_asset + def serialize(self, node, appstruct): if not appstruct: return colander.null - return {asset.uuid for asset in appstruct} + return {asset.uuid.hex for asset in appstruct} def widget_maker(self, **kwargs): from wuttafarm.web.forms.widgets import AssetRefsWidget + model = self.app.model + session = Session() + + if "values" not in kwargs: + query = session.query(model.Asset) + if self.for_asset: + query = query.filter(model.Asset.uuid != self.for_asset.uuid) + query = query.order_by(model.Asset.asset_name) + values = [(asset.uuid.hex, str(asset)) for asset in query] + kwargs["values"] = values + return AssetRefsWidget(self.request, **kwargs) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 0a14638..d74a436 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -393,42 +393,20 @@ class UsersWidget(Widget): ############################## -class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): - """ - Widget for Parents field which references assets. - """ - - def serialize(self, field, cstruct, **kw): - """ """ - model = self.app.model - session = Session() - - readonly = kw.get("readonly", self.readonly) - if readonly: - parents = [] - for uuid in json.loads(cstruct): - parent = session.get(model.Asset, uuid) - parents.append( - HTML.tag( - "li", - c=tags.link_to( - str(parent), - self.request.route_url( - f"{parent.asset_type}_assets.view", uuid=parent.uuid - ), - ), - ) - ) - return HTML.tag("ul", c=parents) - - return super().serialize(field, cstruct, **kw) - - -class AssetRefsWidget(WuttaCheckboxChoiceWidget): +class AssetRefsWidget(Widget): """ Widget for Assets field (of various kinds). """ + template = "assetrefs" + values = () + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + def serialize(self, field, cstruct, **kw): """ """ model = self.app.model @@ -452,7 +430,28 @@ class AssetRefsWidget(WuttaCheckboxChoiceWidget): ) return HTML.tag("ul", c=assets) - return super().serialize(field, cstruct, **kw) + values = kw.get("values", self.values) + if not isinstance(values, sequence_types): + raise TypeError("Values must be a sequence type (list, tuple, or range).") + + kw["values"] = _normalize_choices(values) + tmpl_values = self.get_template_values(field, cstruct, kw) + return field.renderer(self.template, **tmpl_values) + + def get_template_values(self, field, cstruct, kw): + """ """ + values = super().get_template_values(field, cstruct, kw) + + values["js_values"] = json.dumps(values["values"]) + + return values + + def deserialize(self, field, pstruct): + """ """ + if not pstruct: + return colander.null + + return set(pstruct.split(",")) class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): diff --git a/src/wuttafarm/web/templates/deform/assetrefs.pt b/src/wuttafarm/web/templates/deform/assetrefs.pt new file mode 100644 index 0000000..b2e9660 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/assetrefs.pt @@ -0,0 +1,11 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako index 37b176e..1a07690 100644 --- a/src/wuttafarm/web/templates/wuttafarm-components.mako +++ b/src/wuttafarm/web/templates/wuttafarm-components.mako @@ -1,9 +1,116 @@ <%def name="make_wuttafarm_components()"> + ${self.make_assets_picker_component()} ${self.make_animal_type_picker_component()} ${self.make_plant_types_picker_component()} +<%def name="make_assets_picker_component()"> + + + + <%def name="make_animal_type_picker_component()"> + +<%def name="make_seasons_picker_component()"> + + + diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 674d76e..445f810 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -95,6 +95,8 @@ class CommonView(base.CommonView): "farmos_quantities_standard.view", "farmos_quantity_types.list", "farmos_quantity_types.view", + "farmos_seasons.list", + "farmos_seasons.view", "farmos_structure_assets.list", "farmos_structure_assets.view", "farmos_structure_types.list", @@ -132,6 +134,12 @@ class CommonView(base.CommonView): "logs_observation.view", "logs_observation.versions", "quick.eggs", + "plant_types.list", + "plant_types.view", + "plant_types.versions", + "seasons.list", + "seasons.view", + "seasons.versions", "structure_types.list", "structure_types.view", "structure_types.versions", diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py index 57bf2d4..40768c4 100644 --- a/src/wuttafarm/web/views/farmos/plants.py +++ b/src/wuttafarm/web/views/farmos/plants.py @@ -32,7 +32,12 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos.master import TaxonomyMasterView from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType, StructureType, FarmOSPlantTypes +from wuttafarm.web.forms.schema import ( + UsersType, + StructureType, + FarmOSPlantTypes, + FarmOSRefs, +) from wuttafarm.web.forms.widgets import ImageWidget @@ -75,6 +80,43 @@ class PlantTypeView(TaxonomyMasterView): return buttons +class SeasonView(TaxonomyMasterView): + """ + Master view for Seasons in farmOS. + """ + + model_name = "farmos_season" + model_title = "farmOS Season" + model_title_plural = "farmOS Seasons" + + route_prefix = "farmos_seasons" + url_prefix = "/farmOS/seasons" + + farmos_taxonomy_type = "season" + farmos_refurl_path = "/admin/structure/taxonomy/manage/season/overview" + + def get_xref_buttons(self, season): + buttons = super().get_xref_buttons(season) + model = self.app.model + session = self.Session() + + if wf_season := ( + session.query(model.Season) + .filter(model.Season.farmos_uuid == season["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("seasons.view", uuid=wf_season.uuid), + icon_left="eye", + ) + ) + + return buttons + + class PlantAssetView(FarmOSMasterView): """ Master view for farmOS Plant Assets @@ -89,6 +131,10 @@ class PlantAssetView(FarmOSMasterView): farmos_refurl_path = "/assets/plant" + labels = { + "seasons": "Season", + } + grid_columns = [ "name", "archived", @@ -99,6 +145,7 @@ class PlantAssetView(FarmOSMasterView): form_fields = [ "name", "plant_types", + "seasons", "archived", "owners", "location", @@ -151,6 +198,21 @@ class PlantAssetView(FarmOSMasterView): } ) + # add seasons + if seasons := relationships.get("season"): + if seasons["data"]: + data["seasons"] = [] + for season in seasons["data"]: + season = self.farmos_client.resource.get_id( + "taxonomy_term", "season", season["id"] + ) + data["seasons"].append( + { + "uuid": season["data"]["id"], + "name": season["data"]["attributes"]["name"], + } + ) + # add location if location := relationships.get("location"): if location["data"]: @@ -199,22 +261,14 @@ class PlantAssetView(FarmOSMasterView): return plant["name"] def normalize_plant(self, plant): - - if notes := plant["attributes"]["notes"]: - notes = notes["value"] - - if self.farmos_4x: - archived = plant["attributes"]["archived"] - else: - archived = plant["attributes"]["status"] == "archived" - + normal = self.normal.normalize_farmos_asset(plant) return { - "uuid": plant["id"], - "drupal_id": plant["attributes"]["drupal_internal__id"], - "name": plant["attributes"]["name"], + "uuid": normal["uuid"], + "drupal_id": normal["drupal_id"], + "name": normal["asset_name"], "location": colander.null, # TODO - "archived": archived, - "notes": notes or colander.null, + "archived": normal["archived"], + "notes": normal["notes"] or colander.null, } def configure_form(self, form): @@ -225,6 +279,9 @@ class PlantAssetView(FarmOSMasterView): # plant_types f.set_node("plant_types", FarmOSPlantTypes(self.request)) + # seasons + f.set_node("seasons", FarmOSRefs(self.request, "farmos_seasons")) + # location f.set_node("location", StructureType(self.request)) @@ -279,6 +336,9 @@ def defaults(config, **kwargs): PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) PlantTypeView.defaults(config) + SeasonView = kwargs.get("SeasonView", base["SeasonView"]) + SeasonView.defaults(config) + PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) PlantAssetView.defaults(config) diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index 2d4bdce..16bd3c0 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -28,9 +28,9 @@ from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.util import get_form_data -from wuttafarm.db.model import PlantType, PlantAsset +from wuttafarm.db.model import PlantType, Season, PlantAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView -from wuttafarm.web.forms.schema import PlantTypeRefs +from wuttafarm.web.forms.schema import PlantTypeRefs, SeasonRefs from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.util import get_farmos_client_for_user @@ -195,6 +195,166 @@ class PlantTypeView(AssetTypeMasterView): ) +class SeasonView(AssetTypeMasterView): + """ + Master view for Seasons + """ + + model_class = Season + route_prefix = "seasons" + url_prefix = "/seasons" + + farmos_entity_type = "taxonomy_term" + farmos_bundle = "season" + farmos_refurl_path = "/admin/structure/taxonomy/manage/season/overview" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "drupal_id", + "farmos_uuid", + ] + + has_rows = True + row_model_class = PlantAsset + rows_viewable = True + + row_grid_columns = [ + "asset_name", + "archived", + ] + + rows_sort_defaults = "asset_name" + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_farmos_url(self, season): + return self.app.get_farmos_url(f"/taxonomy/term/{season.drupal_id}") + + def get_xref_buttons(self, season): + buttons = super().get_xref_buttons(season) + + if season.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_seasons.view", uuid=season.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + def delete(self): + season = self.get_instance() + + if season._plant_assets: + self.request.session.flash( + "Cannot delete season which is still referenced by plant assets.", + "warning", + ) + url = self.get_action_url("view", season) + return self.redirect(self.request.get_referrer(default=url)) + + return super().delete() + + def get_row_grid_data(self, season): + model = self.app.model + session = self.Session() + return ( + session.query(model.PlantAsset) + .join(model.Asset) + .outerjoin(model.PlantAssetSeason) + .filter(model.PlantAssetSeason.season == season) + ) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + model = self.app.model + + # asset_name + g.set_link("asset_name") + g.set_sorter("asset_name", model.Asset.asset_name) + g.set_filter("asset_name", model.Asset.asset_name) + + # archived + g.set_renderer("archived", "boolean") + g.set_sorter("archived", model.Asset.archived) + g.set_filter("archived", model.Asset.archived) + + def get_row_action_url_view(self, plant, i): + return self.request.route_url("plant_assets.view", uuid=plant.uuid) + + def ajax_create(self): + """ + AJAX view to create a new season. + """ + model = self.app.model + session = self.Session() + data = get_form_data(self.request) + + name = data.get("name") + if not name: + return {"error": "Name is required"} + + season = model.Season(name=name) + session.add(season) + session.flush() + + if self.app.is_farmos_mirror(): + client = get_farmos_client_for_user(self.request) + self.app.auto_sync_to_farmos(season, client=client) + + return { + "uuid": season.uuid.hex, + "name": season.name, + "farmos_uuid": season.farmos_uuid.hex, + "drupal_id": season.drupal_id, + } + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._season_defaults(config) + + @classmethod + def _season_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + + # ajax_create + config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new") + config.add_view( + cls, + attr="ajax_create", + route_name=f"{route_prefix}.ajax_create", + permission=f"{permission_prefix}.create", + renderer="json", + ) + + class PlantAssetView(AssetMasterView): """ Master view for Plant Assets @@ -209,6 +369,7 @@ class PlantAssetView(AssetMasterView): labels = { "plant_types": "Crop/Variety", + "seasons": "Season", } grid_columns = [ @@ -254,17 +415,26 @@ class PlantAssetView(AssetMasterView): f.set_default("plant_types", [pt.uuid for pt in plant.plant_types]) # season - if not (self.creating or self.editing): # TODO - f.fields.insert_after("plant_types", "season") + f.fields.insert_after("plant_types", "seasons") + f.set_node("seasons", SeasonRefs(self.request)) + f.set_required("seasons", False) + if not self.creating: + # nb. must explcitly declare value for non-standard field + f.set_default("seasons", plant.seasons) def objectify(self, form): - model = self.app.model - session = self.Session() plant = super().objectify(form) data = form.validated + self.set_plant_types(plant, data["plant_types"]) + self.set_seasons(plant, data["seasons"]) + + return plant + + def set_plant_types(self, plant, desired): + model = self.app.model + session = self.Session() current = [pt.uuid for pt in plant.plant_types] - desired = data["plant_types"] for uuid in desired: if uuid not in current: @@ -278,7 +448,22 @@ class PlantAssetView(AssetMasterView): assert plant_type plant.plant_types.remove(plant_type) - return plant + def set_seasons(self, plant, desired): + model = self.app.model + session = self.Session() + current = [s.uuid for s in plant.seasons] + + for uuid in desired: + if uuid not in current: + season = session.get(model.Season, uuid) + assert season + plant.seasons.append(season) + + for uuid in current: + if uuid not in desired: + season = session.get(model.Season, uuid) + assert season + plant.seasons.remove(season) def defaults(config, **kwargs): @@ -287,6 +472,9 @@ def defaults(config, **kwargs): PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) PlantTypeView.defaults(config) + SeasonView = kwargs.get("SeasonView", base["SeasonView"]) + SeasonView.defaults(config) + PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) PlantAssetView.defaults(config) From 6d80937e0c31aad382cabf620e4369f672494784 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Mar 2026 21:59:38 -0600 Subject: [PATCH 06/20] feat: expose Assets field when editing a Log record --- src/wuttafarm/web/views/logs.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 9c983b7..0a893c2 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -256,10 +256,9 @@ class LogMasterView(WuttaFarmMasterView): f.set_default("timestamp", self.app.make_utc()) # assets - if self.creating or self.editing: - f.remove("assets") # TODO: need to support this - else: - f.set_node("assets", AssetRefs(self.request)) + f.set_node("assets", AssetRefs(self.request)) + f.set_required("assets", False) + if not self.creating: # nb. must explicity declare value for non-standard field f.set_default("assets", log.assets) @@ -324,13 +323,33 @@ class LogMasterView(WuttaFarmMasterView): def objectify(self, form): log = super().objectify(form) + data = form.validated if self.creating: model_class = self.get_model_class() log.log_type = self.get_farmos_log_type() + self.set_assets(log, data["assets"] or []) + return log + def set_assets(self, log, desired): + model = self.app.model + session = self.Session() + current = [a.uuid for a in log.assets] + + for uuid in desired: + if uuid not in current: + asset = session.get(model.Asset, uuid) + assert asset + log.assets.append(asset) + + for uuid in current: + if uuid not in desired: + asset = session.get(model.Asset, uuid) + assert asset + log.assets.remove(asset) + def get_farmos_url(self, log): return self.app.get_farmos_url(f"/log/{log.drupal_id}") From 797c045f678b4502ac2150f525673fef0ef749f0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Mar 2026 10:27:45 -0600 Subject: [PATCH 07/20] feat: add edit/sync support for `Log.locations` also set `Log.owners` to current user, when creating new log --- src/wuttafarm/farmos/importing/model.py | 14 +++++++++ src/wuttafarm/farmos/importing/wuttafarm.py | 2 ++ src/wuttafarm/web/forms/schema.py | 5 +++- src/wuttafarm/web/forms/widgets.py | 2 +- src/wuttafarm/web/views/logs.py | 32 +++++++++++++++++---- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index ff1022d..0593d22 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -631,6 +631,7 @@ class ToFarmOSLog(ToFarmOS): "notes", "quick", "assets", + "locations", "quantities", ] @@ -693,6 +694,9 @@ class ToFarmOSLog(ToFarmOS): "notes": normal["notes"], "quick": normal["quick"], "assets": [(a["asset_type"], UUID(a["uuid"])) for a in normal["assets"]], + "locations": [ + (l["asset_type"], UUID(l["uuid"])) for l in normal["locations"] + ], "quantities": [UUID(uuid) for uuid in normal["quantity_uuids"]], } @@ -725,6 +729,16 @@ class ToFarmOSLog(ToFarmOS): } ) rels["asset"] = {"data": assets} + if "locations" in self.fields: + locations = [] + for asset_type, uuid in source_data["locations"]: + locations.append( + { + "type": f"asset--{asset_type}", + "id": str(uuid), + } + ) + rels["location"] = {"data": locations} if "quantities" in self.fields: quantities = [] for uuid in source_data["quantities"]: diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index b492fac..74b2b7e 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -458,6 +458,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "notes", "quick", "assets", + "locations", "quantities", ] @@ -472,6 +473,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "notes": log.notes, "quick": self.config.parse_list(log.quick) if log.quick else [], "assets": [(a.asset_type, a.farmos_uuid) for a in log.assets], + "locations": [(l.asset_type, l.farmos_uuid) for l in log.locations], "quantities": [qty.farmos_uuid for qty in log.quantities], "_src_object": log, } diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 2bcb4c8..ac5279b 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -402,9 +402,10 @@ class AssetRefs(WuttaSet): Schema type for Assets field (on a Log record) """ - def __init__(self, request, for_asset=None, **kwargs): + def __init__(self, request, for_asset=None, is_location=None, **kwargs): super().__init__(request, **kwargs) self.for_asset = for_asset + self.is_location = is_location def serialize(self, node, appstruct): if not appstruct: @@ -420,6 +421,8 @@ class AssetRefs(WuttaSet): if "values" not in kwargs: query = session.query(model.Asset) + if self.is_location is not None: + query = query.filter(model.Asset.is_location == self.is_location) if self.for_asset: query = query.filter(model.Asset.uuid != self.for_asset.uuid) query = query.order_by(model.Asset.asset_name) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 6cf1cfc..545ea78 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -521,7 +521,7 @@ class AssetRefsWidget(Widget): def deserialize(self, field, pstruct): """ """ if not pstruct: - return colander.null + return set() return set(pstruct.split(",")) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 0a893c2..ce08097 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -271,10 +271,8 @@ class LogMasterView(WuttaFarmMasterView): f.set_default("groups", log.groups) # locations - if self.creating or self.editing: - f.remove("locations") # TODO: need to support this - else: - f.set_node("locations", AssetRefs(self.request)) + f.set_node("locations", AssetRefs(self.request, is_location=True)) + if not self.creating: # nb. must explicity declare value for non-standard field f.set_default("locations", log.locations) @@ -326,10 +324,15 @@ class LogMasterView(WuttaFarmMasterView): data = form.validated if self.creating: - model_class = self.get_model_class() + + # log_type log.log_type = self.get_farmos_log_type() - self.set_assets(log, data["assets"] or []) + # owner + log.owners = [self.request.user] + + self.set_assets(log, data["assets"]) + self.set_locations(log, data["locations"]) return log @@ -350,6 +353,23 @@ class LogMasterView(WuttaFarmMasterView): assert asset log.assets.remove(asset) + def set_locations(self, log, desired): + model = self.app.model + session = self.Session() + current = [l.uuid for l in log.locations] + + for uuid in desired: + if uuid not in current: + location = session.get(model.Asset, uuid) + assert location + log.locations.append(location) + + for uuid in current: + if uuid not in desired: + location = session.get(model.Asset, uuid) + assert location + log.locations.remove(location) + def get_farmos_url(self, log): return self.app.get_farmos_url(f"/log/{log.drupal_id}") From 1d303a818cd0f8409fbfb6e2dd456deb8b1a8e6d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Mar 2026 10:40:42 -0600 Subject: [PATCH 08/20] feat: add edit/sync support for `Log.groups` --- src/wuttafarm/farmos/importing/model.py | 12 +++++++++++ src/wuttafarm/farmos/importing/wuttafarm.py | 2 ++ src/wuttafarm/web/forms/schema.py | 9 ++++++-- src/wuttafarm/web/views/logs.py | 24 +++++++++++++++++---- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 0593d22..9994f55 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -632,6 +632,7 @@ class ToFarmOSLog(ToFarmOS): "quick", "assets", "locations", + "groups", "quantities", ] @@ -697,6 +698,7 @@ class ToFarmOSLog(ToFarmOS): "locations": [ (l["asset_type"], UUID(l["uuid"])) for l in normal["locations"] ], + "groups": [(g["asset_type"], UUID(g["uuid"])) for g in normal["groups"]], "quantities": [UUID(uuid) for uuid in normal["quantity_uuids"]], } @@ -739,6 +741,16 @@ class ToFarmOSLog(ToFarmOS): } ) rels["location"] = {"data": locations} + if "groups" in self.fields: + groups = [] + for asset_type, uuid in source_data["groups"]: + groups.append( + { + "type": f"asset--{asset_type}", + "id": str(uuid), + } + ) + rels["group"] = {"data": groups} if "quantities" in self.fields: quantities = [] for uuid in source_data["quantities"]: diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 74b2b7e..8b682a3 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -459,6 +459,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "quick", "assets", "locations", + "groups", "quantities", ] @@ -474,6 +475,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "quick": self.config.parse_list(log.quick) if log.quick else [], "assets": [(a.asset_type, a.farmos_uuid) for a in log.assets], "locations": [(l.asset_type, l.farmos_uuid) for l in log.locations], + "groups": [(g.asset_type, g.farmos_uuid) for g in log.groups], "quantities": [qty.farmos_uuid for qty in log.quantities], "_src_object": log, } diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index ac5279b..2d56cd4 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -402,10 +402,13 @@ class AssetRefs(WuttaSet): Schema type for Assets field (on a Log record) """ - def __init__(self, request, for_asset=None, is_location=None, **kwargs): + def __init__( + self, request, for_asset=None, is_group=None, is_location=None, **kwargs + ): super().__init__(request, **kwargs) - self.for_asset = for_asset + self.is_group = is_group self.is_location = is_location + self.for_asset = for_asset def serialize(self, node, appstruct): if not appstruct: @@ -421,6 +424,8 @@ class AssetRefs(WuttaSet): if "values" not in kwargs: query = session.query(model.Asset) + if self.is_group is not None: + query = query.join(model.GroupAsset) if self.is_location is not None: query = query.filter(model.Asset.is_location == self.is_location) if self.for_asset: diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index ce08097..9f53f50 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -263,10 +263,8 @@ class LogMasterView(WuttaFarmMasterView): f.set_default("assets", log.assets) # groups - if self.creating or self.editing: - f.remove("groups") # TODO: need to support this - else: - f.set_node("groups", AssetRefs(self.request)) + f.set_node("groups", AssetRefs(self.request, is_group=True)) + if not self.creating: # nb. must explicity declare value for non-standard field f.set_default("groups", log.groups) @@ -333,6 +331,7 @@ class LogMasterView(WuttaFarmMasterView): self.set_assets(log, data["assets"]) self.set_locations(log, data["locations"]) + self.set_groups(log, data["groups"]) return log @@ -370,6 +369,23 @@ class LogMasterView(WuttaFarmMasterView): assert location log.locations.remove(location) + def set_groups(self, log, desired): + model = self.app.model + session = self.Session() + current = [g.uuid for g in log.groups] + + for uuid in desired: + if uuid not in current: + group = session.get(model.Asset, uuid) + assert group + log.groups.append(group) + + for uuid in current: + if uuid not in desired: + group = session.get(model.Asset, uuid) + assert group + log.groups.remove(group) + def get_farmos_url(self, log): return self.app.get_farmos_url(f"/log/{log.drupal_id}") From a43f98c304397706ccccb4eb4251c8a6fda6b7e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Mar 2026 12:27:05 -0500 Subject: [PATCH 09/20] feat: add edit/sync support for Log Quantities er, just Standard Quantities so far..and just supported enough to move the ball forward, it still needs lots more polish --- src/wuttafarm/app.py | 26 ++ src/wuttafarm/db/model/quantities.py | 6 +- src/wuttafarm/web/forms/schema.py | 33 +- src/wuttafarm/web/forms/widgets.py | 85 ++++- .../web/templates/deform/quantityrefs.pt | 13 + .../web/templates/wuttafarm-components.mako | 352 ++++++++++++++++++ src/wuttafarm/web/views/logs.py | 61 ++- src/wuttafarm/web/views/master.py | 5 +- 8 files changed, 555 insertions(+), 26 deletions(-) create mode 100644 src/wuttafarm/web/templates/deform/quantityrefs.pt diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index cb9aed3..6bbca34 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -151,6 +151,32 @@ class WuttaFarmAppHandler(base.AppHandler): factory = self.load_object(spec) return factory(self.config, farmos_client) + def get_quantity_types(self, session=None): + """ + Returns a list of all known quantity types. + """ + model = self.model + with self.short_session(session=session) as sess: + return ( + sess.query(model.QuantityType).order_by(model.QuantityType.name).all() + ) + + def get_measures(self, session=None): + """ + Returns a list of all known measures. + """ + model = self.model + with self.short_session(session=session) as sess: + return sess.query(model.Measure).order_by(model.Measure.name).all() + + def get_units(self, session=None): + """ + Returns a list of all known units. + """ + model = self.model + with self.short_session(session=session) as sess: + return sess.query(model.Unit).order_by(model.Unit.name).all() + def auto_sync_to_farmos(self, obj, model_name=None, client=None, require=True): """ Export the given object to farmOS, using configured handler. diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py index 4bed6a0..bcc2183 100644 --- a/src/wuttafarm/db/model/quantities.py +++ b/src/wuttafarm/db/model/quantities.py @@ -181,9 +181,13 @@ class Quantity(model.Base): creator=make_log_quantity, ) + def get_value_decimal(self): + # TODO: should actually return a decimal here? + return self.value_numerator / self.value_denominator + def render_as_text(self, config=None): measure = str(self.measure or self.measure_id or "") - value = self.value_numerator / self.value_denominator + value = self.get_value_decimal() if config: app = config.get_app() value = app.render_quantity(value) diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 2d56cd4..f9dc33a 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -437,21 +437,46 @@ class AssetRefs(WuttaSet): return AssetRefsWidget(self.request, **kwargs) -class LogQuantityRefs(WuttaSet): +class QuantityRefs(colander.List): """ Schema type for Quantities field (on a Log record) """ + def __init__(self, request): + super().__init__() + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + def serialize(self, node, appstruct): if not appstruct: return colander.null - return {qty.uuid for qty in appstruct} + quantities = [] + for qty in appstruct: + quantities.append( + { + "uuid": qty.uuid.hex, + "quantity_type": { + "id": qty.quantity_type_id, + "name": qty.quantity_type.name, + }, + "measure": qty.measure_id, + "value": qty.get_value_decimal(), + "units": { + "uuid": qty.units.uuid.hex, + "name": qty.units.name, + }, + "as_text": qty.render_as_text(self.config), + } + ) + + return quantities def widget_maker(self, **kwargs): - from wuttafarm.web.forms.widgets import LogQuantityRefsWidget + from wuttafarm.web.forms.widgets import QuantityRefsWidget - return LogQuantityRefsWidget(self.request, **kwargs) + return QuantityRefsWidget(self.request, **kwargs) class OwnerRefs(WuttaSet): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 545ea78..f1cf067 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -526,11 +526,19 @@ class AssetRefsWidget(Widget): return set(pstruct.split(",")) -class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): +class QuantityRefsWidget(Widget): """ Widget for Quantities field (on a Log record) """ + template = "quantityrefs" + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + def serialize(self, field, cstruct, **kw): """ """ model = self.app.model @@ -538,24 +546,71 @@ class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): readonly = kw.get("readonly", self.readonly) if readonly: + if not cstruct: + return "" + quantities = [] - for uuid in cstruct or []: - qty = session.get(model.Quantity, uuid) - quantities.append( - HTML.tag( - "li", - c=tags.link_to( - qty.render_as_text(self.config), - # TODO - self.request.route_url( - "quantities_standard.view", uuid=qty.uuid - ), - ), - ) + + for qty in cstruct: + # TODO: support more quantity types + url = self.request.route_url( + "quantities_standard.view", uuid=qty["uuid"] ) + quantities.append(HTML.tag("li", c=tags.link_to(qty["as_text"], url))) + return HTML.tag("ul", c=quantities) - return super().serialize(field, cstruct, **kw) + tmpl_values = self.get_template_values(field, cstruct, kw) + return field.renderer(self.template, **tmpl_values) + + def get_template_values(self, field, cstruct, kw): + model = self.app.model + session = Session() + values = super().get_template_values(field, cstruct, kw) + + qtypes = [] + for qtype in self.app.get_quantity_types(session): + # TODO: add support for other quantity types + if qtype.drupal_id == "standard": + qtypes.append( + { + "uuid": qtype.uuid.hex, + "drupal_id": qtype.drupal_id, + "name": qtype.name, + } + ) + values["quantity_types"] = qtypes + + measures = [] + for measure in self.app.get_measures(session): + measures.append( + { + "uuid": measure.uuid.hex, + "drupal_id": measure.drupal_id, + "name": measure.name, + } + ) + values["measures"] = measures + + units = [] + for unit in self.app.get_units(session): + units.append( + { + "uuid": unit.uuid.hex, + "drupal_id": unit.drupal_id, + "name": unit.name, + } + ) + values["units"] = units + + return values + + def deserialize(self, field, pstruct): + """ """ + if not pstruct: + return set() + + return json.loads(pstruct) class OwnerRefsWidget(WuttaCheckboxChoiceWidget): diff --git a/src/wuttafarm/web/templates/deform/quantityrefs.pt b/src/wuttafarm/web/templates/deform/quantityrefs.pt new file mode 100644 index 0000000..5c98b84 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/quantityrefs.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako index c537489..d735274 100644 --- a/src/wuttafarm/web/templates/wuttafarm-components.mako +++ b/src/wuttafarm/web/templates/wuttafarm-components.mako @@ -2,6 +2,8 @@ <%def name="make_wuttafarm_components()"> ${self.make_assets_picker_component()} ${self.make_animal_type_picker_component()} + ${self.make_quantity_editor_component()} + ${self.make_quantities_editor_component()} ${self.make_plant_types_picker_component()} ${self.make_seasons_picker_component()} @@ -239,6 +241,356 @@ +<%def name="make_quantity_editor_component()"> + + + + +<%def name="make_quantities_editor_component()"> + + + + <%def name="make_plant_types_picker_component()"> +<%def name="make_material_types_picker_component()"> + + + + <%def name="make_quantity_editor_component()"> + + + +<%def name="make_equipment_types_picker_component()"> + + + + <%def name="make_assets_picker_component()">