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

View file

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

View file

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

View file

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

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()">
${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()}
</%def>
@ -239,6 +241,356 @@
</script>
</%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()">
<script type="text/x-template" id="plant-types-picker-template">
<div>

View file

@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views import WuttaFarmMasterView
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
@ -287,10 +287,8 @@ class LogMasterView(WuttaFarmMasterView):
f.set_readonly("log_type")
# quantities
if self.creating or self.editing:
f.remove("quantities") # TODO: need to support this
else:
f.set_node("quantities", LogQuantityRefs(self.request))
f.set_node("quantities", QuantityRefs(self.request))
if not self.creating:
# nb. must explicity declare value for non-standard field
f.set_default("quantities", log.quantities)
@ -332,6 +330,7 @@ class LogMasterView(WuttaFarmMasterView):
self.set_assets(log, data["assets"])
self.set_locations(log, data["locations"])
self.set_groups(log, data["groups"])
self.set_quantities(log, data["quantities"])
return log
@ -386,6 +385,58 @@ class LogMasterView(WuttaFarmMasterView):
assert 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):
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
if self.app.is_farmos_mirror():
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)
def get_farmos_entity_type(self):