From c353d5bcef076b623f94dddc57666a7786e0a714 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 22:34:51 -0600 Subject: [PATCH] feat: add support for edit, import/export of plant type data esp. plant types for a plant asset --- src/wuttafarm/db/model/asset_plant.py | 16 ++ src/wuttafarm/farmos/importing/model.py | 6 + src/wuttafarm/farmos/importing/wuttafarm.py | 23 ++ src/wuttafarm/importing/farmos.py | 6 +- src/wuttafarm/web/forms/schema.py | 17 +- src/wuttafarm/web/forms/widgets.py | 62 +++++- .../web/templates/deform/planttyperefs.pt | 13 ++ .../web/templates/wuttafarm-components.mako | 196 ++++++++++++++++++ src/wuttafarm/web/views/plants.py | 101 ++++++++- 9 files changed, 416 insertions(+), 24 deletions(-) create mode 100644 src/wuttafarm/web/templates/deform/planttyperefs.pt diff --git a/src/wuttafarm/db/model/asset_plant.py b/src/wuttafarm/db/model/asset_plant.py index 5f10e7c..62f7e9b 100644 --- a/src/wuttafarm/db/model/asset_plant.py +++ b/src/wuttafarm/db/model/asset_plant.py @@ -25,6 +25,7 @@ Model definition for Plant Assets import sqlalchemy as sa from sqlalchemy import orm +from sqlalchemy.ext.associationproxy import association_proxy from wuttjamaican.db import model @@ -80,6 +81,12 @@ class PlantType(model.Base): """, ) + _plant_assets = orm.relationship( + "PlantAssetPlantType", + cascade_backrefs=False, + back_populates="plant_type", + ) + def __str__(self): return self.name or "" @@ -99,9 +106,17 @@ class PlantAsset(AssetMixin, model.Base): _plant_types = orm.relationship( "PlantAssetPlantType", + cascade="all, delete-orphan", + cascade_backrefs=False, back_populates="plant_asset", ) + plant_types = association_proxy( + "_plant_types", + "plant_type", + creator=lambda pt: PlantAssetPlantType(plant_type=pt), + ) + add_asset_proxies(PlantAsset) @@ -129,4 +144,5 @@ class PlantAssetPlantType(model.Base): doc=""" Reference to the plant type. """, + back_populates="_plant_assets", ) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 337649c..04d80c1 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -347,6 +347,12 @@ class LandAssetImporter(ToFarmOSAsset): return payload +class PlantTypeImporter(ToFarmOSTaxonomy): + + model_title = "PlantType" + farmos_taxonomy_type = "plant_type" + + class PlantAssetImporter(ToFarmOSAsset): model_title = "PlantAsset" diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index a39fe97..bb5350d 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -99,6 +99,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter + importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter importers["ActivityLog"] = ActivityLogImporter @@ -264,6 +265,28 @@ class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter) } +class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter): + """ + WuttaFarm → farmOS API exporter for Plant Types + """ + + source_model_class = model.PlantType + + supported_fields = [ + "uuid", + "name", + ] + + drupal_internal_id_field = "drupal_internal__tid" + + def normalize_source_object(self, plant_type): + return { + "uuid": plant_type.farmos_uuid or self.app.make_true_uuid(), + "name": plant_type.name, + "_src_object": plant_type, + } + + class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter): """ WuttaFarm → farmOS API exporter for Plant Assets diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index a1e9631..9e922da 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -623,7 +623,7 @@ class PlantAssetImporter(AssetImporterBase): def normalize_source_object(self, plant): """ """ - plant_types = None + plant_types = [] if relationships := plant.get("relationships"): if plant_type := relationships.get("plant_type"): @@ -640,7 +640,7 @@ class PlantAssetImporter(AssetImporterBase): data.update( { "asset_type": "plant", - "plant_types": plant_types, + "plant_types": set(plant_types), } ) return data @@ -649,7 +649,7 @@ class PlantAssetImporter(AssetImporterBase): data = super().normalize_target_object(plant) if "plant_types" in self.fields: - data["plant_types"] = [t.plant_type_uuid for t in plant._plant_types] + data["plant_types"] = set([pt.uuid for pt in plant.plant_types]) return data diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index c6095ff..548ee81 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -27,6 +27,7 @@ import json import colander +from wuttaweb.db import Session from wuttaweb.forms.schema import ObjectRef, WuttaSet from wuttaweb.forms.widgets import NotesWidget @@ -242,13 +243,23 @@ class PlantTypeRefs(WuttaSet): def serialize(self, node, appstruct): if not appstruct: - appstruct = [] - uuids = [u.hex for u in appstruct] - return json.dumps(uuids) + return colander.null + + return [uuid.hex for uuid in appstruct] def widget_maker(self, **kwargs): from wuttafarm.web.forms.widgets import PlantTypeRefsWidget + model = self.app.model + session = Session() + + if "values" not in kwargs: + plant_types = ( + session.query(model.PlantType).order_by(model.PlantType.name).all() + ) + values = [(pt.uuid.hex, str(pt)) for pt in plant_types] + kwargs["values"] = values + return PlantTypeRefsWidget(self.request, **kwargs) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 7f5808f..ae9aa10 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -26,7 +26,7 @@ Custom form widgets for WuttaFarm import json import colander -from deform.widget import Widget, SelectWidget +from deform.widget import Widget, SelectWidget, sequence_types, _normalize_choices from webhelpers2.html import HTML, tags from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget @@ -258,22 +258,40 @@ class FarmOSPlantTypesWidget(Widget): return super().serialize(field, cstruct, **kw) -class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget): +class PlantTypeRefsWidget(Widget): """ Widget for Plant Types field (on a Plant Asset). """ + template = "planttyperefs" + 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 session = Session() - readonly = kw.get("readonly", self.readonly) - if readonly: - plant_types = [] - for uuid in json.loads(cstruct): - plant_type = session.get(model.PlantType, uuid) - plant_types.append( + if cstruct in (colander.null, None): + cstruct = () + + if readonly := kw.get("readonly", self.readonly): + items = [] + + plant_types = ( + session.query(model.PlantType) + .filter(model.PlantType.uuid.in_(cstruct)) + .order_by(model.PlantType.name) + .all() + ) + + for plant_type in plant_types: + items.append( HTML.tag( "li", c=tags.link_to( @@ -284,9 +302,33 @@ class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget): ), ) ) - return HTML.tag("ul", c=plant_types) - return super().serialize(field, cstruct, **kw) + return HTML.tag("ul", c=items) + + 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"]) + + if self.request.has_perm("plant_types.create"): + values["can_create"] = True + + return values + + def deserialize(self, field, pstruct): + if not pstruct: + return colander.null + + return set(pstruct.split(",")) class StructureWidget(Widget): diff --git a/src/wuttafarm/web/templates/deform/planttyperefs.pt b/src/wuttafarm/web/templates/deform/planttyperefs.pt new file mode 100644 index 0000000..83cb095 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/planttyperefs.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako index b973cb1..37b176e 100644 --- a/src/wuttafarm/web/templates/wuttafarm-components.mako +++ b/src/wuttafarm/web/templates/wuttafarm-components.mako @@ -1,6 +1,7 @@ <%def name="make_wuttafarm_components()"> ${self.make_animal_type_picker_component()} + ${self.make_plant_types_picker_component()} <%def name="make_animal_type_picker_component()"> @@ -126,3 +127,198 @@ <% request.register_component('animal-type-picker', 'AnimalTypePicker') %> + +<%def name="make_plant_types_picker_component()"> + + + diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index cd6cb34..a2d0cb1 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -26,6 +26,7 @@ Master view for Plants 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.web.views.assets import AssetTypeMasterView, AssetMasterView @@ -42,8 +43,9 @@ class PlantTypeView(AssetTypeMasterView): route_prefix = "plant_types" url_prefix = "/plant-types" + farmos_entity_type = "taxonomy_term" + farmos_bundle = "plant_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" - farmos_bundle = "plant" grid_columns = [ "name", @@ -101,6 +103,19 @@ class PlantTypeView(AssetTypeMasterView): return buttons + def delete(self): + plant_type = self.get_instance() + + if plant_type._plant_assets: + self.request.session.flash( + "Cannot delete plant type which is still referenced by plant assets.", + "warning", + ) + url = self.get_action_url("view", plant_type) + return self.redirect(self.request.get_referrer(default=url)) + + return super().delete() + def get_row_grid_data(self, plant_type): model = self.app.model session = self.Session() @@ -129,6 +144,55 @@ class PlantTypeView(AssetTypeMasterView): 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 plant type. + """ + 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"} + + plant_type = model.PlantType(name=name) + session.add(plant_type) + session.flush() + + if self.app.is_farmos_mirror(): + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_to_farmos(plant_type, token=token) + + return { + "uuid": plant_type.uuid.hex, + "name": plant_type.name, + "farmos_uuid": plant_type.farmos_uuid.hex, + "drupal_id": plant_type.drupal_id, + } + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._plant_type_defaults(config) + + @classmethod + def _plant_type_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): """ @@ -139,6 +203,7 @@ class PlantAssetView(AssetMasterView): route_prefix = "plant_assets" url_prefix = "/assets/plant" + farmos_bundle = "plant" farmos_refurl_path = "/assets/plant" labels = { @@ -196,18 +261,38 @@ class PlantAssetView(AssetMasterView): plant = f.model_instance # plant_types - if self.creating or self.editing: - f.remove("plant_types") # TODO: add support for this - else: - f.set_node("plant_types", PlantTypeRefs(self.request)) - f.set_default( - "plant_types", [t.plant_type_uuid for t in plant._plant_types] - ) + f.set_node("plant_types", PlantTypeRefs(self.request)) + if not self.creating: + # nb. must explcitly declare value for non-standard field + f.set_default("plant_types", [pt.uuid for pt in plant.plant_types]) # season if self.creating or self.editing: f.remove("season") # TODO: add support for this + def objectify(self, form): + model = self.app.model + session = self.Session() + plant = super().objectify(form) + data = form.validated + + current = [pt.uuid for pt in plant.plant_types] + desired = data["plant_types"] + + for uuid in desired: + if uuid not in current: + plant_type = session.get(model.PlantType, uuid) + assert plant_type + plant.plant_types.append(plant_type) + + for uuid in current: + if uuid not in desired: + plant_type = session.get(model.PlantType, uuid) + assert plant_type + plant.plant_types.remove(plant_type) + + return plant + def defaults(config, **kwargs): base = globals()