diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index fab984d..ad1cb38 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -443,6 +443,138 @@ class StructureAssetImporter(ToFarmOSAsset): 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 ############################## @@ -464,8 +596,14 @@ class ToFarmOSLog(ToFarmOS): "status", "notes", "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): result = self.farmos_client.log.get(self.farmos_log_type) return result["data"] @@ -511,19 +649,18 @@ class ToFarmOSLog(ToFarmOS): return self.normalize_target_object(result["data"]) def normalize_target_object(self, log): - - if notes := log["attributes"]["notes"]: - notes = notes["value"] - + normal = self.normal.normalize_farmos_log(log) return { - "uuid": UUID(log["id"]), - "name": log["attributes"]["name"], - "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), - "is_movement": log["attributes"]["is_movement"], - "is_group_assignment": log["attributes"]["is_group_assignment"], - "status": log["attributes"]["status"], - "notes": notes, - "quick": log["attributes"]["quick"], + "uuid": UUID(normal["uuid"]), + "name": normal["name"], + "timestamp": self.app.make_utc(normal["timestamp"]), + "is_movement": normal["is_movement"], + "is_group_assignment": normal["is_group_assignment"], + "status": normal["status"], + "notes": normal["notes"], + "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): @@ -542,10 +679,32 @@ class ToFarmOSLog(ToFarmOS): if "notes" in self.fields: attrs["notes"] = {"value": source_data["notes"]} 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 diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 8d76285..8394e4c 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -104,6 +104,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter + importers["StandardQuantity"] = StandardQuantityImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter 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 ############################## @@ -365,6 +409,9 @@ class FromWuttaFarmLog(FromWuttaFarm): "is_group_assignment", "status", "notes", + "quick", + "assets", + "quantities", ] def normalize_source_object(self, log): @@ -376,6 +423,9 @@ class FromWuttaFarmLog(FromWuttaFarm): "is_group_assignment": log.is_group_assignment, "status": log.status, "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, } diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py index 8aafeea..a388559 100644 --- a/src/wuttafarm/web/views/farmos/quantities.py +++ b/src/wuttafarm/web/views/farmos/quantities.py @@ -32,6 +32,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.schema import FarmOSUnitRef +from wuttafarm.web.grids import ResourceData class QuantityTypeView(FarmOSMasterView): @@ -130,13 +131,15 @@ class QuantityMasterView(FarmOSMasterView): farmos_quantity_type = None grid_columns = [ + "drupal_id", + "as_text", "measure", "value", + "unit", "label", - "changed", ] - sort_defaults = ("changed", "desc") + sort_defaults = ("drupal_id", "desc") form_fields = [ "measure", @@ -147,20 +150,58 @@ class QuantityMasterView(FarmOSMasterView): "changed", ] - def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.resource.get("quantity", self.farmos_quantity_type) - return [self.normalize_quantity(t) for t in result["data"]] + def get_farmos_api_includes(self): + return {"units"} + + 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): g = grid 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 - g.set_link("value") + g.set_renderer("value", self.render_value_for_grid) + + # unit + g.set_renderer("unit", self.render_unit_for_grid) # changed 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): quantity = self.farmos_client.resource.get_id( "quantity", self.farmos_quantity_type, self.request.matchdict["uuid"] @@ -187,7 +228,7 @@ class QuantityMasterView(FarmOSMasterView): def get_instance_title(self, quantity): return quantity["value"] - def normalize_quantity(self, quantity): + def normalize_quantity(self, quantity, included={}): if created := quantity["attributes"]["created"]: created = datetime.datetime.fromisoformat(created) @@ -197,11 +238,37 @@ class QuantityMasterView(FarmOSMasterView): changed = datetime.datetime.fromisoformat(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 { "uuid": quantity["id"], "drupal_id": quantity["attributes"]["drupal_internal__id"], + "quantity_type": quantity_type_object, + "quantity_type_uuid": quantity_type_uuid, "measure": quantity["attributes"]["measure"], "value": quantity["attributes"]["value"], + "unit": unit_object, + "unit_uuid": unit_uuid, "label": quantity["attributes"]["label"] or colander.null, "created": created, "changed": changed, diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py index 9be6665..059ac01 100644 --- a/src/wuttafarm/web/views/quick/base.py +++ b/src/wuttafarm/web/views/quick/base.py @@ -28,6 +28,7 @@ import logging from pyramid.renderers import render_to_response from wuttaweb.views import View +from wuttaweb.db import Session from wuttafarm.web.util import get_farmos_client_for_user @@ -40,6 +41,8 @@ class QuickFormView(View): Base class for quick form views. """ + Session = Session + def __init__(self, request, context=None): super().__init__(request, context=context) self.farmos_client = get_farmos_client_for_user(self.request) diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index 3a21ff7..8aae46e 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -34,7 +34,6 @@ from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.quick import QuickFormView -from wuttafarm.web.util import get_farmos_client_for_user class EggsQuickForm(QuickFormView): @@ -49,6 +48,9 @@ class EggsQuickForm(QuickFormView): _layer_assets = None + # TODO: make this configurable? + unit_name = "egg(s)" + def make_quick_form(self): f = self.make_form( fields=[ @@ -89,6 +91,47 @@ class EggsQuickForm(QuickFormView): if self._layer_assets is not None: 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 = [] params = { "filter[produces_eggs]": 1, @@ -108,24 +151,14 @@ class EggsQuickForm(QuickFormView): result = self.farmos_client.asset.get("group", params=params) assets.extend([normalize(a) for a in result["data"]]) - assets.sort(key=lambda a: a["name"]) - self._layer_assets = assets 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_wrapper(): + return self.save_to_farmos(form) - if self.app.is_farmos_mirror(): - 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 + return self.save_to_wuttafarm(form) def save_to_farmos(self, form): data = form.validated @@ -135,7 +168,7 @@ class EggsQuickForm(QuickFormView): asset = assets[data["asset"]] # TODO: make this configurable? - unit_name = "egg(s)" + unit_name = self.unit_name unit = {"data": {"type": "taxonomy_term--unit"}} new_unit = None @@ -234,13 +267,87 @@ class EggsQuickForm(QuickFormView): blueprints.insert(0, new_unit) blueprint = SubrequestsBlueprint.parse_obj(blueprints) response = self.farmos_client.subrequests.send(blueprint, format=Format.json) - return response - def redirect_after_save(self, result): - return self.redirect( - self.request.route_url( - "farmos_logs_harvest.view", uuid=result["data"]["id"] + log = json.loads(response["create-log#body{0}"]["body"]) + + if self.app.is_farmos_mirror(): + 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"]) )