From aecbfc6c024af435655151da16059facda38943a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 5 Mar 2026 20:06:17 -0600 Subject: [PATCH 01/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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()"> + % endif diff --git a/src/wuttafarm/web/templates/base.mako b/src/wuttafarm/web/templates/base.mako index b28b52f..caa5c67 100644 --- a/src/wuttafarm/web/templates/base.mako +++ b/src/wuttafarm/web/templates/base.mako @@ -1,6 +1,16 @@ <%inherit file="wuttaweb:templates/base.mako" /> <%namespace file="/wuttafarm-components.mako" import="make_wuttafarm_components" /> +<%def name="head_tags()"> + ${parent.head_tags()} + + ## TODO: this likely does not belong in the base template, and should be + ## included per template where actually needed. but this is easier for now. + + + + + <%def name="index_title_controls()"> ${parent.index_title_controls()} diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 64f4dbc..35c3b21 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -23,6 +23,7 @@ Master view for Assets """ +import re from collections import OrderedDict from webhelpers2.html import tags @@ -359,6 +360,36 @@ class AssetMasterView(WuttaFarmMasterView): return buttons + def get_template_context(self, context): + context = super().get_template_context(context) + + if self.viewing: + asset = context["instance"] + + # add location geometry if applicable + if asset.is_fixed and asset.farmos_uuid and not self.app.is_standalone(): + + # TODO: eventually sync GIS data, avoid this API call? + client = get_farmos_client_for_user(self.request) + result = client.asset.get_id(asset.asset_type, asset.farmos_uuid) + geometry = result["data"]["attributes"]["intrinsic_geometry"] + + context["map_center"] = [geometry["lon"], geometry["lat"]] + + context["map_bounds"] = [ + [geometry["left"], geometry["bottom"]], + [geometry["right"], geometry["top"]], + ] + + if match := re.match( + r"^POLYGON \(\((?P[^\)]+)\)\)$", geometry["value"] + ): + points = match.group("points").split(", ") + points = [[float(pt) for pt in pair.split(" ")] for pair in points] + context["map_polygon"] = [points] + + return context + def get_version_joins(self): """ We override this to declare the relationship between the From f9d9923acf34de92c957d6be895a484175c96bfb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Mar 2026 10:08:27 -0500 Subject: [PATCH 26/32] =?UTF-8?q?bump:=20version=200.10.0=20=E2=86=92=200.?= =?UTF-8?q?11.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c85559..4ee6b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.0 (2026-03-15) + +### Feat + +- show basic map for "fixed" assets + +### Fix + +- include LogQuantity changes when viewing Log revision + ## v0.10.0 (2026-03-11) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 7fdd859..c1a5cc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.10.0" +version = "0.11.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ @@ -34,7 +34,7 @@ dependencies = [ "pyramid_exclog", "uvicorn[standard]", "WuttaSync", - "WuttaWeb[continuum]>=0.29.2", + "WuttaWeb[continuum]>=0.29.3", ] From ca5e1420e4ab48e3f16cdfeb972c521ebdc3653c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 15:03:06 -0500 Subject: [PATCH 27/32] fix: use correct uuid when processing webhook to delete record --- src/wuttafarm/cli/process_webhooks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wuttafarm/cli/process_webhooks.py b/src/wuttafarm/cli/process_webhooks.py index 9731247..9d66a70 100644 --- a/src/wuttafarm/cli/process_webhooks.py +++ b/src/wuttafarm/cli/process_webhooks.py @@ -94,8 +94,7 @@ class ChangeProcessor: return # delete corresponding record from our app - obj = importer.get_target_object((change.uuid,)) - if obj: + if obj := importer.get_target_object((change.farmos_uuid,)): importer.delete_target_object(obj) # TODO: this should live elsewhere From cc4b94a7b8c37642e4391a74dc9170c1d1ac7296 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 15:06:26 -0500 Subject: [PATCH 28/32] fix: improve behavior when deleting mirrored record from farmOS in some cases (maybe just dev?) the record does not exist in farmOS; if so we should silently ignore. and there seemed to be a problem with the sequence of events: - user clicks delete in WF - record is deleted from WF DB - delete request sent to farmOS API - webhook on farmOS side calls back to WF webhook URI somewhere in there, in practice things seemed to hang after user clicks delete. i suppose the thread handling user's request is "tied up" somehow, such that the webhook receiver can't process that request? that doesn't exactly make sense to me, but if we split off to a separate thread to request the farmOS deletion, things seem to work okay. so maybe that idea is more accurate than i'd expect --- src/wuttafarm/web/views/master.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index d9fe986..ac9e2ed 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -23,6 +23,9 @@ Base class for WuttaFarm master views """ +import threading + +import requests from webhelpers2.html import tags from wuttaweb.views import MasterView @@ -145,10 +148,24 @@ class WuttaFarmMasterView(MasterView): # maybe delete from farmOS also if farmos_uuid: - entity_type = self.get_farmos_entity_type() - bundle = self.get_farmos_bundle() client = get_farmos_client_for_user(self.request) + # nb. must use separate thread to avoid some kind of race + # condition (?) - seems as though maybe a "boomerang" + # effect is happening; this seems to help anyway + thread = threading.Thread( + target=self.delete_from_farmos, args=(client, farmos_uuid) + ) + thread.start() + + def delete_from_farmos(self, client, farmos_uuid): + entity_type = self.get_farmos_entity_type() + bundle = self.get_farmos_bundle() + try: client.resource.delete(entity_type, bundle, farmos_uuid) + except requests.HTTPError as exc: + # ignore if record not found in farmOS + if exc.response.status_code != 404: + raise class TaxonomyMasterView(WuttaFarmMasterView): From f0fa189bcdf47b5eeeeaee2ad68bb51e861bfd50 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 15:11:36 -0500 Subject: [PATCH 29/32] =?UTF-8?q?bump:=20version=200.11.0=20=E2=86=92=200.?= =?UTF-8?q?11.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee6b10..00ff67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.1 (2026-03-21) + +### Fix + +- improve behavior when deleting mirrored record from farmOS +- use correct uuid when processing webhook to delete record + ## v0.11.0 (2026-03-15) ### Feat diff --git a/pyproject.toml b/pyproject.toml index c1a5cc0..e165b20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.11.0" +version = "0.11.1" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 969497826d556e68e355cc7f0fda89b353e50e76 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 15:24:36 -0500 Subject: [PATCH 30/32] fix: avoid error if asset has no geometry --- src/wuttafarm/web/views/assets.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 35c3b21..1ada778 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -372,21 +372,23 @@ class AssetMasterView(WuttaFarmMasterView): # TODO: eventually sync GIS data, avoid this API call? client = get_farmos_client_for_user(self.request) result = client.asset.get_id(asset.asset_type, asset.farmos_uuid) - geometry = result["data"]["attributes"]["intrinsic_geometry"] + if geometry := result["data"]["attributes"]["intrinsic_geometry"]: - context["map_center"] = [geometry["lon"], geometry["lat"]] + context["map_center"] = [geometry["lon"], geometry["lat"]] - context["map_bounds"] = [ - [geometry["left"], geometry["bottom"]], - [geometry["right"], geometry["top"]], - ] + context["map_bounds"] = [ + [geometry["left"], geometry["bottom"]], + [geometry["right"], geometry["top"]], + ] - if match := re.match( - r"^POLYGON \(\((?P[^\)]+)\)\)$", geometry["value"] - ): - points = match.group("points").split(", ") - points = [[float(pt) for pt in pair.split(" ")] for pair in points] - context["map_polygon"] = [points] + if match := re.match( + r"^POLYGON \(\((?P[^\)]+)\)\)$", geometry["value"] + ): + points = match.group("points").split(", ") + points = [ + [float(pt) for pt in pair.split(" ")] for pair in points + ] + context["map_polygon"] = [points] return context From 9707c365538373267970065c8319542254e49fac Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 20:18:32 -0500 Subject: [PATCH 31/32] fix: use separate thread to sync changes to farmOS i.e. when creating or editing an asset/log, or submitting quick eggs form --- src/wuttafarm/web/views/master.py | 26 +++++++++++++++-- src/wuttafarm/web/views/quick/eggs.py | 40 ++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index ac9e2ed..c828b96 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -24,6 +24,7 @@ Base class for WuttaFarm master views """ import threading +import time import requests from webhelpers2.html import tags @@ -110,17 +111,36 @@ class WuttaFarmMasterView(MasterView): f.set_readonly("drupal_id") def persist(self, obj, session=None): + session = session or self.Session() # save per usual super().persist(obj, session) # maybe also sync change to farmOS if self.app.is_farmos_mirror(): + if self.creating: + session.flush() # need the new uuid client = get_farmos_client_for_user(self.request) - self.auto_sync_to_farmos(client, obj) + thread = threading.Thread( + target=self.auto_sync_to_farmos, args=(client, obj.uuid) + ) + thread.start() - def auto_sync_to_farmos(self, client, obj): - self.app.auto_sync_to_farmos(obj, client=client, require=False) + def auto_sync_to_farmos(self, client, uuid): + model = self.app.model + model_class = self.get_model_class() + + with self.app.short_session(commit=True) as session: + if user := session.query(model.User).filter_by(username="farmos").first(): + session.info["continuum_user_id"] = user.uuid + + obj = None + while not obj: + obj = session.get(model_class, uuid) + if not obj: + time.sleep(0.1) + + self.app.auto_sync_to_farmos(obj, client=client, require=False) def get_farmos_entity_type(self): if self.farmos_entity_type: diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index 8aae46e..fded73c 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -24,6 +24,8 @@ Quick Form for "Eggs" """ import json +import threading +import time import colander from deform.widget import SelectWidget @@ -331,13 +333,43 @@ class EggsQuickForm(QuickFormView): session.flush() if self.app.is_farmos_mirror(): - if new_unit: - self.app.auto_sync_to_farmos(unit, client=self.farmos_client) - self.app.auto_sync_to_farmos(quantity, client=self.farmos_client) - self.app.auto_sync_to_farmos(log, client=self.farmos_client) + thread = threading.Thread( + target=self.auto_sync_to_farmos, + args=(log.uuid, quantity.uuid, new_unit.uuid if new_unit else None), + ) + thread.start() return log + def auto_sync_to_farmos(self, log_uuid, quantity_uuid, new_unit_uuid): + model = self.app.model + + with self.app.short_session(commit=True) as session: + if user := session.query(model.User).filter_by(username="farmos").first(): + session.info["continuum_user_id"] = user.uuid + + if new_unit_uuid: + new_unit = None + while not new_unit: + new_unit = session.get(model.Unit, new_unit_uuid) + if not new_unit: + time.sleep(0.1) + self.app.auto_sync_to_farmos(unit, client=self.farmos_client) + + quantity = None + while not quantity: + quantity = session.get(model.StandardQuantity, quantity_uuid) + if not quantity: + time.sleep(0.1) + self.app.auto_sync_to_farmos(quantity, client=self.farmos_client) + + log = None + while not log: + log = session.get(model.HarvestLog, log_uuid) + if not log: + time.sleep(0.1) + self.app.auto_sync_to_farmos(log, client=self.farmos_client) + def redirect_after_save(self, log): model = self.app.model From a5b699a52ab7f92cae7ef87e2d5eff62d547880c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 20:21:52 -0500 Subject: [PATCH 32/32] =?UTF-8?q?bump:=20version=200.11.1=20=E2=86=92=200.?= =?UTF-8?q?11.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ff67e..579fc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.2 (2026-03-21) + +### Fix + +- use separate thread to sync changes to farmOS +- avoid error if asset has no geometry + ## v0.11.1 (2026-03-21) ### Fix diff --git a/pyproject.toml b/pyproject.toml index e165b20..b702d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.11.1" +version = "0.11.2" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ @@ -34,7 +34,7 @@ dependencies = [ "pyramid_exclog", "uvicorn[standard]", "WuttaSync", - "WuttaWeb[continuum]>=0.29.3", + "WuttaWeb[continuum]>=0.30.1", ]