feat: add common normalizer to simplify code in view, importer etc.

only the "log" normalizer exists so far, but will add more..
This commit is contained in:
Lance Edgar 2026-02-22 19:20:46 -06:00
parent 1a6870b8fe
commit e7ef5c3d32
9 changed files with 331 additions and 208 deletions

View file

@ -56,24 +56,38 @@ class WuttaFarmAppHandler(base.AppHandler):
Returns the integration mode for farmOS, i.e. to control the Returns the integration mode for farmOS, i.e. to control the
app's behavior regarding that. app's behavior regarding that.
""" """
handler = self.get_farmos_handler() enum = self.enum
return handler.get_farmos_integration_mode() return self.config.get(
f"{self.appname}.farmos_integration_mode",
default=enum.FARMOS_INTEGRATION_MODE_WRAPPER,
)
def is_farmos_mirror(self): def is_farmos_mirror(self):
""" """
Returns ``True`` if the app is configured in "mirror" Returns ``True`` if the app is configured in "mirror"
integration mode with regard to farmOS. integration mode with regard to farmOS.
""" """
handler = self.get_farmos_handler() enum = self.enum
return handler.is_farmos_mirror() mode = self.get_farmos_integration_mode()
return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR
def is_farmos_wrapper(self): def is_farmos_wrapper(self):
""" """
Returns ``True`` if the app is configured in "wrapper" Returns ``True`` if the app is configured in "wrapper"
integration mode with regard to farmOS. integration mode with regard to farmOS.
""" """
handler = self.get_farmos_handler() enum = self.enum
return handler.is_farmos_wrapper() mode = self.get_farmos_integration_mode()
return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER
def is_standalone(self):
"""
Returns ``True`` if the app is configured in "standalone" mode
with regard to farmOS.
"""
enum = self.enum
mode = self.get_farmos_integration_mode()
return mode == enum.FARMOS_INTEGRATION_MODE_NONE
def get_farmos_url(self, *args, **kwargs): def get_farmos_url(self, *args, **kwargs):
""" """
@ -109,7 +123,20 @@ class WuttaFarmAppHandler(base.AppHandler):
handler = self.get_farmos_handler() handler = self.get_farmos_handler()
return handler.is_farmos_4x(*args, **kwargs) return handler.is_farmos_4x(*args, **kwargs)
def export_to_farmos(self, obj, require=True): def get_normalizer(self, farmos_client=None):
"""
Get the configured farmOS integration handler.
:rtype: :class:`~wuttafarm.farmos.FarmOSHandler`
"""
spec = self.config.get(
f"{self.appname}.normalizer_spec",
default="wuttafarm.normal:Normalizer",
)
factory = self.load_object(spec)
return factory(self.config, farmos_client)
def auto_sync_to_farmos(self, obj, model_name=None, require=True):
""" """
Export the given object to farmOS, using configured handler. Export the given object to farmOS, using configured handler.
@ -127,7 +154,8 @@ class WuttaFarmAppHandler(base.AppHandler):
""" """
handler = self.app.get_import_handler("export.to_farmos.from_wuttafarm") handler = self.app.get_import_handler("export.to_farmos.from_wuttafarm")
model_name = type(obj).__name__ if not model_name:
model_name = type(obj).__name__
if model_name not in handler.importers: if model_name not in handler.importers:
if require: if require:
raise ValueError(f"no exporter found for {model_name}") raise ValueError(f"no exporter found for {model_name}")
@ -141,6 +169,37 @@ class WuttaFarmAppHandler(base.AppHandler):
normal = importer.normalize_source_object(obj) normal = importer.normalize_source_object(obj)
importer.process_data(source_data=[normal]) importer.process_data(source_data=[normal])
def auto_sync_from_farmos(self, obj, model_name, require=True):
"""
Import the given object from farmOS, using configured handler.
:param obj: Any data record from farmOS.
:param model_name': Model name for the importer to use,
e.g. ``"AnimalAsset"``.
:param require: If true, this will *require* the import
handler to support objects of the given type. If false,
then nothing will happen / import is silently skipped when
there is no such importer.
"""
handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos")
if model_name not in handler.importers:
if require:
raise ValueError(f"no importer found for {model_name}")
return
# nb. begin txn to establish the API client
# TODO: should probably use current user oauth2 token instead
# of always making a new one here, which is what happens IIUC
handler.begin_source_transaction()
with self.short_session(commit=True) as session:
handler.target_session = session
importer = handler.get_importer(model_name, caches_target=False)
normal = importer.normalize_source_object(obj)
importer.process_data(source_data=[normal])
class WuttaFarmAppProvider(base.AppProvider): class WuttaFarmAppProvider(base.AppProvider):
""" """

View file

@ -34,35 +34,6 @@ class FarmOSHandler(GenericHandler):
:term:`handler`. :term:`handler`.
""" """
def get_farmos_integration_mode(self):
"""
Returns the integration mode for farmOS, i.e. to control the
app's behavior regarding that.
"""
enum = self.app.enum
return self.config.get(
f"{self.app.appname}.farmos_integration_mode",
default=enum.FARMOS_INTEGRATION_MODE_WRAPPER,
)
def is_farmos_mirror(self):
"""
Returns ``True`` if the app is configured in "mirror"
integration mode with regard to farmOS.
"""
enum = self.app.enum
mode = self.get_farmos_integration_mode()
return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR
def is_farmos_wrapper(self):
"""
Returns ``True`` if the app is configured in "wrapper"
integration mode with regard to farmOS.
"""
enum = self.app.enum
mode = self.get_farmos_integration_mode()
return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER
def get_farmos_client(self, hostname=None, **kwargs): def get_farmos_client(self, hostname=None, **kwargs):
""" """
Returns a new farmOS API client. Returns a new farmOS API client.

View file

@ -53,6 +53,7 @@ class FromFarmOSHandler(ImportHandler):
token = self.get_farmos_oauth2_token() token = self.get_farmos_oauth2_token()
self.farmos_client = self.app.get_farmos_client(token=token) self.farmos_client = self.app.get_farmos_client(token=token)
self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
self.normal = self.app.get_normalizer(self.farmos_client)
def get_farmos_oauth2_token(self): def get_farmos_oauth2_token(self):
@ -76,6 +77,7 @@ class FromFarmOSHandler(ImportHandler):
kwargs = super().get_importer_kwargs(key, **kwargs) kwargs = super().get_importer_kwargs(key, **kwargs)
kwargs["farmos_client"] = self.farmos_client kwargs["farmos_client"] = self.farmos_client
kwargs["farmos_4x"] = self.farmos_4x kwargs["farmos_4x"] = self.farmos_4x
kwargs["normal"] = self.normal
return kwargs return kwargs
@ -981,33 +983,25 @@ class LogImporterBase(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
log_type = self.get_farmos_log_type() log_type = self.get_farmos_log_type()
result = self.farmos_client.log.get(log_type) return list(self.farmos_client.log.iterate(log_type))
return result["data"]
def get_asset_type(self, asset):
return asset["type"].split("--")[1]
def normalize_source_object(self, log): def normalize_source_object(self, log):
""" """ """ """
if notes := log["attributes"]["notes"]: data = self.normal.normalize_farmos_log(log)
notes = notes["value"]
data["farmos_uuid"] = UUID(data.pop("uuid"))
data["message"] = data.pop("name")
data["timestamp"] = self.app.make_utc(data["timestamp"])
# TODO
data["log_type"] = self.get_farmos_log_type()
assets = None
if "assets" in self.fields: if "assets" in self.fields:
assets = [] data["assets"] = [
for asset in log["relationships"]["asset"]["data"]: (a["asset_type"], UUID(a["uuid"])) for a in data["assets"]
assets.append((self.get_asset_type(asset), UUID(asset["id"]))) ]
return { return data
"farmos_uuid": UUID(log["id"]),
"drupal_id": log["attributes"]["drupal_internal__id"],
"log_type": self.get_farmos_log_type(),
"message": log["attributes"]["name"],
"timestamp": self.normalize_datetime(log["attributes"]["timestamp"]),
"notes": notes,
"status": log["attributes"]["status"],
"assets": assets,
}
def normalize_target_object(self, log): def normalize_target_object(self, log):
data = super().normalize_target_object(log) data = super().normalize_target_object(log)
@ -1183,6 +1177,28 @@ class QuantityImporterBase(FromFarmOS, ToWutta):
result = self.farmos_client.resource.get("quantity", quantity_type) result = self.farmos_client.resource.get("quantity", quantity_type)
return result["data"] return result["data"]
def get_quantity_type_by_farmos_uuid(self, uuid):
if hasattr(self, "quantity_types_by_farmos_uuid"):
return self.quantity_types_by_farmos_uuid.get(UUID(uuid))
model = self.app.model
return (
self.target_session.query(model.QuantityType)
.filter(model.QuantityType.farmos_uuid == uuid)
.one()
)
def get_unit_by_farmos_uuid(self, uuid):
if hasattr(self, "units_by_farmos_uuid"):
return self.units_by_farmos_uuid.get(UUID(uuid))
model = self.app.model
return (
self.target_session.query(model.Unit)
.filter(model.Unit.farmos_uuid == uuid)
.one()
)
def normalize_source_object(self, quantity): def normalize_source_object(self, quantity):
""" """ """ """
quantity_type_id = None quantity_type_id = None
@ -1191,16 +1207,14 @@ class QuantityImporterBase(FromFarmOS, ToWutta):
if quantity_type := relationships.get("quantity_type"): if quantity_type := relationships.get("quantity_type"):
if quantity_type["data"]: if quantity_type["data"]:
if wf_quantity_type := self.quantity_types_by_farmos_uuid.get( if wf_quantity_type := self.get_quantity_type_by_farmos_uuid(
UUID(quantity_type["data"]["id"]) quantity_type["data"]["id"]
): ):
quantity_type_id = wf_quantity_type.drupal_id quantity_type_id = wf_quantity_type.drupal_id
if units := relationships.get("units"): if units := relationships.get("units"):
if units["data"]: if units["data"]:
if wf_unit := self.units_by_farmos_uuid.get( if wf_unit := self.get_unit_by_farmos_uuid(units["data"]["id"]):
UUID(units["data"]["id"])
):
units_uuid = wf_unit.uuid units_uuid = wf_unit.uuid
if not quantity_type_id: if not quantity_type_id:

199
src/wuttafarm/normal.py Normal file
View file

@ -0,0 +1,199 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Data normalizer for WuttaFarm / farmOS
"""
import datetime
from wuttjamaican.app import GenericHandler
class Normalizer(GenericHandler):
"""
Base class and default implementation for the global data
normalizer. This should be used for normalizing records from
WuttaFarm and/or farmOS.
The point here is to have a single place to put the normalization
logic, and let it be another thing which can be customized via
subclass.
"""
_farmos_units = None
_farmos_measures = None
def __init__(self, config, farmos_client=None):
super().__init__(config)
self.farmos_client = farmos_client
def get_farmos_measures(self):
if self._farmos_measures:
return self._farmos_measures
measures = {}
response = self.farmos_client.session.get(
self.app.get_farmos_url("/api/quantity/standard/resource/schema")
)
response.raise_for_status()
data = response.json()
for measure in data["definitions"]["attributes"]["properties"]["measure"][
"oneOf"
]:
measures[measure["const"]] = measure["title"]
self._farmos_measures = measures
return self._farmos_measures
def get_farmos_measure_name(self, measure_id):
measures = self.get_farmos_measures()
return measures[measure_id]
def get_farmos_unit(self, uuid):
units = self.get_farmos_units()
return units[uuid]
def get_farmos_units(self):
if self._farmos_units:
return self._farmos_units
units = {}
result = self.farmos_client.resource.get("taxonomy_term", "unit")
for unit in result["data"]:
units[unit["id"]] = unit
self._farmos_units = units
return self._farmos_units
def normalize_farmos_log(self, log, included={}):
if timestamp := log["attributes"]["timestamp"]:
timestamp = datetime.datetime.fromisoformat(timestamp)
timestamp = self.app.localtime(timestamp)
if notes := log["attributes"]["notes"]:
notes = notes["value"]
log_type_object = {}
log_type_uuid = None
asset_objects = []
quantity_objects = []
quantity_uuids = []
owner_objects = []
owner_uuids = []
if relationships := log.get("relationships"):
if log_type := relationships.get("log_type"):
log_type_uuid = log_type["data"]["id"]
if log_type := included.get(log_type_uuid):
log_type_object = {
"uuid": log_type["id"],
"name": log_type["attributes"]["label"],
}
if assets := relationships.get("asset"):
for asset in assets["data"]:
asset_object = {
"uuid": asset["id"],
"type": asset["type"],
"asset_type": asset["type"].split("--")[1],
}
if asset := included.get(asset["id"]):
attrs = asset["attributes"]
rels = asset["relationships"]
asset_object.update(
{
"drupal_id": attrs["drupal_internal__id"],
"name": attrs["name"],
"is_location": attrs["is_location"],
"is_fixed": attrs["is_fixed"],
"archived": attrs["archived"],
"notes": attrs["notes"],
}
)
asset_objects.append(asset_object)
if quantities := relationships.get("quantity"):
for quantity in quantities["data"]:
quantity_uuid = quantity["id"]
quantity_uuids.append(quantity_uuid)
if quantity := included.get(quantity_uuid):
attrs = quantity["attributes"]
rels = quantity["relationships"]
value = attrs["value"]
unit_uuid = rels["units"]["data"]["id"]
unit = self.get_farmos_unit(unit_uuid)
measure_id = attrs["measure"]
quantity_objects.append(
{
"uuid": quantity["id"],
"drupal_id": attrs["drupal_internal__id"],
"quantity_type_uuid": rels["quantity_type"]["data"][
"id"
],
"quantity_type_id": rels["quantity_type"]["data"][
"meta"
]["drupal_internal__target_id"],
"measure_id": measure_id,
"measure_name": self.get_farmos_measure_name(
measure_id
),
"value_numerator": value["numerator"],
"value_decimal": value["decimal"],
"value_denominator": value["denominator"],
"unit_uuid": unit_uuid,
"unit_name": unit["attributes"]["name"],
}
)
if owners := relationships.get("owner"):
for user in owners["data"]:
user_uuid = user["id"]
owner_uuids.append(user_uuid)
if user := included.get(user_uuid):
owner_objects.append(
{
"uuid": user["id"],
"name": user["attributes"]["name"],
}
)
return {
"uuid": log["id"],
"drupal_id": log["attributes"]["drupal_internal__id"],
"log_type_uuid": log_type_uuid,
"log_type": log_type_object,
"name": log["attributes"]["name"],
"timestamp": timestamp,
"assets": asset_objects,
"quantities": quantity_objects,
"quantity_uuids": quantity_uuids,
"is_group_assignment": log["attributes"]["is_group_assignment"],
"quick": log["attributes"]["quick"],
"status": log["attributes"]["status"],
"notes": notes,
"owners": owner_objects,
"owner_uuids": owner_uuids,
}

View file

@ -23,12 +23,9 @@
View for farmOS Harvest Logs View for farmOS Harvest Logs
""" """
import datetime
import colander
from webhelpers2.html import tags from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum, Notes
from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.views.farmos import FarmOSMasterView
@ -58,9 +55,6 @@ class LogMasterView(FarmOSMasterView):
filterable = True filterable = True
sort_on_backend = True sort_on_backend = True
_farmos_units = None
_farmos_measures = None
labels = { labels = {
"name": "Log Name", "name": "Log Name",
"log_type_name": "Log Type", "log_type_name": "Log Type",
@ -193,141 +187,14 @@ class LogMasterView(FarmOSMasterView):
def get_instance_title(self, log): def get_instance_title(self, log):
return log["name"] return log["name"]
def get_farmos_units(self):
if self._farmos_units:
return self._farmos_units
units = {}
result = self.farmos_client.resource.get("taxonomy_term", "unit")
for unit in result["data"]:
units[unit["id"]] = unit
self._farmos_units = units
return self._farmos_units
def get_farmos_unit(self, uuid):
units = self.get_farmos_units()
return units[uuid]
def get_farmos_measures(self):
if self._farmos_measures:
return self._farmos_measures
measures = {}
response = self.farmos_client.session.get(
self.app.get_farmos_url("/api/quantity/standard/resource/schema")
)
response.raise_for_status()
data = response.json()
for measure in data["definitions"]["attributes"]["properties"]["measure"][
"oneOf"
]:
measures[measure["const"]] = measure["title"]
self._farmos_measures = measures
return self._farmos_measures
def get_farmos_measure_name(self, measure_id):
measures = self.get_farmos_measures()
return measures[measure_id]
def normalize_log(self, log, included): def normalize_log(self, log, included):
data = self.normal.normalize_farmos_log(log, included)
if timestamp := log["attributes"]["timestamp"]: data.update(
timestamp = datetime.datetime.fromisoformat(timestamp) {
timestamp = self.app.localtime(timestamp) "log_type_name": data["log_type"].get("name"),
}
if notes := log["attributes"]["notes"]: )
notes = notes["value"] return data
log_type_object = {}
log_type_name = None
asset_objects = []
quantity_objects = []
owner_objects = []
if relationships := log.get("relationships"):
if log_type := relationships.get("log_type"):
log_type = included[log_type["data"]["id"]]
log_type_object = {
"uuid": log_type["id"],
"name": log_type["attributes"]["label"],
}
log_type_name = log_type_object["name"]
if assets := relationships.get("asset"):
for asset in assets["data"]:
asset = included[asset["id"]]
attrs = asset["attributes"]
rels = asset["relationships"]
asset_objects.append(
{
"uuid": asset["id"],
"drupal_id": attrs["drupal_internal__id"],
"name": attrs["name"],
"is_location": attrs["is_location"],
"is_fixed": attrs["is_fixed"],
"archived": attrs["archived"],
"notes": attrs["notes"],
"asset_type": asset["type"].split("--")[1],
}
)
if quantities := relationships.get("quantity"):
for quantity in quantities["data"]:
quantity = included[quantity["id"]]
attrs = quantity["attributes"]
rels = quantity["relationships"]
value = attrs["value"]
unit_uuid = rels["units"]["data"]["id"]
unit = self.get_farmos_unit(unit_uuid)
measure_id = attrs["measure"]
quantity_objects.append(
{
"uuid": quantity["id"],
"drupal_id": attrs["drupal_internal__id"],
"quantity_type_uuid": rels["quantity_type"]["data"]["id"],
"quantity_type_id": rels["quantity_type"]["data"]["meta"][
"drupal_internal__target_id"
],
"measure_id": measure_id,
"measure_name": self.get_farmos_measure_name(measure_id),
"value_numerator": value["numerator"],
"value_decimal": value["decimal"],
"value_denominator": value["denominator"],
"unit_uuid": unit_uuid,
"unit_name": unit["attributes"]["name"],
}
)
if owners := relationships.get("owner"):
for user in owners["data"]:
user = included[user["id"]]
owner_objects.append(
{
"uuid": user["id"],
"name": user["attributes"]["name"],
}
)
return {
"uuid": log["id"],
"drupal_id": log["attributes"]["drupal_internal__id"],
"log_type": log_type_object,
"log_type_name": log_type_name,
"name": log["attributes"]["name"],
"timestamp": timestamp,
"assets": asset_objects,
"quantities": quantity_objects,
"is_group_assignment": log["attributes"]["is_group_assignment"],
"quick": log["attributes"]["quick"],
"status": log["attributes"]["status"],
"notes": notes or colander.null,
"owners": owner_objects,
}
def configure_form(self, form): def configure_form(self, form):
f = form f = form
@ -346,7 +213,7 @@ class LogMasterView(FarmOSMasterView):
f.set_node("quantities", FarmOSQuantityRefs(self.request)) f.set_node("quantities", FarmOSQuantityRefs(self.request))
# notes # notes
f.set_widget("notes", "notes") f.set_node("notes", Notes())
# status # status
f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS))

View file

@ -72,6 +72,7 @@ class FarmOSMasterView(MasterView):
super().__init__(request, context=context) super().__init__(request, context=context)
self.farmos_client = self.get_farmos_client() self.farmos_client = self.get_farmos_client()
self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
self.normal = self.app.get_normalizer(self.farmos_client)
self.raw_json = None self.raw_json = None
self.farmos_style_grid_links = use_farmos_style_grid_links(self.config) self.farmos_style_grid_links = use_farmos_style_grid_links(self.config)

View file

@ -105,4 +105,4 @@ class WuttaFarmMasterView(MasterView):
def persist(self, obj, session=None): def persist(self, obj, session=None):
super().persist(obj, session) super().persist(obj, session)
self.app.export_to_farmos(obj, require=False) self.app.auto_sync_to_farmos(obj, require=False)

View file

@ -44,6 +44,7 @@ class QuickFormView(View):
super().__init__(request, context=context) super().__init__(request, context=context)
self.farmos_client = self.get_farmos_client() self.farmos_client = self.get_farmos_client()
self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
self.normal = self.app.get_normalizer(self.farmos_client)
@classmethod @classmethod
def get_route_slug(cls): def get_route_slug(cls):

View file

@ -112,6 +112,18 @@ class EggsQuickForm(QuickFormView):
return assets return assets
def save_quick_form(self, form): def save_quick_form(self, form):
response = self.save_to_farmos(form)
log = json.loads(response["create-log#body{0}"]["body"])
if self.app.is_farmos_mirror():
quantity = json.loads(response["create-quantity"]["body"])
self.app.auto_sync_from_farmos(quantity["data"], "StandardQuantity")
self.app.auto_sync_from_farmos(log["data"], "HarvestLog")
return log
def save_to_farmos(self, form):
data = form.validated data = form.validated
assets = self.get_layer_assets() assets = self.get_layer_assets()
@ -217,8 +229,7 @@ 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)
result = json.loads(response["create-log#body{0}"]["body"]) return response
return result
def redirect_after_save(self, result): def redirect_after_save(self, result):
return self.redirect( return self.redirect(