diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c85559..794220d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,6 @@ 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 7fdd859..51737ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.10.0" +version = "0.9.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index c5a581b..decd44f 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -270,7 +270,6 @@ 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: @@ -281,10 +280,6 @@ 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 71d4994..cd06344 100644 --- a/src/wuttafarm/cli/__init__.py +++ b/src/wuttafarm/cli/__init__.py @@ -29,4 +29,3 @@ 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 deleted file mode 100644 index 9731247..0000000 --- a/src/wuttafarm/cli/process_webhooks.py +++ /dev/null @@ -1,181 +0,0 @@ -# -*- 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 deleted file mode 100644 index 5b566b2..0000000 --- a/src/wuttafarm/db/alembic/versions/dd4d4142b96d_add_webhookchange.py +++ /dev/null @@ -1,41 +0,0 @@ -"""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 716fd1c..d90272b 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -59,6 +59,3 @@ 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 deleted file mode 100644 index b96a572..0000000 --- a/src/wuttafarm/db/model/webhook.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- 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/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index d60a96f..746f761 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -248,6 +248,8 @@ class EquipmentAssetImporter( def get_supported_fields(self): fields = list(super().get_supported_fields()) + + print(fields) fields.extend( [ "manufacturer", diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 4f5a47a..c739bad 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -339,8 +339,6 @@ 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( @@ -363,17 +361,6 @@ 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 @@ -381,7 +368,7 @@ class AnimalAssetImporter(AssetImporterBase): if animal_type := relationships.get("animal_type"): if animal_type["data"]: - if wf_animal_type := self.get_animal_type_by_farmos_uuid( + if wf_animal_type := self.animal_types_by_farmos_uuid.get( UUID(animal_type["data"]["id"]) ): animal_type_uuid = wf_animal_type.uuid @@ -513,8 +500,6 @@ 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( @@ -535,17 +520,6 @@ 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) @@ -556,7 +530,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.get_equipment_type_by_farmos_uuid( + if wf_equipment_type := self.equipment_types_by_farmos_uuid.get( UUID(equipment_type["id"]) ): equipment_types.append(wf_equipment_type.uuid) @@ -658,8 +632,6 @@ 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( @@ -678,21 +650,10 @@ 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.get_land_type_by_id(land_type_id) + land_type = self.land_types_by_id.get(land_type_id) if not land_type: log.warning( "invalid land_type '%s' for farmOS Land Asset: %s", land_type_id, land @@ -797,9 +758,6 @@ 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( @@ -824,26 +782,6 @@ 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) @@ -855,7 +793,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.get_plant_type_by_farmos_uuid( + if wf_plant_type := self.plant_types_by_farmos_uuid.get( UUID(plant_type["id"]) ): plant_types.append(wf_plant_type.uuid) @@ -865,7 +803,7 @@ class PlantAssetImporter(AssetImporterBase): if season := relationships.get("season"): seasons = [] for season in season["data"]: - if wf_season := self.get_season_by_farmos_uuid(UUID(season["id"])): + if wf_season := self.seasons_by_farmos_uuid.get(UUID(season["id"])): seasons.append(wf_season.uuid) else: log.warning("season not found: %s", season["id"]) @@ -948,8 +886,6 @@ 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( @@ -967,21 +903,10 @@ 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.get_structure_type_by_id(structure_type_id) + structure_type = self.structure_types_by_id.get(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 26d6a54..912eef0 100644 --- a/src/wuttafarm/web/templates/appinfo/configure.mako +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -57,11 +57,6 @@ - - - - . -# -################################################################################ -""" -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)