feat: add support for edit, import/export of plant type data

esp. plant types for a plant asset
This commit is contained in:
Lance Edgar 2026-02-27 22:34:51 -06:00
parent bdda586ccd
commit c353d5bcef
9 changed files with 416 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,13 @@
<div tal:define="
name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;
can_create can_create|False;"
tal:omit-tag="">
<plant-types-picker tal:attributes="name name;
v-model vmodel;
:plant-types js_values;
:can-create str(can_create).lower();" />
</div>

View file

@ -1,6 +1,7 @@
<%def name="make_wuttafarm_components()">
${self.make_animal_type_picker_component()}
${self.make_plant_types_picker_component()}
</%def>
<%def name="make_animal_type_picker_component()">
@ -126,3 +127,198 @@
<% request.register_component('animal-type-picker', 'AnimalTypePicker') %>
</script>
</%def>
<%def name="make_plant_types_picker_component()">
<script type="text/x-template" id="plant-types-picker-template">
<div>
<input type="hidden" :name="name" :value="value" />
<div style="display: flex; gap: 0.5rem; align-items: center;">
<span>Add:</span>
<b-autocomplete v-model="addName"
ref="addName"
:data="addNameData"
field="name"
open-on-focus
keep-first
@select="addNameSelected"
clear-on-select
style="flex-grow: 1;">
<template #empty>No results found</template>
</b-autocomplete>
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="createInit()">
New
</b-button>
</div>
<${b}-table :data="plantTypeData">
<${b}-table-column field="name" v-slot="props">
<span>{{ props.row.name }}</span>
</${b}-table-column>
<${b}-table-column v-slot="props">
<a href="#"
class="has-text-danger"
@click.prevent="removePlantType(props.row)">
<i class="fas fa-trash" /> &nbsp; Remove
</a>
</${b}-table-column>
</${b}-table>
<${b}-modal v-if="canCreate"
has-modal-card
% if request.use_oruga:
v-model:active="createShowDialog"
% else:
:active.sync="createShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">New Plant Type</p>
</header>
<section class="modal-card-body">
<b-field label="Name" horizontal>
<b-input v-model="createName"
ref="createName"
expanded
@keydown.native="createNameKeydown" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="createSave()"
:disabled="createSaving || !createName"
icon-pack="fas"
icon-left="save">
{{ createSaving ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="createShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
</div>
</script>
<script>
const PlantTypesPicker = {
template: '#plant-types-picker-template',
mixins: [WuttaRequestMixin],
props: {
name: String,
value: Array,
plantTypes: Array,
canCreate: Boolean,
},
data() {
return {
internalPlantTypes: this.plantTypes.map((pt) => {
return {uuid: pt[0], name: pt[1]}
}),
addShowDialog: false,
addName: '',
createShowDialog: false,
createName: null,
createSaving: false,
}
},
computed: {
plantTypeData() {
const data = []
if (this.value) {
for (let ptype of this.internalPlantTypes) {
// ptype = {uuid: ptype[0], name: ptype[1]}
if (this.value.includes(ptype.uuid)) {
data.push(ptype)
}
}
}
return data
},
addNameData() {
if (!this.addName) {
return this.internalPlantTypes
}
return this.internalPlantTypes.filter((ptype) => {
return ptype.name.toLowerCase().indexOf(this.addName.toLowerCase()) >= 0
})
},
},
methods: {
addNameSelected(option) {
const value = Array.from(this.value || [])
if (!value.includes(option.uuid)) {
value.push(option.uuid)
this.$emit('input', value)
}
this.addName = null
},
createInit() {
this.createName = this.addName
this.createShowDialog = true
this.$nextTick(() => {
this.$refs.createName.focus()
})
},
createNameKeydown(event) {
// nb. must prevent main form submit on ENTER
// (since ultimately this lives within an outer form)
// but also we can submit the modal pseudo-form
if (event.which == 13) {
event.preventDefault()
this.createSave()
}
},
createSave() {
this.createSaving = true
const url = "${url('plant_types.ajax_create')}"
const params = {name: this.createName}
this.wuttaPOST(url, params, response => {
this.internalPlantTypes.push(response.data)
const value = Array.from(this.value || [])
value.push(response.data.uuid)
this.$emit('input', value)
this.addName = null
this.createSaving = false
this.createShowDialog = false
}, response => {
this.createSaving = false
})
},
removePlantType(ptype) {
let value = Array.from(this.value)
const i = value.indexOf(ptype.uuid)
value.splice(i, 1)
this.$emit('input', value)
},
},
}
Vue.component('plant-types-picker', PlantTypesPicker)
<% request.register_component('plant-types-picker', 'PlantTypesPicker') %>
</script>
</%def>

View file

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