From ec6ac443fb1b3a53a465b65edb431e8d7cddc073 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Feb 2026 11:22:49 -0600 Subject: [PATCH 01/55] fix: add separate permission for each quick form view --- src/wuttafarm/web/views/quick/__init__.py | 5 +++++ src/wuttafarm/web/views/quick/base.py | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/quick/__init__.py b/src/wuttafarm/web/views/quick/__init__.py index 92595e1..8423b0d 100644 --- a/src/wuttafarm/web/views/quick/__init__.py +++ b/src/wuttafarm/web/views/quick/__init__.py @@ -27,4 +27,9 @@ from .base import QuickFormView def includeme(config): + + # perm group + config.add_wutta_permission_group("quick", "Quick Forms", overwrite=False) + + # quick form views config.include("wuttafarm.web.views.quick.eggs") diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py index 2fb73e4..a40e8e3 100644 --- a/src/wuttafarm/web/views/quick/base.py +++ b/src/wuttafarm/web/views/quick/base.py @@ -151,6 +151,10 @@ class QuickFormView(View): def _defaults(cls, config): route_slug = cls.get_route_slug() url_slug = cls.get_url_slug() + form_title = cls.get_form_title() + config.add_wutta_permission("quick", f"quick.{route_slug}", form_title) config.add_route(f"quick.{route_slug}", f"/quick/{url_slug}") - config.add_view(cls, route_name=f"quick.{route_slug}") + config.add_view( + cls, route_name=f"quick.{route_slug}", permission=f"quick.{route_slug}" + ) From df517cfbfafc6a4e4a625b9bf70dfca256a0f064 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Feb 2026 14:36:28 -0600 Subject: [PATCH 02/55] fix: expose config for farmOS OAuth2 client_id and scope refs: #3 --- src/wuttafarm/farmos/handler.py | 6 +++++ .../web/templates/appinfo/configure.mako | 22 +++++++++++++++++++ src/wuttafarm/web/views/auth.py | 5 +++-- src/wuttafarm/web/views/settings.py | 13 ++++++++++- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/wuttafarm/farmos/handler.py b/src/wuttafarm/farmos/handler.py index 6eee14f..e905f92 100644 --- a/src/wuttafarm/farmos/handler.py +++ b/src/wuttafarm/farmos/handler.py @@ -94,3 +94,9 @@ class FarmOSHandler(GenericHandler): return f"{base}/{path}" return base + + def get_oauth2_client_id(self): + return self.config.get("farmos.oauth2.client_id", default="farm") + + def get_oauth2_scope(self): + return self.config.get("farmos.oauth2.scope", default="farm_manager") diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako index 3760577..8dc5e8a 100644 --- a/src/wuttafarm/web/templates/appinfo/configure.mako +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -14,6 +14,28 @@ + + + + + + + + + + + + + + + + + + Date: Wed, 25 Feb 2026 14:55:30 -0600 Subject: [PATCH 03/55] fix: only show quick form menu if perms allow --- src/wuttafarm/web/menus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 6ce4a8d..fe7719e 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -72,7 +72,7 @@ class WuttaFarmMenuHandler(base.MenuHandler): { "title": "Eggs", "route": "quick.eggs", - # "perm": "assets.list", + "perm": "quick.eggs", }, ], } From 127ea49d744bd0492be424084501f985cb8710ba Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Feb 2026 14:55:47 -0600 Subject: [PATCH 04/55] fix: add more default perms for first site admin user --- src/wuttafarm/web/views/common.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index f15e92b..674d76e 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -87,10 +87,20 @@ class CommonView(base.CommonView): "farmos_logs_medical.view", "farmos_logs_observation.list", "farmos_logs_observation.view", + "farmos_plant_assets.list", + "farmos_plant_assets.view", + "farmos_plant_types.list", + "farmos_plant_types.view", + "farmos_quantities_standard.list", + "farmos_quantities_standard.view", + "farmos_quantity_types.list", + "farmos_quantity_types.view", "farmos_structure_assets.list", "farmos_structure_assets.view", "farmos_structure_types.list", "farmos_structure_types.view", + "farmos_units.list", + "farmos_units.view", "farmos_users.list", "farmos_users.view", "group_assets.create", @@ -121,6 +131,7 @@ class CommonView(base.CommonView): "logs_observation.list", "logs_observation.view", "logs_observation.versions", + "quick.eggs", "structure_types.list", "structure_types.view", "structure_types.versions", From f4b5f3960c7b251ecee78170024d047c0ec58645 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Feb 2026 15:22:25 -0600 Subject: [PATCH 05/55] fix: set log type, status enums for log grids --- src/wuttafarm/util.py | 37 +++++++++++++++++++++++++++++++ src/wuttafarm/web/views/assets.py | 7 ++++++ src/wuttafarm/web/views/logs.py | 22 ++++++++---------- 3 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 src/wuttafarm/util.py diff --git a/src/wuttafarm/util.py b/src/wuttafarm/util.py new file mode 100644 index 0000000..1700998 --- /dev/null +++ b/src/wuttafarm/util.py @@ -0,0 +1,37 @@ +# -*- 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 . +# +################################################################################ +""" +misc. utilities +""" + +from collections import OrderedDict + + +def get_log_type_enum(config, session=None): + app = config.get_app() + model = app.model + log_types = OrderedDict() + with app.short_session(session=session) as sess: + query = sess.query(model.LogType).order_by(model.LogType.name) + for log_type in query: + log_types[log_type.drupal_id] = log_type.name + return log_types diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b78f149..70534db 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -32,6 +32,7 @@ from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import Asset, Log from wuttafarm.web.forms.schema import AssetParentRefs from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.util import get_log_type_enum def get_asset_type_enum(config): @@ -301,7 +302,12 @@ class AssetMasterView(WuttaFarmMasterView): def configure_row_grid(self, grid): g = grid super().configure_row_grid(g) + enum = self.app.enum model = self.app.model + session = self.Session() + + # status + g.set_enum("status", enum.LOG_STATUS) # drupal_id g.set_label("drupal_id", "ID", column_only=True) @@ -318,6 +324,7 @@ class AssetMasterView(WuttaFarmMasterView): # log_type g.set_sorter("log_type", model.Log.log_type) g.set_filter("log_type", model.Log.log_type) + g.set_enum("log_type", get_log_type_enum(self.config, session=session)) def get_row_action_url_view(self, log, i): return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index eeef49e..b393cec 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -34,17 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import LogType, Log from wuttafarm.web.forms.schema import LogAssetRefs - - -def get_log_type_enum(config): - app = config.get_app() - model = app.model - session = Session() - log_types = OrderedDict() - query = session.query(model.LogType).order_by(model.LogType.name) - for log_type in query: - log_types[log_type.drupal_id] = log_type.name - return log_types +from wuttafarm.util import get_log_type_enum class LogTypeView(WuttaFarmMasterView): @@ -142,6 +132,7 @@ class LogView(WuttaFarmMasterView): def configure_grid(self, grid): g = grid super().configure_grid(g) + session = self.Session() # drupal_id g.set_label("drupal_id", "ID", column_only=True) @@ -154,7 +145,7 @@ class LogView(WuttaFarmMasterView): g.set_link("message") # log_type - g.set_enum("log_type", get_log_type_enum(self.config)) + g.set_enum("log_type", get_log_type_enum(self.config, session=session)) # assets g.set_renderer("assets", self.render_assets_for_grid) @@ -224,8 +215,10 @@ class LogMasterView(WuttaFarmMasterView): g = grid super().configure_grid(g) model = self.app.model + enum = self.app.enum # status + g.set_enum("status", enum.LOG_STATUS) g.set_sorter("status", model.Log.status) g.set_filter("status", model.Log.status) @@ -255,6 +248,7 @@ class LogMasterView(WuttaFarmMasterView): f = form super().configure_form(f) enum = self.app.enum + session = self.Session() log = f.model_instance # timestamp @@ -280,7 +274,9 @@ class LogMasterView(WuttaFarmMasterView): else: f.set_node( "log_type", - WuttaDictEnum(self.request, get_log_type_enum(self.config)), + WuttaDictEnum( + self.request, get_log_type_enum(self.config, session=session) + ), ) f.set_readonly("log_type") From 9b4afb845b546e1e0bf111df72594110ce751ee9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 17:04:55 -0600 Subject: [PATCH 06/55] fix: use current user token for auto-sync within web app to ensure data writes to farmOS have correct authorship --- src/wuttafarm/app.py | 18 ++++++++++-------- src/wuttafarm/farmos/importing/wuttafarm.py | 5 +++-- src/wuttafarm/importing/farmos.py | 5 +++-- src/wuttafarm/web/views/master.py | 3 ++- src/wuttafarm/web/views/quick/eggs.py | 7 +++++-- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index d0ca392..30c7f51 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -136,7 +136,7 @@ class WuttaFarmAppHandler(base.AppHandler): factory = self.load_object(spec) return factory(self.config, farmos_client) - def auto_sync_to_farmos(self, obj, model_name=None, require=True): + def auto_sync_to_farmos(self, obj, model_name=None, token=None, require=True): """ Export the given object to farmOS, using configured handler. @@ -147,6 +147,9 @@ class WuttaFarmAppHandler(base.AppHandler): :param obj: Any data object in WuttaFarm, e.g. AnimalAsset instance. + :param token: OAuth2 token for the farmOS client. If not + specified, the import handler will obtain a new token. + :param require: If true, this will *require* the export handler to support objects of the given type. If false, then nothing will happen / export is silently skipped when @@ -162,14 +165,12 @@ class WuttaFarmAppHandler(base.AppHandler): 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_target_transaction() + handler.begin_target_transaction(token) importer = handler.get_importer(model_name, caches_target=False) normal = importer.normalize_source_object(obj) importer.process_data(source_data=[normal]) - def auto_sync_from_farmos(self, obj, model_name, require=True): + def auto_sync_from_farmos(self, obj, model_name, token=None, require=True): """ Import the given object from farmOS, using configured handler. @@ -178,6 +179,9 @@ class WuttaFarmAppHandler(base.AppHandler): :param model_name': Model name for the importer to use, e.g. ``"AnimalAsset"``. + :param token: OAuth2 token for the farmOS client. If not + specified, the import handler will obtain a new token. + :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 @@ -191,9 +195,7 @@ class WuttaFarmAppHandler(base.AppHandler): 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() + handler.begin_source_transaction(token) with self.short_session(commit=True) as session: handler.target_session = session importer = handler.get_importer(model_name, caches_target=False) diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index e11663f..a39fe97 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -50,11 +50,12 @@ class ToFarmOSHandler(ImportHandler): # TODO: a lot of duplication to cleanup here; see FromFarmOSHandler - def begin_target_transaction(self): + def begin_target_transaction(self, token=None): """ Establish the farmOS API client. """ - token = self.get_farmos_oauth2_token() + if not token: + 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) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index e17825b..a1e9631 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -46,11 +46,12 @@ class FromFarmOSHandler(ImportHandler): source_key = "farmos" generic_source_title = "farmOS" - def begin_source_transaction(self): + def begin_source_transaction(self, token=None): """ Establish the farmOS API client. """ - token = self.get_farmos_oauth2_token() + if not token: + 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) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 2250d1b..6ab0631 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -105,4 +105,5 @@ class WuttaFarmMasterView(MasterView): def persist(self, obj, session=None): super().persist(obj, session) - self.app.auto_sync_to_farmos(obj, require=False) + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_to_farmos(obj, token=token, require=False) diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index aa663b6..0482132 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -118,8 +118,11 @@ class EggsQuickForm(QuickFormView): 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") + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_from_farmos( + quantity["data"], "StandardQuantity", token=token + ) + self.app.auto_sync_from_farmos(log["data"], "HarvestLog", token=token) return log From f2be7d0a53edd92cc35bcf551c076e48af69d3f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 17:25:49 -0600 Subject: [PATCH 07/55] fix: add `get_farmos_client_for_user()` convenience function --- src/wuttafarm/web/util.py | 20 ++++++++++++++++++++ src/wuttafarm/web/views/farmos/master.py | 20 ++------------------ src/wuttafarm/web/views/quick/base.py | 20 ++------------------ 3 files changed, 24 insertions(+), 36 deletions(-) diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py index 2d51851..977550a 100644 --- a/src/wuttafarm/web/util.py +++ b/src/wuttafarm/web/util.py @@ -23,9 +23,29 @@ Misc. utilities for web app """ +from pyramid import httpexceptions from webhelpers2.html import HTML +def get_farmos_client_for_user(request): + token = request.session.get("farmos.oauth2.token") + if not token: + raise httpexceptions.HTTPForbidden() + + # nb. must give a *copy* of the token to farmOS client, since it + # will mutate it in-place and we don't want that to happen for our + # original copy in the user session. (otherwise the auto-refresh + # will not work correctly for subsequent calls.) + token = dict(token) + + def token_updater(token): + save_farmos_oauth2_token(request, token) + + config = request.wutta_config + app = config.get_app() + return app.get_farmos_client(token=token, token_updater=token_updater) + + def save_farmos_oauth2_token(request, token): """ Common logic for saving the given OAuth2 token within the user diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 742ce14..1e2ceab 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -34,7 +34,7 @@ from wuttaweb.views import MasterView from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget -from wuttafarm.web.util import save_farmos_oauth2_token, use_farmos_style_grid_links +from wuttafarm.web.util import get_farmos_client_for_user, use_farmos_style_grid_links from wuttafarm.web.grids import ( ResourceData, StringFilter, @@ -70,28 +70,12 @@ class FarmOSMasterView(MasterView): def __init__(self, request, context=None): super().__init__(request, context=context) - self.farmos_client = self.get_farmos_client() + self.farmos_client = get_farmos_client_for_user(self.request) 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) - def get_farmos_client(self): - token = self.request.session.get("farmos.oauth2.token") - if not token: - raise self.forbidden() - - # nb. must give a *copy* of the token to farmOS client, since - # it will mutate it in-place and we don't want that to happen - # for our original copy in the user session. (otherwise the - # auto-refresh will not work correctly for subsequent calls.) - token = dict(token) - - def token_updater(token): - save_farmos_oauth2_token(self.request, token) - - return self.app.get_farmos_client(token=token, token_updater=token_updater) - def get_fallback_templates(self, template): """ """ templates = super().get_fallback_templates(template) diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py index a40e8e3..9be6665 100644 --- a/src/wuttafarm/web/views/quick/base.py +++ b/src/wuttafarm/web/views/quick/base.py @@ -29,7 +29,7 @@ from pyramid.renderers import render_to_response from wuttaweb.views import View -from wuttafarm.web.util import save_farmos_oauth2_token +from wuttafarm.web.util import get_farmos_client_for_user log = logging.getLogger(__name__) @@ -42,7 +42,7 @@ class QuickFormView(View): def __init__(self, request, context=None): super().__init__(request, context=context) - self.farmos_client = self.get_farmos_client() + self.farmos_client = get_farmos_client_for_user(self.request) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) self.normal = self.app.get_normalizer(self.farmos_client) @@ -127,22 +127,6 @@ class QuickFormView(View): def get_template_context(self, context): return context - def get_farmos_client(self): - token = self.request.session.get("farmos.oauth2.token") - if not token: - raise self.forbidden() - - # nb. must give a *copy* of the token to farmOS client, since - # it will mutate it in-place and we don't want that to happen - # for our original copy in the user session. (otherwise the - # auto-refresh will not work correctly for subsequent calls.) - token = dict(token) - - def token_updater(token): - save_farmos_oauth2_token(self.request, token) - - return self.app.get_farmos_client(token=token, token_updater=token_updater) - @classmethod def defaults(cls, config): cls._defaults(config) From 38dad49bbda65f85a78c87c2c686ce83f3454043 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 17:35:05 -0600 Subject: [PATCH 08/55] fix: fix Sex field when empty and deleting an animal --- src/wuttafarm/web/views/animals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 76e0335..8f05584 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -216,7 +216,7 @@ class AnimalAssetView(AssetMasterView): f.set_node("animal_type", AnimalTypeRef(self.request)) # sex - if self.viewing and animal.sex is None: + if not (self.creating or self.editing) and animal.sex is None: pass # TODO: dict enum widget does not handle null values well else: f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) From 96ccf30e46f14af6780a0a58935b24e4106bb80b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 17:36:37 -0600 Subject: [PATCH 09/55] feat: auto-delete asset from farmOS if deleting via mirror app --- src/wuttafarm/web/views/assets.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 70534db..2dade09 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -33,6 +33,7 @@ from wuttafarm.db.model import Asset, Log from wuttafarm.web.forms.schema import AssetParentRefs from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.util import get_log_type_enum +from wuttafarm.web.util import get_farmos_client_for_user def get_asset_type_enum(config): @@ -267,11 +268,30 @@ class AssetMasterView(WuttaFarmMasterView): asset = super().objectify(form) if self.creating: - model_class = self.get_model_class() - asset.asset_type = model_class.__wutta_hint__["farmos_asset_type"] + asset.asset_type = self.get_asset_type() return asset + def get_asset_type(self): + model_class = self.get_model_class() + return model_class.__wutta_hint__["farmos_asset_type"] + + def delete_instance(self, obj): + + # save farmOS UUID if we need it + farmos_uuid = None + if self.app.is_farmos_mirror() and hasattr(obj, "farmos_uuid"): + farmos_uuid = obj.farmos_uuid + + # delete per usual + super().delete_instance(obj) + + # maybe delete from farmOS also + if farmos_uuid: + client = get_farmos_client_for_user(self.request) + asset_type = self.get_asset_type() + client.asset.delete(asset_type, farmos_uuid) + def get_farmos_url(self, asset): return self.app.get_farmos_url(f"/asset/{asset.drupal_id}") @@ -279,7 +299,7 @@ class AssetMasterView(WuttaFarmMasterView): buttons = super().get_xref_buttons(asset) if asset.farmos_uuid: - asset_type = self.get_model_class().__wutta_hint__["farmos_asset_type"] + asset_type = self.get_asset_type() route = f"farmos_{asset_type}_assets.view" url = self.request.route_url(route, uuid=asset.farmos_uuid) buttons.append( From a5d7f89fcb3df02bd2061b1e8d23c371040be671 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 19:10:41 -0600 Subject: [PATCH 10/55] feat: improve mirror/deletion for assets, logs, animal types --- src/wuttafarm/db/model/asset.py | 7 +++- src/wuttafarm/db/model/log.py | 7 +++- src/wuttafarm/web/views/animals.py | 3 ++ src/wuttafarm/web/views/assets.py | 18 +-------- src/wuttafarm/web/views/groups.py | 1 + src/wuttafarm/web/views/land.py | 1 + src/wuttafarm/web/views/logs.py | 2 + src/wuttafarm/web/views/logs_activity.py | 1 + src/wuttafarm/web/views/logs_harvest.py | 1 + src/wuttafarm/web/views/logs_medical.py | 1 + src/wuttafarm/web/views/logs_observation.py | 1 + src/wuttafarm/web/views/master.py | 44 +++++++++++++++++++-- src/wuttafarm/web/views/plants.py | 1 + src/wuttafarm/web/views/structures.py | 1 + src/wuttafarm/web/views/units.py | 2 + 15 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/wuttafarm/db/model/asset.py b/src/wuttafarm/db/model/asset.py index 90372e2..3e4de6e 100644 --- a/src/wuttafarm/db/model/asset.py +++ b/src/wuttafarm/db/model/asset.py @@ -196,7 +196,12 @@ class AssetMixin: @declared_attr def asset(cls): - return orm.relationship(Asset) + return orm.relationship( + Asset, + single_parent=True, + cascade="all, delete-orphan", + cascade_backrefs=False, + ) def __str__(self): return self.asset_name or "" diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index a86c447..fd59478 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -165,7 +165,12 @@ class LogMixin: @declared_attr def log(cls): - return orm.relationship(Log) + return orm.relationship( + Log, + single_parent=True, + cascade="all, delete-orphan", + cascade_backrefs=False, + ) def __str__(self): return self.message or "" diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 8f05584..241f1bb 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -42,6 +42,8 @@ class AnimalTypeView(AssetTypeMasterView): route_prefix = "animal_types" url_prefix = "/animal-types" + farmos_entity_type = "taxonomy_term" + farmos_bundle = "animal_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview" grid_columns = [ @@ -145,6 +147,7 @@ class AnimalAssetView(AssetMasterView): url_prefix = "/assets/animal" farmos_refurl_path = "/assets/animal" + farmos_bundle = "animal" labels = { "animal_type": "Species/Breed", diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 2dade09..f5046f9 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -135,6 +135,8 @@ class AssetMasterView(WuttaFarmMasterView): Base class for Asset master views """ + farmos_entity_type = "asset" + sort_defaults = "asset_name" filter_defaults = { @@ -276,22 +278,6 @@ class AssetMasterView(WuttaFarmMasterView): model_class = self.get_model_class() return model_class.__wutta_hint__["farmos_asset_type"] - def delete_instance(self, obj): - - # save farmOS UUID if we need it - farmos_uuid = None - if self.app.is_farmos_mirror() and hasattr(obj, "farmos_uuid"): - farmos_uuid = obj.farmos_uuid - - # delete per usual - super().delete_instance(obj) - - # maybe delete from farmOS also - if farmos_uuid: - client = get_farmos_client_for_user(self.request) - asset_type = self.get_asset_type() - client.asset.delete(asset_type, farmos_uuid) - def get_farmos_url(self, asset): return self.app.get_farmos_url(f"/asset/{asset.drupal_id}") diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 4b26463..61394fa 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -37,6 +37,7 @@ class GroupView(AssetMasterView): url_prefix = "/assets/group" farmos_refurl_path = "/assets/group" + farmos_bundle = "group" grid_columns = [ "thumbnail", diff --git a/src/wuttafarm/web/views/land.py b/src/wuttafarm/web/views/land.py index 22827a0..9523cb5 100644 --- a/src/wuttafarm/web/views/land.py +++ b/src/wuttafarm/web/views/land.py @@ -139,6 +139,7 @@ class LandAssetView(AssetMasterView): route_prefix = "land_assets" url_prefix = "/assets/land" + farmos_bundle = "land" farmos_refurl_path = "/assets/land" grid_columns = [ diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index b393cec..9dc2bd4 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -166,6 +166,8 @@ class LogMasterView(WuttaFarmMasterView): Base class for Asset master views """ + farmos_entity_type = "log" + labels = { "message": "Log Name", "owners": "Owner", diff --git a/src/wuttafarm/web/views/logs_activity.py b/src/wuttafarm/web/views/logs_activity.py index dda3ca7..19f8782 100644 --- a/src/wuttafarm/web/views/logs_activity.py +++ b/src/wuttafarm/web/views/logs_activity.py @@ -36,6 +36,7 @@ class ActivityLogView(LogMasterView): route_prefix = "logs_activity" url_prefix = "/logs/activity" + farmos_bundle = "activity" farmos_refurl_path = "/logs/activity" diff --git a/src/wuttafarm/web/views/logs_harvest.py b/src/wuttafarm/web/views/logs_harvest.py index 825c864..1c9a6f2 100644 --- a/src/wuttafarm/web/views/logs_harvest.py +++ b/src/wuttafarm/web/views/logs_harvest.py @@ -36,6 +36,7 @@ class HarvestLogView(LogMasterView): route_prefix = "logs_harvest" url_prefix = "/logs/harvest" + farmos_bundle = "harvest" farmos_refurl_path = "/logs/harvest" diff --git a/src/wuttafarm/web/views/logs_medical.py b/src/wuttafarm/web/views/logs_medical.py index d582db9..c5769e8 100644 --- a/src/wuttafarm/web/views/logs_medical.py +++ b/src/wuttafarm/web/views/logs_medical.py @@ -36,6 +36,7 @@ class MedicalLogView(LogMasterView): route_prefix = "logs_medical" url_prefix = "/logs/medical" + farmos_bundle = "medical" farmos_refurl_path = "/logs/medical" diff --git a/src/wuttafarm/web/views/logs_observation.py b/src/wuttafarm/web/views/logs_observation.py index a4b9e8e..5b190d1 100644 --- a/src/wuttafarm/web/views/logs_observation.py +++ b/src/wuttafarm/web/views/logs_observation.py @@ -36,6 +36,7 @@ class ObservationLogView(LogMasterView): route_prefix = "logs_observation" url_prefix = "/logs/observation" + farmos_bundle = "observation" farmos_refurl_path = "/logs/observation" diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 6ab0631..ec3c913 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -27,7 +27,7 @@ from webhelpers2.html import tags from wuttaweb.views import MasterView -from wuttafarm.web.util import use_farmos_style_grid_links +from wuttafarm.web.util import use_farmos_style_grid_links, get_farmos_client_for_user class WuttaFarmMasterView(MasterView): @@ -36,6 +36,8 @@ class WuttaFarmMasterView(MasterView): """ farmos_refurl_path = None + farmos_entity_type = None + farmos_bundle = None labels = { "farmos_uuid": "farmOS UUID", @@ -104,6 +106,42 @@ class WuttaFarmMasterView(MasterView): f.set_readonly("drupal_id") def persist(self, obj, session=None): + + # save per usual super().persist(obj, session) - token = self.request.session.get("farmos.oauth2.token") - self.app.auto_sync_to_farmos(obj, token=token, require=False) + + # maybe also sync change to farmOS + if self.app.is_farmos_mirror(): + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_to_farmos(obj, token=token, require=False) + + def get_farmos_entity_type(self): + if self.farmos_entity_type: + return self.farmos_entity_type + raise NotImplementedError( + f"must define {self.__class__.__name__}.farmos_entity_type" + ) + + def get_farmos_bundle(self): + if self.farmos_bundle: + return self.farmos_bundle + raise NotImplementedError( + f"must define {self.__class__.__name__}.farmos_bundle" + ) + + def delete_instance(self, obj): + + # save farmOS UUID if we need it + farmos_uuid = None + if hasattr(obj, "farmos_uuid") and self.app.is_farmos_mirror(): + farmos_uuid = obj.farmos_uuid + + # delete per usual + super().delete_instance(obj) + + # 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) + client.resource.delete(entity_type, bundle, farmos_uuid) diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index 4bd32c6..4a343a6 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -41,6 +41,7 @@ class PlantTypeView(AssetTypeMasterView): url_prefix = "/plant-types" farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" + farmos_bundle = "plant" grid_columns = [ "name", diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index aa9bf31..11a21b9 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -138,6 +138,7 @@ class StructureAssetView(AssetMasterView): route_prefix = "structure_assets" url_prefix = "/asset/structures" + farmos_bundle = "structure" farmos_refurl_path = "/assets/structure" grid_columns = [ diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index 3b86426..a36a238 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -69,6 +69,8 @@ class UnitView(WuttaFarmMasterView): route_prefix = "units" url_prefix = "/units" + farmos_entity_type = "taxonomy_term" + farmos_bundle = "unit" farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" grid_columns = [ From 2a375b0a6f08f71f2b145b38968084a537bcb8a5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 19:19:40 -0600 Subject: [PATCH 11/55] fix: add enum, row hilite for log status --- src/wuttafarm/web/views/logs.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 9dc2bd4..53fc91e 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -189,6 +189,7 @@ class LogMasterView(WuttaFarmMasterView): filter_defaults = { "message": {"active": True, "verb": "contains"}, + "status": {"active": True, "verb": "not_equal", "value": "abandoned"}, } form_fields = [ @@ -222,7 +223,12 @@ class LogMasterView(WuttaFarmMasterView): # status g.set_enum("status", enum.LOG_STATUS) g.set_sorter("status", model.Log.status) - g.set_filter("status", model.Log.status) + g.set_filter( + "status", + model.Log.status, + verbs=["equal", "not_equal"], + choices=enum.LOG_STATUS, + ) # drupal_id g.set_label("drupal_id", "ID", column_only=True) @@ -246,6 +252,13 @@ class LogMasterView(WuttaFarmMasterView): def render_assets_for_grid(self, log, field, value): return ", ".join([a.asset.asset_name for a in log.log._assets]) + def grid_row_class(self, log, data, i): + if log.status == "pending": + return "has-background-warning" + if log.status == "abandoned": + return "has-background-danger" + return None + def configure_form(self, form): f = form super().configure_form(f) From f374ae426ca2b6104c36ea47a014f7d2c0351633 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 19:23:39 -0600 Subject: [PATCH 12/55] fix: remove 'contains' verb for sex filter --- src/wuttafarm/web/views/animals.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 241f1bb..734763b 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -127,6 +127,7 @@ class AnimalTypeView(AssetTypeMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) + g.filters["sex"].verbs = ["equal", "not_equal"] # archived g.set_renderer("archived", "boolean") @@ -204,6 +205,7 @@ class AnimalAssetView(AssetMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) + g.filters["sex"].verbs = ["equal", "not_equal"] def render_animal_type_for_grid(self, animal, field, value): url = self.request.route_url("animal_types.view", uuid=animal.animal_type_uuid) From 5046171b76baca3acda5e1124e7c9a6e4a126613 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 19:25:02 -0600 Subject: [PATCH 13/55] fix: prevent edit for user farmos_uuid, drupal_id --- src/wuttafarm/web/views/users.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wuttafarm/web/views/users.py b/src/wuttafarm/web/views/users.py index 21e26d9..ffda747 100644 --- a/src/wuttafarm/web/views/users.py +++ b/src/wuttafarm/web/views/users.py @@ -55,11 +55,13 @@ class UserView(base.UserView): # farmos_uuid if not self.creating: f.fields.append("farmos_uuid") + f.set_readonly("farmos_uuid") f.set_default("farmos_uuid", user.farmos_uuid or colander.null) # drupal_id if not self.creating: f.fields.append("drupal_id") + f.set_readonly("drupal_id") f.set_default("drupal_id", user.drupal_id or colander.null) def get_xref_buttons(self, user): From 7d5ff47e8e853769885560358edbe56eda772d91 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 11:53:12 -0600 Subject: [PATCH 14/55] feat: add related version tables for asset/log revision history --- src/wuttafarm/web/views/assets.py | 15 +++++++++++++++ src/wuttafarm/web/views/logs.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index f5046f9..d9e6205 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -296,6 +296,21 @@ class AssetMasterView(WuttaFarmMasterView): return buttons + def get_version_joins(self): + """ + We override this to declare the relationship between the + view's data model (which is some type of asset table) and the + canonical ``Asset`` model, so the revision history views + include transactions which reference either version table. + + See also parent method, + :meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_version_joins()` + """ + model = self.app.model + return super().get_version_joins() + [ + model.Asset, + ] + def get_row_grid_data(self, asset): model = self.app.model session = self.Session() diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 53fc91e..af0e375 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -344,6 +344,22 @@ class LogMasterView(WuttaFarmMasterView): return buttons + def get_version_joins(self): + """ + We override this to declare the relationship between the + view's data model (which is some type of log table) and the + canonical ``Log`` model, so the revision history views include + transactions which reference either version table. + + See also parent method, + :meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_version_joins()` + """ + model = self.app.model + return super().get_version_joins() + [ + model.Log, + (model.LogAsset, "log_uuid", "uuid"), + ] + def defaults(config, **kwargs): base = globals() From 1c0286eda0d9a69e9a4f6965f8c043bbd40a3aac Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 13:17:52 -0600 Subject: [PATCH 15/55] fix: add reminder to restart if changing integration mode --- .../web/templates/appinfo/configure.mako | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako index 8dc5e8a..912eef0 100644 --- a/src/wuttafarm/web/templates/appinfo/configure.mako +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -37,13 +37,24 @@ - - % for value, label in enum.FARMOS_INTEGRATION_MODE.items(): - - % endfor - +
+ + % for value, label in enum.FARMOS_INTEGRATION_MODE.items(): + + % endfor + + <${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}"> + + + +
Date: Fri, 27 Feb 2026 16:35:56 -0600 Subject: [PATCH 16/55] feat: add way to create animal type when editing animal --- src/wuttafarm/web/app.py | 9 ++ src/wuttafarm/web/forms/schema.py | 25 +--- src/wuttafarm/web/forms/widgets.py | 53 ++++---- src/wuttafarm/web/templates/base.mako | 6 + .../web/templates/deform/animaltyperef.pt | 13 ++ .../web/templates/wuttafarm-components.mako | 128 ++++++++++++++++++ src/wuttafarm/web/views/animals.py | 50 +++++++ 7 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 src/wuttafarm/web/templates/deform/animaltyperef.pt create mode 100644 src/wuttafarm/web/templates/wuttafarm-components.mako diff --git a/src/wuttafarm/web/app.py b/src/wuttafarm/web/app.py index 2fcb48d..161a876 100644 --- a/src/wuttafarm/web/app.py +++ b/src/wuttafarm/web/app.py @@ -40,6 +40,15 @@ def main(global_config, **settings): "wuttaweb:templates", ], ) + settings.setdefault( + "pyramid_deform.template_search_path", + " ".join( + [ + "wuttafarm.web:templates/deform", + "wuttaweb:templates/deform", + ] + ), + ) # make config objects wutta_config = base.make_wutta_config(settings) diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 075c36c..c6095ff 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -55,6 +55,12 @@ class AnimalTypeRef(ObjectRef): animal_type = obj return self.request.route_url("animal_types.view", uuid=animal_type.uuid) + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import AnimalTypeRefWidget + + kwargs["factory"] = AnimalTypeRefWidget + return super().widget_maker(**kwargs) + class LogQuick(WuttaSet): @@ -185,25 +191,6 @@ class FarmOSQuantityRefs(WuttaSet): return FarmOSQuantityRefsWidget(**kwargs) -class AnimalTypeType(colander.SchemaType): - - def __init__(self, request, *args, **kwargs): - super().__init__(*args, **kwargs) - self.request = request - - def serialize(self, node, appstruct): - if appstruct is colander.null: - return colander.null - - return json.dumps(appstruct) - - def widget_maker(self, **kwargs): # pylint: disable=empty-docstring - """ """ - from wuttafarm.web.forms.widgets import AnimalTypeWidget - - return AnimalTypeWidget(self.request, **kwargs) - - class FarmOSPlantTypes(colander.SchemaType): def __init__(self, request, *args, **kwargs): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 9dcc51f..7f5808f 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -29,7 +29,7 @@ import colander from deform.widget import Widget, SelectWidget from webhelpers2.html import HTML, tags -from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget +from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget from wuttaweb.db import Session from wuttafarm.web.util import render_quantity_objects @@ -228,33 +228,6 @@ class FarmOSUnitRefWidget(Widget): return super().serialize(field, cstruct, **kw) -class AnimalTypeWidget(Widget): - """ - Widget to display an "animal type" field. - """ - - def __init__(self, request, *args, **kwargs): - super().__init__(*args, **kwargs) - self.request = request - - def serialize(self, field, cstruct, **kw): - """ """ - readonly = kw.get("readonly", self.readonly) - if readonly: - if cstruct in (colander.null, None): - return HTML.tag("span") - - animal_type = json.loads(cstruct) - return tags.link_to( - animal_type["name"], - self.request.route_url( - "farmos_animal_types.view", uuid=animal_type["uuid"] - ), - ) - - return super().serialize(field, cstruct, **kw) - - class FarmOSPlantTypesWidget(Widget): """ Widget to display a farmOS "plant types" field. @@ -372,6 +345,11 @@ class UsersWidget(Widget): return super().serialize(field, cstruct, **kw) +############################## +# native data widgets +############################## + + class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): """ Widget for Parents field which references assets. @@ -432,3 +410,22 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): return HTML.tag("ul", c=assets) return super().serialize(field, cstruct, **kw) + + +class AnimalTypeRefWidget(ObjectRefWidget): + """ + Custom widget which uses the ```` component. + """ + + template = "animaltyperef" + + def get_template_values(self, field, cstruct, kw): + """ """ + values = super().get_template_values(field, cstruct, kw) + + values["js_values"] = json.dumps(values["values"]) + + if self.request.has_perm("animal_types.create"): + values["can_create"] = True + + return values diff --git a/src/wuttafarm/web/templates/base.mako b/src/wuttafarm/web/templates/base.mako index 3e5d544..b28b52f 100644 --- a/src/wuttafarm/web/templates/base.mako +++ b/src/wuttafarm/web/templates/base.mako @@ -1,4 +1,5 @@ <%inherit file="wuttaweb:templates/base.mako" /> +<%namespace file="/wuttafarm-components.mako" import="make_wuttafarm_components" /> <%def name="index_title_controls()"> ${parent.index_title_controls()} @@ -14,3 +15,8 @@ % endif + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${make_wuttafarm_components()} + diff --git a/src/wuttafarm/web/templates/deform/animaltyperef.pt b/src/wuttafarm/web/templates/deform/animaltyperef.pt new file mode 100644 index 0000000..61dd770 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/animaltyperef.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako new file mode 100644 index 0000000..b973cb1 --- /dev/null +++ b/src/wuttafarm/web/templates/wuttafarm-components.mako @@ -0,0 +1,128 @@ + +<%def name="make_wuttafarm_components()"> + ${self.make_animal_type_picker_component()} + + +<%def name="make_animal_type_picker_component()"> + + + diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 734763b..1c9fdfe 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -26,6 +26,7 @@ Master view for Animals from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum +from wuttaweb.util import get_form_data from wuttafarm.db.model import AnimalType, AnimalAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView @@ -137,6 +138,55 @@ class AnimalTypeView(AssetTypeMasterView): def get_row_action_url_view(self, animal, i): return self.request.route_url("animal_assets.view", uuid=animal.uuid) + def ajax_create(self): + """ + AJAX view to create a new animal type. + """ + model = self.app.model + session = self.Session() + data = get_form_data(self.request) + + name = data.get("name") + if not name: + return {"error": "Name is required"} + + animal_type = model.AnimalType(name=name) + session.add(animal_type) + session.flush() + + if self.app.is_farmos_mirror(): + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_to_farmos(animal_type, token=token) + + return { + "uuid": animal_type.uuid.hex, + "name": animal_type.name, + "farmos_uuid": animal_type.farmos_uuid.hex, + "drupal_id": animal_type.drupal_id, + } + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._animal_type_defaults(config) + + @classmethod + def _animal_type_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + + # ajax_create + config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new") + config.add_view( + cls, + attr="ajax_create", + route_name=f"{route_prefix}.ajax_create", + permission=f"{permission_prefix}.create", + renderer="json", + ) + class AnimalAssetView(AssetMasterView): """ From 338da0208cb6171ed3128cddf5f7d3b7076f9f6f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 16:49:56 -0600 Subject: [PATCH 17/55] fix: prevent delete if animal type is still being referenced --- src/wuttafarm/db/model/asset_animal.py | 9 +++++++++ src/wuttafarm/web/views/animals.py | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/wuttafarm/db/model/asset_animal.py b/src/wuttafarm/db/model/asset_animal.py index 768b0f9..cf88b83 100644 --- a/src/wuttafarm/db/model/asset_animal.py +++ b/src/wuttafarm/db/model/asset_animal.py @@ -80,6 +80,14 @@ class AnimalType(model.Base): """, ) + animal_assets = orm.relationship( + "AnimalAsset", + doc=""" + List of animal assets of this type. + """, + back_populates="animal_type", + ) + def __str__(self): return self.name or "" @@ -103,6 +111,7 @@ class AnimalAsset(AssetMixin, EggMixin, model.Base): doc=""" Reference to the animal type. """, + back_populates="animal_assets", ) birthdate = sa.Column( diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 1c9fdfe..fc4c646 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -106,6 +106,19 @@ class AnimalTypeView(AssetTypeMasterView): return buttons + def delete(self): + animal_type = self.get_instance() + + if animal_type.animal_assets: + self.request.session.flash( + "Cannot delete animal type which is still referenced by animal assets.", + "warning", + ) + url = self.get_action_url("view", animal_type) + return self.redirect(self.request.get_referrer(default=url)) + + return super().delete() + def get_row_grid_data(self, animal_type): model = self.app.model session = self.Session() From 28ecb4d78675ef0e5a12a1736177297b1bd06393 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 16:55:02 -0600 Subject: [PATCH 18/55] fix: remove unique constraint for `AnimalType.name` since it is not guaranteed unique in farmOS; can't do it here either or else import may fail --- ...2ed2_remove_unique_for_animal_type_name.py | 37 +++++++++++++++++++ src/wuttafarm/db/model/asset_animal.py | 1 - 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/wuttafarm/db/alembic/versions/45c7718d2ed2_remove_unique_for_animal_type_name.py diff --git a/src/wuttafarm/db/alembic/versions/45c7718d2ed2_remove_unique_for_animal_type_name.py b/src/wuttafarm/db/alembic/versions/45c7718d2ed2_remove_unique_for_animal_type_name.py new file mode 100644 index 0000000..03759cf --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/45c7718d2ed2_remove_unique_for_animal_type_name.py @@ -0,0 +1,37 @@ +"""remove unique for animal_type.name + +Revision ID: 45c7718d2ed2 +Revises: 5b6c87d8cddf +Create Date: 2026-02-27 16:53:59.310342 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "45c7718d2ed2" +down_revision: Union[str, None] = "5b6c87d8cddf" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # animal_type + op.drop_constraint(op.f("uq_animal_type_name"), "animal_type", type_="unique") + + +def downgrade() -> None: + + # animal_type + op.create_unique_constraint( + op.f("uq_animal_type_name"), + "animal_type", + ["name"], + postgresql_nulls_not_distinct=False, + ) diff --git a/src/wuttafarm/db/model/asset_animal.py b/src/wuttafarm/db/model/asset_animal.py index cf88b83..443a984 100644 --- a/src/wuttafarm/db/model/asset_animal.py +++ b/src/wuttafarm/db/model/asset_animal.py @@ -48,7 +48,6 @@ class AnimalType(model.Base): name = sa.Column( sa.String(length=100), nullable=False, - unique=True, doc=""" Name of the animal type. """, From 3343524325f7888b8aa80efe5a8d8eb1c2d886d5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 17:05:20 -0600 Subject: [PATCH 19/55] fix: add farmOS-style links for Parents column in Land Assets grid --- src/wuttafarm/web/views/assets.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index d9e6205..ce101f8 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -25,6 +25,8 @@ Master view for Assets from collections import OrderedDict +from webhelpers2.html import tags + from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.db import Session @@ -210,7 +212,19 @@ class AssetMasterView(WuttaFarmMasterView): g.set_filter("archived", model.Asset.archived) def render_parents_for_grid(self, asset, field, value): - parents = [str(p.parent) for p in asset.asset._parents] + parents = asset.asset._parents + + if self.farmos_style_grid_links: + links = [] + for parent in parents: + parent = parent.parent + url = self.request.route_url( + f"{parent.asset_type}_assets.view", uuid=parent.uuid + ) + links.append(tags.link_to(str(parent), url)) + return ", ".join(links) + + parents = [str(p.parent) for p in parents] return ", ".join(parents) def grid_row_class(self, asset, data, i): From 2f84f76d89d8160eb9dfdc139d42b6bdb39a90fd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 17:16:50 -0600 Subject: [PATCH 20/55] fix: prevent edit for asset types, land types when app is mirror --- src/wuttafarm/web/views/asset_types.py | 13 +++++++++++++ src/wuttafarm/web/views/land.py | 13 +++++++++++++ src/wuttafarm/web/views/structures.py | 13 +++++++++++++ src/wuttafarm/web/views/units.py | 13 +++++++++++++ 4 files changed, 52 insertions(+) diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py index b9f560a..f9aadfb 100644 --- a/src/wuttafarm/web/views/asset_types.py +++ b/src/wuttafarm/web/views/asset_types.py @@ -78,6 +78,19 @@ class AssetTypeView(WuttaFarmMasterView): return buttons + @classmethod + def defaults(cls, config): + """ """ + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + + if app.is_farmos_mirror(): + cls.creatable = False + cls.editable = False + cls.deletable = False + + cls._defaults(config) + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/land.py b/src/wuttafarm/web/views/land.py index 9523cb5..23b899d 100644 --- a/src/wuttafarm/web/views/land.py +++ b/src/wuttafarm/web/views/land.py @@ -129,6 +129,19 @@ class LandTypeView(AssetTypeMasterView): def get_row_action_url_view(self, land_asset, i): return self.request.route_url("land_assets.view", uuid=land_asset.uuid) + @classmethod + def defaults(cls, config): + """ """ + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + + if app.is_farmos_mirror(): + cls.creatable = False + cls.editable = False + cls.deletable = False + + cls._defaults(config) + class LandAssetView(AssetMasterView): """ diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 11a21b9..4d36d41 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -128,6 +128,19 @@ class StructureTypeView(AssetTypeMasterView): def get_row_action_url_view(self, structure, i): return self.request.route_url("structure_assets.view", uuid=structure.uuid) + @classmethod + def defaults(cls, config): + """ """ + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + + if app.is_farmos_mirror(): + cls.creatable = False + cls.editable = False + cls.deletable = False + + cls._defaults(config) + class StructureAssetView(AssetMasterView): """ diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index a36a238..add7b2b 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -59,6 +59,19 @@ class MeasureView(WuttaFarmMasterView): # name g.set_link("name") + @classmethod + def defaults(cls, config): + """ """ + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + + if app.is_farmos_mirror(): + cls.creatable = False + cls.editable = False + cls.deletable = False + + cls._defaults(config) + class UnitView(WuttaFarmMasterView): """ From 0d989dcb2c74f33702b838926c748cd26cb85a1c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 17:17:18 -0600 Subject: [PATCH 21/55] fix: fix land asset type --- src/wuttafarm/db/model/asset_land.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttafarm/db/model/asset_land.py b/src/wuttafarm/db/model/asset_land.py index bbd7bf0..00bdd27 100644 --- a/src/wuttafarm/db/model/asset_land.py +++ b/src/wuttafarm/db/model/asset_land.py @@ -88,7 +88,7 @@ class LandAsset(AssetMixin, model.Base): __wutta_hint__ = { "model_title": "Land Asset", "model_title_plural": "Land Assets", - "farmos_asset_type": "animal", + "farmos_asset_type": "land", } land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False, unique=True) From bdda586ccdfb09397abb22698e611e430129dbc9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 17:23:49 -0600 Subject: [PATCH 22/55] fix: render links for Plant Type column in Plant Assets grid --- src/wuttafarm/web/views/plants.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index 4a343a6..cd6cb34 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -23,6 +23,8 @@ Master view for Plants """ +from webhelpers2.html import tags + from wuttaweb.forms.schema import WuttaDictEnum from wuttafarm.db.model import PlantType, PlantAsset @@ -172,10 +174,20 @@ class PlantAssetView(AssetMasterView): super().configure_grid(g) # plant_types - g.set_renderer("plant_types", self.render_grid_plant_types) + g.set_renderer("plant_types", self.render_plant_types_for_grid) - def render_grid_plant_types(self, plant, field, value): - return ", ".join([t.plant_type.name for t in plant._plant_types]) + def render_plant_types_for_grid(self, plant, field, value): + plant_types = plant._plant_types + + if self.farmos_style_grid_links: + links = [] + for plant_type in plant_types: + plant_type = plant_type.plant_type + url = self.request.route_url("plant_types.view", uuid=plant_type.uuid) + links.append(tags.link_to(str(plant_type), url)) + return ", ".join(links) + + return ", ".join([str(pt.plant_type) for pt in plant_types]) def configure_form(self, form): f = form From c353d5bcef076b623f94dddc57666a7786e0a714 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 22:34:51 -0600 Subject: [PATCH 23/55] feat: add support for edit, import/export of plant type data esp. plant types for a plant asset --- src/wuttafarm/db/model/asset_plant.py | 16 ++ src/wuttafarm/farmos/importing/model.py | 6 + src/wuttafarm/farmos/importing/wuttafarm.py | 23 ++ src/wuttafarm/importing/farmos.py | 6 +- src/wuttafarm/web/forms/schema.py | 17 +- src/wuttafarm/web/forms/widgets.py | 62 +++++- .../web/templates/deform/planttyperefs.pt | 13 ++ .../web/templates/wuttafarm-components.mako | 196 ++++++++++++++++++ src/wuttafarm/web/views/plants.py | 101 ++++++++- 9 files changed, 416 insertions(+), 24 deletions(-) create mode 100644 src/wuttafarm/web/templates/deform/planttyperefs.pt diff --git a/src/wuttafarm/db/model/asset_plant.py b/src/wuttafarm/db/model/asset_plant.py index 5f10e7c..62f7e9b 100644 --- a/src/wuttafarm/db/model/asset_plant.py +++ b/src/wuttafarm/db/model/asset_plant.py @@ -25,6 +25,7 @@ Model definition for Plant Assets import sqlalchemy as sa from sqlalchemy import orm +from sqlalchemy.ext.associationproxy import association_proxy from wuttjamaican.db import model @@ -80,6 +81,12 @@ class PlantType(model.Base): """, ) + _plant_assets = orm.relationship( + "PlantAssetPlantType", + cascade_backrefs=False, + back_populates="plant_type", + ) + def __str__(self): return self.name or "" @@ -99,9 +106,17 @@ class PlantAsset(AssetMixin, model.Base): _plant_types = orm.relationship( "PlantAssetPlantType", + cascade="all, delete-orphan", + cascade_backrefs=False, back_populates="plant_asset", ) + plant_types = association_proxy( + "_plant_types", + "plant_type", + creator=lambda pt: PlantAssetPlantType(plant_type=pt), + ) + add_asset_proxies(PlantAsset) @@ -129,4 +144,5 @@ class PlantAssetPlantType(model.Base): doc=""" Reference to the plant type. """, + back_populates="_plant_assets", ) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 337649c..04d80c1 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -347,6 +347,12 @@ class LandAssetImporter(ToFarmOSAsset): return payload +class PlantTypeImporter(ToFarmOSTaxonomy): + + model_title = "PlantType" + farmos_taxonomy_type = "plant_type" + + class PlantAssetImporter(ToFarmOSAsset): model_title = "PlantAsset" diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index a39fe97..bb5350d 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -99,6 +99,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter + importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter importers["ActivityLog"] = ActivityLogImporter @@ -264,6 +265,28 @@ class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter) } +class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter): + """ + WuttaFarm → farmOS API exporter for Plant Types + """ + + source_model_class = model.PlantType + + supported_fields = [ + "uuid", + "name", + ] + + drupal_internal_id_field = "drupal_internal__tid" + + def normalize_source_object(self, plant_type): + return { + "uuid": plant_type.farmos_uuid or self.app.make_true_uuid(), + "name": plant_type.name, + "_src_object": plant_type, + } + + class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter): """ WuttaFarm → farmOS API exporter for Plant Assets diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index a1e9631..9e922da 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -623,7 +623,7 @@ class PlantAssetImporter(AssetImporterBase): def normalize_source_object(self, plant): """ """ - plant_types = None + plant_types = [] if relationships := plant.get("relationships"): if plant_type := relationships.get("plant_type"): @@ -640,7 +640,7 @@ class PlantAssetImporter(AssetImporterBase): data.update( { "asset_type": "plant", - "plant_types": plant_types, + "plant_types": set(plant_types), } ) return data @@ -649,7 +649,7 @@ class PlantAssetImporter(AssetImporterBase): data = super().normalize_target_object(plant) if "plant_types" in self.fields: - data["plant_types"] = [t.plant_type_uuid for t in plant._plant_types] + data["plant_types"] = set([pt.uuid for pt in plant.plant_types]) return data diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index c6095ff..548ee81 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -27,6 +27,7 @@ import json import colander +from wuttaweb.db import Session from wuttaweb.forms.schema import ObjectRef, WuttaSet from wuttaweb.forms.widgets import NotesWidget @@ -242,13 +243,23 @@ class PlantTypeRefs(WuttaSet): def serialize(self, node, appstruct): if not appstruct: - appstruct = [] - uuids = [u.hex for u in appstruct] - return json.dumps(uuids) + return colander.null + + return [uuid.hex for uuid in appstruct] def widget_maker(self, **kwargs): from wuttafarm.web.forms.widgets import PlantTypeRefsWidget + model = self.app.model + session = Session() + + if "values" not in kwargs: + plant_types = ( + session.query(model.PlantType).order_by(model.PlantType.name).all() + ) + values = [(pt.uuid.hex, str(pt)) for pt in plant_types] + kwargs["values"] = values + return PlantTypeRefsWidget(self.request, **kwargs) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 7f5808f..ae9aa10 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -26,7 +26,7 @@ Custom form widgets for WuttaFarm import json import colander -from deform.widget import Widget, SelectWidget +from deform.widget import Widget, SelectWidget, sequence_types, _normalize_choices from webhelpers2.html import HTML, tags from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget @@ -258,22 +258,40 @@ class FarmOSPlantTypesWidget(Widget): return super().serialize(field, cstruct, **kw) -class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget): +class PlantTypeRefsWidget(Widget): """ Widget for Plant Types field (on a Plant Asset). """ + template = "planttyperefs" + values = () + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + def serialize(self, field, cstruct, **kw): """ """ model = self.app.model session = Session() - readonly = kw.get("readonly", self.readonly) - if readonly: - plant_types = [] - for uuid in json.loads(cstruct): - plant_type = session.get(model.PlantType, uuid) - plant_types.append( + if cstruct in (colander.null, None): + cstruct = () + + if readonly := kw.get("readonly", self.readonly): + items = [] + + plant_types = ( + session.query(model.PlantType) + .filter(model.PlantType.uuid.in_(cstruct)) + .order_by(model.PlantType.name) + .all() + ) + + for plant_type in plant_types: + items.append( HTML.tag( "li", c=tags.link_to( @@ -284,9 +302,33 @@ class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget): ), ) ) - return HTML.tag("ul", c=plant_types) - return super().serialize(field, cstruct, **kw) + return HTML.tag("ul", c=items) + + values = kw.get("values", self.values) + if not isinstance(values, sequence_types): + raise TypeError("Values must be a sequence type (list, tuple, or range).") + + kw["values"] = _normalize_choices(values) + tmpl_values = self.get_template_values(field, cstruct, kw) + return field.renderer(self.template, **tmpl_values) + + def get_template_values(self, field, cstruct, kw): + """ """ + values = super().get_template_values(field, cstruct, kw) + + values["js_values"] = json.dumps(values["values"]) + + if self.request.has_perm("plant_types.create"): + values["can_create"] = True + + return values + + def deserialize(self, field, pstruct): + if not pstruct: + return colander.null + + return set(pstruct.split(",")) class StructureWidget(Widget): diff --git a/src/wuttafarm/web/templates/deform/planttyperefs.pt b/src/wuttafarm/web/templates/deform/planttyperefs.pt new file mode 100644 index 0000000..83cb095 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/planttyperefs.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako index b973cb1..37b176e 100644 --- a/src/wuttafarm/web/templates/wuttafarm-components.mako +++ b/src/wuttafarm/web/templates/wuttafarm-components.mako @@ -1,6 +1,7 @@ <%def name="make_wuttafarm_components()"> ${self.make_animal_type_picker_component()} + ${self.make_plant_types_picker_component()} <%def name="make_animal_type_picker_component()"> @@ -126,3 +127,198 @@ <% request.register_component('animal-type-picker', 'AnimalTypePicker') %> + +<%def name="make_plant_types_picker_component()"> + + + diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index cd6cb34..a2d0cb1 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -26,6 +26,7 @@ Master view for Plants from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum +from wuttaweb.util import get_form_data from wuttafarm.db.model import PlantType, PlantAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView @@ -42,8 +43,9 @@ class PlantTypeView(AssetTypeMasterView): route_prefix = "plant_types" url_prefix = "/plant-types" + farmos_entity_type = "taxonomy_term" + farmos_bundle = "plant_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" - farmos_bundle = "plant" grid_columns = [ "name", @@ -101,6 +103,19 @@ class PlantTypeView(AssetTypeMasterView): return buttons + def delete(self): + plant_type = self.get_instance() + + if plant_type._plant_assets: + self.request.session.flash( + "Cannot delete plant type which is still referenced by plant assets.", + "warning", + ) + url = self.get_action_url("view", plant_type) + return self.redirect(self.request.get_referrer(default=url)) + + return super().delete() + def get_row_grid_data(self, plant_type): model = self.app.model session = self.Session() @@ -129,6 +144,55 @@ class PlantTypeView(AssetTypeMasterView): def get_row_action_url_view(self, plant, i): return self.request.route_url("plant_assets.view", uuid=plant.uuid) + def ajax_create(self): + """ + AJAX view to create a new plant type. + """ + model = self.app.model + session = self.Session() + data = get_form_data(self.request) + + name = data.get("name") + if not name: + return {"error": "Name is required"} + + plant_type = model.PlantType(name=name) + session.add(plant_type) + session.flush() + + if self.app.is_farmos_mirror(): + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_to_farmos(plant_type, token=token) + + return { + "uuid": plant_type.uuid.hex, + "name": plant_type.name, + "farmos_uuid": plant_type.farmos_uuid.hex, + "drupal_id": plant_type.drupal_id, + } + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._plant_type_defaults(config) + + @classmethod + def _plant_type_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + + # ajax_create + config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new") + config.add_view( + cls, + attr="ajax_create", + route_name=f"{route_prefix}.ajax_create", + permission=f"{permission_prefix}.create", + renderer="json", + ) + class PlantAssetView(AssetMasterView): """ @@ -139,6 +203,7 @@ class PlantAssetView(AssetMasterView): route_prefix = "plant_assets" url_prefix = "/assets/plant" + farmos_bundle = "plant" farmos_refurl_path = "/assets/plant" labels = { @@ -196,18 +261,38 @@ class PlantAssetView(AssetMasterView): plant = f.model_instance # plant_types - if self.creating or self.editing: - f.remove("plant_types") # TODO: add support for this - else: - f.set_node("plant_types", PlantTypeRefs(self.request)) - f.set_default( - "plant_types", [t.plant_type_uuid for t in plant._plant_types] - ) + f.set_node("plant_types", PlantTypeRefs(self.request)) + if not self.creating: + # nb. must explcitly declare value for non-standard field + f.set_default("plant_types", [pt.uuid for pt in plant.plant_types]) # season if self.creating or self.editing: f.remove("season") # TODO: add support for this + def objectify(self, form): + model = self.app.model + session = self.Session() + plant = super().objectify(form) + data = form.validated + + current = [pt.uuid for pt in plant.plant_types] + desired = data["plant_types"] + + for uuid in desired: + if uuid not in current: + plant_type = session.get(model.PlantType, uuid) + assert plant_type + plant.plant_types.append(plant_type) + + for uuid in current: + if uuid not in desired: + plant_type = session.get(model.PlantType, uuid) + assert plant_type + plant.plant_types.remove(plant_type) + + return plant + def defaults(config, **kwargs): base = globals() From d465934818a4827599a3b5a05beb5168a3d708af Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 12:53:18 -0600 Subject: [PATCH 24/55] fix: ensure token refresh works regardless where API client is used --- src/wuttafarm/app.py | 16 ++++++++-------- src/wuttafarm/farmos/importing/wuttafarm.py | 8 +++++--- src/wuttafarm/importing/farmos.py | 8 +++++--- src/wuttafarm/web/views/animals.py | 5 +++-- src/wuttafarm/web/views/master.py | 4 ++-- src/wuttafarm/web/views/plants.py | 5 +++-- src/wuttafarm/web/views/quick/eggs.py | 7 ++++--- 7 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 30c7f51..a3fa566 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -136,7 +136,7 @@ class WuttaFarmAppHandler(base.AppHandler): factory = self.load_object(spec) return factory(self.config, farmos_client) - def auto_sync_to_farmos(self, obj, model_name=None, token=None, require=True): + def auto_sync_to_farmos(self, obj, model_name=None, client=None, require=True): """ Export the given object to farmOS, using configured handler. @@ -147,8 +147,8 @@ class WuttaFarmAppHandler(base.AppHandler): :param obj: Any data object in WuttaFarm, e.g. AnimalAsset instance. - :param token: OAuth2 token for the farmOS client. If not - specified, the import handler will obtain a new token. + :param client: Existing farmOS API client to use. If not + specified, a new one will be instantiated. :param require: If true, this will *require* the export handler to support objects of the given type. If false, @@ -165,12 +165,12 @@ class WuttaFarmAppHandler(base.AppHandler): return # nb. begin txn to establish the API client - handler.begin_target_transaction(token) + handler.begin_target_transaction(client) importer = handler.get_importer(model_name, caches_target=False) normal = importer.normalize_source_object(obj) importer.process_data(source_data=[normal]) - def auto_sync_from_farmos(self, obj, model_name, token=None, require=True): + def auto_sync_from_farmos(self, obj, model_name, client=None, require=True): """ Import the given object from farmOS, using configured handler. @@ -179,8 +179,8 @@ class WuttaFarmAppHandler(base.AppHandler): :param model_name': Model name for the importer to use, e.g. ``"AnimalAsset"``. - :param token: OAuth2 token for the farmOS client. If not - specified, the import handler will obtain a new token. + :param client: Existing farmOS API client to use. If not + specified, a new one will be instantiated. :param require: If true, this will *require* the import handler to support objects of the given type. If false, @@ -195,7 +195,7 @@ class WuttaFarmAppHandler(base.AppHandler): return # nb. begin txn to establish the API client - handler.begin_source_transaction(token) + handler.begin_source_transaction(client) with self.short_session(commit=True) as session: handler.target_session = session importer = handler.get_importer(model_name, caches_target=False) diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index bb5350d..96cefb2 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -50,13 +50,15 @@ class ToFarmOSHandler(ImportHandler): # TODO: a lot of duplication to cleanup here; see FromFarmOSHandler - def begin_target_transaction(self, token=None): + def begin_target_transaction(self, client=None): """ Establish the farmOS API client. """ - if not token: + if client: + self.farmos_client = client + else: token = self.get_farmos_oauth2_token() - self.farmos_client = self.app.get_farmos_client(token=token) + self.farmos_client = self.app.get_farmos_client(token=token) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) def get_farmos_oauth2_token(self): diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 9e922da..5bc351e 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -46,13 +46,15 @@ class FromFarmOSHandler(ImportHandler): source_key = "farmos" generic_source_title = "farmOS" - def begin_source_transaction(self, token=None): + def begin_source_transaction(self, client=None): """ Establish the farmOS API client. """ - if not token: + if client: + self.farmos_client = client + else: token = self.get_farmos_oauth2_token() - self.farmos_client = self.app.get_farmos_client(token=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) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index fc4c646..b52a353 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -32,6 +32,7 @@ from wuttafarm.db.model import AnimalType, AnimalAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.forms.schema import AnimalTypeRef from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.web.util import get_farmos_client_for_user class AnimalTypeView(AssetTypeMasterView): @@ -168,8 +169,8 @@ class AnimalTypeView(AssetTypeMasterView): session.flush() if self.app.is_farmos_mirror(): - token = self.request.session.get("farmos.oauth2.token") - self.app.auto_sync_to_farmos(animal_type, token=token) + client = get_farmos_client_for_user(self.request) + self.app.auto_sync_to_farmos(animal_type, client=client) return { "uuid": animal_type.uuid.hex, diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index ec3c913..747cdc5 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -112,8 +112,8 @@ class WuttaFarmMasterView(MasterView): # maybe also sync change to farmOS if self.app.is_farmos_mirror(): - token = self.request.session.get("farmos.oauth2.token") - self.app.auto_sync_to_farmos(obj, token=token, require=False) + client = get_farmos_client_for_user(self.request) + 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/plants.py b/src/wuttafarm/web/views/plants.py index a2d0cb1..c831201 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -32,6 +32,7 @@ from wuttafarm.db.model import PlantType, PlantAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.forms.schema import PlantTypeRefs from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.web.util import get_farmos_client_for_user class PlantTypeView(AssetTypeMasterView): @@ -161,8 +162,8 @@ class PlantTypeView(AssetTypeMasterView): session.flush() if self.app.is_farmos_mirror(): - token = self.request.session.get("farmos.oauth2.token") - self.app.auto_sync_to_farmos(plant_type, token=token) + client = get_farmos_client_for_user(self.request) + self.app.auto_sync_to_farmos(plant_type, client=client) return { "uuid": plant_type.uuid.hex, diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index 0482132..e5461b1 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -34,6 +34,7 @@ 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): @@ -118,11 +119,11 @@ class EggsQuickForm(QuickFormView): if self.app.is_farmos_mirror(): quantity = json.loads(response["create-quantity"]["body"]) - token = self.request.session.get("farmos.oauth2.token") + client = get_farmos_client_for_user(self.request) self.app.auto_sync_from_farmos( - quantity["data"], "StandardQuantity", token=token + quantity["data"], "StandardQuantity", client=client ) - self.app.auto_sync_from_farmos(log["data"], "HarvestLog", token=token) + self.app.auto_sync_from_farmos(log["data"], "HarvestLog", client=client) return log From d1817a3611aa66067ea1dd7a8f532d16097211e4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 18:40:35 -0600 Subject: [PATCH 25/55] fix: rename views for "all records" (all assets, all logs etc.) just for clarity's sake, i think it's better --- src/wuttafarm/web/views/assets.py | 6 +++--- src/wuttafarm/web/views/logs.py | 6 +++--- src/wuttafarm/web/views/quantities.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index ce101f8..963fe78 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -49,7 +49,7 @@ def get_asset_type_enum(config): return asset_types -class AssetView(WuttaFarmMasterView): +class AllAssetView(WuttaFarmMasterView): """ Master view for Assets """ @@ -368,8 +368,8 @@ class AssetMasterView(WuttaFarmMasterView): def defaults(config, **kwargs): base = globals() - AssetView = kwargs.get("AssetView", base["AssetView"]) - AssetView.defaults(config) + AllAssetView = kwargs.get("AllAssetView", base["AllAssetView"]) + AllAssetView.defaults(config) def includeme(config): diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index af0e375..7f5d9cf 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -89,7 +89,7 @@ class LogTypeView(WuttaFarmMasterView): return buttons -class LogView(WuttaFarmMasterView): +class AllLogView(WuttaFarmMasterView): """ Master view for All Logs """ @@ -367,8 +367,8 @@ def defaults(config, **kwargs): LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) LogTypeView.defaults(config) - LogView = kwargs.get("LogView", base["LogView"]) - LogView.defaults(config) + AllLogView = kwargs.get("AllLogView", base["AllLogView"]) + AllLogView.defaults(config) def includeme(config): diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py index 7d75290..fb5279d 100644 --- a/src/wuttafarm/web/views/quantities.py +++ b/src/wuttafarm/web/views/quantities.py @@ -248,7 +248,7 @@ class QuantityMasterView(WuttaFarmMasterView): return buttons -class QuantityView(QuantityMasterView): +class AllQuantityView(QuantityMasterView): """ Master view for All Quantities """ @@ -280,8 +280,8 @@ def defaults(config, **kwargs): QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) QuantityTypeView.defaults(config) - QuantityView = kwargs.get("QuantityView", base["QuantityView"]) - QuantityView.defaults(config) + AllQuantityView = kwargs.get("AllQuantityView", base["AllQuantityView"]) + AllQuantityView.defaults(config) StandardQuantityView = kwargs.get( "StandardQuantityView", base["StandardQuantityView"] From 86e36bc64ac8fb2bc4a8e6f3b9941a0f0ff63697 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 18:59:18 -0600 Subject: [PATCH 26/55] fix: make AllLogView inherit from LogMasterView and improve asset rendering for those grids --- src/wuttafarm/db/model/log.py | 8 ++ src/wuttafarm/web/views/logs.py | 136 +++++++++++++++----------------- 2 files changed, 70 insertions(+), 74 deletions(-) diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index fd59478..8352a8e 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -26,6 +26,7 @@ Model definition for Logs import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.ext.associationproxy import association_proxy from wuttjamaican.db import model @@ -155,6 +156,12 @@ class Log(model.Base): _assets = orm.relationship("LogAsset", back_populates="log") + assets = association_proxy( + "_assets", + "asset", + creator=lambda asset: LogAsset(asset=asset), + ) + def __str__(self): return self.message or "" @@ -184,6 +191,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "timestamp") Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") + Log.make_proxy(subclass, "log", "assets") class LogAsset(model.Base): diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 7f5d9cf..fe46298 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -26,6 +26,7 @@ Base views for Logs from collections import OrderedDict import colander +from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.db import Session @@ -89,78 +90,6 @@ class LogTypeView(WuttaFarmMasterView): return buttons -class AllLogView(WuttaFarmMasterView): - """ - Master view for All Logs - """ - - model_class = Log - route_prefix = "log" - url_prefix = "/logs" - - farmos_refurl_path = "/logs" - - viewable = False - creatable = False - editable = False - deletable = False - model_is_versioned = False - - labels = { - "message": "Log Name", - } - - grid_columns = [ - "status", - "drupal_id", - "timestamp", - "message", - "log_type", - "assets", - "location", - "quantity", - "groups", - "is_group_assignment", - ] - - sort_defaults = ("timestamp", "desc") - - filter_defaults = { - "message": {"active": True, "verb": "contains"}, - } - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - session = self.Session() - - # drupal_id - g.set_label("drupal_id", "ID", column_only=True) - - # timestamp - g.set_renderer("timestamp", "date") - g.set_link("timestamp") - - # message - g.set_link("message") - - # log_type - g.set_enum("log_type", get_log_type_enum(self.config, session=session)) - - # assets - g.set_renderer("assets", self.render_assets_for_grid) - - # view action links to final log record - def log_url(log, i): - return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) - - g.add_action("view", icon="eye", url=log_url) - - def render_assets_for_grid(self, log, field, value): - assets = [str(a.asset) for a in log._assets] - return ", ".join(assets) - - class LogMasterView(WuttaFarmMasterView): """ Base class for Asset master views @@ -212,7 +141,10 @@ class LogMasterView(WuttaFarmMasterView): model = self.app.model model_class = self.get_model_class() session = session or self.Session() - return session.query(model_class).join(model.Log) + query = session.query(model_class) + if model_class is not model.Log: + query = query.join(model.Log) + return query def configure_grid(self, grid): g = grid @@ -250,7 +182,17 @@ class LogMasterView(WuttaFarmMasterView): g.set_renderer("assets", self.render_assets_for_grid) def render_assets_for_grid(self, log, field, value): - return ", ".join([a.asset.asset_name for a in log.log._assets]) + + if self.farmos_style_grid_links: + links = [] + for asset in log.assets: + url = self.request.route_url( + f"{asset.asset_type}_assets.view", uuid=asset.uuid + ) + links.append(tags.link_to(str(asset), url)) + return ", ".join(links) + + return ", ".join([str(a) for a in log.assets]) def grid_row_class(self, log, data, i): if log.status == "pending": @@ -361,6 +303,52 @@ class LogMasterView(WuttaFarmMasterView): ] +class AllLogView(LogMasterView): + """ + Master view for All Logs + """ + + model_class = Log + route_prefix = "log" + url_prefix = "/logs" + + farmos_refurl_path = "/logs" + + viewable = False + creatable = False + editable = False + deletable = False + model_is_versioned = False + + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "log_type", + "assets", + "location", + "quantity", + "groups", + "is_group_assignment", + "owner", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + session = self.Session() + + # log_type + g.set_enum("log_type", get_log_type_enum(self.config, session=session)) + + # view action links to final log record + def log_url(log, i): + return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) + + g.add_action("view", icon="eye", url=log_url) + + def defaults(config, **kwargs): base = globals() From ae73d2f87fef74f0305dc24964f9158d4188faba Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 18:59:50 -0600 Subject: [PATCH 27/55] fix: define log grid columns to match farmOS some of these still do not have values yet.. --- src/wuttafarm/web/views/logs.py | 2 +- src/wuttafarm/web/views/logs_harvest.py | 10 ++++++++++ src/wuttafarm/web/views/logs_medical.py | 10 ++++++++++ src/wuttafarm/web/views/logs_observation.py | 12 ++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index fe46298..245c448 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -108,7 +108,7 @@ class LogMasterView(WuttaFarmMasterView): "timestamp", "message", "assets", - # "location", + "location", "quantity", "is_group_assignment", "owners", diff --git a/src/wuttafarm/web/views/logs_harvest.py b/src/wuttafarm/web/views/logs_harvest.py index 1c9a6f2..f2b29e0 100644 --- a/src/wuttafarm/web/views/logs_harvest.py +++ b/src/wuttafarm/web/views/logs_harvest.py @@ -39,6 +39,16 @@ class HarvestLogView(LogMasterView): farmos_bundle = "harvest" farmos_refurl_path = "/logs/harvest" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "assets", + "quantity", + "owners", + ] + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/logs_medical.py b/src/wuttafarm/web/views/logs_medical.py index c5769e8..2531237 100644 --- a/src/wuttafarm/web/views/logs_medical.py +++ b/src/wuttafarm/web/views/logs_medical.py @@ -39,6 +39,16 @@ class MedicalLogView(LogMasterView): farmos_bundle = "medical" farmos_refurl_path = "/logs/medical" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "assets", + "veterinarian", + "owners", + ] + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/logs_observation.py b/src/wuttafarm/web/views/logs_observation.py index 5b190d1..0485f50 100644 --- a/src/wuttafarm/web/views/logs_observation.py +++ b/src/wuttafarm/web/views/logs_observation.py @@ -39,6 +39,18 @@ class ObservationLogView(LogMasterView): farmos_bundle = "observation" farmos_refurl_path = "/logs/observation" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "assets", + "location", + "groups", + "is_group_assignment", + "owners", + ] + def defaults(config, **kwargs): base = globals() From 64e4392a926024c5148b1dc1e2c308ed51e4c747 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 19:52:09 -0600 Subject: [PATCH 28/55] feat: add support for log 'owners' --- .../versions/47d0ebd84554_add_logowner.py | 108 ++++++++++++++++++ src/wuttafarm/db/model/log.py | 45 +++++++- src/wuttafarm/importing/farmos.py | 40 +++++-- src/wuttafarm/web/forms/schema.py | 23 +++- src/wuttafarm/web/forms/widgets.py | 33 +++++- src/wuttafarm/web/views/logs.py | 27 ++++- 6 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py diff --git a/src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py b/src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py new file mode 100644 index 0000000..8dffce9 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py @@ -0,0 +1,108 @@ +"""add LogOwner + +Revision ID: 47d0ebd84554 +Revises: 45c7718d2ed2 +Create Date: 2026-02-28 19:18:49.122090 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "47d0ebd84554" +down_revision: Union[str, None] = "45c7718d2ed2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_owner + op.create_table( + "log_owner", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("user_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["log_uuid"], ["log.uuid"], name=op.f("fk_log_owner_log_uuid_log") + ), + sa.ForeignKeyConstraint( + ["user_uuid"], ["user.uuid"], name=op.f("fk_log_owner_user_uuid_user") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_owner")), + ) + op.create_table( + "log_owner_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "user_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_owner_version") + ), + ) + op.create_index( + op.f("ix_log_owner_version_end_transaction_id"), + "log_owner_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_owner_version_operation_type"), + "log_owner_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_owner_version_pk_transaction_id", + "log_owner_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_owner_version_pk_validity", + "log_owner_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_owner_version_transaction_id"), + "log_owner_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_owner + op.drop_index( + op.f("ix_log_owner_version_transaction_id"), table_name="log_owner_version" + ) + op.drop_index("ix_log_owner_version_pk_validity", table_name="log_owner_version") + op.drop_index( + "ix_log_owner_version_pk_transaction_id", table_name="log_owner_version" + ) + op.drop_index( + op.f("ix_log_owner_version_operation_type"), table_name="log_owner_version" + ) + op.drop_index( + op.f("ix_log_owner_version_end_transaction_id"), table_name="log_owner_version" + ) + op.drop_table("log_owner_version") + op.drop_table("log_owner") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 8352a8e..6142229 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -154,7 +154,12 @@ class Log(model.Base): """, ) - _assets = orm.relationship("LogAsset", back_populates="log") + _assets = orm.relationship( + "LogAsset", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) assets = association_proxy( "_assets", @@ -162,6 +167,19 @@ class Log(model.Base): creator=lambda asset: LogAsset(asset=asset), ) + _owners = orm.relationship( + "LogOwner", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) + + owners = association_proxy( + "_owners", + "user", + creator=lambda user: LogOwner(user=user), + ) + def __str__(self): return self.message or "" @@ -192,6 +210,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") Log.make_proxy(subclass, "log", "assets") + Log.make_proxy(subclass, "log", "owners") class LogAsset(model.Base): @@ -216,3 +235,27 @@ class LogAsset(model.Base): "Asset", foreign_keys=asset_uuid, ) + + +class LogOwner(model.Base): + """ + Represents a "log's owner relationship" from farmOS. + """ + + __tablename__ = "log_owner" + __versioned__ = {} + + uuid = model.uuid_column() + + log_uuid = model.uuid_fk_column("log.uuid", nullable=False) + log = orm.relationship( + Log, + foreign_keys=log_uuid, + back_populates="_owners", + ) + + user_uuid = model.uuid_fk_column("user.uuid", nullable=False) + user = orm.relationship( + model.User, + foreign_keys=user_uuid, + ) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 5bc351e..c931fb4 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -979,6 +979,7 @@ class LogImporterBase(FromFarmOS, ToWutta): fields.extend( [ "assets", + "owners", ] ) return fields @@ -1004,6 +1005,9 @@ class LogImporterBase(FromFarmOS, ToWutta): (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] ] + if "owners" in self.fields: + data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]] + return data def normalize_target_object(self, log): @@ -1011,9 +1015,12 @@ class LogImporterBase(FromFarmOS, ToWutta): if "assets" in self.fields: data["assets"] = [ - (a.asset.asset_type, a.asset.farmos_uuid) for a in log.log._assets + (asset.asset_type, asset.farmos_uuid) for asset in log.assets ] + if "owners" in self.fields: + data["owners"] = [user.farmos_uuid for user in log.owners] + return data def update_target_object(self, log, source_data, target_data=None): @@ -1026,14 +1033,13 @@ class LogImporterBase(FromFarmOS, ToWutta): for key in source_data["assets"]: asset_type, farmos_uuid = key if not target_data or key not in target_data["assets"]: - self.target_session.flush() asset = ( self.target_session.query(model.Asset) .filter(model.Asset.asset_type == asset_type) .filter(model.Asset.farmos_uuid == farmos_uuid) .one() ) - log.log._assets.append(model.LogAsset(asset=asset)) + log.assets.append(asset) if target_data: for key in target_data["assets"]: @@ -1045,13 +1051,31 @@ class LogImporterBase(FromFarmOS, ToWutta): .filter(model.Asset.farmos_uuid == farmos_uuid) .one() ) - asset = ( - self.target_session.query(model.LogAsset) - .filter(model.LogAsset.log == log) - .filter(model.LogAsset.asset == asset) + log.assets.remove(asset) + + if "owners" in self.fields: + if not target_data or target_data["owners"] != source_data["owners"]: + + for farmos_uuid in source_data["owners"]: + if not target_data or farmos_uuid not in target_data["assets"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) + .one() + ) + log.owners.append(user) + + if target_data: + for farmos_uuid in target_data["owners"]: + if farmos_uuid not in source_data["owners"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) .one() ) - self.target_session.delete(asset) + log.owners.remove(user) return log diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 548ee81..8a80054 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -371,9 +371,9 @@ class LogAssetRefs(WuttaSet): def serialize(self, node, appstruct): if not appstruct: - appstruct = [] - uuids = [u.hex for u in appstruct] - return json.dumps(uuids) + return colander.null + + return {asset.uuid for asset in appstruct} def widget_maker(self, **kwargs): from wuttafarm.web.forms.widgets import LogAssetRefsWidget @@ -381,6 +381,23 @@ class LogAssetRefs(WuttaSet): return LogAssetRefsWidget(self.request, **kwargs) +class LogOwnerRefs(WuttaSet): + """ + Schema type for Owners field (on a Log record) + """ + + def serialize(self, node, appstruct): + if not appstruct: + return colander.null + + return {user.uuid for user in appstruct} + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import LogOwnerRefsWidget + + return LogOwnerRefsWidget(self.request, **kwargs) + + class Notes(colander.String): """ Custom schema type for "note" fields. diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index ae9aa10..d3325e6 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -436,7 +436,7 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): readonly = kw.get("readonly", self.readonly) if readonly: assets = [] - for uuid in json.loads(cstruct): + for uuid in cstruct or []: asset = session.get(model.Asset, uuid) assets.append( HTML.tag( @@ -454,6 +454,37 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) +class LogOwnerRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for Owners field (on a Log record) + """ + + def serialize(self, field, cstruct, **kw): + """ """ + model = self.app.model + session = Session() + + readonly = kw.get("readonly", self.readonly) + if readonly: + owners = [session.get(model.User, uuid) for uuid in cstruct or []] + owners = [user for user in owners if user] + owners.sort(key=lambda user: user.username) + links = [] + for user in owners: + links.append( + HTML.tag( + "li", + c=tags.link_to( + user.username, + self.request.route_url("users.view", uuid=user.uuid), + ), + ) + ) + return HTML.tag("ul", c=links) + + return super().serialize(field, cstruct, **kw) + + class AnimalTypeRefWidget(ObjectRefWidget): """ Custom widget which uses the ```` component. diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 245c448..68a06d1 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import LogType, Log -from wuttafarm.web.forms.schema import LogAssetRefs +from wuttafarm.web.forms.schema import LogAssetRefs, LogOwnerRefs from wuttafarm.util import get_log_type_enum @@ -99,7 +99,6 @@ class LogMasterView(WuttaFarmMasterView): labels = { "message": "Log Name", - "owners": "Owner", } grid_columns = [ @@ -181,6 +180,10 @@ class LogMasterView(WuttaFarmMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # owners + g.set_label("owners", "Owner") + g.set_renderer("owners", self.render_owners_for_grid) + def render_assets_for_grid(self, log, field, value): if self.farmos_style_grid_links: @@ -194,6 +197,17 @@ class LogMasterView(WuttaFarmMasterView): return ", ".join([str(a) for a in log.assets]) + def render_owners_for_grid(self, log, field, value): + + if self.farmos_style_grid_links: + links = [] + for user in log.owners: + url = self.request.route_url("users.view", uuid=user.uuid) + links.append(tags.link_to(user.username, url)) + return ", ".join(links) + + return ", ".join([user.username for user in log.owners]) + def grid_row_class(self, log, data, i): if log.status == "pending": return "has-background-warning" @@ -219,7 +233,8 @@ class LogMasterView(WuttaFarmMasterView): f.remove("assets") # TODO: need to support this else: f.set_node("assets", LogAssetRefs(self.request)) - f.set_default("assets", [a.asset_uuid for a in log.log._assets]) + # nb. must explicity declare value for non-standard field + f.set_default("assets", log.assets) # location if self.creating or self.editing: @@ -247,6 +262,10 @@ class LogMasterView(WuttaFarmMasterView): # owners if self.creating or self.editing: f.remove("owners") # TODO: need to support this + else: + f.set_node("owners", LogOwnerRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("owners", log.owners) # status f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) @@ -331,7 +350,7 @@ class AllLogView(LogMasterView): "quantity", "groups", "is_group_assignment", - "owner", + "owners", ] def configure_grid(self, grid): From 61402c183e2a196542a85ac67fce48f04f0b1b61 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 19:58:54 -0600 Subject: [PATCH 29/55] fix: add placeholder for log 'quick' field --- src/wuttafarm/web/views/logs.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 68a06d1..b0b150c 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -131,6 +131,7 @@ class LogMasterView(WuttaFarmMasterView): "log_type", "owners", "is_group_assignment", + "quick", "farmos_uuid", "drupal_id", ] @@ -273,6 +274,9 @@ class LogMasterView(WuttaFarmMasterView): # is_group_assignment f.set_node("is_group_assignment", colander.Boolean()) + # quick + f.set_readonly("quick") # TODO + def objectify(self, form): log = super().objectify(form) From a5550091d3bcce1e8e1eec01781f2bc59587ca1d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 19:59:12 -0600 Subject: [PATCH 30/55] feat: add support for exporting log status, timestamp to farmOS --- src/wuttafarm/farmos/importing/model.py | 8 ++++++++ src/wuttafarm/farmos/importing/wuttafarm.py | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 04d80c1..a938423 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -458,6 +458,8 @@ class ToFarmOSLog(ToFarmOS): supported_fields = [ "uuid", "name", + "timestamp", + "status", "notes", ] @@ -513,6 +515,8 @@ class ToFarmOSLog(ToFarmOS): return { "uuid": UUID(log["id"]), "name": log["attributes"]["name"], + "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), + "status": log["attributes"]["status"], "notes": notes, } @@ -521,6 +525,10 @@ class ToFarmOSLog(ToFarmOS): attrs = {} if "name" in self.fields: attrs["name"] = source_data["name"] + if "timestamp" in self.fields: + attrs["timestamp"] = self.format_datetime(source_data["timestamp"]) + if "status" in self.fields: + attrs["status"] = source_data["status"] if "notes" in self.fields: attrs["notes"] = {"value": source_data["notes"]} diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 96cefb2..d61437b 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -360,6 +360,8 @@ class FromWuttaFarmLog(FromWuttaFarm): supported_fields = [ "uuid", "name", + "timestamp", + "status", "notes", ] @@ -367,6 +369,8 @@ class FromWuttaFarmLog(FromWuttaFarm): return { "uuid": log.farmos_uuid or self.app.make_true_uuid(), "name": log.message, + "timestamp": log.timestamp, + "status": log.status, "notes": log.notes, "_src_object": log, } From 3ae4d639ecf2b4ba5cd64b23139c0a2931ab3152 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 20:15:10 -0600 Subject: [PATCH 31/55] feat: add sync support for `Log.is_group_assignment` --- ...3c7e273bfa3_add_log_is_group_assignment.py | 39 +++++++++++++++++++ src/wuttafarm/db/model/log.py | 9 +++++ src/wuttafarm/farmos/importing/model.py | 4 ++ src/wuttafarm/farmos/importing/wuttafarm.py | 2 + src/wuttafarm/importing/farmos.py | 5 +++ src/wuttafarm/web/views/farmos/logs.py | 5 +++ src/wuttafarm/web/views/logs.py | 5 +++ 7 files changed, 69 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/f3c7e273bfa3_add_log_is_group_assignment.py diff --git a/src/wuttafarm/db/alembic/versions/f3c7e273bfa3_add_log_is_group_assignment.py b/src/wuttafarm/db/alembic/versions/f3c7e273bfa3_add_log_is_group_assignment.py new file mode 100644 index 0000000..986f4db --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/f3c7e273bfa3_add_log_is_group_assignment.py @@ -0,0 +1,39 @@ +"""add Log.is_group_assignment + +Revision ID: f3c7e273bfa3 +Revises: 47d0ebd84554 +Create Date: 2026-02-28 20:04:40.700474 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "f3c7e273bfa3" +down_revision: Union[str, None] = "47d0ebd84554" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log + op.add_column("log", sa.Column("is_group_assignment", sa.Boolean(), nullable=True)) + op.add_column( + "log_version", + sa.Column( + "is_group_assignment", sa.Boolean(), autoincrement=False, nullable=True + ), + ) + + +def downgrade() -> None: + + # log + op.drop_column("log_version", "is_group_assignment") + op.drop_column("log", "is_group_assignment") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 6142229..7234839 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -120,6 +120,14 @@ class Log(model.Base): """, ) + is_group_assignment = sa.Column( + sa.Boolean(), + nullable=True, + doc=""" + Whether the log represents a group assignment. + """, + ) + status = sa.Column( sa.String(length=20), nullable=False, @@ -207,6 +215,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "log_type") Log.make_proxy(subclass, "log", "message") Log.make_proxy(subclass, "log", "timestamp") + Log.make_proxy(subclass, "log", "is_group_assignment") Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") Log.make_proxy(subclass, "log", "assets") diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index a938423..7b900ff 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -459,6 +459,7 @@ class ToFarmOSLog(ToFarmOS): "uuid", "name", "timestamp", + "is_group_assignment", "status", "notes", ] @@ -516,6 +517,7 @@ class ToFarmOSLog(ToFarmOS): "uuid": UUID(log["id"]), "name": log["attributes"]["name"], "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), + "is_group_assignment": log["attributes"]["is_group_assignment"], "status": log["attributes"]["status"], "notes": notes, } @@ -527,6 +529,8 @@ class ToFarmOSLog(ToFarmOS): attrs["name"] = source_data["name"] if "timestamp" in self.fields: attrs["timestamp"] = self.format_datetime(source_data["timestamp"]) + if "is_group_assignment" in self.fields: + attrs["is_group_assignment"] = source_data["is_group_assignment"] if "status" in self.fields: attrs["status"] = source_data["status"] if "notes" in self.fields: diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index d61437b..8679a78 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -361,6 +361,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "uuid", "name", "timestamp", + "is_group_assignment", "status", "notes", ] @@ -370,6 +371,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "uuid": log.farmos_uuid or self.app.make_true_uuid(), "name": log.message, "timestamp": log.timestamp, + "is_group_assignment": log.is_group_assignment, "status": log.status, "notes": log.notes, "_src_object": log, diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index c931fb4..f65ac38 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -967,6 +967,7 @@ class LogImporterBase(FromFarmOS, ToWutta): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", ] @@ -1093,6 +1094,7 @@ class ActivityLogImporter(LogImporterBase): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", "assets", @@ -1112,6 +1114,7 @@ class HarvestLogImporter(LogImporterBase): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", "assets", @@ -1131,6 +1134,7 @@ class MedicalLogImporter(LogImporterBase): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", "assets", @@ -1150,6 +1154,7 @@ class ObservationLogImporter(LogImporterBase): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", "assets", diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index 6e6dc36..fbd2a9d 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -23,6 +23,7 @@ View for farmOS Harvest Logs """ +import colander from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum @@ -85,6 +86,7 @@ class LogMasterView(FarmOSMasterView): "timestamp", "assets", "quantities", + "is_group_assignment", "notes", "status", "log_type_name", @@ -213,6 +215,9 @@ class LogMasterView(FarmOSMasterView): # quantities f.set_node("quantities", FarmOSQuantityRefs(self.request)) + # is_group_assignment + f.set_node("is_group_assignment", colander.Boolean()) + # notes f.set_node("notes", Notes()) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index b0b150c..626f34d 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -181,6 +181,11 @@ class LogMasterView(WuttaFarmMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # is_group_assignment + g.set_renderer("is_group_assignment", "boolean") + g.set_sorter("is_group_assignment", model.Log.is_group_assignment) + g.set_filter("is_group_assignment", model.Log.is_group_assignment) + # owners g.set_label("owners", "Owner") g.set_renderer("owners", self.render_owners_for_grid) From 87f3764ebfa4fb8ec1512e030c6e49fcbf9323da Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 20:56:01 -0600 Subject: [PATCH 32/55] feat: add schema, import support for `Log.locations` still need to add support for edit, export --- .../versions/3bef7d380a38_add_loglocation.py | 118 ++++++++++++++++++ src/wuttafarm/db/model/log.py | 38 ++++++ src/wuttafarm/importing/farmos.py | 38 ++++++ src/wuttafarm/normal.py | 28 +++++ src/wuttafarm/web/views/farmos/logs.py | 30 ++++- .../web/views/farmos/logs_observation.py | 12 ++ src/wuttafarm/web/views/logs.py | 23 ++-- src/wuttafarm/web/views/logs_observation.py | 2 +- 8 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/3bef7d380a38_add_loglocation.py diff --git a/src/wuttafarm/db/alembic/versions/3bef7d380a38_add_loglocation.py b/src/wuttafarm/db/alembic/versions/3bef7d380a38_add_loglocation.py new file mode 100644 index 0000000..0ed92d9 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/3bef7d380a38_add_loglocation.py @@ -0,0 +1,118 @@ +"""add LogLocation + +Revision ID: 3bef7d380a38 +Revises: f3c7e273bfa3 +Create Date: 2026-02-28 20:41:56.051847 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "3bef7d380a38" +down_revision: Union[str, None] = "f3c7e273bfa3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_location + op.create_table( + "log_location", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_uuid"], + ["asset.uuid"], + name=op.f("fk_log_location_asset_uuid_asset"), + ), + sa.ForeignKeyConstraint( + ["log_uuid"], ["log.uuid"], name=op.f("fk_log_location_log_uuid_log") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_location")), + ) + op.create_table( + "log_location_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_location_version") + ), + ) + op.create_index( + op.f("ix_log_location_version_end_transaction_id"), + "log_location_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_location_version_operation_type"), + "log_location_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_location_version_pk_transaction_id", + "log_location_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_location_version_pk_validity", + "log_location_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_location_version_transaction_id"), + "log_location_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_location + op.drop_index( + op.f("ix_log_location_version_transaction_id"), + table_name="log_location_version", + ) + op.drop_index( + "ix_log_location_version_pk_validity", table_name="log_location_version" + ) + op.drop_index( + "ix_log_location_version_pk_transaction_id", table_name="log_location_version" + ) + op.drop_index( + op.f("ix_log_location_version_operation_type"), + table_name="log_location_version", + ) + op.drop_index( + op.f("ix_log_location_version_end_transaction_id"), + table_name="log_location_version", + ) + op.drop_table("log_location_version") + op.drop_table("log_location") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 7234839..b770a12 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -175,6 +175,19 @@ class Log(model.Base): creator=lambda asset: LogAsset(asset=asset), ) + _locations = orm.relationship( + "LogLocation", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) + + locations = association_proxy( + "_locations", + "asset", + creator=lambda asset: LogLocation(asset=asset), + ) + _owners = orm.relationship( "LogOwner", cascade="all, delete-orphan", @@ -219,6 +232,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") Log.make_proxy(subclass, "log", "assets") + Log.make_proxy(subclass, "log", "locations") Log.make_proxy(subclass, "log", "owners") @@ -246,6 +260,30 @@ class LogAsset(model.Base): ) +class LogLocation(model.Base): + """ + Represents a "log's location relationship" from farmOS. + """ + + __tablename__ = "log_location" + __versioned__ = {} + + uuid = model.uuid_column() + + log_uuid = model.uuid_fk_column("log.uuid", nullable=False) + log = orm.relationship( + Log, + foreign_keys=log_uuid, + back_populates="_locations", + ) + + asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + asset = orm.relationship( + "Asset", + foreign_keys=asset_uuid, + ) + + class LogOwner(model.Base): """ Represents a "log's owner relationship" from farmOS. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index f65ac38..44933c3 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -980,6 +980,7 @@ class LogImporterBase(FromFarmOS, ToWutta): fields.extend( [ "assets", + "locations", "owners", ] ) @@ -1006,6 +1007,12 @@ class LogImporterBase(FromFarmOS, ToWutta): (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] ] + if "locations" in self.fields: + data["locations"] = [ + (asset["asset_type"], UUID(asset["uuid"])) + for asset in data["locations"] + ] + if "owners" in self.fields: data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]] @@ -1019,6 +1026,11 @@ class LogImporterBase(FromFarmOS, ToWutta): (asset.asset_type, asset.farmos_uuid) for asset in log.assets ] + if "locations" in self.fields: + data["locations"] = [ + (asset.asset_type, asset.farmos_uuid) for asset in log.locations + ] + if "owners" in self.fields: data["owners"] = [user.farmos_uuid for user in log.owners] @@ -1054,6 +1066,32 @@ class LogImporterBase(FromFarmOS, ToWutta): ) log.assets.remove(asset) + if "locations" in self.fields: + if not target_data or target_data["locations"] != source_data["locations"]: + + for key in source_data["locations"]: + asset_type, farmos_uuid = key + if not target_data or key not in target_data["locations"]: + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + log.locations.append(asset) + + if target_data: + for key in target_data["locations"]: + asset_type, farmos_uuid = key + if key not in source_data["locations"]: + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + log.locations.remove(asset) + if "owners" in self.fields: if not target_data or target_data["owners"] != source_data["owners"]: diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index ca7be39..af1ec17 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -98,6 +98,8 @@ class Normalizer(GenericHandler): asset_objects = [] quantity_objects = [] quantity_uuids = [] + location_objects = [] + location_uuids = [] owner_objects = [] owner_uuids = [] if relationships := log.get("relationships"): @@ -132,6 +134,30 @@ class Normalizer(GenericHandler): ) asset_objects.append(asset_object) + if locations := relationships.get("location"): + for location in locations["data"]: + location_uuid = location["id"] + location_uuids.append(location_uuid) + location_object = { + "uuid": location["id"], + "type": location["type"], + "asset_type": location["type"].split("--")[1], + } + if location := included.get(location_uuid): + attrs = location["attributes"] + rels = location["relationships"] + location_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"], + } + ) + location_objects.append(location_object) + if quantities := relationships.get("quantity"): for quantity in quantities["data"]: quantity_uuid = quantity["id"] @@ -194,6 +220,8 @@ class Normalizer(GenericHandler): "quick": log["attributes"]["quick"], "status": log["attributes"]["status"], "notes": notes, + "locations": location_objects, + "location_uuids": location_uuids, "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 fbd2a9d..e10001c 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -60,6 +60,7 @@ class LogMasterView(FarmOSMasterView): labels = { "name": "Log Name", "log_type_name": "Log Type", + "locations": "Location", "quantities": "Quantity", } @@ -69,6 +70,7 @@ class LogMasterView(FarmOSMasterView): "timestamp", "name", "assets", + "locations", "quantities", "is_group_assignment", "owners", @@ -85,18 +87,19 @@ class LogMasterView(FarmOSMasterView): "name", "timestamp", "assets", + "locations", "quantities", - "is_group_assignment", "notes", "status", "log_type_name", "owners", + "is_group_assignment", "quick", "drupal_id", ] def get_farmos_api_includes(self): - return {"log_type", "quantity", "asset", "owner"} + return {"log_type", "quantity", "asset", "location", "owner"} def get_grid_data(self, **kwargs): return ResourceData( @@ -141,6 +144,9 @@ class LogMasterView(FarmOSMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # locations + g.set_renderer("locations", self.render_locations_for_grid) + # quantities g.set_renderer("quantities", self.render_quantities_for_grid) @@ -165,6 +171,23 @@ class LogMasterView(FarmOSMasterView): assets.append(asset["name"]) return ", ".join(assets) + def render_locations_for_grid(self, log, field, value): + if not value: + return "" + + locations = [] + for location in value: + if self.farmos_style_grid_links: + text = location["name"] + url = self.request.route_url( + f"farmos_{location['asset_type']}_assets.view", + uuid=location["uuid"], + ) + locations.append(tags.link_to(text, url)) + else: + locations.append(text) + return ", ".join(locations) + def render_quantities_for_grid(self, log, field, value): if not value: return None @@ -212,6 +235,9 @@ class LogMasterView(FarmOSMasterView): # assets f.set_node("assets", FarmOSAssetRefs(self.request)) + # locations + f.set_node("locations", FarmOSAssetRefs(self.request)) + # quantities f.set_node("quantities", FarmOSQuantityRefs(self.request)) diff --git a/src/wuttafarm/web/views/farmos/logs_observation.py b/src/wuttafarm/web/views/farmos/logs_observation.py index ab27b5a..0193f93 100644 --- a/src/wuttafarm/web/views/farmos/logs_observation.py +++ b/src/wuttafarm/web/views/farmos/logs_observation.py @@ -41,6 +41,18 @@ class ObservationLogView(LogMasterView): farmos_log_type = "observation" farmos_refurl_path = "/logs/observation" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "name", + "assets", + "locations", + "groups", + "is_group_assignment", + "owners", + ] + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 626f34d..35d9451 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -99,6 +99,7 @@ class LogMasterView(WuttaFarmMasterView): labels = { "message": "Log Name", + "locations": "Location", } grid_columns = [ @@ -107,7 +108,7 @@ class LogMasterView(WuttaFarmMasterView): "timestamp", "message", "assets", - "location", + "locations", "quantity", "is_group_assignment", "owners", @@ -124,7 +125,7 @@ class LogMasterView(WuttaFarmMasterView): "message", "timestamp", "assets", - "location", + "locations", "quantity", "notes", "status", @@ -181,6 +182,9 @@ class LogMasterView(WuttaFarmMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # locations + g.set_renderer("locations", self.render_assets_for_grid) + # is_group_assignment g.set_renderer("is_group_assignment", "boolean") g.set_sorter("is_group_assignment", model.Log.is_group_assignment) @@ -191,17 +195,18 @@ class LogMasterView(WuttaFarmMasterView): g.set_renderer("owners", self.render_owners_for_grid) def render_assets_for_grid(self, log, field, value): + assets = getattr(log, field) if self.farmos_style_grid_links: links = [] - for asset in log.assets: + for asset in assets: url = self.request.route_url( f"{asset.asset_type}_assets.view", uuid=asset.uuid ) links.append(tags.link_to(str(asset), url)) return ", ".join(links) - return ", ".join([str(a) for a in log.assets]) + return ", ".join([str(a) for a in assets]) def render_owners_for_grid(self, log, field, value): @@ -242,9 +247,13 @@ class LogMasterView(WuttaFarmMasterView): # nb. must explicity declare value for non-standard field f.set_default("assets", log.assets) - # location + # locations if self.creating or self.editing: - f.remove("location") # TODO: need to support this + f.remove("locations") # TODO: need to support this + else: + f.set_node("locations", LogAssetRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("locations", log.locations) # log_type if self.creating: @@ -355,7 +364,7 @@ class AllLogView(LogMasterView): "message", "log_type", "assets", - "location", + "locations", "quantity", "groups", "is_group_assignment", diff --git a/src/wuttafarm/web/views/logs_observation.py b/src/wuttafarm/web/views/logs_observation.py index 0485f50..6e283ae 100644 --- a/src/wuttafarm/web/views/logs_observation.py +++ b/src/wuttafarm/web/views/logs_observation.py @@ -45,7 +45,7 @@ class ObservationLogView(LogMasterView): "timestamp", "message", "assets", - "location", + "locations", "groups", "is_group_assignment", "owners", From 1d877545ae371c70a366fe1275e919757326a9e6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 21:43:03 -0600 Subject: [PATCH 33/55] feat: add schema, import support for `Log.groups` --- .../versions/74d32b4ec210_add_loggroup.py | 111 ++++++++++++++++++ src/wuttafarm/db/model/log.py | 38 ++++++ src/wuttafarm/importing/farmos.py | 85 ++++++-------- src/wuttafarm/normal.py | 28 +++++ src/wuttafarm/web/views/farmos/logs.py | 31 ++--- src/wuttafarm/web/views/logs.py | 12 ++ 6 files changed, 238 insertions(+), 67 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/74d32b4ec210_add_loggroup.py diff --git a/src/wuttafarm/db/alembic/versions/74d32b4ec210_add_loggroup.py b/src/wuttafarm/db/alembic/versions/74d32b4ec210_add_loggroup.py new file mode 100644 index 0000000..170e3d2 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/74d32b4ec210_add_loggroup.py @@ -0,0 +1,111 @@ +"""add LogGroup + +Revision ID: 74d32b4ec210 +Revises: 3bef7d380a38 +Create Date: 2026-02-28 21:35:24.125784 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "74d32b4ec210" +down_revision: Union[str, None] = "3bef7d380a38" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_group + op.create_table( + "log_group", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_uuid"], ["asset.uuid"], name=op.f("fk_log_group_asset_uuid_asset") + ), + sa.ForeignKeyConstraint( + ["log_uuid"], ["log.uuid"], name=op.f("fk_log_group_log_uuid_log") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_group")), + ) + op.create_table( + "log_group_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_group_version") + ), + ) + op.create_index( + op.f("ix_log_group_version_end_transaction_id"), + "log_group_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_group_version_operation_type"), + "log_group_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_group_version_pk_transaction_id", + "log_group_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_group_version_pk_validity", + "log_group_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_group_version_transaction_id"), + "log_group_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_group + op.drop_index( + op.f("ix_log_group_version_transaction_id"), table_name="log_group_version" + ) + op.drop_index("ix_log_group_version_pk_validity", table_name="log_group_version") + op.drop_index( + "ix_log_group_version_pk_transaction_id", table_name="log_group_version" + ) + op.drop_index( + op.f("ix_log_group_version_operation_type"), table_name="log_group_version" + ) + op.drop_index( + op.f("ix_log_group_version_end_transaction_id"), table_name="log_group_version" + ) + op.drop_table("log_group_version") + op.drop_table("log_group") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index b770a12..afa637b 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -175,6 +175,19 @@ class Log(model.Base): creator=lambda asset: LogAsset(asset=asset), ) + _groups = orm.relationship( + "LogGroup", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) + + groups = association_proxy( + "_groups", + "asset", + creator=lambda asset: LogGroup(asset=asset), + ) + _locations = orm.relationship( "LogLocation", cascade="all, delete-orphan", @@ -232,6 +245,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") Log.make_proxy(subclass, "log", "assets") + Log.make_proxy(subclass, "log", "groups") Log.make_proxy(subclass, "log", "locations") Log.make_proxy(subclass, "log", "owners") @@ -260,6 +274,30 @@ class LogAsset(model.Base): ) +class LogGroup(model.Base): + """ + Represents a "log's group relationship" from farmOS. + """ + + __tablename__ = "log_group" + __versioned__ = {} + + uuid = model.uuid_column() + + log_uuid = model.uuid_fk_column("log.uuid", nullable=False) + log = orm.relationship( + Log, + foreign_keys=log_uuid, + back_populates="_groups", + ) + + asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + asset = orm.relationship( + "Asset", + foreign_keys=asset_uuid, + ) + + class LogLocation(model.Base): """ Represents a "log's location relationship" from farmOS. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 44933c3..a1b539f 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -980,6 +980,7 @@ class LogImporterBase(FromFarmOS, ToWutta): fields.extend( [ "assets", + "groups", "locations", "owners", ] @@ -1007,6 +1008,11 @@ class LogImporterBase(FromFarmOS, ToWutta): (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] ] + if "groups" in self.fields: + data["groups"] = [ + (asset["asset_type"], UUID(asset["uuid"])) for asset in data["groups"] + ] + if "locations" in self.fields: data["locations"] = [ (asset["asset_type"], UUID(asset["uuid"])) @@ -1026,6 +1032,11 @@ class LogImporterBase(FromFarmOS, ToWutta): (asset.asset_type, asset.farmos_uuid) for asset in log.assets ] + if "groups" in self.fields: + data["groups"] = [ + (asset.asset_type, asset.farmos_uuid) for asset in log.groups + ] + if "locations" in self.fields: data["locations"] = [ (asset.asset_type, asset.farmos_uuid) for asset in log.locations @@ -1066,6 +1077,32 @@ class LogImporterBase(FromFarmOS, ToWutta): ) log.assets.remove(asset) + if "groups" in self.fields: + if not target_data or target_data["groups"] != source_data["groups"]: + + for key in source_data["groups"]: + asset_type, farmos_uuid = key + if not target_data or key not in target_data["groups"]: + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + log.groups.append(asset) + + if target_data: + for key in target_data["groups"]: + asset_type, farmos_uuid = key + if key not in source_data["groups"]: + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + log.groups.remove(asset) + if "locations" in self.fields: if not target_data or target_data["locations"] != source_data["locations"]: @@ -1126,18 +1163,6 @@ class ActivityLogImporter(LogImporterBase): model_class = model.ActivityLog - supported_fields = [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "is_group_assignment", - "notes", - "status", - "assets", - ] - class HarvestLogImporter(LogImporterBase): """ @@ -1146,18 +1171,6 @@ class HarvestLogImporter(LogImporterBase): model_class = model.HarvestLog - supported_fields = [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "is_group_assignment", - "notes", - "status", - "assets", - ] - class MedicalLogImporter(LogImporterBase): """ @@ -1166,18 +1179,6 @@ class MedicalLogImporter(LogImporterBase): model_class = model.MedicalLog - supported_fields = [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "is_group_assignment", - "notes", - "status", - "assets", - ] - class ObservationLogImporter(LogImporterBase): """ @@ -1186,18 +1187,6 @@ class ObservationLogImporter(LogImporterBase): model_class = model.ObservationLog - supported_fields = [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "is_group_assignment", - "notes", - "status", - "assets", - ] - class QuantityImporterBase(FromFarmOS, ToWutta): """ diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index af1ec17..5c40a49 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -96,6 +96,8 @@ class Normalizer(GenericHandler): log_type_object = {} log_type_uuid = None asset_objects = [] + group_objects = [] + group_uuids = [] quantity_objects = [] quantity_uuids = [] location_objects = [] @@ -134,6 +136,30 @@ class Normalizer(GenericHandler): ) asset_objects.append(asset_object) + if groups := relationships.get("group"): + for group in groups["data"]: + group_uuid = group["id"] + group_uuids.append(group_uuid) + group_object = { + "uuid": group["id"], + "type": group["type"], + "asset_type": group["type"].split("--")[1], + } + if group := included.get(group_uuid): + attrs = group["attributes"] + rels = group["relationships"] + group_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"], + } + ) + group_objects.append(group_object) + if locations := relationships.get("location"): for location in locations["data"]: location_uuid = location["id"] @@ -214,6 +240,8 @@ class Normalizer(GenericHandler): "name": log["attributes"]["name"], "timestamp": timestamp, "assets": asset_objects, + "groups": group_objects, + "group_uuids": group_uuids, "quantities": quantity_objects, "quantity_uuids": quantity_uuids, "is_group_assignment": log["attributes"]["is_group_assignment"], diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index e10001c..d0ee388 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -87,6 +87,7 @@ class LogMasterView(FarmOSMasterView): "name", "timestamp", "assets", + "groups", "locations", "quantities", "notes", @@ -99,7 +100,7 @@ class LogMasterView(FarmOSMasterView): ] def get_farmos_api_includes(self): - return {"log_type", "quantity", "asset", "location", "owner"} + return {"log_type", "quantity", "asset", "group", "location", "owner"} def get_grid_data(self, **kwargs): return ResourceData( @@ -144,8 +145,11 @@ class LogMasterView(FarmOSMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # groups + g.set_renderer("groups", self.render_assets_for_grid) + # locations - g.set_renderer("locations", self.render_locations_for_grid) + g.set_renderer("locations", self.render_assets_for_grid) # quantities g.set_renderer("quantities", self.render_quantities_for_grid) @@ -160,6 +164,9 @@ class LogMasterView(FarmOSMasterView): g.set_renderer("owners", self.render_owners_for_grid) def render_assets_for_grid(self, log, field, value): + if not value: + return "" + assets = [] for asset in value: if self.farmos_style_grid_links: @@ -171,23 +178,6 @@ class LogMasterView(FarmOSMasterView): assets.append(asset["name"]) return ", ".join(assets) - def render_locations_for_grid(self, log, field, value): - if not value: - return "" - - locations = [] - for location in value: - if self.farmos_style_grid_links: - text = location["name"] - url = self.request.route_url( - f"farmos_{location['asset_type']}_assets.view", - uuid=location["uuid"], - ) - locations.append(tags.link_to(text, url)) - else: - locations.append(text) - return ", ".join(locations) - def render_quantities_for_grid(self, log, field, value): if not value: return None @@ -235,6 +225,9 @@ class LogMasterView(FarmOSMasterView): # assets f.set_node("assets", FarmOSAssetRefs(self.request)) + # groups + f.set_node("groups", FarmOSAssetRefs(self.request)) + # locations f.set_node("locations", FarmOSAssetRefs(self.request)) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 35d9451..2679c3f 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -125,6 +125,7 @@ class LogMasterView(WuttaFarmMasterView): "message", "timestamp", "assets", + "groups", "locations", "quantity", "notes", @@ -182,6 +183,9 @@ class LogMasterView(WuttaFarmMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # groups + g.set_renderer("groups", self.render_assets_for_grid) + # locations g.set_renderer("locations", self.render_assets_for_grid) @@ -247,6 +251,14 @@ class LogMasterView(WuttaFarmMasterView): # nb. must explicity declare value for non-standard field f.set_default("assets", log.assets) + # groups + if self.creating or self.editing: + f.remove("groups") # TODO: need to support this + else: + f.set_node("groups", LogAssetRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("groups", log.groups) + # locations if self.creating or self.editing: f.remove("locations") # TODO: need to support this From 7d2ae48067f70433067b9ab55235c3b45e316ce3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 22:06:41 -0600 Subject: [PATCH 34/55] feat: add schema, import support for `Log.quantities` --- .../versions/9e875e5cbdc1_add_logquantity.py | 118 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 2 +- src/wuttafarm/db/model/log.py | 38 ++++++ src/wuttafarm/importing/farmos.py | 32 +++++ .../web/views/farmos/logs_harvest.py | 1 - src/wuttafarm/web/views/logs.py | 17 ++- src/wuttafarm/web/views/logs_harvest.py | 2 +- 7 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/9e875e5cbdc1_add_logquantity.py diff --git a/src/wuttafarm/db/alembic/versions/9e875e5cbdc1_add_logquantity.py b/src/wuttafarm/db/alembic/versions/9e875e5cbdc1_add_logquantity.py new file mode 100644 index 0000000..3867b17 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/9e875e5cbdc1_add_logquantity.py @@ -0,0 +1,118 @@ +"""add LogQuantity + +Revision ID: 9e875e5cbdc1 +Revises: 74d32b4ec210 +Create Date: 2026-02-28 21:55:31.876087 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "9e875e5cbdc1" +down_revision: Union[str, None] = "74d32b4ec210" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_quantity + op.create_table( + "log_quantity", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("quantity_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["log_uuid"], ["log.uuid"], name=op.f("fk_log_quantity_log_uuid_log") + ), + sa.ForeignKeyConstraint( + ["quantity_uuid"], + ["quantity.uuid"], + name=op.f("fk_log_quantity_quantity_uuid_quantity"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_quantity")), + ) + op.create_table( + "log_quantity_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "quantity_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_quantity_version") + ), + ) + op.create_index( + op.f("ix_log_quantity_version_end_transaction_id"), + "log_quantity_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_quantity_version_operation_type"), + "log_quantity_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_quantity_version_pk_transaction_id", + "log_quantity_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_quantity_version_pk_validity", + "log_quantity_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_quantity_version_transaction_id"), + "log_quantity_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_quantity + op.drop_index( + op.f("ix_log_quantity_version_transaction_id"), + table_name="log_quantity_version", + ) + op.drop_index( + "ix_log_quantity_version_pk_validity", table_name="log_quantity_version" + ) + op.drop_index( + "ix_log_quantity_version_pk_transaction_id", table_name="log_quantity_version" + ) + op.drop_index( + op.f("ix_log_quantity_version_operation_type"), + table_name="log_quantity_version", + ) + op.drop_index( + op.f("ix_log_quantity_version_end_transaction_id"), + table_name="log_quantity_version", + ) + op.drop_table("log_quantity_version") + op.drop_table("log_quantity") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 68695e5..15514fb 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -38,7 +38,7 @@ from .asset_structure import StructureType, StructureAsset from .asset_animal import AnimalType, AnimalAsset from .asset_group import GroupAsset from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType -from .log import LogType, Log, LogAsset +from .log import LogType, Log, LogAsset, LogGroup, LogLocation, LogQuantity, LogOwner from .log_activity import ActivityLog from .log_harvest import HarvestLog from .log_medical import MedicalLog diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index afa637b..b77898f 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -201,6 +201,19 @@ class Log(model.Base): creator=lambda asset: LogLocation(asset=asset), ) + _quantities = orm.relationship( + "LogQuantity", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) + + quantities = association_proxy( + "_quantities", + "quantity", + creator=lambda quantity: LogQuantity(quantity=quantity), + ) + _owners = orm.relationship( "LogOwner", cascade="all, delete-orphan", @@ -247,6 +260,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "assets") Log.make_proxy(subclass, "log", "groups") Log.make_proxy(subclass, "log", "locations") + Log.make_proxy(subclass, "log", "quantities") Log.make_proxy(subclass, "log", "owners") @@ -322,6 +336,30 @@ class LogLocation(model.Base): ) +class LogQuantity(model.Base): + """ + Represents a "log's quantity relationship" from farmOS. + """ + + __tablename__ = "log_quantity" + __versioned__ = {} + + uuid = model.uuid_column() + + log_uuid = model.uuid_fk_column("log.uuid", nullable=False) + log = orm.relationship( + Log, + foreign_keys=log_uuid, + back_populates="_quantities", + ) + + quantity_uuid = model.uuid_fk_column("quantity.uuid", nullable=False) + quantity = orm.relationship( + "Quantity", + foreign_keys=quantity_uuid, + ) + + class LogOwner(model.Base): """ Represents a "log's owner relationship" from farmOS. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index a1b539f..a35b35d 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -982,6 +982,7 @@ class LogImporterBase(FromFarmOS, ToWutta): "assets", "groups", "locations", + "quantities", "owners", ] ) @@ -1019,6 +1020,9 @@ class LogImporterBase(FromFarmOS, ToWutta): for asset in data["locations"] ] + if "quantities" in self.fields: + data["quantities"] = [UUID(uuid) for uuid in data["quantity_uuids"]] + if "owners" in self.fields: data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]] @@ -1042,6 +1046,9 @@ class LogImporterBase(FromFarmOS, ToWutta): (asset.asset_type, asset.farmos_uuid) for asset in log.locations ] + if "quantities" in self.fields: + data["quantities"] = [qty.farmos_uuid for qty in log.quantities] + if "owners" in self.fields: data["owners"] = [user.farmos_uuid for user in log.owners] @@ -1129,6 +1136,31 @@ class LogImporterBase(FromFarmOS, ToWutta): ) log.locations.remove(asset) + if "quantities" in self.fields: + if ( + not target_data + or target_data["quantities"] != source_data["quantities"] + ): + + for farmos_uuid in source_data["quantities"]: + if not target_data or farmos_uuid not in target_data["quantities"]: + qty = ( + self.target_session.query(model.Quantity) + .filter(model.Quantity.farmos_uuid == farmos_uuid) + .one() + ) + log.quantities.append(qty) + + if target_data: + for farmos_uuid in target_data["quantities"]: + if farmos_uuid not in source_data["quantities"]: + qty = ( + self.target_session.query(model.Quantity) + .filter(model.Quantity.farmos_uuid == farmos_uuid) + .one() + ) + log.quantities.remove(qty) + if "owners" in self.fields: if not target_data or target_data["owners"] != source_data["owners"]: diff --git a/src/wuttafarm/web/views/farmos/logs_harvest.py b/src/wuttafarm/web/views/farmos/logs_harvest.py index 08b2629..bfe7121 100644 --- a/src/wuttafarm/web/views/farmos/logs_harvest.py +++ b/src/wuttafarm/web/views/farmos/logs_harvest.py @@ -48,7 +48,6 @@ class HarvestLogView(LogMasterView): "name", "assets", "quantities", - "is_group_assignment", "owners", ] diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 2679c3f..0608573 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -26,7 +26,7 @@ Base views for Logs from collections import OrderedDict import colander -from webhelpers2.html import tags +from webhelpers2.html import tags, HTML from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.db import Session @@ -100,6 +100,7 @@ class LogMasterView(WuttaFarmMasterView): labels = { "message": "Log Name", "locations": "Location", + "quantities": "Quantity", } grid_columns = [ @@ -109,7 +110,7 @@ class LogMasterView(WuttaFarmMasterView): "message", "assets", "locations", - "quantity", + "quantities", "is_group_assignment", "owners", ] @@ -189,6 +190,9 @@ class LogMasterView(WuttaFarmMasterView): # locations g.set_renderer("locations", self.render_assets_for_grid) + # quantities + g.set_renderer("quantities", self.render_quantities_for_grid) + # is_group_assignment g.set_renderer("is_group_assignment", "boolean") g.set_sorter("is_group_assignment", model.Log.is_group_assignment) @@ -212,6 +216,13 @@ class LogMasterView(WuttaFarmMasterView): return ", ".join([str(a) for a in assets]) + def render_quantities_for_grid(self, log, field, value): + quantities = getattr(log, field) or [] + items = [] + for qty in quantities: + items.append(HTML.tag("li", c=qty.render_as_text(self.config))) + return HTML.tag("ul", c=items) + def render_owners_for_grid(self, log, field, value): if self.farmos_style_grid_links: @@ -377,7 +388,7 @@ class AllLogView(LogMasterView): "log_type", "assets", "locations", - "quantity", + "quantities", "groups", "is_group_assignment", "owners", diff --git a/src/wuttafarm/web/views/logs_harvest.py b/src/wuttafarm/web/views/logs_harvest.py index f2b29e0..e38c6d7 100644 --- a/src/wuttafarm/web/views/logs_harvest.py +++ b/src/wuttafarm/web/views/logs_harvest.py @@ -45,7 +45,7 @@ class HarvestLogView(LogMasterView): "timestamp", "message", "assets", - "quantity", + "quantities", "owners", ] From d07f3ed716d59b044c8951c730138e67a76e95c0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 22:27:39 -0600 Subject: [PATCH 35/55] feat: add sync support for `MedicalLog.vet` --- .../d459db991404_add_medicallog_vet.py | 37 +++++++++++++++++++ src/wuttafarm/db/model/log_medical.py | 10 +++++ src/wuttafarm/farmos/importing/model.py | 26 +++++++++++++ src/wuttafarm/farmos/importing/wuttafarm.py | 18 +++++++++ src/wuttafarm/importing/farmos.py | 11 ++++++ src/wuttafarm/normal.py | 2 + .../web/views/farmos/logs_medical.py | 30 +++++++++++++++ src/wuttafarm/web/views/logs_medical.py | 12 +++++- 8 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/wuttafarm/db/alembic/versions/d459db991404_add_medicallog_vet.py diff --git a/src/wuttafarm/db/alembic/versions/d459db991404_add_medicallog_vet.py b/src/wuttafarm/db/alembic/versions/d459db991404_add_medicallog_vet.py new file mode 100644 index 0000000..c65c93e --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/d459db991404_add_medicallog_vet.py @@ -0,0 +1,37 @@ +"""add MedicalLog.vet + +Revision ID: d459db991404 +Revises: 9e875e5cbdc1 +Create Date: 2026-02-28 22:17:57.001134 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "d459db991404" +down_revision: Union[str, None] = "9e875e5cbdc1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_medical + op.add_column("log_medical", sa.Column("vet", sa.String(length=100), nullable=True)) + op.add_column( + "log_medical_version", + sa.Column("vet", sa.String(length=100), autoincrement=False, nullable=True), + ) + + +def downgrade() -> None: + + # log_medical + op.drop_column("log_medical_version", "vet") + op.drop_column("log_medical", "vet") diff --git a/src/wuttafarm/db/model/log_medical.py b/src/wuttafarm/db/model/log_medical.py index 439ee3b..6cf308f 100644 --- a/src/wuttafarm/db/model/log_medical.py +++ b/src/wuttafarm/db/model/log_medical.py @@ -23,6 +23,8 @@ Model definition for Medical Logs """ +import sqlalchemy as sa + from wuttjamaican.db import model from wuttafarm.db.model.log import LogMixin, add_log_proxies @@ -41,5 +43,13 @@ class MedicalLog(LogMixin, model.Base): "farmos_log_type": "medical", } + vet = sa.Column( + sa.String(length=100), + nullable=True, + doc=""" + Name of the veterinarian, if applicable. + """, + ) + add_log_proxies(MedicalLog) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 7b900ff..108ebde 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -558,6 +558,32 @@ class MedicalLogImporter(ToFarmOSLog): model_title = "MedicalLog" farmos_log_type = "medical" + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "vet", + ] + ) + return fields + + def normalize_target_object(self, log): + data = super().normalize_target_object(log) + data.update( + { + "vet": log["attributes"]["vet"], + } + ) + return data + + def get_log_payload(self, source_data): + payload = super().get_log_payload(source_data) + + if "vet" in self.fields: + payload["attributes"]["vet"] = source_data["vet"] + + return payload + class ObservationLogImporter(ToFarmOSLog): diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 8679a78..d0ac065 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -401,6 +401,24 @@ class MedicalLogImporter(FromWuttaFarmLog, farmos_importing.model.MedicalLogImpo source_model_class = model.MedicalLog + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "vet", + ] + ) + return fields + + def normalize_source_object(self, log): + data = super().normalize_source_object(log) + data.update( + { + "vet": log.vet, + } + ) + return data + class ObservationLogImporter( FromWuttaFarmLog, farmos_importing.model.ObservationLogImporter diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index a35b35d..ebc5b55 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -1211,6 +1211,17 @@ class MedicalLogImporter(LogImporterBase): model_class = model.MedicalLog + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare proxy fields + fields.extend( + [ + "vet", + ] + ) + return fields + class ObservationLogImporter(LogImporterBase): """ diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index 5c40a49..3efd443 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -252,4 +252,6 @@ class Normalizer(GenericHandler): "location_uuids": location_uuids, "owners": owner_objects, "owner_uuids": owner_uuids, + # TODO: should we do this here or make caller do it? + "vet": log["attributes"].get("vet"), } diff --git a/src/wuttafarm/web/views/farmos/logs_medical.py b/src/wuttafarm/web/views/farmos/logs_medical.py index 95a88c5..2f6a606 100644 --- a/src/wuttafarm/web/views/farmos/logs_medical.py +++ b/src/wuttafarm/web/views/farmos/logs_medical.py @@ -24,6 +24,7 @@ View for farmOS Medical Logs """ from wuttafarm.web.views.farmos.logs import LogMasterView +from wuttafarm.web.grids import SimpleSorter, StringFilter class MedicalLogView(LogMasterView): @@ -41,6 +42,35 @@ class MedicalLogView(LogMasterView): farmos_log_type = "medical" farmos_refurl_path = "/logs/medical" + labels = { + "vet": "Veterinarian", + } + + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "name", + "assets", + "vet", + "owners", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # vet + g.set_sorter("vet", SimpleSorter("vet")) + g.set_filter("vet", StringFilter) + + def configure_form(self, form): + f = form + super().configure_form(f) + + # vet + f.fields.insert_after("timestamp", "vet") + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/logs_medical.py b/src/wuttafarm/web/views/logs_medical.py index 2531237..d00d647 100644 --- a/src/wuttafarm/web/views/logs_medical.py +++ b/src/wuttafarm/web/views/logs_medical.py @@ -39,16 +39,26 @@ class MedicalLogView(LogMasterView): farmos_bundle = "medical" farmos_refurl_path = "/logs/medical" + labels = { + "vet": "Veterinarian", + } + grid_columns = [ "status", "drupal_id", "timestamp", "message", "assets", - "veterinarian", + "vet", "owners", ] + def configure_form(self, f): + super().configure_form(f) + + # vet + f.fields.insert_after("timestamp", "vet") + def defaults(config, **kwargs): base = globals() From 90ff7eb793ab3e960c0d33e509c5f9a0c2da1389 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:29:38 -0600 Subject: [PATCH 36/55] fix: set default grid pagesize to 50 to better match farmOS --- src/wuttafarm/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/config.py b/src/wuttafarm/config.py index 16a7578..b0c860b 100644 --- a/src/wuttafarm/config.py +++ b/src/wuttafarm/config.py @@ -50,11 +50,12 @@ class WuttaFarmConfig(WuttaConfigExtension): f"{config.appname}.app.handler", "wuttafarm.app:WuttaFarmAppHandler" ) - # web app menu + # web app stuff config.setdefault( f"{config.appname}.web.menus.handler.default_spec", "wuttafarm.web.menus:WuttaFarmMenuHandler", ) + config.setdefault("wuttaweb.grids.default_pagesize", "50") # web app libcache # config.setdefault('wuttaweb.static_libcache.module', 'wuttafarm.web.static') From 7890b185682711d7e4f99060bd9cc46d2f35464b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:33:00 -0600 Subject: [PATCH 37/55] fix: set timestamp for new log in quick eggs form --- src/wuttafarm/web/views/quick/eggs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index e5461b1..3a21ff7 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -196,6 +196,7 @@ class EggsQuickForm(QuickFormView): "type": "log--harvest", "attributes": { "name": f"Collected {data['count']} {unit_name}", + "timestamp": self.app.localtime(data["timestamp"]).timestamp(), "notes": notes, "quick": ["eggs"], }, From 32d23a7073725148d33368e95f60c284279af289 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:39:44 -0600 Subject: [PATCH 38/55] feat: show quantities when viewing log --- src/wuttafarm/web/forms/schema.py | 17 ++++++++++++++++ src/wuttafarm/web/forms/widgets.py | 32 ++++++++++++++++++++++++++++++ src/wuttafarm/web/views/logs.py | 12 +++++++---- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 8a80054..0a7a72f 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -381,6 +381,23 @@ class LogAssetRefs(WuttaSet): return LogAssetRefsWidget(self.request, **kwargs) +class LogQuantityRefs(WuttaSet): + """ + Schema type for Quantities field (on a Log record) + """ + + def serialize(self, node, appstruct): + if not appstruct: + return colander.null + + return {qty.uuid for qty in appstruct} + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import LogQuantityRefsWidget + + return LogQuantityRefsWidget(self.request, **kwargs) + + class LogOwnerRefs(WuttaSet): """ Schema type for Owners field (on a Log record) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index d3325e6..046e85b 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -454,6 +454,38 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) +class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for Quantities field (on a Log record) + """ + + def serialize(self, field, cstruct, **kw): + """ """ + model = self.app.model + session = Session() + + readonly = kw.get("readonly", self.readonly) + if readonly: + quantities = [] + for uuid in cstruct or []: + qty = session.get(model.Quantity, uuid) + quantities.append( + HTML.tag( + "li", + c=tags.link_to( + qty.render_as_text(self.config), + # TODO + self.request.route_url( + "quantities_standard.view", uuid=qty.uuid + ), + ), + ) + ) + return HTML.tag("ul", c=quantities) + + return super().serialize(field, cstruct, **kw) + + class LogOwnerRefsWidget(WuttaCheckboxChoiceWidget): """ Widget for Owners field (on a Log record) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 0608573..eba1b96 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import LogType, Log -from wuttafarm.web.forms.schema import LogAssetRefs, LogOwnerRefs +from wuttafarm.web.forms.schema import LogAssetRefs, LogQuantityRefs, LogOwnerRefs from wuttafarm.util import get_log_type_enum @@ -128,7 +128,7 @@ class LogMasterView(WuttaFarmMasterView): "assets", "groups", "locations", - "quantity", + "quantities", "notes", "status", "log_type", @@ -290,9 +290,13 @@ class LogMasterView(WuttaFarmMasterView): ) f.set_readonly("log_type") - # quantity + # quantities if self.creating or self.editing: - f.remove("quantity") # TODO: need to support this + f.remove("quantities") # TODO: need to support this + else: + f.set_node("quantities", LogQuantityRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("quantities", log.quantities) # notes f.set_widget("notes", "notes") From 547cc6e4aed89768b71e6afaf2efa4687a3198f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:47:27 -0600 Subject: [PATCH 39/55] feat: add schema, import support for `Log.quick` --- .../versions/85d4851e8292_add_log_quick.py | 37 +++++++++++++++++++ src/wuttafarm/db/model/log.py | 10 +++++ src/wuttafarm/farmos/importing/model.py | 4 ++ src/wuttafarm/importing/farmos.py | 2 + 4 files changed, 53 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/85d4851e8292_add_log_quick.py diff --git a/src/wuttafarm/db/alembic/versions/85d4851e8292_add_log_quick.py b/src/wuttafarm/db/alembic/versions/85d4851e8292_add_log_quick.py new file mode 100644 index 0000000..97e87bc --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/85d4851e8292_add_log_quick.py @@ -0,0 +1,37 @@ +"""add Log.quick + +Revision ID: 85d4851e8292 +Revises: d459db991404 +Create Date: 2026-03-02 18:42:56.070281 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "85d4851e8292" +down_revision: Union[str, None] = "d459db991404" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log + op.add_column("log", sa.Column("quick", sa.String(length=20), nullable=True)) + op.add_column( + "log_version", + sa.Column("quick", sa.String(length=20), autoincrement=False, nullable=True), + ) + + +def downgrade() -> None: + + # log + op.drop_column("log_version", "quick") + op.drop_column("log", "quick") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index b77898f..c3bfe14 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -144,6 +144,15 @@ class Log(model.Base): """, ) + quick = sa.Column( + sa.String(length=20), + nullable=True, + doc=""" + Identifier of quick form used to create the log, if + applicable. + """, + ) + farmos_uuid = sa.Column( model.UUID(), nullable=True, @@ -257,6 +266,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "is_group_assignment") Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") + Log.make_proxy(subclass, "log", "quick") Log.make_proxy(subclass, "log", "assets") Log.make_proxy(subclass, "log", "groups") Log.make_proxy(subclass, "log", "locations") diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 108ebde..c785141 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -462,6 +462,7 @@ class ToFarmOSLog(ToFarmOS): "is_group_assignment", "status", "notes", + "quick", ] def get_target_objects(self, **kwargs): @@ -520,6 +521,7 @@ class ToFarmOSLog(ToFarmOS): "is_group_assignment": log["attributes"]["is_group_assignment"], "status": log["attributes"]["status"], "notes": notes, + "quick": log["attributes"]["quick"], } def get_log_payload(self, source_data): @@ -535,6 +537,8 @@ class ToFarmOSLog(ToFarmOS): attrs["status"] = source_data["status"] if "notes" in self.fields: attrs["notes"] = {"value": source_data["notes"]} + if "quick" in self.fields: + attrs["quick"] = {"value": source_data["quick"]} payload = {"attributes": attrs} diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index ebc5b55..da69813 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -970,6 +970,7 @@ class LogImporterBase(FromFarmOS, ToWutta): "is_group_assignment", "notes", "status", + "quick", ] ) return fields @@ -1000,6 +1001,7 @@ class LogImporterBase(FromFarmOS, ToWutta): data["farmos_uuid"] = UUID(data.pop("uuid")) data["message"] = data.pop("name") data["timestamp"] = self.app.make_utc(data["timestamp"]) + data["quick"] = ", ".join(data["quick"]) if data["quick"] else None # TODO data["log_type"] = self.get_farmos_log_type() From ce103137a52cf1da8a3ff68c7adbe87142fcf22e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:57:41 -0600 Subject: [PATCH 40/55] fix: add links for Parents column in All Assets grid --- src/wuttafarm/db/model/asset.py | 7 ++ src/wuttafarm/web/views/assets.py | 126 ++++++++++++------------------ 2 files changed, 55 insertions(+), 78 deletions(-) diff --git a/src/wuttafarm/db/model/asset.py b/src/wuttafarm/db/model/asset.py index 3e4de6e..8c975c9 100644 --- a/src/wuttafarm/db/model/asset.py +++ b/src/wuttafarm/db/model/asset.py @@ -26,6 +26,7 @@ Model definition for Asset Types import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.ext.associationproxy import association_proxy from wuttjamaican.db import model @@ -186,6 +187,12 @@ class Asset(model.Base): cascade_backrefs=False, ) + parents = association_proxy( + "_parents", + "parent", + creator=lambda parent: AssetParent(parent=parent), + ) + def __str__(self): return self.asset_name or "" diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 963fe78..b463953 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -49,79 +49,6 @@ def get_asset_type_enum(config): return asset_types -class AllAssetView(WuttaFarmMasterView): - """ - Master view for Assets - """ - - model_class = Asset - route_prefix = "assets" - url_prefix = "/assets" - - farmos_refurl_path = "/assets" - - viewable = False - creatable = False - editable = False - deletable = False - model_is_versioned = False - - grid_columns = [ - "thumbnail", - "drupal_id", - "asset_name", - "asset_type", - "parents", - "archived", - ] - - sort_defaults = "asset_name" - - filter_defaults = { - "asset_name": {"active": True, "verb": "contains"}, - "archived": {"active": True, "verb": "is_false"}, - } - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # thumbnail - g.set_renderer("thumbnail", self.render_grid_thumbnail) - g.set_label("thumbnail", "", column_only=True) - g.set_centered("thumbnail") - - # drupal_id - g.set_label("drupal_id", "ID", column_only=True) - - # asset_name - g.set_link("asset_name") - - # asset_type - g.set_enum("asset_type", get_asset_type_enum(self.config)) - - # parents - g.set_renderer("parents", self.render_parents_for_grid) - - # view action links to final asset record - def asset_url(asset, i): - return self.request.route_url( - f"{asset.asset_type}_assets.view", uuid=asset.uuid - ) - - g.add_action("view", icon="eye", url=asset_url) - - def render_parents_for_grid(self, asset, field, value): - parents = [str(p.parent) for p in asset._parents] - return ", ".join(parents) - - def grid_row_class(self, asset, data, i): - """ """ - if asset.archived: - return "has-background-warning" - return None - - class AssetTypeMasterView(WuttaFarmMasterView): """ Base class for "Asset Type" master views. @@ -181,7 +108,10 @@ class AssetMasterView(WuttaFarmMasterView): model = self.app.model model_class = self.get_model_class() session = session or self.Session() - return session.query(model_class).join(model.Asset) + query = session.query(model_class) + if model_class is not model.Asset: + query = query.join(model.Asset) + return query def configure_grid(self, grid): g = grid @@ -212,19 +142,17 @@ class AssetMasterView(WuttaFarmMasterView): g.set_filter("archived", model.Asset.archived) def render_parents_for_grid(self, asset, field, value): - parents = asset.asset._parents if self.farmos_style_grid_links: links = [] - for parent in parents: - parent = parent.parent + for parent in asset.parents: url = self.request.route_url( f"{parent.asset_type}_assets.view", uuid=parent.uuid ) links.append(tags.link_to(str(parent), url)) return ", ".join(links) - parents = [str(p.parent) for p in parents] + parents = [str(p.parent) for p in asset.parents] return ", ".join(parents) def grid_row_class(self, asset, data, i): @@ -365,6 +293,48 @@ class AssetMasterView(WuttaFarmMasterView): return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) +class AllAssetView(AssetMasterView): + """ + Master view for Assets + """ + + model_class = Asset + route_prefix = "assets" + url_prefix = "/assets" + + farmos_refurl_path = "/assets" + + viewable = False + creatable = False + editable = False + deletable = False + model_is_versioned = False + + grid_columns = [ + "thumbnail", + "drupal_id", + "asset_name", + "asset_type", + "parents", + "archived", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # asset_type + g.set_enum("asset_type", get_asset_type_enum(self.config)) + + # view action links to final asset record + def asset_url(asset, i): + return self.request.route_url( + f"{asset.asset_type}_assets.view", uuid=asset.uuid + ) + + g.add_action("view", icon="eye", url=asset_url) + + def defaults(config, **kwargs): base = globals() From eb16990b0b850d1bbcee7b181b116bf412f2f40c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 19:44:52 -0600 Subject: [PATCH 41/55] feat: add schema, import support for `Asset.owners` --- .../versions/12de43facb95_add_asset_owners.py | 114 +++++++++++++++ src/wuttafarm/db/model/asset.py | 39 +++++ src/wuttafarm/importing/farmos.py | 136 +++++++++--------- src/wuttafarm/normal.py | 34 +++++ src/wuttafarm/web/views/animals.py | 3 + src/wuttafarm/web/views/assets.py | 18 +++ src/wuttafarm/web/views/structures.py | 1 + 7 files changed, 273 insertions(+), 72 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py diff --git a/src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py b/src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py new file mode 100644 index 0000000..67a4c25 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py @@ -0,0 +1,114 @@ +"""add Asset.owners + +Revision ID: 12de43facb95 +Revises: 85d4851e8292 +Create Date: 2026-03-02 19:03:35.511398 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "12de43facb95" +down_revision: Union[str, None] = "85d4851e8292" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_owner + op.create_table( + "asset_owner", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("user_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_uuid"], ["asset.uuid"], name=op.f("fk_asset_owner_asset_uuid_asset") + ), + sa.ForeignKeyConstraint( + ["user_uuid"], ["user.uuid"], name=op.f("fk_asset_owner_user_uuid_user") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_owner")), + ) + op.create_table( + "asset_owner_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "user_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_owner_version") + ), + ) + op.create_index( + op.f("ix_asset_owner_version_end_transaction_id"), + "asset_owner_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_owner_version_operation_type"), + "asset_owner_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_owner_version_pk_transaction_id", + "asset_owner_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_owner_version_pk_validity", + "asset_owner_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_owner_version_transaction_id"), + "asset_owner_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # asset_owner + op.drop_index( + op.f("ix_asset_owner_version_transaction_id"), table_name="asset_owner_version" + ) + op.drop_index( + "ix_asset_owner_version_pk_validity", table_name="asset_owner_version" + ) + op.drop_index( + "ix_asset_owner_version_pk_transaction_id", table_name="asset_owner_version" + ) + op.drop_index( + op.f("ix_asset_owner_version_operation_type"), table_name="asset_owner_version" + ) + op.drop_index( + op.f("ix_asset_owner_version_end_transaction_id"), + table_name="asset_owner_version", + ) + op.drop_table("asset_owner_version") + op.drop_table("asset_owner") diff --git a/src/wuttafarm/db/model/asset.py b/src/wuttafarm/db/model/asset.py index 8c975c9..0face47 100644 --- a/src/wuttafarm/db/model/asset.py +++ b/src/wuttafarm/db/model/asset.py @@ -193,6 +193,19 @@ class Asset(model.Base): creator=lambda parent: AssetParent(parent=parent), ) + _owners = orm.relationship( + "AssetOwner", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="asset", + ) + + owners = association_proxy( + "_owners", + "user", + creator=lambda user: AssetOwner(user=user), + ) + def __str__(self): return self.asset_name or "" @@ -225,6 +238,8 @@ def add_asset_proxies(subclass): Asset.make_proxy(subclass, "asset", "thumbnail_url") Asset.make_proxy(subclass, "asset", "image_url") Asset.make_proxy(subclass, "asset", "archived") + Asset.make_proxy(subclass, "asset", "parents") + Asset.make_proxy(subclass, "asset", "owners") class EggMixin: @@ -262,3 +277,27 @@ class AssetParent(model.Base): Asset, foreign_keys=parent_uuid, ) + + +class AssetOwner(model.Base): + """ + Represents a "asset's owner relationship" from farmOS. + """ + + __tablename__ = "asset_owner" + __versioned__ = {} + + uuid = model.uuid_column() + + asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + asset = orm.relationship( + Asset, + foreign_keys=asset_uuid, + back_populates="_owners", + ) + + user_uuid = model.uuid_fk_column("user.uuid", nullable=False) + user = orm.relationship( + model.User, + foreign_keys=user_uuid, + ) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index da69813..1cd3523 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -187,6 +187,7 @@ class AssetImporterBase(FromFarmOS, ToWutta): fields.extend( [ "parents", + "owners", ] ) return fields @@ -194,8 +195,9 @@ class AssetImporterBase(FromFarmOS, ToWutta): def get_source_objects(self): """ """ asset_type = self.get_farmos_asset_type() - result = self.farmos_client.asset.get(asset_type) - return result["data"] + return list( + self.farmos_client.asset.iterate(asset_type, params={"include": "image"}) + ) def normalize_source_data(self, **kwargs): """ """ @@ -208,49 +210,40 @@ class AssetImporterBase(FromFarmOS, ToWutta): return data - def normalize_asset(self, asset): + def normalize_source_object(self, asset): """ """ - image_url = None - thumbnail_url = None - if relationships := asset.get("relationships"): + data = self.normal.normalize_farmos_asset(asset) - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - if image_style := image["data"]["attributes"].get( - "image_style_uri" - ): - image_url = image_style["large"] - thumbnail_url = image_style["thumbnail"] + data["farmos_uuid"] = UUID(data.pop("uuid")) + data["asset_type"] = self.get_asset_type(asset) - if notes := asset["attributes"]["notes"]: - notes = notes["value"] + if "image_url" in self.fields or "thumbnail_url" in self.fields: + data["image_url"] = None + data["thumbnail_url"] = None + if relationships := asset.get("relationships"): - if self.farmos_4x: - archived = asset["attributes"]["archived"] - else: - archived = asset["attributes"]["status"] == "archived" + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + if image_style := image["data"]["attributes"].get( + "image_style_uri" + ): + data["image_url"] = image_style["large"] + data["thumbnail_url"] = image_style["thumbnail"] - parents = None if "parents" in self.fields: - parents = [] + data["parents"] = [] for parent in asset["relationships"]["parent"]["data"]: - parents.append((self.get_asset_type(parent), UUID(parent["id"]))) + data["parents"].append( + (self.get_asset_type(parent), UUID(parent["id"])) + ) - return { - "farmos_uuid": UUID(asset["id"]), - "drupal_id": asset["attributes"]["drupal_internal__id"], - "asset_name": asset["attributes"]["name"], - "is_location": asset["attributes"]["is_location"], - "is_fixed": asset["attributes"]["is_fixed"], - "archived": archived, - "notes": notes, - "image_url": image_url, - "thumbnail_url": thumbnail_url, - "parents": parents, - } + if "owners" in self.fields: + data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]] + + return data def get_asset_type(self, asset): return asset["type"].split("--")[1] @@ -259,10 +252,10 @@ class AssetImporterBase(FromFarmOS, ToWutta): data = super().normalize_target_object(asset) if "parents" in self.fields: - data["parents"] = [ - (p.parent.asset_type, p.parent.farmos_uuid) - for p in asset.asset._parents - ] + data["parents"] = [(p.asset_type, p.farmos_uuid) for p in asset.parents] + + if "owners" in self.fields: + data["owners"] = [user.farmos_uuid for user in asset.owners] return data @@ -303,6 +296,30 @@ class AssetImporterBase(FromFarmOS, ToWutta): ) self.target_session.delete(parent) + if "owners" in self.fields: + if not target_data or target_data["owners"] != source_data["owners"]: + + for farmos_uuid in source_data["owners"]: + if not target_data or farmos_uuid not in target_data["owners"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) + .one() + ) + asset.owners.append(user) + + if target_data: + for farmos_uuid in target_data["owners"]: + if farmos_uuid not in source_data["owners"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) + .one() + ) + asset.owners.remove(user) + return asset @@ -338,11 +355,6 @@ class AnimalAssetImporter(AssetImporterBase): if animal_type.farmos_uuid: self.animal_types_by_farmos_uuid[animal_type.farmos_uuid] = animal_type - def get_source_objects(self): - """ """ - animals = self.farmos_client.asset.get("animal") - return animals["data"] - def normalize_source_object(self, animal): """ """ animal_type_uuid = None @@ -374,10 +386,9 @@ class AnimalAssetImporter(AssetImporterBase): else: sterile = animal["attributes"]["is_castrated"] - data = self.normalize_asset(animal) + data = super().normalize_source_object(animal) data.update( { - "asset_type": "animal", "animal_type_uuid": animal_type_uuid, "sex": animal["attributes"]["sex"], "is_sterile": sterile, @@ -468,17 +479,11 @@ class GroupAssetImporter(AssetImporterBase): "parents", ] - def get_source_objects(self): - """ """ - groups = self.farmos_client.asset.get("group") - return groups["data"] - def normalize_source_object(self, group): """ """ - data = self.normalize_asset(group) + data = super().normalize_source_object(group) data.update( { - "asset_type": "group", "produces_eggs": group["attributes"]["produces_eggs"], } ) @@ -514,11 +519,6 @@ 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_source_objects(self): - """ """ - land_assets = self.farmos_client.asset.get("land") - return land_assets["data"] - def normalize_source_object(self, land): """ """ land_type_id = land["attributes"]["land_type"] @@ -529,10 +529,9 @@ class LandAssetImporter(AssetImporterBase): ) return None - data = self.normalize_asset(land) + data = super().normalize_source_object(land) data.update( { - "asset_type": "land", "land_type_uuid": land_type.uuid, } ) @@ -638,10 +637,9 @@ class PlantAssetImporter(AssetImporterBase): else: log.warning("plant type not found: %s", plant_type["id"]) - data = self.normalize_asset(plant) + data = super().normalize_source_object(plant) data.update( { - "asset_type": "plant", "plant_types": set(plant_types), } ) @@ -718,11 +716,6 @@ 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_source_objects(self): - """ """ - structures = self.farmos_client.asset.get("structure") - return structures["data"] - def normalize_source_object(self, structure): """ """ structure_type_id = structure["attributes"]["structure_type"] @@ -735,10 +728,9 @@ class StructureAssetImporter(AssetImporterBase): ) return None - data = self.normalize_asset(structure) + data = super().normalize_source_object(structure) data.update( { - "asset_type": "structure", "structure_type_uuid": structure_type.uuid, } ) @@ -1167,7 +1159,7 @@ class LogImporterBase(FromFarmOS, ToWutta): if not target_data or target_data["owners"] != source_data["owners"]: for farmos_uuid in source_data["owners"]: - if not target_data or farmos_uuid not in target_data["assets"]: + if not target_data or farmos_uuid not in target_data["owners"]: user = ( self.target_session.query(model.User) .join(model.WuttaFarmUser) diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index 3efd443..fa9b9da 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -84,6 +84,40 @@ class Normalizer(GenericHandler): self._farmos_units = units return self._farmos_units + def normalize_farmos_asset(self, asset, included={}): + """ """ + + if notes := asset["attributes"]["notes"]: + notes = notes["value"] + + owner_objects = [] + owner_uuids = [] + if relationships := asset.get("relationships"): + + 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": asset["id"], + "drupal_id": asset["attributes"]["drupal_internal__id"], + "asset_name": asset["attributes"]["name"], + "is_location": asset["attributes"]["is_location"], + "is_fixed": asset["attributes"]["is_fixed"], + "archived": asset["attributes"]["archived"], + "notes": notes, + "owners": owner_objects, + "owner_uuids": owner_uuids, + } + def normalize_farmos_log(self, log, included={}): if timestamp := log["attributes"]["timestamp"]: diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index b52a353..31bbfe8 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -228,6 +228,9 @@ class AnimalAssetView(AssetMasterView): "birthdate", "is_sterile", "sex", + "group_membership", + "owners", + "locations", "archived", ] diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b463953..38746bd 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -136,6 +136,10 @@ class AssetMasterView(WuttaFarmMasterView): # parents g.set_renderer("parents", self.render_parents_for_grid) + # owners + g.set_label("owners", "Owner") + g.set_renderer("owners", self.render_owners_for_grid) + # archived g.set_renderer("archived", "boolean") g.set_sorter("archived", model.Asset.archived) @@ -155,6 +159,17 @@ class AssetMasterView(WuttaFarmMasterView): parents = [str(p.parent) for p in asset.parents] return ", ".join(parents) + def render_owners_for_grid(self, asset, field, value): + + if self.farmos_style_grid_links: + links = [] + for user in asset.owners: + url = self.request.route_url("users.view", uuid=user.uuid) + links.append(tags.link_to(user.username, url)) + return ", ".join(links) + + return ", ".join([user.username for user in asset.owners]) + def grid_row_class(self, asset, data, i): """ """ if asset.archived: @@ -314,8 +329,11 @@ class AllAssetView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", + "group_membership", "asset_type", "parents", + "owners", + "locations", "archived", ] diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 4d36d41..9d5d227 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -160,6 +160,7 @@ class StructureAssetView(AssetMasterView): "asset_name", "structure_type", "parents", + "owners", "archived", ] From 0ac2485bff567f4953e6caed4c708949dbc096c1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 20:27:22 -0600 Subject: [PATCH 42/55] feat: add schema, sync support for `Log.is_movement` --- .../0771322957bd_add_log_is_movement.py | 37 +++++++++++++++++++ src/wuttafarm/db/model/log.py | 9 +++++ src/wuttafarm/farmos/importing/model.py | 4 ++ src/wuttafarm/farmos/importing/wuttafarm.py | 2 + src/wuttafarm/importing/farmos.py | 1 + src/wuttafarm/normal.py | 1 + src/wuttafarm/web/views/farmos/logs.py | 4 ++ src/wuttafarm/web/views/logs.py | 4 ++ 8 files changed, 62 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/0771322957bd_add_log_is_movement.py diff --git a/src/wuttafarm/db/alembic/versions/0771322957bd_add_log_is_movement.py b/src/wuttafarm/db/alembic/versions/0771322957bd_add_log_is_movement.py new file mode 100644 index 0000000..0aa9d54 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/0771322957bd_add_log_is_movement.py @@ -0,0 +1,37 @@ +"""add Log.is_movement + +Revision ID: 0771322957bd +Revises: 12de43facb95 +Create Date: 2026-03-02 20:21:03.889847 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "0771322957bd" +down_revision: Union[str, None] = "12de43facb95" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log + op.add_column("log", sa.Column("is_movement", sa.Boolean(), nullable=True)) + op.add_column( + "log_version", + sa.Column("is_movement", sa.Boolean(), autoincrement=False, nullable=True), + ) + + +def downgrade() -> None: + + # log + op.drop_column("log_version", "is_movement") + op.drop_column("log", "is_movement") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index c3bfe14..020b39d 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -120,6 +120,14 @@ class Log(model.Base): """, ) + is_movement = sa.Column( + sa.Boolean(), + nullable=True, + doc=""" + Whether the log represents a movement to new location. + """, + ) + is_group_assignment = sa.Column( sa.Boolean(), nullable=True, @@ -263,6 +271,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "log_type") Log.make_proxy(subclass, "log", "message") Log.make_proxy(subclass, "log", "timestamp") + Log.make_proxy(subclass, "log", "is_movement") Log.make_proxy(subclass, "log", "is_group_assignment") Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index c785141..fab984d 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -459,6 +459,7 @@ class ToFarmOSLog(ToFarmOS): "uuid", "name", "timestamp", + "is_movement", "is_group_assignment", "status", "notes", @@ -518,6 +519,7 @@ class ToFarmOSLog(ToFarmOS): "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, @@ -531,6 +533,8 @@ class ToFarmOSLog(ToFarmOS): attrs["name"] = source_data["name"] if "timestamp" in self.fields: attrs["timestamp"] = self.format_datetime(source_data["timestamp"]) + if "is_movement" in self.fields: + attrs["is_movement"] = source_data["is_movement"] if "is_group_assignment" in self.fields: attrs["is_group_assignment"] = source_data["is_group_assignment"] if "status" in self.fields: diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index d0ac065..8d76285 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -361,6 +361,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "uuid", "name", "timestamp", + "is_movement", "is_group_assignment", "status", "notes", @@ -371,6 +372,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "uuid": log.farmos_uuid or self.app.make_true_uuid(), "name": log.message, "timestamp": log.timestamp, + "is_movement": log.is_movement, "is_group_assignment": log.is_group_assignment, "status": log.status, "notes": log.notes, diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 1cd3523..6b21090 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -959,6 +959,7 @@ class LogImporterBase(FromFarmOS, ToWutta): "log_type", "message", "timestamp", + "is_movement", "is_group_assignment", "notes", "status", diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index fa9b9da..4fc8796 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -279,6 +279,7 @@ class Normalizer(GenericHandler): "quantities": quantity_objects, "quantity_uuids": quantity_uuids, "is_group_assignment": log["attributes"]["is_group_assignment"], + "is_movement": log["attributes"]["is_movement"], "quick": log["attributes"]["quick"], "status": log["attributes"]["status"], "notes": notes, diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index d0ee388..cb7a87b 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -94,6 +94,7 @@ class LogMasterView(FarmOSMasterView): "status", "log_type_name", "owners", + "is_movement", "is_group_assignment", "quick", "drupal_id", @@ -234,6 +235,9 @@ class LogMasterView(FarmOSMasterView): # quantities f.set_node("quantities", FarmOSQuantityRefs(self.request)) + # is_movement + f.set_node("is_movement", colander.Boolean()) + # is_group_assignment f.set_node("is_group_assignment", colander.Boolean()) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index eba1b96..6a502d2 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -133,6 +133,7 @@ class LogMasterView(WuttaFarmMasterView): "status", "log_type", "owners", + "is_movement", "is_group_assignment", "quick", "farmos_uuid", @@ -312,6 +313,9 @@ class LogMasterView(WuttaFarmMasterView): # status f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) + # is_movement + f.set_node("is_movement", colander.Boolean()) + # is_group_assignment f.set_node("is_group_assignment", colander.Boolean()) From 41870ee2e2fe60a02a02116d7f8c50dbd1a6c21d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 20:33:19 -0600 Subject: [PATCH 43/55] fix: move farmOS UUID field below the Drupal ID --- src/wuttafarm/web/views/animals.py | 4 ++-- src/wuttafarm/web/views/asset_types.py | 2 +- src/wuttafarm/web/views/groups.py | 2 +- src/wuttafarm/web/views/land.py | 4 ++-- src/wuttafarm/web/views/logs.py | 4 ++-- src/wuttafarm/web/views/plants.py | 4 ++-- src/wuttafarm/web/views/quantities.py | 4 ++-- src/wuttafarm/web/views/structures.py | 4 ++-- src/wuttafarm/web/views/units.py | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 31bbfe8..d9d8db7 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -62,8 +62,8 @@ class AnimalTypeView(AssetTypeMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] has_rows = True @@ -244,8 +244,8 @@ class AnimalAssetView(AssetMasterView): "notes", "asset_type", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", "thumbnail_url", "image_url", "thumbnail", diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py index f9aadfb..4a76ccf 100644 --- a/src/wuttafarm/web/views/asset_types.py +++ b/src/wuttafarm/web/views/asset_types.py @@ -50,8 +50,8 @@ class AssetTypeView(WuttaFarmMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 61394fa..4331280 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -53,8 +53,8 @@ class GroupView(AssetMasterView): "asset_type", "produces_eggs", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] diff --git a/src/wuttafarm/web/views/land.py b/src/wuttafarm/web/views/land.py index 23b899d..ca1f016 100644 --- a/src/wuttafarm/web/views/land.py +++ b/src/wuttafarm/web/views/land.py @@ -51,8 +51,8 @@ class LandTypeView(AssetTypeMasterView): form_fields = [ "name", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] has_rows = True @@ -173,8 +173,8 @@ class LandAssetView(AssetMasterView): "is_location", "is_fixed", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 6a502d2..e88e550 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -61,8 +61,8 @@ class LogTypeView(WuttaFarmMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): @@ -136,8 +136,8 @@ class LogMasterView(WuttaFarmMasterView): "is_movement", "is_group_assignment", "quick", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def get_query(self, session=None): diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index c831201..a114e07 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -62,8 +62,8 @@ class PlantTypeView(AssetTypeMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] has_rows = True @@ -227,8 +227,8 @@ class PlantAssetView(AssetMasterView): "notes", "asset_type", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", "thumbnail_url", "image_url", "thumbnail", diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py index fb5279d..6f8bdec 100644 --- a/src/wuttafarm/web/views/quantities.py +++ b/src/wuttafarm/web/views/quantities.py @@ -66,8 +66,8 @@ class QuantityTypeView(WuttaFarmMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): @@ -119,8 +119,8 @@ class QuantityMasterView(WuttaFarmMasterView): "value", "units", "label", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def get_query(self, session=None): diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 9d5d227..e17a39f 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -50,8 +50,8 @@ class StructureTypeView(AssetTypeMasterView): form_fields = [ "name", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] has_rows = True @@ -173,8 +173,8 @@ class StructureAssetView(AssetMasterView): "is_location", "is_fixed", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", "thumbnail_url", "image_url", "thumbnail", diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index add7b2b..549333e 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -100,8 +100,8 @@ class UnitView(WuttaFarmMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): From 759eb906b910625d70f91c400420b858ebd6a4d0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 20:59:27 -0600 Subject: [PATCH 44/55] feat: expose "current location" for assets based on most recent movement log, as in farmOS --- src/wuttafarm/app.py | 15 ++++++++ src/wuttafarm/assets.py | 49 ++++++++++++++++++++++++ src/wuttafarm/web/forms/schema.py | 12 +++--- src/wuttafarm/web/forms/widgets.py | 8 ++-- src/wuttafarm/web/views/animals.py | 4 +- src/wuttafarm/web/views/assets.py | 42 +++++++++++++++++++- src/wuttafarm/web/views/farmos/assets.py | 3 +- src/wuttafarm/web/views/logs.py | 10 ++--- 8 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 src/wuttafarm/assets.py diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index a3fa566..cb9aed3 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -36,6 +36,21 @@ class WuttaFarmAppHandler(base.AppHandler): default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler" default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler" + def get_asset_handler(self): + """ + Get the configured asset handler. + + :rtype: :class:`~wuttafarm.assets.AssetHandler` + """ + if "asset" not in self.handlers: + spec = self.config.get( + f"{self.appname}.asset_handler", + default="wuttafarm.assets:AssetHandler", + ) + factory = self.load_object(spec) + self.handlers["asset"] = factory(self.config) + return self.handlers["asset"] + def get_farmos_handler(self): """ Get the configured farmOS integration handler. diff --git a/src/wuttafarm/assets.py b/src/wuttafarm/assets.py new file mode 100644 index 0000000..321c3e5 --- /dev/null +++ b/src/wuttafarm/assets.py @@ -0,0 +1,49 @@ +# -*- 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 . +# +################################################################################ +""" +Asset handler +""" + +from wuttjamaican.app import GenericHandler + + +class AssetHandler(GenericHandler): + """ + Base class and default implementation for the asset + :term:`handler`. + """ + + def get_locations(self, asset): + model = self.app.model + session = self.app.get_session(asset) + + loclog = ( + session.query(model.Log) + .join(model.LogAsset) + .filter(model.LogAsset.asset == asset) + .filter(model.Log.is_movement == True) + .order_by(model.Log.timestamp.desc()) + .first() + ) + if loclog: + return loclog.locations + return [] diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 0a7a72f..a2a72b5 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -364,7 +364,7 @@ class AssetParentRefs(WuttaSet): return AssetParentRefsWidget(self.request, **kwargs) -class LogAssetRefs(WuttaSet): +class AssetRefs(WuttaSet): """ Schema type for Assets field (on a Log record) """ @@ -376,9 +376,9 @@ class LogAssetRefs(WuttaSet): return {asset.uuid for asset in appstruct} def widget_maker(self, **kwargs): - from wuttafarm.web.forms.widgets import LogAssetRefsWidget + from wuttafarm.web.forms.widgets import AssetRefsWidget - return LogAssetRefsWidget(self.request, **kwargs) + return AssetRefsWidget(self.request, **kwargs) class LogQuantityRefs(WuttaSet): @@ -398,7 +398,7 @@ class LogQuantityRefs(WuttaSet): return LogQuantityRefsWidget(self.request, **kwargs) -class LogOwnerRefs(WuttaSet): +class OwnerRefs(WuttaSet): """ Schema type for Owners field (on a Log record) """ @@ -410,9 +410,9 @@ class LogOwnerRefs(WuttaSet): return {user.uuid for user in appstruct} def widget_maker(self, **kwargs): - from wuttafarm.web.forms.widgets import LogOwnerRefsWidget + from wuttafarm.web.forms.widgets import OwnerRefsWidget - return LogOwnerRefsWidget(self.request, **kwargs) + return OwnerRefsWidget(self.request, **kwargs) class Notes(colander.String): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 046e85b..0fd9221 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -423,9 +423,9 @@ class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) -class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): +class AssetRefsWidget(WuttaCheckboxChoiceWidget): """ - Widget for Assets field (on a Log record) + Widget for Assets field (of various kinds). """ def serialize(self, field, cstruct, **kw): @@ -486,9 +486,9 @@ class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) -class LogOwnerRefsWidget(WuttaCheckboxChoiceWidget): +class OwnerRefsWidget(WuttaCheckboxChoiceWidget): """ - Widget for Owners field (on a Log record) + Widget for Owners field (on an Asset or Log record) """ def serialize(self, field, cstruct, **kw): diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index d9d8db7..5756525 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -215,7 +215,7 @@ class AnimalAssetView(AssetMasterView): farmos_bundle = "animal" labels = { - "animal_type": "Species/Breed", + "animal_type": "Species / Breed", "is_sterile": "Sterile", } @@ -243,6 +243,8 @@ class AnimalAssetView(AssetMasterView): "is_sterile", "notes", "asset_type", + "owners", + "locations", "archived", "drupal_id", "farmos_uuid", diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 38746bd..7035413 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -32,7 +32,7 @@ from wuttaweb.db import Session from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import Asset, Log -from wuttafarm.web.forms.schema import AssetParentRefs +from wuttafarm.web.forms.schema import AssetParentRefs, OwnerRefs, AssetRefs from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.util import get_log_type_enum from wuttafarm.web.util import get_farmos_client_for_user @@ -140,6 +140,10 @@ class AssetMasterView(WuttaFarmMasterView): g.set_label("owners", "Owner") g.set_renderer("owners", self.render_owners_for_grid) + # locations + g.set_label("locations", "Location") + g.set_renderer("locations", self.render_locations_for_grid) + # archived g.set_renderer("archived", "boolean") g.set_sorter("archived", model.Asset.archived) @@ -170,6 +174,21 @@ class AssetMasterView(WuttaFarmMasterView): return ", ".join([user.username for user in asset.owners]) + def render_locations_for_grid(self, asset, field, value): + asset_handler = self.app.get_asset_handler() + locations = asset_handler.get_locations(asset) + + if self.farmos_style_grid_links: + links = [] + for loc in locations: + url = self.request.route_url( + f"{loc.asset_type}_assets.view", uuid=loc.uuid + ) + links.append(tags.link_to(str(loc), url)) + return ", ".join(links) + + return ", ".join([str(loc) for loc in locations]) + def grid_row_class(self, asset, data, i): """ """ if asset.archived: @@ -179,6 +198,7 @@ class AssetMasterView(WuttaFarmMasterView): def configure_form(self, form): f = form super().configure_form(f) + asset_handler = self.app.get_asset_handler() asset = form.model_instance # asset_type @@ -191,12 +211,30 @@ class AssetMasterView(WuttaFarmMasterView): ) f.set_readonly("asset_type") + # owners + if self.creating or self.editing: + f.remove("owners") # TODO: need to support this + else: + f.set_node("owners", OwnerRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("owners", asset.owners) + + # locations + if self.creating or self.editing: + # nb. this is a calculated field + f.remove("locations") + else: + f.set_label("locations", "Current Location") + f.set_node("locations", AssetRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("locations", asset_handler.get_locations(asset)) + # parents if self.creating or self.editing: f.remove("parents") # TODO: add support for this else: f.set_node("parents", AssetParentRefs(self.request)) - f.set_default("parents", [p.parent_uuid for p in asset.asset._parents]) + f.set_default("parents", [p.uuid for p in asset.parents]) # notes f.set_widget("notes", "notes") diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index d1ae226..69e6321 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -53,7 +53,6 @@ class AssetMasterView(FarmOSMasterView): labels = { "name": "Asset Name", "asset_type_name": "Asset Type", - "owners": "Owner", "locations": "Location", "thumbnail_url": "Thumbnail URL", "image_url": "Image URL", @@ -104,6 +103,7 @@ class AssetMasterView(FarmOSMasterView): g.set_filter("name", StringFilter) # owners + g.set_label("owners", "Owner") g.set_renderer("owners", self.render_owners_for_grid) # locations @@ -239,6 +239,7 @@ class AssetMasterView(FarmOSMasterView): if self.creating or self.editing: f.remove("locations") else: + f.set_label("locations", "Current Location") f.set_node("locations", FarmOSLocationRefs(self.request)) # owners diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index e88e550..9c983b7 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import LogType, Log -from wuttafarm.web.forms.schema import LogAssetRefs, LogQuantityRefs, LogOwnerRefs +from wuttafarm.web.forms.schema import AssetRefs, LogQuantityRefs, OwnerRefs from wuttafarm.util import get_log_type_enum @@ -259,7 +259,7 @@ class LogMasterView(WuttaFarmMasterView): if self.creating or self.editing: f.remove("assets") # TODO: need to support this else: - f.set_node("assets", LogAssetRefs(self.request)) + f.set_node("assets", AssetRefs(self.request)) # nb. must explicity declare value for non-standard field f.set_default("assets", log.assets) @@ -267,7 +267,7 @@ class LogMasterView(WuttaFarmMasterView): if self.creating or self.editing: f.remove("groups") # TODO: need to support this else: - f.set_node("groups", LogAssetRefs(self.request)) + f.set_node("groups", AssetRefs(self.request)) # nb. must explicity declare value for non-standard field f.set_default("groups", log.groups) @@ -275,7 +275,7 @@ class LogMasterView(WuttaFarmMasterView): if self.creating or self.editing: f.remove("locations") # TODO: need to support this else: - f.set_node("locations", LogAssetRefs(self.request)) + f.set_node("locations", AssetRefs(self.request)) # nb. must explicity declare value for non-standard field f.set_default("locations", log.locations) @@ -306,7 +306,7 @@ class LogMasterView(WuttaFarmMasterView): if self.creating or self.editing: f.remove("owners") # TODO: need to support this else: - f.set_node("owners", LogOwnerRefs(self.request)) + f.set_node("owners", OwnerRefs(self.request)) # nb. must explicity declare value for non-standard field f.set_default("owners", log.owners) From b2c3d3a301db304338f52230c65aa04006d4a699 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 12:23:12 -0600 Subject: [PATCH 45/55] fix: remove unique constraint for `LandAsset.land_type_uuid` not sure why that was in there..assuming a mistake --- ...5a80e_remove_unwanted_unique_constraint.py | 39 +++++++++++++++++++ src/wuttafarm/db/model/asset_land.py | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/wuttafarm/db/alembic/versions/5f474125a80e_remove_unwanted_unique_constraint.py diff --git a/src/wuttafarm/db/alembic/versions/5f474125a80e_remove_unwanted_unique_constraint.py b/src/wuttafarm/db/alembic/versions/5f474125a80e_remove_unwanted_unique_constraint.py new file mode 100644 index 0000000..e5d28ab --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/5f474125a80e_remove_unwanted_unique_constraint.py @@ -0,0 +1,39 @@ +"""remove unwanted unique constraint + +Revision ID: 5f474125a80e +Revises: 0771322957bd +Create Date: 2026-03-04 12:03:16.034291 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "5f474125a80e" +down_revision: Union[str, None] = "0771322957bd" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_land + op.drop_constraint( + op.f("uq_asset_land_land_type_uuid"), "asset_land", type_="unique" + ) + + +def downgrade() -> None: + + # asset_land + op.create_unique_constraint( + op.f("uq_asset_land_land_type_uuid"), + "asset_land", + ["land_type_uuid"], + postgresql_nulls_not_distinct=False, + ) diff --git a/src/wuttafarm/db/model/asset_land.py b/src/wuttafarm/db/model/asset_land.py index 00bdd27..6c65c54 100644 --- a/src/wuttafarm/db/model/asset_land.py +++ b/src/wuttafarm/db/model/asset_land.py @@ -91,7 +91,7 @@ class LandAsset(AssetMixin, model.Base): "farmos_asset_type": "land", } - land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False, unique=True) + land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False) land_type = orm.relationship(LandType, back_populates="land_assets") From e8a8ce2528b318bf6032177834da1c54661576bd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 12:29:23 -0600 Subject: [PATCH 46/55] feat: expose "group membership" for assets --- src/wuttafarm/assets.py | 16 +++++++++++ src/wuttafarm/web/views/animals.py | 3 ++- src/wuttafarm/web/views/assets.py | 33 ++++++++++++++++++++++- src/wuttafarm/web/views/farmos/animals.py | 11 +++++--- src/wuttafarm/web/views/farmos/assets.py | 1 + 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/wuttafarm/assets.py b/src/wuttafarm/assets.py index 321c3e5..36d3b22 100644 --- a/src/wuttafarm/assets.py +++ b/src/wuttafarm/assets.py @@ -32,6 +32,22 @@ class AssetHandler(GenericHandler): :term:`handler`. """ + def get_groups(self, asset): + model = self.app.model + session = self.app.get_session(asset) + + grplog = ( + session.query(model.Log) + .join(model.LogAsset) + .filter(model.LogAsset.asset == asset) + .filter(model.Log.is_group_assignment == True) + .order_by(model.Log.timestamp.desc()) + .first() + ) + if grplog: + return grplog.groups + return [] + def get_locations(self, asset): model = self.app.model session = self.app.get_session(asset) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 5756525..f4c97e2 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -228,7 +228,7 @@ class AnimalAssetView(AssetMasterView): "birthdate", "is_sterile", "sex", - "group_membership", + "groups", "owners", "locations", "archived", @@ -245,6 +245,7 @@ class AnimalAssetView(AssetMasterView): "asset_type", "owners", "locations", + "groups", "archived", "drupal_id", "farmos_uuid", diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 7035413..b4e4d31 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -66,6 +66,10 @@ class AssetMasterView(WuttaFarmMasterView): farmos_entity_type = "asset" + labels = { + "groups": "Group Membership", + } + sort_defaults = "asset_name" filter_defaults = { @@ -136,6 +140,9 @@ class AssetMasterView(WuttaFarmMasterView): # parents g.set_renderer("parents", self.render_parents_for_grid) + # groups + g.set_renderer("groups", self.render_groups_for_grid) + # owners g.set_label("owners", "Owner") g.set_renderer("owners", self.render_owners_for_grid) @@ -174,6 +181,21 @@ class AssetMasterView(WuttaFarmMasterView): return ", ".join([user.username for user in asset.owners]) + def render_groups_for_grid(self, asset, field, value): + asset_handler = self.app.get_asset_handler() + groups = asset_handler.get_groups(asset) + + if self.farmos_style_grid_links: + links = [] + for group in groups: + url = self.request.route_url( + f"{group.asset_type}_assets.view", uuid=group.uuid + ) + links.append(tags.link_to(str(group), url)) + return ", ".join(links) + + return ", ".join([str(group) for group in groups]) + def render_locations_for_grid(self, asset, field, value): asset_handler = self.app.get_asset_handler() locations = asset_handler.get_locations(asset) @@ -229,6 +251,15 @@ class AssetMasterView(WuttaFarmMasterView): # nb. must explicity declare value for non-standard field f.set_default("locations", asset_handler.get_locations(asset)) + # groups + if self.creating or self.editing: + # nb. this is a calculated field + f.remove("groups") + else: + f.set_node("groups", AssetRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("groups", asset_handler.get_groups(asset)) + # parents if self.creating or self.editing: f.remove("parents") # TODO: add support for this @@ -367,7 +398,7 @@ class AllAssetView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", - "group_membership", + "groups", "asset_type", "parents", "owners", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 690e7ee..c99cc5a 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -39,7 +39,7 @@ from wuttafarm.web.grids import ( NullableBooleanFilter, DateTimeFilter, ) -from wuttafarm.web.forms.schema import FarmOSRef +from wuttafarm.web.forms.schema import FarmOSRef, FarmOSAssetRefs class AnimalView(AssetMasterView): @@ -87,9 +87,9 @@ class AnimalView(AssetMasterView): "is_sterile", "notes", "asset_type_name", - "groups", "owners", "locations", + "groups", "archived", "thumbnail_url", "image_url", @@ -147,7 +147,7 @@ class AnimalView(AssetMasterView): def render_groups_for_grid(self, animal, field, value): groups = [] - for group in animal["group_objects"]: + for group in animal["groups"]: if self.farmos_style_grid_links: url = self.request.route_url( "farmos_group_assets.view", uuid=group["uuid"] @@ -209,6 +209,7 @@ class AnimalView(AssetMasterView): group = { "uuid": group["id"], "name": group["attributes"]["name"], + "asset_type": "group", } group_objects.append(group) group_names.append(group["name"]) @@ -218,7 +219,7 @@ class AnimalView(AssetMasterView): "animal_type": animal_type_object, "animal_type_uuid": animal_type_object["uuid"], "animal_type_name": animal_type_object["name"], - "group_objects": group_objects, + "groups": group_objects, "group_names": group_names, "birthdate": birthdate, "sex": animal["attributes"]["sex"] or colander.null, @@ -273,6 +274,8 @@ class AnimalView(AssetMasterView): # groups if self.creating or self.editing: f.remove("groups") # TODO + else: + f.set_node("groups", FarmOSAssetRefs(self.request)) def get_api_payload(self, animal): payload = super().get_api_payload(animal) diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 69e6321..11f744b 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -54,6 +54,7 @@ class AssetMasterView(FarmOSMasterView): "name": "Asset Name", "asset_type_name": "Asset Type", "locations": "Location", + "groups": "Group Membership", "thumbnail_url": "Thumbnail URL", "image_url": "Image URL", } From a0f73e6a32717a881e6ad9aa09d1d75fa00c9cc1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 12:33:29 -0600 Subject: [PATCH 47/55] fix: show drupal ID column for asset types --- src/wuttafarm/web/views/asset_types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py index 4a76ccf..2fb0239 100644 --- a/src/wuttafarm/web/views/asset_types.py +++ b/src/wuttafarm/web/views/asset_types.py @@ -38,6 +38,7 @@ class AssetTypeView(WuttaFarmMasterView): grid_columns = [ "name", + "drupal_id", "description", ] From 0a1aee591adad6c342e72d9673310e887a8de058 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 14:14:52 -0600 Subject: [PATCH 48/55] =?UTF-8?q?bump:=20version=200.6.0=20=E2=86=92=200.7?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c040e..f58be88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ 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.7.0 (2026-03-04) + +### Feat + +- expose "group membership" for assets +- expose "current location" for assets +- add schema, sync support for `Log.is_movement` +- add schema, import support for `Asset.owners` +- add schema, import support for `Log.quick` +- show quantities when viewing log +- add sync support for `MedicalLog.vet` +- add schema, import support for `Log.quantities` +- add schema, import support for `Log.groups` +- add schema, import support for `Log.locations` +- add sync support for `Log.is_group_assignment` +- add support for exporting log status, timestamp to farmOS +- add support for log 'owners' +- add support for edit, import/export of plant type data +- add way to create animal type when editing animal +- add related version tables for asset/log revision history +- improve mirror/deletion for assets, logs, animal types +- auto-delete asset from farmOS if deleting via mirror app + +### Fix + +- show drupal ID column for asset types +- remove unique constraint for `LandAsset.land_type_uuid` +- move farmOS UUID field below the Drupal ID +- add links for Parents column in All Assets grid +- set timestamp for new log in quick eggs form +- set default grid pagesize to 50 +- add placeholder for log 'quick' field +- define log grid columns to match farmOS +- make AllLogView inherit from LogMasterView +- rename views for "all records" (all assets, all logs etc.) +- ensure token refresh works regardless where API client is used +- render links for Plant Type column in Plant Assets grid +- fix land asset type +- prevent edit for asset types, land types when app is mirror +- add farmOS-style links for Parents column in Land Assets grid +- remove unique constraint for `AnimalType.name` +- prevent delete if animal type is still being referenced +- add reminder to restart if changing integration mode +- prevent edit for user farmos_uuid, drupal_id +- remove 'contains' verb for sex filter +- add enum, row hilite for log status +- fix Sex field when empty and deleting an animal +- add `get_farmos_client_for_user()` convenience function +- use current user token for auto-sync within web app +- set log type, status enums for log grids +- add more default perms for first site admin user +- only show quick form menu if perms allow +- expose config for farmOS OAuth2 client_id and scope +- add separate permission for each quick form view + ## v0.6.0 (2026-02-25) ### Feat diff --git a/pyproject.toml b/pyproject.toml index c66f0b9..e04615f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.6.0" +version = "0.7.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 7bffa6cba6b2d448db59a6280c31bed8bd4d82d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 14:15:23 -0600 Subject: [PATCH 49/55] fix: bump version requirement for wuttaweb --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e04615f..9617e5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "pyramid_exclog", "uvicorn[standard]", "WuttaSync", - "WuttaWeb[continuum]>=0.28.1", + "WuttaWeb[continuum]>=0.29.0", ] From b2b49d93aef87301d7469f2d05e4d015fb29624a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 14:20:09 -0600 Subject: [PATCH 50/55] docs: fix doc warning --- src/wuttafarm/web/forms/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 0fd9221..0a14638 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -325,6 +325,7 @@ class PlantTypeRefsWidget(Widget): return values def deserialize(self, field, pstruct): + """ """ if not pstruct: return colander.null From 81fa22bbd8339b1e988cea7936190227db864c7d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 14:49:12 -0600 Subject: [PATCH 51/55] feat: show link to Log record when viewing Quantity --- src/wuttafarm/db/model/log.py | 1 + src/wuttafarm/db/model/quantities.py | 21 +++++++++++++++++++++ src/wuttafarm/web/forms/schema.py | 25 +++++++++++++++++++++++++ src/wuttafarm/web/views/quantities.py | 10 +++++++++- 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 020b39d..7823353 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -376,6 +376,7 @@ class LogQuantity(model.Base): quantity = orm.relationship( "Quantity", foreign_keys=quantity_uuid, + back_populates="_log", ) diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py index 4f537b9..4bed6a0 100644 --- a/src/wuttafarm/db/model/quantities.py +++ b/src/wuttafarm/db/model/quantities.py @@ -26,6 +26,7 @@ Model definition for Quantities import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.ext.associationproxy import association_proxy from wuttjamaican.db import model @@ -161,6 +162,25 @@ class Quantity(model.Base): """, ) + _log = orm.relationship( + "LogQuantity", + uselist=False, + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="quantity", + ) + + def make_log_quantity(log): + from wuttafarm.db.model import LogQuantity + + return LogQuantity(log=log) + + log = association_proxy( + "_log", + "log", + creator=make_log_quantity, + ) + def render_as_text(self, config=None): measure = str(self.measure or self.measure_id or "") value = self.value_numerator / self.value_denominator @@ -202,6 +222,7 @@ def add_quantity_proxies(subclass): Quantity.make_proxy(subclass, "quantity", "units_uuid") Quantity.make_proxy(subclass, "quantity", "units") Quantity.make_proxy(subclass, "quantity", "label") + Quantity.make_proxy(subclass, "quantity", "log") class StandardQuantity(QuantityMixin, model.Base): diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index a2a72b5..6bf434e 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -77,6 +77,31 @@ class LogQuick(WuttaSet): return LogQuickWidget(**kwargs) +class LogRef(ObjectRef): + """ + Custom schema type for a + :class:`~wuttafarm.db.model.log.Log` reference field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): # pylint: disable=empty-docstring + """ """ + model = self.app.model + return model.Log + + def sort_query(self, query): # pylint: disable=empty-docstring + """ """ + return query.order_by(self.model_class.message) + + def get_object_url(self, obj): # pylint: disable=empty-docstring + """ """ + log = obj + return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) + + class FarmOSUnitRef(colander.SchemaType): def serialize(self, node, appstruct): diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py index 6f8bdec..d4112cf 100644 --- a/src/wuttafarm/web/views/quantities.py +++ b/src/wuttafarm/web/views/quantities.py @@ -29,7 +29,7 @@ from wuttaweb.db import Session from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import QuantityType, Quantity, StandardQuantity -from wuttafarm.web.forms.schema import UnitRef +from wuttafarm.web.forms.schema import UnitRef, LogRef def get_quantity_type_enum(config): @@ -119,6 +119,7 @@ class QuantityMasterView(WuttaFarmMasterView): "value", "units", "label", + "log", "drupal_id", "farmos_uuid", ] @@ -231,6 +232,13 @@ class QuantityMasterView(WuttaFarmMasterView): # TODO: ugh f.set_default("units", quantity.quantity.units) + # log + if self.creating or self.editing: + f.remove("log") + else: + f.set_node("log", LogRef(self.request)) + f.set_default("log", quantity.log) + def get_xref_buttons(self, quantity): buttons = super().get_xref_buttons(quantity) From a547188a9057716788ef7bce0bd2f7283dd1115f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 16:49:28 -0600 Subject: [PATCH 52/55] feat: show related Quantity records when viewing a Unit --- src/wuttafarm/web/views/units.py | 53 +++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index 549333e..3356428 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -24,7 +24,7 @@ Master view for Units """ from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.db.model import Measure, Unit +from wuttafarm.db.model import Measure, Unit, Quantity class MeasureView(WuttaFarmMasterView): @@ -104,6 +104,26 @@ class UnitView(WuttaFarmMasterView): "farmos_uuid", ] + has_rows = True + row_model_class = Quantity + rows_viewable = True + + row_labels = { + "quantity_type_id": "Quantity Type ID", + "measure_id": "Measure ID", + } + + row_grid_columns = [ + "drupal_id", + "as_text", + "quantity_type", + "measure", + "value", + "label", + ] + + rows_sort_defaults = ("drupal_id", "desc") + def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -131,6 +151,37 @@ class UnitView(WuttaFarmMasterView): return buttons + def get_row_grid_data(self, unit): + model = self.app.model + session = self.Session() + return session.query(model.Quantity).filter(model.Quantity.units == unit) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_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) + g.set_link("as_text") + + # value + g.set_renderer("value", self.render_value_for_grid) + + def render_as_text_for_grid(self, quantity, field, value): + return quantity.render_as_text(self.config) + + def render_value_for_grid(self, quantity, field, value): + value = quantity.value_numerator / quantity.value_denominator + return self.app.render_quantity(value) + + def get_row_action_url_view(self, quantity, i): + return self.request.route_url( + f"quantities_{quantity.quantity_type_id}.view", uuid=quantity.uuid + ) + def defaults(config, **kwargs): base = globals() From 609a900f3965bd5c85e0ea25ee324408776ebe2d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 16:51:26 -0600 Subject: [PATCH 53/55] feat: show related Quantity records when viewing a Measure --- src/wuttafarm/web/views/units.py | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index 3356428..fe8dafe 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -52,6 +52,26 @@ class MeasureView(WuttaFarmMasterView): "drupal_id", ] + has_rows = True + row_model_class = Quantity + rows_viewable = True + + row_labels = { + "quantity_type_id": "Quantity Type ID", + "measure_id": "Measure ID", + } + + row_grid_columns = [ + "drupal_id", + "as_text", + "quantity_type", + "value", + "units", + "label", + ] + + rows_sort_defaults = ("drupal_id", "desc") + def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -59,6 +79,37 @@ class MeasureView(WuttaFarmMasterView): # name g.set_link("name") + def get_row_grid_data(self, measure): + model = self.app.model + session = self.Session() + return session.query(model.Quantity).filter(model.Quantity.measure == measure) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_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) + g.set_link("as_text") + + # value + g.set_renderer("value", self.render_value_for_grid) + + def render_as_text_for_grid(self, quantity, field, value): + return quantity.render_as_text(self.config) + + def render_value_for_grid(self, quantity, field, value): + value = quantity.value_numerator / quantity.value_denominator + return self.app.render_quantity(value) + + def get_row_action_url_view(self, quantity, i): + return self.request.route_url( + f"quantities_{quantity.quantity_type_id}.view", uuid=quantity.uuid + ) + @classmethod def defaults(cls, config): """ """ From 23af35842d17dd2016ce8a425950596d52ff04bb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 20:36:56 -0600 Subject: [PATCH 54/55] feat: improve support for exporting quantity, log data and make the Eggs quick form save to wuttafarm app DB first, then export to farmOS, if app is running as mirror --- src/wuttafarm/farmos/importing/model.py | 187 +++++++++++++++++-- src/wuttafarm/farmos/importing/wuttafarm.py | 50 +++++ src/wuttafarm/web/views/farmos/quantities.py | 81 +++++++- src/wuttafarm/web/views/quick/base.py | 3 + src/wuttafarm/web/views/quick/eggs.py | 147 +++++++++++++-- 5 files changed, 427 insertions(+), 41 deletions(-) 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"]) ) From af2ea18e1d022da1bb9c68c7259872245ba66010 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 20:43:03 -0600 Subject: [PATCH 55/55] =?UTF-8?q?bump:=20version=200.7.0=20=E2=86=92=200.8?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f58be88..f1eedfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.8.0 (2026-03-04) + +### Feat + +- improve support for exporting quantity, log data +- show related Quantity records when viewing a Measure +- show related Quantity records when viewing a Unit +- show link to Log record when viewing Quantity + +### Fix + +- bump version requirement for wuttaweb + ## v0.7.0 (2026-03-04) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 9617e5a..1bb1dda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.7.0" +version = "0.8.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [