From 190efb7beac5e23bbdf6e9498e837d844772d2cc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Mar 2026 11:19:36 -0500 Subject: [PATCH 01/12] fix: remove print statement --- src/wuttafarm/farmos/importing/wuttafarm.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 746f761..d60a96f 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -248,8 +248,6 @@ class EquipmentAssetImporter( def get_supported_fields(self): fields = list(super().get_supported_fields()) - - print(fields) fields.extend( [ "manufacturer", From 0f3ef5227b070d43797d89723032868723bf7e27 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Mar 2026 08:57:50 -0500 Subject: [PATCH 02/12] feat: add support for webhooks module in farmOS this lets farmOS send a POST request to a webhook URL in our app, which then records a "stub" record in a change queue table. from there a daemon should process the queue and import/delete records as needed in our app DB. this all requires setup on the farmOS side as well..those details will be documented elsewhere (eventually!) --- src/wuttafarm/app.py | 5 + src/wuttafarm/cli/__init__.py | 1 + src/wuttafarm/cli/process_webhooks.py | 181 ++++++++++++++++++ .../dd4d4142b96d_add_webhookchange.py | 41 ++++ src/wuttafarm/db/model/__init__.py | 3 + src/wuttafarm/db/model/webhook.py | 61 ++++++ src/wuttafarm/importing/farmos.py | 87 ++++++++- .../web/templates/appinfo/configure.mako | 5 + src/wuttafarm/web/views/__init__.py | 4 + src/wuttafarm/web/views/webhooks.py | 96 ++++++++++ 10 files changed, 478 insertions(+), 6 deletions(-) create mode 100644 src/wuttafarm/cli/process_webhooks.py create mode 100644 src/wuttafarm/db/alembic/versions/dd4d4142b96d_add_webhookchange.py create mode 100644 src/wuttafarm/db/model/webhook.py create mode 100644 src/wuttafarm/web/views/webhooks.py diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index decd44f..c5a581b 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -270,6 +270,7 @@ class WuttaFarmAppHandler(base.AppHandler): then nothing will happen / import is silently skipped when there is no such importer. """ + model = self.app.model handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos") if model_name not in handler.importers: @@ -280,6 +281,10 @@ class WuttaFarmAppHandler(base.AppHandler): # nb. begin txn to establish the API client handler.begin_source_transaction(client) with self.short_session(commit=True) as session: + + if user := session.query(model.User).filter_by(username="farmos").first(): + session.info["continuum_user_id"] = user.uuid + handler.target_session = session importer = handler.get_importer(model_name, caches_target=False) normal = importer.normalize_source_object(obj) diff --git a/src/wuttafarm/cli/__init__.py b/src/wuttafarm/cli/__init__.py index cd06344..71d4994 100644 --- a/src/wuttafarm/cli/__init__.py +++ b/src/wuttafarm/cli/__init__.py @@ -29,3 +29,4 @@ from .base import wuttafarm_typer from . import export_farmos from . import import_farmos from . import install +from . import process_webhooks diff --git a/src/wuttafarm/cli/process_webhooks.py b/src/wuttafarm/cli/process_webhooks.py new file mode 100644 index 0000000..9731247 --- /dev/null +++ b/src/wuttafarm/cli/process_webhooks.py @@ -0,0 +1,181 @@ +# -*- 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 . +# +################################################################################ +""" +WuttaFarm CLI +""" + +import logging +import time + +import typer +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2Session + +from wuttafarm.cli import wuttafarm_typer + + +log = logging.getLogger(__name__) + + +class ChangeProcessor: + + def __init__(self, config): + self.config = config + self.app = config.get_app() + + def process_change(self, change): + if change.deleted: + self.delete_record(change) + else: + self.import_record(change) + + def import_record(self, change): + token = self.get_farmos_oauth2_token() + client = self.app.get_farmos_client(token=token) + + full_type = f"{change.entity_type}--{change.bundle}" + record = client.resource.get_id( + change.entity_type, change.bundle, change.farmos_uuid + ) + + importer_map = self.get_importer_map() + model_name = importer_map[full_type] + + self.app.auto_sync_from_farmos(record["data"], model_name, client=client) + + def delete_record(self, change): + model = self.app.model + handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos") + + importer_map = self.get_importer_map() + full_type = f"{change.entity_type}--{change.bundle}" + model_name = importer_map[full_type] + + token = self.get_farmos_oauth2_token() + client = self.app.get_farmos_client(token=token) + + # nb. begin txn to establish the API client + handler.begin_source_transaction(client) + with self.app.short_session(commit=True) as session: + handler.target_session = session + importer = handler.get_importer(model_name, caches_target=False) + + # try to attribute change to 'farmos' user + if user := session.query(model.User).filter_by(username="farmos").first(): + session.info["continuum_user_id"] = user.uuid + + # only support importers with farmos_uuid as key + # (pretty sure that covers us..can revise if needed) + if importer.get_keys() != ["farmos_uuid"]: + log.warning( + "unsupported keys for %s importer: %s", + model_name, + importer.get_keys(), + ) + return + + # delete corresponding record from our app + obj = importer.get_target_object((change.uuid,)) + if obj: + importer.delete_target_object(obj) + + # TODO: this should live elsewhere + def get_farmos_oauth2_token(self): + + client_id = self.config.get( + "farmos.oauth2.importing.client_id", default="wuttafarm" + ) + client_secret = self.config.require("farmos.oauth2.importing.client_secret") + scope = self.config.get("farmos.oauth2.importing.scope", default="farm_manager") + + client = BackendApplicationClient(client_id=client_id) + oauth = OAuth2Session(client=client) + + return oauth.fetch_token( + token_url=self.app.get_farmos_url("/oauth/token"), + include_client_id=True, + client_secret=client_secret, + scope=scope, + ) + + # TODO: this should live elsewhere + def get_importer_map(self): + return { + "asset--animal": "AnimalAsset", + "asset--equipment": "EquipmentAsset", + "asset--group": "GroupAsset", + "asset--land": "LandAsset", + "asset--plant": "PlantAsset", + "asset--structure": "StructureAsset", + "asset--water": "WaterAsset", + "log--activity": "ActivityLog", + "log--harvest": "HarvestLog", + "log--medical": "MedicalLog", + "log--observation": "ObservationLog", + "log--seeding": "SeedingLog", + "quantity--material": "MaterialQuantity", + "quantity--standard": "StandardQuantity", + "taxonomy_term--animal_type": "AnimalType", + "taxonomy_term--equipment_type": "EquipmentType", + "taxonomy_term--plant_type": "PlantType", + "taxonomy_term--season": "Season", + "taxonomy_term--material_type": "MaterialType", + "taxonomy_term--unit": "Unit", + } + + +@wuttafarm_typer.command() +def process_webhooks( + ctx: typer.Context, +): + """ + Process incoming webhook requests from farmOS. + """ + config = ctx.parent.wutta_config + app = config.get_app() + model = app.model + processor = ChangeProcessor(config) + + while True: + with app.short_session(commit=True) as session: + + query = session.query(model.WebhookChange).order_by( + model.WebhookChange.received + ) + + # nb. fetch (at most) 2 changes instead of just 1; + # this will control time delay behavior below + if changes := query[:2]: + + # process first change + change = changes[0] + log.info("processing webhook change: %s", change) + processor.process_change(change) + session.delete(change) + + # minimal time delay if 2nd change exists + if len(changes) == 2: + time.sleep(0.1) + continue + + # nothing in queue, so wait 1 sec before checking again + time.sleep(1) diff --git a/src/wuttafarm/db/alembic/versions/dd4d4142b96d_add_webhookchange.py b/src/wuttafarm/db/alembic/versions/dd4d4142b96d_add_webhookchange.py new file mode 100644 index 0000000..5b566b2 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/dd4d4142b96d_add_webhookchange.py @@ -0,0 +1,41 @@ +"""add WebhookChange + +Revision ID: dd4d4142b96d +Revises: dca5b48a5562 +Create Date: 2026-03-10 22:31:54.324952 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "dd4d4142b96d" +down_revision: Union[str, None] = "dca5b48a5562" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # webhook_change + op.create_table( + "webhook_change", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("entity_type", sa.String(length=100), nullable=False), + sa.Column("bundle", sa.String(length=100), nullable=False), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("deleted", sa.Boolean(), nullable=False), + sa.Column("received", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_webhook_change")), + ) + + +def downgrade() -> None: + + # webhook_change + op.drop_table("webhook_change") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index d90272b..716fd1c 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -59,3 +59,6 @@ from .log_harvest import HarvestLog from .log_medical import MedicalLog from .log_observation import ObservationLog from .log_seeding import SeedingLog + +# misc. +from .webhook import WebhookChange diff --git a/src/wuttafarm/db/model/webhook.py b/src/wuttafarm/db/model/webhook.py new file mode 100644 index 0000000..b96a572 --- /dev/null +++ b/src/wuttafarm/db/model/webhook.py @@ -0,0 +1,61 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for webhook changes +""" + +import sqlalchemy as sa + +from wuttjamaican.db import model +from wuttjamaican.util import make_utc + + +class WebhookChange(model.Base): + """ + Represents a "change" (create/update/delete) notification which + originated in farmOS and delivered via webhook. + + This table serves as a "FIFO queue" for processing the changes. + """ + + __tablename__ = "webhook_change" + + uuid = model.uuid_column() + + entity_type = sa.Column(sa.String(length=100), nullable=False) + bundle = sa.Column(sa.String(length=100), nullable=False) + farmos_uuid = sa.Column(model.UUID(), nullable=False) + + deleted = sa.Column(sa.Boolean(), nullable=False) + + received = sa.Column( + sa.DateTime(), + nullable=False, + default=make_utc, + doc=""" + Date and time when the change was obtained from the watcher thread. + """, + ) + + def __str__(self): + event_type = "delete" if self.deleted else "create/update" + return f"{event_type} {self.entity_type}--{self.bundle}: {self.farmos_uuid}" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index c739bad..4f5a47a 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -339,6 +339,8 @@ class AnimalAssetImporter(AssetImporterBase): model_class = model.AnimalAsset + animal_types_by_farmos_uuid = None + def get_supported_fields(self): fields = list(super().get_supported_fields()) fields.extend( @@ -361,6 +363,17 @@ class AnimalAssetImporter(AssetImporterBase): if animal_type.farmos_uuid: self.animal_types_by_farmos_uuid[animal_type.farmos_uuid] = animal_type + def get_animal_type_by_farmos_uuid(self, uuid): + if self.animal_types_by_farmos_uuid is not None: + return self.animal_types_by_farmos_uuid.get(uuid) + + model = self.app.model + return ( + self.target_session.query(model.AnimalType) + .filter(model.AnimalType.farmos_uuid == uuid) + .first() + ) + def normalize_source_object(self, animal): """ """ animal_type_uuid = None @@ -368,7 +381,7 @@ class AnimalAssetImporter(AssetImporterBase): if animal_type := relationships.get("animal_type"): if animal_type["data"]: - if wf_animal_type := self.animal_types_by_farmos_uuid.get( + if wf_animal_type := self.get_animal_type_by_farmos_uuid( UUID(animal_type["data"]["id"]) ): animal_type_uuid = wf_animal_type.uuid @@ -500,6 +513,8 @@ class EquipmentAssetImporter(AssetImporterBase): model_class = model.EquipmentAsset + equipment_types_by_farmos_uuid = None + def get_supported_fields(self): fields = list(super().get_supported_fields()) fields.extend( @@ -520,6 +535,17 @@ class EquipmentAssetImporter(AssetImporterBase): equipment_type ) + def get_equipment_type_by_farmos_uuid(self, uuid): + if self.equipment_types_by_farmos_uuid is not None: + return self.equipment_types_by_farmos_uuid.get(uuid) + + model = self.app.model + return ( + self.target_session.query(model.EquipmentType) + .filter_by(farmos_uuid=uuid) + .first() + ) + def normalize_source_object(self, equipment): """ """ data = super().normalize_source_object(equipment) @@ -530,7 +556,7 @@ class EquipmentAssetImporter(AssetImporterBase): if equipment_type := relationships.get("equipment_type"): equipment_types = [] for equipment_type in equipment_type["data"]: - if wf_equipment_type := self.equipment_types_by_farmos_uuid.get( + if wf_equipment_type := self.get_equipment_type_by_farmos_uuid( UUID(equipment_type["id"]) ): equipment_types.append(wf_equipment_type.uuid) @@ -632,6 +658,8 @@ class LandAssetImporter(AssetImporterBase): model_class = model.LandAsset + land_types_by_id = None + def get_supported_fields(self): fields = list(super().get_supported_fields()) fields.extend( @@ -650,10 +678,21 @@ class LandAssetImporter(AssetImporterBase): for land_type in self.target_session.query(model.LandType): self.land_types_by_id[land_type.drupal_id] = land_type + def get_land_type_by_id(self, drupal_id): + if self.land_types_by_id is not None: + return self.land_types_by_id.get(drupal_id) + + model = self.app.model + return ( + self.target_session.query(model.LandType) + .filter_by(drupal_id=drupal_id) + .first() + ) + def normalize_source_object(self, land): """ """ land_type_id = land["attributes"]["land_type"] - land_type = self.land_types_by_id.get(land_type_id) + land_type = self.get_land_type_by_id(land_type_id) if not land_type: log.warning( "invalid land_type '%s' for farmOS Land Asset: %s", land_type_id, land @@ -758,6 +797,9 @@ class PlantAssetImporter(AssetImporterBase): model_class = model.PlantAsset + plant_types_by_farmos_uuid = None + seasons_by_farmos_uuid = None + def get_supported_fields(self): fields = list(super().get_supported_fields()) fields.extend( @@ -782,6 +824,26 @@ class PlantAssetImporter(AssetImporterBase): if season.farmos_uuid: self.seasons_by_farmos_uuid[season.farmos_uuid] = season + def get_plant_type_by_farmos_uuid(self, uuid): + if self.plant_types_by_farmos_uuid is not None: + return self.plant_types_by_farmos_uuid.get(uuid) + + model = self.app.model + return ( + self.target_session.query(model.PlantType) + .filter_by(farmos_uuid=uuid) + .first() + ) + + def get_season_by_farmos_uuid(self, uuid): + if self.seasons_by_farmos_uuid is not None: + return self.seasons_by_farmos_uuid.get(uuid) + + model = self.app.model + return ( + self.target_session.query(model.Season).filter_by(farmos_uuid=uuid).first() + ) + def normalize_source_object(self, plant): """ """ data = super().normalize_source_object(plant) @@ -793,7 +855,7 @@ class PlantAssetImporter(AssetImporterBase): if plant_type := relationships.get("plant_type"): plant_types = [] for plant_type in plant_type["data"]: - if wf_plant_type := self.plant_types_by_farmos_uuid.get( + if wf_plant_type := self.get_plant_type_by_farmos_uuid( UUID(plant_type["id"]) ): plant_types.append(wf_plant_type.uuid) @@ -803,7 +865,7 @@ class PlantAssetImporter(AssetImporterBase): if season := relationships.get("season"): seasons = [] for season in season["data"]: - if wf_season := self.seasons_by_farmos_uuid.get(UUID(season["id"])): + if wf_season := self.get_season_by_farmos_uuid(UUID(season["id"])): seasons.append(wf_season.uuid) else: log.warning("season not found: %s", season["id"]) @@ -886,6 +948,8 @@ class StructureAssetImporter(AssetImporterBase): model_class = model.StructureAsset + structure_types_by_id = None + def get_supported_fields(self): fields = list(super().get_supported_fields()) fields.extend( @@ -903,10 +967,21 @@ class StructureAssetImporter(AssetImporterBase): for structure_type in self.target_session.query(model.StructureType): self.structure_types_by_id[structure_type.drupal_id] = structure_type + def get_structure_type_by_id(self, drupal_id): + if self.structure_types_by_id is not None: + return self.structure_types_by_id.get(drupal_id) + + model = self.app.model + return ( + self.target_session.query(model.StructureType) + .filter_by(drupal_id=drupal_id) + .first() + ) + def normalize_source_object(self, structure): """ """ structure_type_id = structure["attributes"]["structure_type"] - structure_type = self.structure_types_by_id.get(structure_type_id) + structure_type = self.get_structure_type_by_id(structure_type_id) if not structure_type: log.warning( "invalid structure_type '%s' for farmOS Structure Asset: %s", diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako index 912eef0..26d6a54 100644 --- a/src/wuttafarm/web/templates/appinfo/configure.mako +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -57,6 +57,11 @@ + + + + . +# +################################################################################ +""" +Views for use as webhooks +""" + +import logging +from uuid import UUID + +from wuttaweb.views import View +from wuttaweb.db import Session + + +log = logging.getLogger(__name__) + + +class WebhookView(View): + """ + Webhook views + """ + + def farmos_webhook(self): + model = self.app.model + session = Session() + + try: + data = self.request.json + log.debug("got webhook payload: %s", data) + + _, entity_type, event_type = data["event"].split(":") + + uuid = data["entity"]["uuid"][0]["value"] + if entity_type == "taxonomy_term": + bundle = data["entity"]["vid"][0]["target_id"] + else: + bundle = data["entity"]["type"][0]["target_id"] + + change = model.WebhookChange( + entity_type=entity_type, + bundle=bundle, + farmos_uuid=UUID(uuid), + deleted=event_type == "delete", + ) + session.add(change) + + except: + log.exception("failed to process webhook request") + + return {} + + @classmethod + def defaults(cls, config): + cls._webhook_defaults(config) + + @classmethod + def _webhook_defaults(cls, config): + + # farmos webhook + config.add_route("webhooks.farmos", "/farmos/webhook", request_method="POST") + config.add_view( + cls, + attr="farmos_webhook", + route_name="webhooks.farmos", + require_csrf=False, + renderer="json", + ) + + +def defaults(config, **kwargs): + base = globals() + + WebhookView = kwargs.get("WebhookView", base["WebhookView"]) + WebhookView.defaults(config) + + +def includeme(config): + defaults(config) From bd7d412b97729c9d8debca60c35fbc20ce3de7bf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Mar 2026 16:05:28 -0500 Subject: [PATCH 03/12] =?UTF-8?q?bump:=20version=200.9.0=20=E2=86=92=200.1?= =?UTF-8?q?0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 794220d..6c85559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.10.0 (2026-03-11) + +### Feat + +- add support for webhooks module in farmOS + +### Fix + +- remove print statement + ## v0.9.0 (2026-03-10) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 51737ce..7fdd859 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.9.0" +version = "0.10.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From d65de5e8ce6168e4ff7b4bdb1561abf92253b269 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Mar 2026 18:29:25 -0500 Subject: [PATCH 04/12] fix: include LogQuantity changes when viewing Log revision --- src/wuttafarm/web/views/logs.py | 1 + 1 file changed, 1 insertion(+) 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"), ] From eee2a1df65ffc6d4b46c63322924b91fdd8719a3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Mar 2026 22:38:30 -0500 Subject: [PATCH 05/12] feat: show basic map for "fixed" assets this is just to get our foot in the door so to speak. not sure yet how sophisticated this map needs to be etc. but thought it would be nice to at least show something..since the data is available --- .../web/templates/assets/master/view.mako | 71 ++++++++++++++++++- src/wuttafarm/web/templates/base.mako | 10 +++ src/wuttafarm/web/views/assets.py | 31 ++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) 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 name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if map_polygon: + + % endif 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 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..35c3b21 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,36 @@ 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) + 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 From f9d9923acf34de92c957d6be895a484175c96bfb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Mar 2026 10:08:27 -0500 Subject: [PATCH 06/12] =?UTF-8?q?bump:=20version=200.10.0=20=E2=86=92=200.?= =?UTF-8?q?11.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c85559..4ee6b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.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..c1a5cc0 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.0" 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.29.3", ] From ca5e1420e4ab48e3f16cdfeb972c521ebdc3653c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 15:03:06 -0500 Subject: [PATCH 07/12] fix: use correct uuid when processing webhook to delete record --- src/wuttafarm/cli/process_webhooks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 From cc4b94a7b8c37642e4391a74dc9170c1d1ac7296 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 15:06:26 -0500 Subject: [PATCH 08/12] fix: improve behavior when deleting mirrored record from farmOS in some cases (maybe just dev?) the record does not exist in farmOS; if so we should silently ignore. and there seemed to be a problem with the sequence of events: - user clicks delete in WF - record is deleted from WF DB - delete request sent to farmOS API - webhook on farmOS side calls back to WF webhook URI somewhere in there, in practice things seemed to hang after user clicks delete. i suppose the thread handling user's request is "tied up" somehow, such that the webhook receiver can't process that request? that doesn't exactly make sense to me, but if we split off to a separate thread to request the farmOS deletion, things seem to work okay. so maybe that idea is more accurate than i'd expect --- src/wuttafarm/web/views/master.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index d9fe986..ac9e2ed 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -23,6 +23,9 @@ Base class for WuttaFarm master views """ +import threading + +import requests from webhelpers2.html import tags from wuttaweb.views import MasterView @@ -145,10 +148,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): From f0fa189bcdf47b5eeeeaee2ad68bb51e861bfd50 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 15:11:36 -0500 Subject: [PATCH 09/12] =?UTF-8?q?bump:=20version=200.11.0=20=E2=86=92=200.?= =?UTF-8?q?11.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee6b10..00ff67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index c1a5cc0..e165b20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.11.0" +version = "0.11.1" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 969497826d556e68e355cc7f0fda89b353e50e76 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 15:24:36 -0500 Subject: [PATCH 10/12] fix: avoid error if asset has no geometry --- src/wuttafarm/web/views/assets.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 35c3b21..1ada778 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -372,21 +372,23 @@ class AssetMasterView(WuttaFarmMasterView): # 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) - geometry = result["data"]["attributes"]["intrinsic_geometry"] + if geometry := result["data"]["attributes"]["intrinsic_geometry"]: - context["map_center"] = [geometry["lon"], geometry["lat"]] + context["map_center"] = [geometry["lon"], geometry["lat"]] - context["map_bounds"] = [ - [geometry["left"], geometry["bottom"]], - [geometry["right"], geometry["top"]], - ] + 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] + 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 From 9707c365538373267970065c8319542254e49fac Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 20:18:32 -0500 Subject: [PATCH 11/12] fix: use separate thread to sync changes to farmOS i.e. when creating or editing an asset/log, or submitting quick eggs form --- src/wuttafarm/web/views/master.py | 26 +++++++++++++++-- src/wuttafarm/web/views/quick/eggs.py | 40 ++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index ac9e2ed..c828b96 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -24,6 +24,7 @@ Base class for WuttaFarm master views """ import threading +import time import requests from webhelpers2.html import tags @@ -110,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: 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 From a5b699a52ab7f92cae7ef87e2d5eff62d547880c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2026 20:21:52 -0500 Subject: [PATCH 12/12] =?UTF-8?q?bump:=20version=200.11.1=20=E2=86=92=200.?= =?UTF-8?q?11.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ff67e..579fc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index e165b20..b702d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.11.1" +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.3", + "WuttaWeb[continuum]>=0.30.1", ]