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
This commit is contained in:
Lance Edgar 2026-03-08 12:27:05 -05:00
parent 1d303a818c
commit a43f98c304
8 changed files with 555 additions and 26 deletions

View file

@ -151,6 +151,32 @@ class WuttaFarmAppHandler(base.AppHandler):
factory = self.load_object(spec) factory = self.load_object(spec)
return factory(self.config, farmos_client) 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): def auto_sync_to_farmos(self, obj, model_name=None, client=None, require=True):
""" """
Export the given object to farmOS, using configured handler. Export the given object to farmOS, using configured handler.

View file

@ -181,9 +181,13 @@ class Quantity(model.Base):
creator=make_log_quantity, 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): def render_as_text(self, config=None):
measure = str(self.measure or self.measure_id or "") measure = str(self.measure or self.measure_id or "")
value = self.value_numerator / self.value_denominator value = self.get_value_decimal()
if config: if config:
app = config.get_app() app = config.get_app()
value = app.render_quantity(value) value = app.render_quantity(value)

View file

@ -437,21 +437,46 @@ class AssetRefs(WuttaSet):
return AssetRefsWidget(self.request, **kwargs) return AssetRefsWidget(self.request, **kwargs)
class LogQuantityRefs(WuttaSet): class QuantityRefs(colander.List):
""" """
Schema type for Quantities field (on a Log record) 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): def serialize(self, node, appstruct):
if not appstruct: if not appstruct:
return colander.null 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): 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): class OwnerRefs(WuttaSet):

View file

@ -526,11 +526,19 @@ class AssetRefsWidget(Widget):
return set(pstruct.split(",")) return set(pstruct.split(","))
class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): class QuantityRefsWidget(Widget):
""" """
Widget for Quantities field (on a Log record) 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): def serialize(self, field, cstruct, **kw):
""" """ """ """
model = self.app.model model = self.app.model
@ -538,24 +546,71 @@ class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget):
readonly = kw.get("readonly", self.readonly) readonly = kw.get("readonly", self.readonly)
if readonly: if readonly:
if not cstruct:
return ""
quantities = [] quantities = []
for uuid in cstruct or []:
qty = session.get(model.Quantity, uuid) for qty in cstruct:
quantities.append( # TODO: support more quantity types
HTML.tag( url = self.request.route_url(
"li", "quantities_standard.view", uuid=qty["uuid"]
c=tags.link_to(
qty.render_as_text(self.config),
# TODO
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 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): class OwnerRefsWidget(WuttaCheckboxChoiceWidget):

View file

@ -0,0 +1,13 @@
<div tal:define="
name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;"
tal:omit-tag="">
<quantities-editor tal:attributes="name name;
v-model vmodel;
:quantity-types quantity_types;
:measures measures;
:units units;" />
</div>

View file

@ -2,6 +2,8 @@
<%def name="make_wuttafarm_components()"> <%def name="make_wuttafarm_components()">
${self.make_assets_picker_component()} ${self.make_assets_picker_component()}
${self.make_animal_type_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_plant_types_picker_component()}
${self.make_seasons_picker_component()} ${self.make_seasons_picker_component()}
</%def> </%def>
@ -239,6 +241,356 @@
</script> </script>
</%def> </%def>
<%def name="make_quantity_editor_component()">
<script type="text/x-template" id="quantity-editor-template">
<div>
<b-field label="Measure" horizontal
## TODO: why is this needed?
style="margin-bottom: 1rem;">
<b-select v-model="value.measure"
ref="measure">
<option v-for="m in measures"
:value="m.drupal_id">
{{ m.name }}
</option>
</b-field>
<b-field label="Value" horizontal
## TODO: why is this needed?
style="margin-bottom: 1rem;">
<b-input v-model="value.value"
ref="value"
@keydown.native="inputKeydown" />
</b-field>
<b-field label="Units" horizontal
style="margin-bottom: 1rem;">
<b-button v-if="value.units.uuid"
@click="changeUnit()">
{{ value.units.name }} &nbsp; &nbsp; (click to change)
</b-button>
<b-autocomplete v-show="!value.units.uuid"
v-model="unitsName"
ref="unitsName"
:data="unitsNameData"
field="name"
open-on-focus
keep-first
@select="unitsNameSelected"
clear-on-select>
<template #empty>No results found</template>
</b-autocomplete>
</b-field>
<div class="buttons" style="margin-left: 8rem;">
<b-button type="is-primary"
ref="save"
@click="save"
:disabled="saveDisabled">
{{ creating ? "Create" : "Update" }} Quantity
</b-button>
<b-button @click="$emit('cancel')">
Cancel
</b-button>
</div>
</div>
</script>
<script>
const QuantityEditor = {
template: '#quantity-editor-template',
props: {
name: String,
value: Object,
measures: Array,
units: Array,
creating: {
type: Boolean,
default: false,
}
},
data() {
return {
measure: this.value.measure,
valueAmount: this.value.value,
unit: this.value.units,
unitsName: '',
}
},
computed: {
saveDisabled() {
if (!this.value.measure) {
return true
}
if (!this.value.value) {
return true
}
if (!this.value.units.uuid) {
return true
}
return false
},
unitsNameData() {
if (!this.unitsName) {
return this.units
}
return this.units.filter((unit) => {
return unit.name.toLowerCase().indexOf(this.unitsName.toLowerCase()) >= 0
})
},
},
methods: {
focusMeasure() {
this.$refs.measure.focus()
},
focusValue() {
this.$refs.value.focus()
},
changeUnit() {
this.value.units = {}
this.$emit('input', this.value)
this.$nextTick(() => {
this.$refs.unitsName.focus()
})
},
unitsNameSelected(option) {
if (option) {
this.value.units = option
this.$emit('input', this.value)
this.unitsName = null
if (this.value.measure && this.value.value) {
this.$nextTick(() => {
this.$refs.save.$el.focus()
})
}
}
},
inputKeydown(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.save()
}
},
save() {
this.$emit('save', this.value)
},
},
}
Vue.component('quantity-editor', QuantityEditor)
<% request.register_component('quantity-editor', 'QuantityEditor') %>
</script>
</%def>
<%def name="make_quantities_editor_component()">
<script type="text/x-template" id="quantities-editor-template">
<div>
<input type="hidden" :name="name" :value="value ? JSON.stringify(value) : ''" />
<${b}-table ref="table"
:data="value || []"
detailed
detail-key="uuid"
icon-pack="fas"
:show-detail-icon="false">
<${b}-table-column field="as_text"
v-slot="props"
label="Quantity">
<span>{{ props.row.as_text }}</span>
</${b}-table-column>
<${b}-table-column field="quantity_type"
v-slot="props"
label="Quantity Type">
<span>{{ props.row.quantity_type.name }}</span>
</${b}-table-column>
<${b}-table-column v-slot="props">
<div v-show="!editing[props.row.uuid]">
<a href="#"
@click.prevent="editInit(props.row)">
<i class="fas fa-edit" /> Edit
</a>
&nbsp;
<a href="#"
class="has-text-danger"
@click.prevent="removeQuantity(props.row)">
<i class="fas fa-trash" /> Remove
</a>
</div>
</${b}-table-column>
<template #detail="props">
<quantity-editor v-model="props.row"
:measures="measures"
:units="units"
@save="editSave"
@cancel="editCancel(props.row)" />
</template>
</${b}-table>
<b-field grouped
v-show="!creating">
<b-select v-model="quantityType">
<option v-for="qtype of quantityTypes"
:value="qtype.drupal_id">
{{ qtype.name }}
</option>
</b-select>
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="createInit()">
Add New Quantity
</b-button>
</b-field>
<quantity-editor v-show="creating"
v-model="newQuantity"
ref="newQuantity"
creating
@save="createSave"
@cancel="createCancel"
:measures="measures"
:units="units" />
</div>
</script>
<script>
const QuantitiesEditor = {
template: '#quantities-editor-template',
props: {
name: String,
value: Array,
quantityTypes: Array,
defaultQuantityType: {
type: String,
default: 'standard',
},
measures: Array,
units: Array,
},
data() {
const editing = {}
if (this.value) {
for (qty of this.value) {
editing[qty.uuid] = false
}
}
const measureMap = {}
for (let m of this.measures) {
measureMap[m.drupal_id] = m.name
}
return {
measureMap,
quantityType: this.defaultQuantityType,
editShowDialog: false,
editNew: true,
editQuantity: null,
editMeasure: null,
editValue: null,
editUnits: null,
editing: editing,
creating: false,
newQuantity: {
measure: null,
value: null,
units: {},
},
newCounter: 1,
}
},
methods: {
createSave(qty) {
qty = Object.fromEntries(Object.entries(qty))
qty.uuid = 'new_' + this.newCounter++
qty.as_text = "( " + this.measureMap[qty.measure] + " ) " + qty.value + " " + qty.units.name
const value = Array.from(this.value || [])
value.push(qty)
this.$emit('input', value)
this.creating = false
},
createCancel() {
this.creating = false
},
createInit() {
this.newQuantity.quantity_type = {
drupal_id: this.quantityType,
## TODO: add support for other quantity types
name: "Standard",
}
this.newQuantity.measure = null
this.newQuantity.value = null
this.newQuantity.units = {}
this.creating = true
this.$nextTick(() => {
this.$refs.newQuantity.focusMeasure()
})
},
editInit(qty) {
this.$refs.table.openDetailRow(qty)
this.editing[qty.uuid] = true
},
editSave(row) {
row.as_text = "( " + this.measureMap[row.measure] + " ) " + row.value + " " + row.units.name
this.$emit('input', this.value)
this.editing[row.uuid] = false
this.$refs.table.closeDetailRow(row)
},
editCancel(qty) {
this.$refs.table.closeDetailRow(qty)
this.editing[qty.uuid] = false
},
removeQuantity(qty) {
let value = Array.from(this.value)
const i = value.indexOf(qty)
value.splice(i, 1)
this.$emit('input', value)
},
},
}
Vue.component('quantities-editor', QuantitiesEditor)
<% request.register_component('quantities-editor', 'QuantitiesEditor') %>
</script>
</%def>
<%def name="make_plant_types_picker_component()"> <%def name="make_plant_types_picker_component()">
<script type="text/x-template" id="plant-types-picker-template"> <script type="text/x-template" id="plant-types-picker-template">
<div> <div>

View file

@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import LogType, Log from wuttafarm.db.model import LogType, Log
from wuttafarm.web.forms.schema import AssetRefs, LogQuantityRefs, OwnerRefs from wuttafarm.web.forms.schema import AssetRefs, QuantityRefs, OwnerRefs
from wuttafarm.util import get_log_type_enum from wuttafarm.util import get_log_type_enum
@ -287,10 +287,8 @@ class LogMasterView(WuttaFarmMasterView):
f.set_readonly("log_type") f.set_readonly("log_type")
# quantities # quantities
if self.creating or self.editing: f.set_node("quantities", QuantityRefs(self.request))
f.remove("quantities") # TODO: need to support this if not self.creating:
else:
f.set_node("quantities", LogQuantityRefs(self.request))
# nb. must explicity declare value for non-standard field # nb. must explicity declare value for non-standard field
f.set_default("quantities", log.quantities) f.set_default("quantities", log.quantities)
@ -332,6 +330,7 @@ class LogMasterView(WuttaFarmMasterView):
self.set_assets(log, data["assets"]) self.set_assets(log, data["assets"])
self.set_locations(log, data["locations"]) self.set_locations(log, data["locations"])
self.set_groups(log, data["groups"]) self.set_groups(log, data["groups"])
self.set_quantities(log, data["quantities"])
return log return log
@ -386,6 +385,58 @@ class LogMasterView(WuttaFarmMasterView):
assert group assert group
log.groups.remove(group) log.groups.remove(group)
def set_quantities(self, log, desired):
model = self.app.model
session = self.Session()
current = {qty.uuid.hex: qty for qty in log.quantities}
for new_qty in desired:
units = session.get(model.Unit, new_qty["units"]["uuid"])
assert units
if new_qty["uuid"].startswith("new_"):
assert new_qty["quantity_type"]["drupal_id"] == "standard"
factory = model.StandardQuantity
qty = factory(
quantity_type_id=new_qty["quantity_type"]["drupal_id"],
measure_id=new_qty["measure"],
value_numerator=int(new_qty["value"]),
value_denominator=1,
units=units,
)
# nb. must ensure "typed" quantity record persists!
session.add(qty)
# but must add "generic" quantity record to log
log.quantities.append(qty.quantity)
else:
old_qty = current[new_qty["uuid"]]
old_qty.measure_id = new_qty["measure"]
old_qty.value_numerator = int(new_qty["value"])
old_qty.value_denominator = 1
old_qty.units = units
desired = [qty["uuid"] for qty in desired]
for old_qty in list(log.quantities):
# nb. "old_qty" may be newly-created, w/ no uuid yet
# (this logic may break if session gets flushed early!)
if old_qty.uuid and old_qty.uuid.hex not in desired:
log.quantities.remove(old_qty)
def auto_sync_to_farmos(self, client, log):
model = self.app.model
session = self.Session()
# nb. ensure quantities have uuid keys
session.flush()
for qty in log.quantities:
# TODO: support more quantity types
if qty.quantity_type_id == "standard":
qty = session.get(model.StandardQuantity, qty.uuid)
assert qty
self.app.auto_sync_to_farmos(qty, client=client)
self.app.auto_sync_to_farmos(log, client=client)
def get_farmos_url(self, log): def get_farmos_url(self, log):
return self.app.get_farmos_url(f"/log/{log.drupal_id}") return self.app.get_farmos_url(f"/log/{log.drupal_id}")

View file

@ -113,6 +113,9 @@ class WuttaFarmMasterView(MasterView):
# maybe also sync change to farmOS # maybe also sync change to farmOS
if self.app.is_farmos_mirror(): if self.app.is_farmos_mirror():
client = get_farmos_client_for_user(self.request) client = get_farmos_client_for_user(self.request)
self.auto_sync_to_farmos(client, obj)
def auto_sync_to_farmos(self, client, obj):
self.app.auto_sync_to_farmos(obj, client=client, require=False) self.app.auto_sync_to_farmos(obj, client=client, require=False)
def get_farmos_entity_type(self): def get_farmos_entity_type(self):