diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c85559..579fc2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ All notable changes to WuttaFarm will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.11.2 (2026-03-21)
+
+### Fix
+
+- use separate thread to sync changes to farmOS
+- avoid error if asset has no geometry
+
+## v0.11.1 (2026-03-21)
+
+### Fix
+
+- improve behavior when deleting mirrored record from farmOS
+- use correct uuid when processing webhook to delete record
+
+## v0.11.0 (2026-03-15)
+
+### Feat
+
+- show basic map for "fixed" assets
+
+### Fix
+
+- include LogQuantity changes when viewing Log revision
+
## v0.10.0 (2026-03-11)
### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 7fdd859..b702d8c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaFarm"
-version = "0.10.0"
+version = "0.11.2"
description = "Web app to integrate with and extend farmOS"
readme = "README.md"
authors = [
@@ -34,7 +34,7 @@ dependencies = [
"pyramid_exclog",
"uvicorn[standard]",
"WuttaSync",
- "WuttaWeb[continuum]>=0.29.2",
+ "WuttaWeb[continuum]>=0.30.1",
]
diff --git a/src/wuttafarm/cli/process_webhooks.py b/src/wuttafarm/cli/process_webhooks.py
index 9731247..9d66a70 100644
--- a/src/wuttafarm/cli/process_webhooks.py
+++ b/src/wuttafarm/cli/process_webhooks.py
@@ -94,8 +94,7 @@ class ChangeProcessor:
return
# delete corresponding record from our app
- obj = importer.get_target_object((change.uuid,))
- if obj:
+ if obj := importer.get_target_object((change.farmos_uuid,)):
importer.delete_target_object(obj)
# TODO: this should live elsewhere
diff --git a/src/wuttafarm/web/templates/assets/master/view.mako b/src/wuttafarm/web/templates/assets/master/view.mako
index dac5a1c..5b7b822 100644
--- a/src/wuttafarm/web/templates/assets/master/view.mako
+++ b/src/wuttafarm/web/templates/assets/master/view.mako
@@ -10,5 +10,74 @@
% endif
- ${parent.page_content()}
+
+
+ ## main form
+
+ ${parent.page_content()}
+
+
+ ## location map
+ % if map_polygon:
+
+ % endif
+
+
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+ % if map_polygon:
+
+ % endif
%def>
diff --git a/src/wuttafarm/web/templates/base.mako b/src/wuttafarm/web/templates/base.mako
index b28b52f..caa5c67 100644
--- a/src/wuttafarm/web/templates/base.mako
+++ b/src/wuttafarm/web/templates/base.mako
@@ -1,6 +1,16 @@
<%inherit file="wuttaweb:templates/base.mako" />
<%namespace file="/wuttafarm-components.mako" import="make_wuttafarm_components" />
+<%def name="head_tags()">
+ ${parent.head_tags()}
+
+ ## TODO: this likely does not belong in the base template, and should be
+ ## included per template where actually needed. but this is easier for now.
+
+
+
+%def>
+
<%def name="index_title_controls()">
${parent.index_title_controls()}
diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py
index 64f4dbc..1ada778 100644
--- a/src/wuttafarm/web/views/assets.py
+++ b/src/wuttafarm/web/views/assets.py
@@ -23,6 +23,7 @@
Master view for Assets
"""
+import re
from collections import OrderedDict
from webhelpers2.html import tags
@@ -359,6 +360,38 @@ class AssetMasterView(WuttaFarmMasterView):
return buttons
+ def get_template_context(self, context):
+ context = super().get_template_context(context)
+
+ if self.viewing:
+ asset = context["instance"]
+
+ # add location geometry if applicable
+ if asset.is_fixed and asset.farmos_uuid and not self.app.is_standalone():
+
+ # TODO: eventually sync GIS data, avoid this API call?
+ client = get_farmos_client_for_user(self.request)
+ result = client.asset.get_id(asset.asset_type, asset.farmos_uuid)
+ if geometry := result["data"]["attributes"]["intrinsic_geometry"]:
+
+ context["map_center"] = [geometry["lon"], geometry["lat"]]
+
+ context["map_bounds"] = [
+ [geometry["left"], geometry["bottom"]],
+ [geometry["right"], geometry["top"]],
+ ]
+
+ if match := re.match(
+ r"^POLYGON \(\((?P[^\)]+)\)\)$", geometry["value"]
+ ):
+ points = match.group("points").split(", ")
+ points = [
+ [float(pt) for pt in pair.split(" ")] for pair in points
+ ]
+ context["map_polygon"] = [points]
+
+ return context
+
def get_version_joins(self):
"""
We override this to declare the relationship between the
diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py
index 3d91ba1..2a4e6e0 100644
--- a/src/wuttafarm/web/views/logs.py
+++ b/src/wuttafarm/web/views/logs.py
@@ -491,6 +491,7 @@ class LogMasterView(WuttaFarmMasterView):
return super().get_version_joins() + [
model.Log,
(model.LogAsset, "log_uuid", "uuid"),
+ (model.LogQuantity, "log_uuid", "uuid"),
]
diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py
index d9fe986..c828b96 100644
--- a/src/wuttafarm/web/views/master.py
+++ b/src/wuttafarm/web/views/master.py
@@ -23,6 +23,10 @@
Base class for WuttaFarm master views
"""
+import threading
+import time
+
+import requests
from webhelpers2.html import tags
from wuttaweb.views import MasterView
@@ -107,17 +111,36 @@ class WuttaFarmMasterView(MasterView):
f.set_readonly("drupal_id")
def persist(self, obj, session=None):
+ session = session or self.Session()
# save per usual
super().persist(obj, session)
# maybe also sync change to farmOS
if self.app.is_farmos_mirror():
+ if self.creating:
+ session.flush() # need the new uuid
client = get_farmos_client_for_user(self.request)
- self.auto_sync_to_farmos(client, obj)
+ thread = threading.Thread(
+ target=self.auto_sync_to_farmos, args=(client, obj.uuid)
+ )
+ thread.start()
- def auto_sync_to_farmos(self, client, obj):
- self.app.auto_sync_to_farmos(obj, client=client, require=False)
+ def auto_sync_to_farmos(self, client, uuid):
+ model = self.app.model
+ model_class = self.get_model_class()
+
+ with self.app.short_session(commit=True) as session:
+ if user := session.query(model.User).filter_by(username="farmos").first():
+ session.info["continuum_user_id"] = user.uuid
+
+ obj = None
+ while not obj:
+ obj = session.get(model_class, uuid)
+ if not obj:
+ time.sleep(0.1)
+
+ self.app.auto_sync_to_farmos(obj, client=client, require=False)
def get_farmos_entity_type(self):
if self.farmos_entity_type:
@@ -145,10 +168,24 @@ class WuttaFarmMasterView(MasterView):
# maybe delete from farmOS also
if farmos_uuid:
- entity_type = self.get_farmos_entity_type()
- bundle = self.get_farmos_bundle()
client = get_farmos_client_for_user(self.request)
+ # nb. must use separate thread to avoid some kind of race
+ # condition (?) - seems as though maybe a "boomerang"
+ # effect is happening; this seems to help anyway
+ thread = threading.Thread(
+ target=self.delete_from_farmos, args=(client, farmos_uuid)
+ )
+ thread.start()
+
+ def delete_from_farmos(self, client, farmos_uuid):
+ entity_type = self.get_farmos_entity_type()
+ bundle = self.get_farmos_bundle()
+ try:
client.resource.delete(entity_type, bundle, farmos_uuid)
+ except requests.HTTPError as exc:
+ # ignore if record not found in farmOS
+ if exc.response.status_code != 404:
+ raise
class TaxonomyMasterView(WuttaFarmMasterView):
diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py
index 8aae46e..fded73c 100644
--- a/src/wuttafarm/web/views/quick/eggs.py
+++ b/src/wuttafarm/web/views/quick/eggs.py
@@ -24,6 +24,8 @@ Quick Form for "Eggs"
"""
import json
+import threading
+import time
import colander
from deform.widget import SelectWidget
@@ -331,13 +333,43 @@ class EggsQuickForm(QuickFormView):
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)
+ thread = threading.Thread(
+ target=self.auto_sync_to_farmos,
+ args=(log.uuid, quantity.uuid, new_unit.uuid if new_unit else None),
+ )
+ thread.start()
return log
+ def auto_sync_to_farmos(self, log_uuid, quantity_uuid, new_unit_uuid):
+ model = self.app.model
+
+ with self.app.short_session(commit=True) as session:
+ if user := session.query(model.User).filter_by(username="farmos").first():
+ session.info["continuum_user_id"] = user.uuid
+
+ if new_unit_uuid:
+ new_unit = None
+ while not new_unit:
+ new_unit = session.get(model.Unit, new_unit_uuid)
+ if not new_unit:
+ time.sleep(0.1)
+ self.app.auto_sync_to_farmos(unit, client=self.farmos_client)
+
+ quantity = None
+ while not quantity:
+ quantity = session.get(model.StandardQuantity, quantity_uuid)
+ if not quantity:
+ time.sleep(0.1)
+ self.app.auto_sync_to_farmos(quantity, client=self.farmos_client)
+
+ log = None
+ while not log:
+ log = session.get(model.HarvestLog, log_uuid)
+ if not log:
+ time.sleep(0.1)
+ self.app.auto_sync_to_farmos(log, client=self.farmos_client)
+
def redirect_after_save(self, log):
model = self.app.model