diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 2df38e9..d0ca392 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -56,24 +56,38 @@ class WuttaFarmAppHandler(base.AppHandler): Returns the integration mode for farmOS, i.e. to control the app's behavior regarding that. """ - handler = self.get_farmos_handler() - return handler.get_farmos_integration_mode() + enum = self.enum + return self.config.get( + f"{self.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. """ - handler = self.get_farmos_handler() - return handler.is_farmos_mirror() + enum = self.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. """ - handler = self.get_farmos_handler() - return handler.is_farmos_wrapper() + enum = self.enum + 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): """ @@ -109,7 +123,20 @@ class WuttaFarmAppHandler(base.AppHandler): handler = self.get_farmos_handler() 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. @@ -127,7 +154,8 @@ class WuttaFarmAppHandler(base.AppHandler): """ 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 require: raise ValueError(f"no exporter found for {model_name}") @@ -141,6 +169,37 @@ class WuttaFarmAppHandler(base.AppHandler): normal = importer.normalize_source_object(obj) 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): """ diff --git a/src/wuttafarm/farmos/handler.py b/src/wuttafarm/farmos/handler.py index 393d121..6eee14f 100644 --- a/src/wuttafarm/farmos/handler.py +++ b/src/wuttafarm/farmos/handler.py @@ -34,35 +34,6 @@ class FarmOSHandler(GenericHandler): :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): """ Returns a new farmOS API client. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 5cf2242..e17825b 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -53,6 +53,7 @@ class FromFarmOSHandler(ImportHandler): token = self.get_farmos_oauth2_token() self.farmos_client = self.app.get_farmos_client(token=token) 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): @@ -76,6 +77,7 @@ class FromFarmOSHandler(ImportHandler): kwargs = super().get_importer_kwargs(key, **kwargs) kwargs["farmos_client"] = self.farmos_client kwargs["farmos_4x"] = self.farmos_4x + kwargs["normal"] = self.normal return kwargs @@ -981,33 +983,25 @@ class LogImporterBase(FromFarmOS, ToWutta): def get_source_objects(self): """ """ log_type = self.get_farmos_log_type() - result = self.farmos_client.log.get(log_type) - return result["data"] - - def get_asset_type(self, asset): - return asset["type"].split("--")[1] + return list(self.farmos_client.log.iterate(log_type)) def normalize_source_object(self, log): """ """ - if notes := log["attributes"]["notes"]: - notes = notes["value"] + data = self.normal.normalize_farmos_log(log) + + 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: - assets = [] - for asset in log["relationships"]["asset"]["data"]: - assets.append((self.get_asset_type(asset), UUID(asset["id"]))) + data["assets"] = [ + (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] + ] - return { - "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, - } + return data def normalize_target_object(self, log): data = super().normalize_target_object(log) @@ -1183,6 +1177,28 @@ class QuantityImporterBase(FromFarmOS, ToWutta): result = self.farmos_client.resource.get("quantity", quantity_type) 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): """ """ quantity_type_id = None @@ -1191,16 +1207,14 @@ class QuantityImporterBase(FromFarmOS, ToWutta): if quantity_type := relationships.get("quantity_type"): if quantity_type["data"]: - if wf_quantity_type := self.quantity_types_by_farmos_uuid.get( - UUID(quantity_type["data"]["id"]) + if wf_quantity_type := self.get_quantity_type_by_farmos_uuid( + quantity_type["data"]["id"] ): quantity_type_id = wf_quantity_type.drupal_id if units := relationships.get("units"): if units["data"]: - if wf_unit := self.units_by_farmos_uuid.get( - UUID(units["data"]["id"]) - ): + if wf_unit := self.get_unit_by_farmos_uuid(units["data"]["id"]): units_uuid = wf_unit.uuid if not quantity_type_id: diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py new file mode 100644 index 0000000..ca7be39 --- /dev/null +++ b/src/wuttafarm/normal.py @@ -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 . +# +################################################################################ +""" +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, + } diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index 4c704ce..f20eb0e 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -23,12 +23,9 @@ View for farmOS Harvest Logs """ -import datetime - -import colander 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 wuttafarm.web.views.farmos import FarmOSMasterView @@ -58,9 +55,6 @@ class LogMasterView(FarmOSMasterView): filterable = True sort_on_backend = True - _farmos_units = None - _farmos_measures = None - labels = { "name": "Log Name", "log_type_name": "Log Type", @@ -193,141 +187,14 @@ class LogMasterView(FarmOSMasterView): def get_instance_title(self, log): 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): - - 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_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, - } + data = self.normal.normalize_farmos_log(log, included) + data.update( + { + "log_type_name": data["log_type"].get("name"), + } + ) + return data def configure_form(self, form): f = form @@ -346,7 +213,7 @@ class LogMasterView(FarmOSMasterView): f.set_node("quantities", FarmOSQuantityRefs(self.request)) # notes - f.set_widget("notes", "notes") + f.set_node("notes", Notes()) # status f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 36d1778..742ce14 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -72,6 +72,7 @@ class FarmOSMasterView(MasterView): super().__init__(request, context=context) self.farmos_client = self.get_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.farmos_style_grid_links = use_farmos_style_grid_links(self.config) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 82d64bc..2250d1b 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -105,4 +105,4 @@ class WuttaFarmMasterView(MasterView): def persist(self, obj, session=None): super().persist(obj, session) - self.app.export_to_farmos(obj, require=False) + self.app.auto_sync_to_farmos(obj, require=False) diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py index 29ba7ef..2fb73e4 100644 --- a/src/wuttafarm/web/views/quick/base.py +++ b/src/wuttafarm/web/views/quick/base.py @@ -44,6 +44,7 @@ class QuickFormView(View): super().__init__(request, context=context) self.farmos_client = self.get_farmos_client() self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + self.normal = self.app.get_normalizer(self.farmos_client) @classmethod def get_route_slug(cls): diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index c505381..aa663b6 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -112,6 +112,18 @@ class EggsQuickForm(QuickFormView): return assets 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 assets = self.get_layer_assets() @@ -217,8 +229,7 @@ class EggsQuickForm(QuickFormView): blueprints.insert(0, new_unit) blueprint = SubrequestsBlueprint.parse_obj(blueprints) response = self.farmos_client.subrequests.send(blueprint, format=Format.json) - result = json.loads(response["create-log#body{0}"]["body"]) - return result + return response def redirect_after_save(self, result): return self.redirect(