feat: improve support for exporting quantity, log data

and make the Eggs quick form save to wuttafarm app DB first, then
export to farmOS, if app is running as mirror
This commit is contained in:
Lance Edgar 2026-03-04 20:36:56 -06:00
parent 609a900f39
commit 23af35842d
5 changed files with 427 additions and 41 deletions

View file

@ -443,6 +443,138 @@ class StructureAssetImporter(ToFarmOSAsset):
return payload return payload
##############################
# quantity importers
##############################
class ToFarmOSQuantity(ToFarmOS):
"""
Base class for quantity data importer targeting the farmOS API.
"""
farmos_quantity_type = None
supported_fields = [
"uuid",
"measure",
"value_numerator",
"value_denominator",
"label",
"quantity_type_uuid",
"unit_uuid",
]
def get_target_objects(self, **kwargs):
return list(
self.farmos_client.resource.iterate("quantity", self.farmos_quantity_type)
)
def get_target_object(self, key):
# fetch from cache, if applicable
if self.caches_target:
return super().get_target_object(key)
# okay now must fetch via API
if self.get_keys() != ["uuid"]:
raise ValueError("must use uuid key for this to work")
uuid = key[0]
try:
qty = self.farmos_client.resource.get_id(
"quantity", self.farmos_quantity_type, str(uuid)
)
except requests.HTTPError as exc:
if exc.response.status_code == 404:
return None
raise
return qty["data"]
def create_target_object(self, key, source_data):
if source_data.get("__ignoreme__"):
return None
if self.dry_run:
return source_data
payload = self.get_quantity_payload(source_data)
result = self.farmos_client.resource.send(
"quantity", self.farmos_quantity_type, payload
)
normal = self.normalize_target_object(result["data"])
normal["_new_object"] = result["data"]
return normal
def update_target_object(self, quantity, source_data, target_data=None):
if self.dry_run:
return quantity
payload = self.get_quantity_payload(source_data)
payload["id"] = str(source_data["uuid"])
result = self.farmos_client.resource.send(
"quantity", self.farmos_quantity_type, payload
)
return self.normalize_target_object(result["data"])
def normalize_target_object(self, qty):
result = {
"uuid": UUID(qty["id"]),
"measure": qty["attributes"]["measure"],
"value_numerator": qty["attributes"]["value"]["numerator"],
"value_denominator": qty["attributes"]["value"]["denominator"],
"label": qty["attributes"]["label"],
"quantity_type_uuid": UUID(
qty["relationships"]["quantity_type"]["data"]["id"]
),
"unit_uuid": None,
}
if unit := qty["relationships"]["units"]["data"]:
result["unit_uuid"] = UUID(unit["id"])
return result
def get_quantity_payload(self, source_data):
attrs = {}
if "measure" in self.fields:
attrs["measure"] = source_data["measure"]
if "value_numerator" in self.fields and "value_denominator" in self.fields:
attrs["value"] = {
"numerator": source_data["value_numerator"],
"denominator": source_data["value_denominator"],
}
if "label" in self.fields:
attrs["label"] = source_data["label"]
rels = {}
if "quantity_type_uuid" in self.fields:
rels["quantity_type"] = {
"data": {
"id": str(source_data["quantity_type_uuid"]),
"type": "quantity_type--quantity_type",
}
}
if "unit_uuid" in self.fields:
rels["units"] = {
"data": {
"id": str(source_data["unit_uuid"]),
"type": "taxonomy_term--unit",
}
}
payload = {"attributes": attrs, "relationships": rels}
return payload
class StandardQuantityImporter(ToFarmOSQuantity):
model_title = "StandardQuantity"
farmos_quantity_type = "standard"
############################## ##############################
# log importers # log importers
############################## ##############################
@ -464,8 +596,14 @@ class ToFarmOSLog(ToFarmOS):
"status", "status",
"notes", "notes",
"quick", "quick",
"assets",
"quantities",
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.normal = self.app.get_normalizer(self.farmos_client)
def get_target_objects(self, **kwargs): def get_target_objects(self, **kwargs):
result = self.farmos_client.log.get(self.farmos_log_type) result = self.farmos_client.log.get(self.farmos_log_type)
return result["data"] return result["data"]
@ -511,19 +649,18 @@ class ToFarmOSLog(ToFarmOS):
return self.normalize_target_object(result["data"]) return self.normalize_target_object(result["data"])
def normalize_target_object(self, log): def normalize_target_object(self, log):
normal = self.normal.normalize_farmos_log(log)
if notes := log["attributes"]["notes"]:
notes = notes["value"]
return { return {
"uuid": UUID(log["id"]), "uuid": UUID(normal["uuid"]),
"name": log["attributes"]["name"], "name": normal["name"],
"timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), "timestamp": self.app.make_utc(normal["timestamp"]),
"is_movement": log["attributes"]["is_movement"], "is_movement": normal["is_movement"],
"is_group_assignment": log["attributes"]["is_group_assignment"], "is_group_assignment": normal["is_group_assignment"],
"status": log["attributes"]["status"], "status": normal["status"],
"notes": notes, "notes": normal["notes"],
"quick": log["attributes"]["quick"], "quick": normal["quick"],
"assets": [(a["asset_type"], UUID(a["uuid"])) for a in normal["assets"]],
"quantities": [UUID(uuid) for uuid in normal["quantity_uuids"]],
} }
def get_log_payload(self, source_data): def get_log_payload(self, source_data):
@ -542,10 +679,32 @@ class ToFarmOSLog(ToFarmOS):
if "notes" in self.fields: if "notes" in self.fields:
attrs["notes"] = {"value": source_data["notes"]} attrs["notes"] = {"value": source_data["notes"]}
if "quick" in self.fields: if "quick" in self.fields:
attrs["quick"] = {"value": source_data["quick"]} attrs["quick"] = source_data["quick"]
payload = {"attributes": attrs} rels = {}
if "assets" in self.fields:
assets = []
for asset_type, uuid in source_data["assets"]:
assets.append(
{
"type": f"asset--{asset_type}",
"id": str(uuid),
}
)
rels["asset"] = {"data": assets}
if "quantities" in self.fields:
quantities = []
for uuid in source_data["quantities"]:
quantities.append(
{
# TODO: support other quantity types
"type": "quantity--standard",
"id": str(uuid),
}
)
rels["quantity"] = {"data": quantities}
payload = {"attributes": attrs, "relationships": rels}
return payload return payload

View file

@ -104,6 +104,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
importers["PlantType"] = PlantTypeImporter importers["PlantType"] = PlantTypeImporter
importers["PlantAsset"] = PlantAssetImporter importers["PlantAsset"] = PlantAssetImporter
importers["Unit"] = UnitImporter importers["Unit"] = UnitImporter
importers["StandardQuantity"] = StandardQuantityImporter
importers["ActivityLog"] = ActivityLogImporter importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter importers["HarvestLog"] = HarvestLogImporter
importers["MedicalLog"] = MedicalLogImporter importers["MedicalLog"] = MedicalLogImporter
@ -347,6 +348,49 @@ class StructureAssetImporter(
} }
##############################
# quantity importers
##############################
class FromWuttaFarmQuantity(FromWuttaFarm):
"""
Base class for WuttaFarm -> farmOS quantity importers
"""
supported_fields = [
"uuid",
"measure",
"value_numerator",
"value_denominator",
"label",
"quantity_type_uuid",
"unit_uuid",
]
def normalize_source_object(self, qty):
return {
"uuid": qty.farmos_uuid or self.app.make_true_uuid(),
"measure": qty.measure_id,
"value_numerator": qty.value_numerator,
"value_denominator": qty.value_denominator,
"label": qty.label,
"quantity_type_uuid": qty.quantity_type.farmos_uuid,
"unit_uuid": qty.units.farmos_uuid,
"_src_object": qty,
}
class StandardQuantityImporter(
FromWuttaFarmQuantity, farmos_importing.model.StandardQuantityImporter
):
"""
WuttaFarm farmOS API exporter for Standard Quantities
"""
source_model_class = model.StandardQuantity
############################## ##############################
# log importers # log importers
############################## ##############################
@ -365,6 +409,9 @@ class FromWuttaFarmLog(FromWuttaFarm):
"is_group_assignment", "is_group_assignment",
"status", "status",
"notes", "notes",
"quick",
"assets",
"quantities",
] ]
def normalize_source_object(self, log): def normalize_source_object(self, log):
@ -376,6 +423,9 @@ class FromWuttaFarmLog(FromWuttaFarm):
"is_group_assignment": log.is_group_assignment, "is_group_assignment": log.is_group_assignment,
"status": log.status, "status": log.status,
"notes": log.notes, "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],
"quantities": [qty.farmos_uuid for qty in log.quantities],
"_src_object": log, "_src_object": log,
} }

View file

@ -32,6 +32,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.schema import FarmOSUnitRef from wuttafarm.web.forms.schema import FarmOSUnitRef
from wuttafarm.web.grids import ResourceData
class QuantityTypeView(FarmOSMasterView): class QuantityTypeView(FarmOSMasterView):
@ -130,13 +131,15 @@ class QuantityMasterView(FarmOSMasterView):
farmos_quantity_type = None farmos_quantity_type = None
grid_columns = [ grid_columns = [
"drupal_id",
"as_text",
"measure", "measure",
"value", "value",
"unit",
"label", "label",
"changed",
] ]
sort_defaults = ("changed", "desc") sort_defaults = ("drupal_id", "desc")
form_fields = [ form_fields = [
"measure", "measure",
@ -147,20 +150,58 @@ class QuantityMasterView(FarmOSMasterView):
"changed", "changed",
] ]
def get_grid_data(self, columns=None, session=None): def get_farmos_api_includes(self):
result = self.farmos_client.resource.get("quantity", self.farmos_quantity_type) return {"units"}
return [self.normalize_quantity(t) for t in result["data"]]
def get_grid_data(self, **kwargs):
return ResourceData(
self.config,
self.farmos_client,
f"quantity--{self.farmos_quantity_type}",
include=",".join(self.get_farmos_api_includes()),
normalizer=self.normalize_quantity,
)
def configure_grid(self, grid): def configure_grid(self, grid):
g = grid g = grid
super().configure_grid(g) super().configure_grid(g)
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
# as_text
g.set_renderer("as_text", self.render_as_text_for_grid)
# measure
g.set_renderer("measure", self.render_measure_for_grid)
# value # value
g.set_link("value") g.set_renderer("value", self.render_value_for_grid)
# unit
g.set_renderer("unit", self.render_unit_for_grid)
# changed # changed
g.set_renderer("changed", "datetime") g.set_renderer("changed", "datetime")
def render_as_text_for_grid(self, qty, field, value):
measure = qty["measure"].capitalize()
value = qty["value"]["decimal"]
units = qty["unit"]["name"] if qty["unit"] else "??"
return f"( {measure} ) {value} {units}"
def render_measure_for_grid(self, qty, field, value):
return qty["measure"].capitalize()
def render_unit_for_grid(self, qty, field, value):
unit = qty[field]
if not unit:
return ""
return unit["name"]
def render_value_for_grid(self, qty, field, value):
return qty["value"]["decimal"]
def get_instance(self): def get_instance(self):
quantity = self.farmos_client.resource.get_id( quantity = self.farmos_client.resource.get_id(
"quantity", self.farmos_quantity_type, self.request.matchdict["uuid"] "quantity", self.farmos_quantity_type, self.request.matchdict["uuid"]
@ -187,7 +228,7 @@ class QuantityMasterView(FarmOSMasterView):
def get_instance_title(self, quantity): def get_instance_title(self, quantity):
return quantity["value"] return quantity["value"]
def normalize_quantity(self, quantity): def normalize_quantity(self, quantity, included={}):
if created := quantity["attributes"]["created"]: if created := quantity["attributes"]["created"]:
created = datetime.datetime.fromisoformat(created) created = datetime.datetime.fromisoformat(created)
@ -197,11 +238,37 @@ class QuantityMasterView(FarmOSMasterView):
changed = datetime.datetime.fromisoformat(changed) changed = datetime.datetime.fromisoformat(changed)
changed = self.app.localtime(changed) changed = self.app.localtime(changed)
quantity_type_object = None
quantity_type_uuid = None
unit_object = None
unit_uuid = None
if relationships := quantity["relationships"]:
if quantity_type := relationships["quantity_type"]["data"]:
quantity_type_uuid = quantity_type["id"]
quantity_type_object = {
"uuid": quantity_type_uuid,
"type": "quantity_type--quantity_type",
}
if unit := relationships["units"]["data"]:
unit_uuid = unit["id"]
if unit := included.get(unit_uuid):
unit_object = {
"uuid": unit_uuid,
"type": "taxonomy_term--unit",
"name": unit["attributes"]["name"],
}
return { return {
"uuid": quantity["id"], "uuid": quantity["id"],
"drupal_id": quantity["attributes"]["drupal_internal__id"], "drupal_id": quantity["attributes"]["drupal_internal__id"],
"quantity_type": quantity_type_object,
"quantity_type_uuid": quantity_type_uuid,
"measure": quantity["attributes"]["measure"], "measure": quantity["attributes"]["measure"],
"value": quantity["attributes"]["value"], "value": quantity["attributes"]["value"],
"unit": unit_object,
"unit_uuid": unit_uuid,
"label": quantity["attributes"]["label"] or colander.null, "label": quantity["attributes"]["label"] or colander.null,
"created": created, "created": created,
"changed": changed, "changed": changed,

View file

@ -28,6 +28,7 @@ import logging
from pyramid.renderers import render_to_response from pyramid.renderers import render_to_response
from wuttaweb.views import View from wuttaweb.views import View
from wuttaweb.db import Session
from wuttafarm.web.util import get_farmos_client_for_user from wuttafarm.web.util import get_farmos_client_for_user
@ -40,6 +41,8 @@ class QuickFormView(View):
Base class for quick form views. Base class for quick form views.
""" """
Session = Session
def __init__(self, request, context=None): def __init__(self, request, context=None):
super().__init__(request, context=context) super().__init__(request, context=context)
self.farmos_client = get_farmos_client_for_user(self.request) self.farmos_client = get_farmos_client_for_user(self.request)

View file

@ -34,7 +34,6 @@ from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.quick import QuickFormView from wuttafarm.web.views.quick import QuickFormView
from wuttafarm.web.util import get_farmos_client_for_user
class EggsQuickForm(QuickFormView): class EggsQuickForm(QuickFormView):
@ -49,6 +48,9 @@ class EggsQuickForm(QuickFormView):
_layer_assets = None _layer_assets = None
# TODO: make this configurable?
unit_name = "egg(s)"
def make_quick_form(self): def make_quick_form(self):
f = self.make_form( f = self.make_form(
fields=[ fields=[
@ -89,6 +91,47 @@ class EggsQuickForm(QuickFormView):
if self._layer_assets is not None: if self._layer_assets is not None:
return self._layer_assets return self._layer_assets
if self.app.is_farmos_wrapper():
assets = self.get_layer_assets_from_farmos()
else:
assets = self.get_layer_assets_from_wuttafarm()
assets.sort(key=lambda a: a["name"])
self._layer_assets = assets
return assets
def get_layer_assets_from_wuttafarm(self):
model = self.app.model
session = self.Session()
assets = []
def normalize(asset):
asset_type = asset.__wutta_hint__["farmos_asset_type"]
return {
"uuid": str(asset.farmos_uuid),
"name": asset.asset_name,
"type": f"asset--{asset_type}",
}
query = (
session.query(model.AnimalAsset)
.join(model.Asset)
.filter(model.AnimalAsset.produces_eggs == True)
.order_by(model.Asset.asset_name)
)
assets.extend([normalize(a) for a in query])
query = (
session.query(model.GroupAsset)
.join(model.Asset)
.filter(model.GroupAsset.produces_eggs == True)
.order_by(model.Asset.asset_name)
)
assets.extend([normalize(a) for a in query])
return assets
def get_layer_assets_from_farmos(self):
assets = [] assets = []
params = { params = {
"filter[produces_eggs]": 1, "filter[produces_eggs]": 1,
@ -108,24 +151,14 @@ class EggsQuickForm(QuickFormView):
result = self.farmos_client.asset.get("group", params=params) result = self.farmos_client.asset.get("group", params=params)
assets.extend([normalize(a) for a in result["data"]]) assets.extend([normalize(a) for a in result["data"]])
assets.sort(key=lambda a: a["name"])
self._layer_assets = assets
return assets return assets
def save_quick_form(self, form): def save_quick_form(self, form):
response = self.save_to_farmos(form) if self.app.is_farmos_wrapper():
log = json.loads(response["create-log#body{0}"]["body"]) return self.save_to_farmos(form)
if self.app.is_farmos_mirror(): return self.save_to_wuttafarm(form)
quantity = json.loads(response["create-quantity"]["body"])
client = get_farmos_client_for_user(self.request)
self.app.auto_sync_from_farmos(
quantity["data"], "StandardQuantity", client=client
)
self.app.auto_sync_from_farmos(log["data"], "HarvestLog", client=client)
return log
def save_to_farmos(self, form): def save_to_farmos(self, form):
data = form.validated data = form.validated
@ -135,7 +168,7 @@ class EggsQuickForm(QuickFormView):
asset = assets[data["asset"]] asset = assets[data["asset"]]
# TODO: make this configurable? # TODO: make this configurable?
unit_name = "egg(s)" unit_name = self.unit_name
unit = {"data": {"type": "taxonomy_term--unit"}} unit = {"data": {"type": "taxonomy_term--unit"}}
new_unit = None new_unit = None
@ -234,13 +267,87 @@ class EggsQuickForm(QuickFormView):
blueprints.insert(0, new_unit) blueprints.insert(0, new_unit)
blueprint = SubrequestsBlueprint.parse_obj(blueprints) blueprint = SubrequestsBlueprint.parse_obj(blueprints)
response = self.farmos_client.subrequests.send(blueprint, format=Format.json) response = self.farmos_client.subrequests.send(blueprint, format=Format.json)
return response
def redirect_after_save(self, result): log = json.loads(response["create-log#body{0}"]["body"])
return self.redirect(
self.request.route_url( if self.app.is_farmos_mirror():
"farmos_logs_harvest.view", uuid=result["data"]["id"] if new_unit:
unit = json.loads(response["create-unit"]["body"])
self.app.auto_sync_from_farmos(
unit["data"], "Unit", client=self.farmos_client
)
quantity = json.loads(response["create-quantity"]["body"])
self.app.auto_sync_from_farmos(
quantity["data"], "StandardQuantity", client=self.farmos_client
) )
self.app.auto_sync_from_farmos(
log["data"], "HarvestLog", client=self.farmos_client
)
return log
def save_to_wuttafarm(self, form):
model = self.app.model
session = self.Session()
data = form.validated
asset = (
session.query(model.Asset)
.filter(model.Asset.farmos_uuid == data["asset"])
.one()
)
# TODO: make this configurable?
unit_name = self.unit_name
new_unit = False
unit = session.query(model.Unit).filter(model.Unit.name == unit_name).first()
if not unit:
unit = model.Unit(name=unit_name)
session.add(unit)
new_unit = True
quantity = model.StandardQuantity(
quantity_type_id="standard",
measure_id="count",
value_numerator=data["count"],
value_denominator=1,
units=unit,
)
session.add(quantity)
log = model.HarvestLog(
log_type="harvest",
message=f"Collected {data['count']} {unit_name}",
timestamp=self.app.make_utc(data["timestamp"]),
notes=data["notes"] or None,
quick="eggs",
status="done",
)
session.add(log)
log.assets.append(asset)
log.quantities.append(quantity.quantity)
log.owners.append(self.request.user)
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)
return log
def redirect_after_save(self, log):
model = self.app.model
if isinstance(log, model.HarvestLog):
return self.redirect(
self.request.route_url("logs_harvest.view", uuid=log.uuid)
)
return self.redirect(
self.request.route_url("farmos_logs_harvest.view", uuid=log["data"]["id"])
) )