From 185cd86efb4fdbab0a1d06c953916d0acd9ff84a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 19:09:39 -0600 Subject: [PATCH 01/18] fix: fix default admin user perms, per new log schema --- src/wuttafarm/web/views/common.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 121e631..ce5fba2 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -51,9 +51,6 @@ class CommonView(base.CommonView): site_admin = session.query(model.Role).filter_by(name="Site Admin").first() if site_admin: site_admin_perms = [ - "activity_logs.list", - "activity_logs.view", - "activity_logs.versions", "animal_types.create", "animal_types.edit", "animal_types.list", @@ -84,17 +81,23 @@ class CommonView(base.CommonView): "farmos_log_types.view", "farmos_logs_activity.list", "farmos_logs_activity.view", + "farmos_logs_harvest.list", + "farmos_logs_harvest.view", + "farmos_logs_medical.list", + "farmos_logs_medical.view", + "farmos_logs_observation.list", + "farmos_logs_observation.view", "farmos_structure_types.list", "farmos_structure_types.view", "farmos_structures.list", "farmos_structures.view", "farmos_users.list", "farmos_users.view", - "group_asests.create", - "group_asests.edit", - "group_asests.list", - "group_asests.view", - "group_asests.versions", + "group_assets.create", + "group_assets.edit", + "group_assets.list", + "group_assets.view", + "group_assets.versions", "land_assets.create", "land_assets.edit", "land_assets.list", @@ -106,6 +109,18 @@ class CommonView(base.CommonView): "log_types.list", "log_types.view", "log_types.versions", + "logs_activity.list", + "logs_activity.view", + "logs_activity.versions", + "logs_harvest.list", + "logs_harvest.view", + "logs_harvest.versions", + "logs_medical.list", + "logs_medical.view", + "logs_medical.versions", + "logs_observation.list", + "logs_observation.view", + "logs_observation.versions", "structure_types.list", "structure_types.view", "structure_types.versions", From e7b493d7c993088c32ab352973d024969c1f14b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 19:31:41 -0600 Subject: [PATCH 02/18] fix: add WuttaFarm -> farmOS export for Plant Assets --- src/wuttafarm/farmos/importing/model.py | 53 +++++++++++++++++++++ src/wuttafarm/farmos/importing/wuttafarm.py | 27 +++++++++++ src/wuttafarm/web/views/plants.py | 13 ++++- 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index d20c068..9bb0bf5 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -333,6 +333,59 @@ class LandAssetImporter(ToFarmOSAsset): return payload +class PlantAssetImporter(ToFarmOSAsset): + + model_title = "PlantAsset" + farmos_asset_type = "plant" + + supported_fields = [ + "uuid", + "asset_name", + "plant_type_uuids", + "notes", + "archived", + ] + + def normalize_target_object(self, plant): + data = super().normalize_target_object(plant) + data.update( + { + "plant_type_uuids": [ + UUID(p["id"]) for p in plant["relationships"]["plant_type"]["data"] + ], + } + ) + return data + + def get_asset_payload(self, source_data): + payload = super().get_asset_payload(source_data) + + attrs = {} + if "sex" in self.fields: + attrs["sex"] = source_data["sex"] + if "is_sterile" in self.fields: + attrs["is_sterile"] = source_data["is_sterile"] + if "birthdate" in self.fields: + attrs["birthdate"] = self.format_datetime(source_data["birthdate"]) + + rels = {} + if "plant_type_uuids" in self.fields: + rels["plant_type"] = {"data": []} + for uuid in source_data["plant_type_uuids"]: + rels["plant_type"]["data"].append( + { + "id": str(uuid), + "type": "taxonomy_term--plant_type", + } + ) + + payload["attributes"].update(attrs) + if rels: + payload.setdefault("relationships", {}).update(rels) + + return payload + + class StructureAssetImporter(ToFarmOSAsset): model_title = "StructureAsset" diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index ffd78b7..5b3a25e 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -98,6 +98,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter + importers["PlantAsset"] = PlantAssetImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter @@ -239,6 +240,32 @@ class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter) } +class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter): + """ + WuttaFarm → farmOS API exporter for Plant Assets + """ + + source_model_class = model.PlantAsset + + supported_fields = [ + "uuid", + "asset_name", + "plant_type_uuids", + "notes", + "archived", + ] + + def normalize_source_object(self, plant): + return { + "uuid": plant.farmos_uuid or self.app.make_true_uuid(), + "asset_name": plant.asset_name, + "plant_type_uuids": [t.plant_type.farmos_uuid for t in plant._plant_types], + "notes": plant.notes, + "archived": plant.archived, + "_src_object": plant, + } + + class StructureAssetImporter( FromWuttaFarm, farmos_importing.model.StructureAssetImporter ): diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index d92949a..4bd32c6 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -183,8 +183,17 @@ class PlantAssetView(AssetMasterView): plant = f.model_instance # plant_types - f.set_node("plant_types", PlantTypeRefs(self.request)) - f.set_default("plant_types", [t.plant_type_uuid for t in plant._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] + ) + + # season + if self.creating or self.editing: + f.remove("season") # TODO: add support for this def defaults(config, **kwargs): From bc0836fc3ccc0133ed26d9feb317ea50e7ed743d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 19:31:58 -0600 Subject: [PATCH 03/18] fix: reword some menu entries --- src/wuttafarm/web/menus.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index d52a6ca..d56977a 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -156,30 +156,30 @@ class WuttaFarmMenuHandler(base.MenuHandler): }, {"type": "sep"}, { - "title": "Animals", + "title": "Animal Assets", "route": "farmos_animals", "perm": "farmos_animals.list", }, { - "title": "Groups", + "title": "Group Assets", "route": "farmos_groups", "perm": "farmos_groups.list", }, { - "title": "Plants", + "title": "Land Assets", + "route": "farmos_land_assets", + "perm": "farmos_land_assets.list", + }, + { + "title": "Plant Assets", "route": "farmos_asset_plant", "perm": "farmos_asset_plant.list", }, { - "title": "Structures", + "title": "Structure Assets", "route": "farmos_structures", "perm": "farmos_structures.list", }, - { - "title": "Land", - "route": "farmos_land_assets", - "perm": "farmos_land_assets.list", - }, {"type": "sep"}, { "title": "Activity Logs", @@ -207,6 +207,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Land Types", + "route": "farmos_land_types", + "perm": "farmos_land_types.list", + }, { "title": "Plant Types", "route": "farmos_plant_types", @@ -217,11 +222,7 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_structure_types", "perm": "farmos_structure_types.list", }, - { - "title": "Land Types", - "route": "farmos_land_types", - "perm": "farmos_land_types.list", - }, + {"type": "sep"}, { "title": "Asset Types", "route": "farmos_asset_types", From 0a0d43aa9f86c47c81c110fc48eca91f7def52f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 20:13:58 -0600 Subject: [PATCH 04/18] feat: add Units table, views, import/export --- .../versions/ea88e72a5fa5_add_units.py | 102 ++++++++++++ src/wuttafarm/db/model/__init__.py | 1 + src/wuttafarm/db/model/unit.py | 81 ++++++++++ src/wuttafarm/farmos/importing/model.py | 152 ++++++++++-------- src/wuttafarm/farmos/importing/wuttafarm.py | 23 +++ src/wuttafarm/importing/farmos.py | 30 ++++ src/wuttafarm/web/menus.py | 10 ++ src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/common.py | 5 + src/wuttafarm/web/views/farmos/__init__.py | 1 + .../web/views/farmos/animal_types.py | 90 +---------- src/wuttafarm/web/views/farmos/master.py | 90 +++++++++++ src/wuttafarm/web/views/farmos/plants.py | 83 +--------- src/wuttafarm/web/views/farmos/units.py | 74 +++++++++ src/wuttafarm/web/views/units.py | 95 +++++++++++ 15 files changed, 604 insertions(+), 234 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py create mode 100644 src/wuttafarm/db/model/unit.py create mode 100644 src/wuttafarm/web/views/farmos/units.py create mode 100644 src/wuttafarm/web/views/units.py diff --git a/src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py b/src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py new file mode 100644 index 0000000..e85afed --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py @@ -0,0 +1,102 @@ +"""add Units + +Revision ID: ea88e72a5fa5 +Revises: 82a03f4ef1a4 +Create Date: 2026-02-18 20:01:40.720138 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "ea88e72a5fa5" +down_revision: Union[str, None] = "82a03f4ef1a4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # unit + op.create_table( + "unit", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_unit")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_unit_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_unit_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_unit_name")), + ) + op.create_table( + "unit_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_id", sa.Integer(), 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_unit_version")), + ) + op.create_index( + op.f("ix_unit_version_end_transaction_id"), + "unit_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_unit_version_operation_type"), + "unit_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_unit_version_pk_transaction_id", + "unit_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_unit_version_pk_validity", + "unit_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_unit_version_transaction_id"), + "unit_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # unit + op.drop_index(op.f("ix_unit_version_transaction_id"), table_name="unit_version") + op.drop_index("ix_unit_version_pk_validity", table_name="unit_version") + op.drop_index("ix_unit_version_pk_transaction_id", table_name="unit_version") + op.drop_index(op.f("ix_unit_version_operation_type"), table_name="unit_version") + op.drop_index(op.f("ix_unit_version_end_transaction_id"), table_name="unit_version") + op.drop_table("unit_version") + op.drop_table("unit") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index f9eb790..a0b856d 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,6 +30,7 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models +from .unit import Unit from .asset import AssetType, Asset, AssetParent from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset diff --git a/src/wuttafarm/db/model/unit.py b/src/wuttafarm/db/model/unit.py new file mode 100644 index 0000000..8cbdd5a --- /dev/null +++ b/src/wuttafarm/db/model/unit.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaFarm --Web app to integrate with and extend farmOS +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaFarm. +# +# WuttaFarm is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaFarm. If not, see . +# +################################################################################ +""" +Model definition for Units +""" + +import sqlalchemy as sa + +from wuttjamaican.db import model + + +class Unit(model.Base): + """ + Represents an "unit" (taxonomy term) from farmOS + """ + + __tablename__ = "unit" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Unit", + "model_title_plural": "Units", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the unit. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional description for the unit. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the unit within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the unit. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 9bb0bf5..337649c 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -64,6 +64,81 @@ class ToFarmOS(Importer): return self.app.make_utc(dt) +class ToFarmOSTaxonomy(ToFarmOS): + + farmos_taxonomy_type = None + + supported_fields = [ + "uuid", + "name", + ] + + def get_target_objects(self, **kwargs): + result = self.farmos_client.resource.get( + "taxonomy_term", self.farmos_taxonomy_type + ) + return result["data"] + + 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: + result = self.farmos_client.resource.get_id( + "taxonomy_term", self.farmos_taxonomy_type, str(uuid) + ) + except requests.HTTPError as exc: + if exc.response.status_code == 404: + return None + raise + return result["data"] + + def normalize_target_object(self, obj): + return { + "uuid": UUID(obj["id"]), + "name": obj["attributes"]["name"], + } + + def get_term_payload(self, source_data): + return { + "attributes": { + "name": source_data["name"], + } + } + + 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_term_payload(source_data) + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + normal = self.normalize_target_object(result["data"]) + normal["_new_object"] = result["data"] + return normal + + def update_target_object(self, asset, source_data, target_data=None): + if self.dry_run: + return asset + + payload = self.get_term_payload(source_data) + payload["id"] = str(source_data["uuid"]) + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + return self.normalize_target_object(result["data"]) + + class ToFarmOSAsset(ToFarmOS): """ Base class for asset data importer targeting the farmOS API. @@ -151,6 +226,12 @@ class ToFarmOSAsset(ToFarmOS): return payload +class UnitImporter(ToFarmOSTaxonomy): + + model_title = "Unit" + farmos_taxonomy_type = "unit" + + class AnimalAssetImporter(ToFarmOSAsset): model_title = "AnimalAsset" @@ -209,77 +290,10 @@ class AnimalAssetImporter(ToFarmOSAsset): return payload -class AnimalTypeImporter(ToFarmOS): +class AnimalTypeImporter(ToFarmOSTaxonomy): model_title = "AnimalType" - - supported_fields = [ - "uuid", - "name", - ] - - def get_target_objects(self, **kwargs): - result = self.farmos_client.resource.get("taxonomy_term", "animal_type") - return result["data"] - - 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: - result = self.farmos_client.resource.get_id( - "taxonomy_term", "animal_type", str(uuid) - ) - except requests.HTTPError as exc: - if exc.response.status_code == 404: - return None - raise - return result["data"] - - def normalize_target_object(self, obj): - return { - "uuid": UUID(obj["id"]), - "name": obj["attributes"]["name"], - } - - def get_type_payload(self, source_data): - return { - "attributes": { - "name": source_data["name"], - } - } - - 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_type_payload(source_data) - result = self.farmos_client.resource.send( - "taxonomy_term", "animal_type", payload - ) - normal = self.normalize_target_object(result["data"]) - normal["_new_object"] = result["data"] - return normal - - def update_target_object(self, asset, source_data, target_data=None): - if self.dry_run: - return asset - - payload = self.get_type_payload(source_data) - payload["id"] = str(source_data["uuid"]) - result = self.farmos_client.resource.send( - "taxonomy_term", "animal_type", payload - ) - return self.normalize_target_object(result["data"]) + farmos_taxonomy_type = "animal_type" class GroupAssetImporter(ToFarmOSAsset): diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 5b3a25e..e11663f 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -99,6 +99,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter importers["PlantAsset"] = PlantAssetImporter + importers["Unit"] = UnitImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter @@ -184,6 +185,28 @@ class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporte } +class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter): + """ + WuttaFarm → farmOS API exporter for Units + """ + + source_model_class = model.Unit + + supported_fields = [ + "uuid", + "name", + ] + + drupal_internal_id_field = "drupal_internal__tid" + + def normalize_source_object(self, unit): + return { + "uuid": unit.farmos_uuid or self.app.make_true_uuid(), + "name": unit.name, + "_src_object": unit, + } + + class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter): """ WuttaFarm → farmOS API exporter for Group Assets diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index d1cac19..fc759f5 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -106,6 +106,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["GroupAsset"] = GroupAssetImporter importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter + importers["Unit"] = UnitImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter @@ -821,6 +822,35 @@ class UserImporter(FromFarmOS, ToWutta): ############################## +class UnitImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Units + """ + + model_class = model.Unit + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + result = self.farmos_client.resource.get("taxonomy_term", "unit") + return result["data"] + + def normalize_source_object(self, unit): + """ """ + return { + "farmos_uuid": UUID(unit["id"]), + "drupal_id": unit["attributes"]["drupal_internal__tid"], + "name": unit["attributes"]["name"], + "description": unit["attributes"]["description"], + } + + class LogTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Log Types diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index d56977a..1e62d09 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -139,6 +139,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "log_types", "perm": "log_types.list", }, + { + "title": "Units", + "route": "units", + "perm": "units.list", + }, ], } @@ -233,6 +238,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_log_types", "perm": "farmos_log_types.list", }, + { + "title": "Units", + "route": "farmos_units", + "perm": "farmos_units.list", + }, {"type": "sep"}, { "title": "Users", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index bb710a2..fa335f5 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -41,6 +41,7 @@ def includeme(config): ) # native table views + config.include("wuttafarm.web.views.units") config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.assets") config.include("wuttafarm.web.views.land") diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index ce5fba2..44a9598 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -129,6 +129,11 @@ class CommonView(base.CommonView): "structure_assets.list", "structure_assets.view", "structure_assets.versions", + "units.create", + "units.edit", + "units.list", + "units.view", + "units.versions", ] for perm in site_admin_perms: auth.grant_permission(site_admin, perm) diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index bda5d03..c0f28a8 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -29,6 +29,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") config.include("wuttafarm.web.views.farmos.asset_types") + config.include("wuttafarm.web.views.farmos.units") config.include("wuttafarm.web.views.farmos.land_types") config.include("wuttafarm.web.views.farmos.land_assets") config.include("wuttafarm.web.views.farmos.structure_types") diff --git a/src/wuttafarm/web/views/farmos/animal_types.py b/src/wuttafarm/web/views/farmos/animal_types.py index 94d02d8..03bd42c 100644 --- a/src/wuttafarm/web/views/farmos/animal_types.py +++ b/src/wuttafarm/web/views/farmos/animal_types.py @@ -23,16 +23,10 @@ View for farmOS animal types """ -import datetime - -import colander - -from wuttaweb.forms.schema import WuttaDateTime - -from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.views.farmos.master import TaxonomyMasterView -class AnimalTypeView(FarmOSMasterView): +class AnimalTypeView(TaxonomyMasterView): """ Master view for Animal Types in farmOS. """ @@ -44,90 +38,14 @@ class AnimalTypeView(FarmOSMasterView): route_prefix = "farmos_animal_types" url_prefix = "/farmOS/animal-types" + farmos_taxonomy_type = "animal_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview" - grid_columns = [ - "name", - "description", - "changed", - ] - - sort_defaults = "name" - - form_fields = [ - "name", - "description", - "changed", - ] - - def get_grid_data(self, columns=None, session=None): - animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type") - return [self.normalize_animal_type(t) for t in animal_types["data"]] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - g.set_searchable("name") - - # changed - g.set_renderer("changed", "datetime") - - def get_instance(self): - animal_type = self.farmos_client.resource.get_id( - "taxonomy_term", "animal_type", self.request.matchdict["uuid"] - ) - self.raw_json = animal_type - return self.normalize_animal_type(animal_type["data"]) - - def get_instance_title(self, animal_type): - return animal_type["name"] - - def normalize_animal_type(self, animal_type): - - if changed := animal_type["attributes"]["changed"]: - changed = datetime.datetime.fromisoformat(changed) - changed = self.app.localtime(changed) - - if description := animal_type["attributes"]["description"]: - description = description["value"] - - return { - "uuid": animal_type["id"], - "drupal_id": animal_type["attributes"]["drupal_internal__tid"], - "name": animal_type["attributes"]["name"], - "description": description or colander.null, - "changed": changed, - } - - def configure_form(self, form): - f = form - super().configure_form(f) - - # description - f.set_widget("description", "notes") - - # changed - f.set_node("changed", WuttaDateTime()) - def get_xref_buttons(self, animal_type): + buttons = super().get_xref_buttons(animal_type) model = self.app.model session = self.Session() - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url( - f"/taxonomy/term/{animal_type['drupal_id']}" - ), - target="_blank", - icon_left="external-link-alt", - ) - ] - if wf_animal_type := ( session.query(model.AnimalType) .filter(model.AnimalType.farmos_uuid == animal_type["uuid"]) diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index fff3d2c..56d70b6 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -23,11 +23,14 @@ Base class for farmOS master views """ +import datetime import json +import colander import markdown from wuttaweb.views import MasterView +from wuttaweb.forms.schema import WuttaDateTime from wuttafarm.web.util import save_farmos_oauth2_token @@ -100,3 +103,90 @@ class FarmOSMasterView(MasterView): ) return context + + +class TaxonomyMasterView(FarmOSMasterView): + """ + Base class for farmOS "taxonomy term" views + """ + + farmos_taxonomy_type = None + + grid_columns = [ + "name", + "description", + "changed", + ] + + sort_defaults = "name" + + form_fields = [ + "name", + "description", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get( + "taxonomy_term", self.farmos_taxonomy_type + ) + return [self.normalize_taxonomy_term(t) for t in result["data"]] + + def normalize_taxonomy_term(self, term): + + if changed := term["attributes"]["changed"]: + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + if description := term["attributes"]["description"]: + description = description["value"] + + return { + "uuid": term["id"], + "drupal_id": term["attributes"]["drupal_internal__tid"], + "name": term["attributes"]["name"], + "description": description or colander.null, + "changed": changed, + } + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + result = self.farmos_client.resource.get_id( + "taxonomy_term", self.farmos_taxonomy_type, self.request.matchdict["uuid"] + ) + self.raw_json = result + return self.normalize_taxonomy_term(result["data"]) + + def get_instance_title(self, term): + return term["name"] + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + # changed + f.set_node("changed", WuttaDateTime()) + + def get_xref_buttons(self, term): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/taxonomy/term/{term['drupal_id']}"), + target="_blank", + icon_left="external-link-alt", + ) + ] diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py index f02801f..95a2dab 100644 --- a/src/wuttafarm/web/views/farmos/plants.py +++ b/src/wuttafarm/web/views/farmos/plants.py @@ -30,12 +30,13 @@ import colander from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget +from wuttafarm.web.views.farmos.master import TaxonomyMasterView from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.schema import UsersType, StructureType, FarmOSPlantTypes from wuttafarm.web.forms.widgets import ImageWidget -class PlantTypeView(FarmOSMasterView): +class PlantTypeView(TaxonomyMasterView): """ Master view for Plant Types in farmOS. """ @@ -47,90 +48,14 @@ class PlantTypeView(FarmOSMasterView): route_prefix = "farmos_plant_types" url_prefix = "/farmOS/plant-types" + farmos_taxonomy_type = "plant_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" - grid_columns = [ - "name", - "description", - "changed", - ] - - sort_defaults = "name" - - form_fields = [ - "name", - "description", - "changed", - ] - - def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.resource.get("taxonomy_term", "plant_type") - return [self.normalize_plant_type(t) for t in result["data"]] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - g.set_searchable("name") - - # changed - g.set_renderer("changed", "datetime") - - def get_instance(self): - plant_type = self.farmos_client.resource.get_id( - "taxonomy_term", "plant_type", self.request.matchdict["uuid"] - ) - self.raw_json = plant_type - return self.normalize_plant_type(plant_type["data"]) - - def get_instance_title(self, plant_type): - return plant_type["name"] - - def normalize_plant_type(self, plant_type): - - if changed := plant_type["attributes"]["changed"]: - changed = datetime.datetime.fromisoformat(changed) - changed = self.app.localtime(changed) - - if description := plant_type["attributes"]["description"]: - description = description["value"] - - return { - "uuid": plant_type["id"], - "drupal_id": plant_type["attributes"]["drupal_internal__tid"], - "name": plant_type["attributes"]["name"], - "description": description or colander.null, - "changed": changed, - } - - def configure_form(self, form): - f = form - super().configure_form(f) - - # description - f.set_widget("description", "notes") - - # changed - f.set_node("changed", WuttaDateTime()) - def get_xref_buttons(self, plant_type): + buttons = super().get_xref_buttons(plant_type) model = self.app.model session = self.Session() - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url( - f"/taxonomy/term/{plant_type['drupal_id']}" - ), - target="_blank", - icon_left="external-link-alt", - ) - ] - if wf_plant_type := ( session.query(model.PlantType) .filter(model.PlantType.farmos_uuid == plant_type["uuid"]) diff --git a/src/wuttafarm/web/views/farmos/units.py b/src/wuttafarm/web/views/farmos/units.py new file mode 100644 index 0000000..397614d --- /dev/null +++ b/src/wuttafarm/web/views/farmos/units.py @@ -0,0 +1,74 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS units +""" + +from wuttafarm.web.views.farmos.master import TaxonomyMasterView + + +class UnitView(TaxonomyMasterView): + """ + Master view for Units in farmOS. + """ + + model_name = "farmos_unit" + model_title = "farmOS Unit" + model_title_plural = "farmOS Units" + + route_prefix = "farmos_units" + url_prefix = "/farmOS/units" + + farmos_taxonomy_type = "unit" + farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" + + def get_xref_buttons(self, unit): + buttons = super().get_xref_buttons(unit) + model = self.app.model + session = self.Session() + + if wf_unit := ( + session.query(model.Unit) + .filter(model.Unit.farmos_uuid == unit["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("units.view", uuid=wf_unit.uuid), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + UnitView = kwargs.get("UnitView", base["UnitView"]) + UnitView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py new file mode 100644 index 0000000..28570d8 --- /dev/null +++ b/src/wuttafarm/web/views/units.py @@ -0,0 +1,95 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Units +""" + +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.db.model import Unit + + +class UnitView(WuttaFarmMasterView): + """ + Master view for Units + """ + + model_class = Unit + route_prefix = "units" + url_prefix = "/animal-types" + + farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_farmos_url(self, unit): + return self.app.get_farmos_url(f"/taxonomy/term/{unit.drupal_id}") + + def get_xref_buttons(self, unit): + buttons = super().get_xref_buttons(unit) + + if unit.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_units.view", uuid=unit.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + UnitView = kwargs.get("UnitView", base["UnitView"]) + UnitView.defaults(config) + + +def includeme(config): + defaults(config) From c93660ec4ab7979753ac905022dc62fa6b43faf5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 21:15:18 -0600 Subject: [PATCH 05/18] feat: add Quantity Types table, views, import --- .../1f98d27cabeb_add_quantity_types.py | 119 +++++++++++++++++ src/wuttafarm/db/model/__init__.py | 1 + src/wuttafarm/db/model/quantities.py | 81 ++++++++++++ src/wuttafarm/importing/farmos.py | 30 +++++ src/wuttafarm/web/menus.py | 10 ++ src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/farmos/__init__.py | 1 + .../web/views/farmos/quantity_types.py | 125 ++++++++++++++++++ src/wuttafarm/web/views/quantities.py | 90 +++++++++++++ 9 files changed, 458 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py create mode 100644 src/wuttafarm/db/model/quantities.py create mode 100644 src/wuttafarm/web/views/farmos/quantity_types.py create mode 100644 src/wuttafarm/web/views/quantities.py diff --git a/src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py b/src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py new file mode 100644 index 0000000..816f05c --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py @@ -0,0 +1,119 @@ +"""add Quantity Types + +Revision ID: 1f98d27cabeb +Revises: ea88e72a5fa5 +Create Date: 2026-02-18 21:03:52.245619 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "1f98d27cabeb" +down_revision: Union[str, None] = "ea88e72a5fa5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # quantity_type + op.create_table( + "quantity_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_type")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_quantity_type_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_quantity_type_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_quantity_type_name")), + ) + op.create_table( + "quantity_type_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_id", sa.String(length=50), 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_quantity_type_version") + ), + ) + op.create_index( + op.f("ix_quantity_type_version_end_transaction_id"), + "quantity_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_type_version_operation_type"), + "quantity_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_quantity_type_version_pk_transaction_id", + "quantity_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_quantity_type_version_pk_validity", + "quantity_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_type_version_transaction_id"), + "quantity_type_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # quantity_type + op.drop_index( + op.f("ix_quantity_type_version_transaction_id"), + table_name="quantity_type_version", + ) + op.drop_index( + "ix_quantity_type_version_pk_validity", table_name="quantity_type_version" + ) + op.drop_index( + "ix_quantity_type_version_pk_transaction_id", table_name="quantity_type_version" + ) + op.drop_index( + op.f("ix_quantity_type_version_operation_type"), + table_name="quantity_type_version", + ) + op.drop_index( + op.f("ix_quantity_type_version_end_transaction_id"), + table_name="quantity_type_version", + ) + op.drop_table("quantity_type_version") + op.drop_table("quantity_type") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index a0b856d..827fc70 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -31,6 +31,7 @@ from .users import WuttaFarmUser # wuttafarm proper models from .unit import Unit +from .quantities import QuantityType from .asset import AssetType, Asset, AssetParent from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py new file mode 100644 index 0000000..b66f9bb --- /dev/null +++ b/src/wuttafarm/db/model/quantities.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaFarm --Web app to integrate with and extend farmOS +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaFarm. +# +# WuttaFarm is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaFarm. If not, see . +# +################################################################################ +""" +Model definition for Quantities +""" + +import sqlalchemy as sa + +from wuttjamaican.db import model + + +class QuantityType(model.Base): + """ + Represents an "quantity type" from farmOS + """ + + __tablename__ = "quantity_type" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Quantity Type", + "model_title_plural": "Quantity Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the quantity type. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Description for the quantity type. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the quantity type within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.String(length=50), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the quantity type. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index fc759f5..90a4a7c 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -107,6 +107,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter + importers["QuantityType"] = QuantityTypeImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter @@ -851,6 +852,35 @@ class UnitImporter(FromFarmOS, ToWutta): } +class QuantityTypeImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Quantity Types + """ + + model_class = model.QuantityType + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + result = self.farmos_client.resource.get("quantity_type") + return result["data"] + + def normalize_source_object(self, quantity_type): + """ """ + return { + "farmos_uuid": UUID(quantity_type["id"]), + "drupal_id": quantity_type["attributes"]["drupal_internal__id"], + "name": quantity_type["attributes"]["label"], + "description": quantity_type["attributes"]["description"], + } + + class LogTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Log Types diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 1e62d09..01e0f07 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -139,6 +139,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "log_types", "perm": "log_types.list", }, + { + "title": "Quantity Types", + "route": "quantity_types", + "perm": "quantity_types.list", + }, { "title": "Units", "route": "units", @@ -238,6 +243,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_log_types", "perm": "farmos_log_types.list", }, + { + "title": "Quantity Types", + "route": "farmos_quantity_types", + "perm": "farmos_quantity_types.list", + }, { "title": "Units", "route": "farmos_units", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index fa335f5..21dcbad 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -42,6 +42,7 @@ def includeme(config): # native table views config.include("wuttafarm.web.views.units") + config.include("wuttafarm.web.views.quantities") config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.assets") config.include("wuttafarm.web.views.land") diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index c0f28a8..cfedfb1 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,6 +28,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") + config.include("wuttafarm.web.views.farmos.quantity_types") config.include("wuttafarm.web.views.farmos.asset_types") config.include("wuttafarm.web.views.farmos.units") config.include("wuttafarm.web.views.farmos.land_types") diff --git a/src/wuttafarm/web/views/farmos/quantity_types.py b/src/wuttafarm/web/views/farmos/quantity_types.py new file mode 100644 index 0000000..2b10a0a --- /dev/null +++ b/src/wuttafarm/web/views/farmos/quantity_types.py @@ -0,0 +1,125 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Quantity Types +""" + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class QuantityTypeView(FarmOSMasterView): + """ + View for farmOS Quantity Types + """ + + model_name = "farmos_quantity_type" + model_title = "farmOS Quantity Type" + model_title_plural = "farmOS Quantity Types" + + route_prefix = "farmos_quantity_types" + url_prefix = "/farmOS/quantity-types" + + grid_columns = [ + "label", + "description", + ] + + sort_defaults = "label" + + form_fields = [ + "label", + "description", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get("quantity_type") + return [self.normalize_quantity_type(t) for t in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # label + g.set_link("label") + g.set_searchable("label") + + # description + g.set_searchable("description") + + def get_instance(self): + result = self.farmos_client.resource.get_id( + "quantity_type", "quantity_type", self.request.matchdict["uuid"] + ) + self.raw_json = result + return self.normalize_quantity_type(result["data"]) + + def get_instance_title(self, quantity_type): + return quantity_type["label"] + + def normalize_quantity_type(self, quantity_type): + return { + "uuid": quantity_type["id"], + "drupal_id": quantity_type["attributes"]["drupal_internal__id"], + "label": quantity_type["attributes"]["label"], + "description": quantity_type["attributes"]["description"], + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + def get_xref_buttons(self, quantity_type): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_quantity_type := ( + session.query(model.QuantityType) + .filter(model.QuantityType.farmos_uuid == quantity_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "quantity_types.view", uuid=wf_quantity_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) + QuantityTypeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py new file mode 100644 index 0000000..1291791 --- /dev/null +++ b/src/wuttafarm/web/views/quantities.py @@ -0,0 +1,90 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Quantities +""" + +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.db.model import QuantityType + + +class QuantityTypeView(WuttaFarmMasterView): + """ + Master view for Quantity Types + """ + + model_class = QuantityType + route_prefix = "quantity_types" + url_prefix = "/quantity-types" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, quantity_type): + buttons = super().get_xref_buttons(quantity_type) + + if quantity_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_quantity_types.view", uuid=quantity_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) + QuantityTypeView.defaults(config) + + +def includeme(config): + defaults(config) From cfe2e4b7b4d64d875836a37e1068ec36de4351c9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 17:48:33 -0600 Subject: [PATCH 06/18] feat: add Standard Quantities table, views, import --- .../5b6c87d8cddf_add_standard_quantities.py | 293 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 4 +- src/wuttafarm/db/model/quantities.py | 140 +++++++++ src/wuttafarm/db/model/unit.py | 36 +++ src/wuttafarm/importing/farmos.py | 144 +++++++++ src/wuttafarm/web/forms/schema.py | 41 +++ src/wuttafarm/web/forms/widgets.py | 27 ++ src/wuttafarm/web/menus.py | 21 ++ src/wuttafarm/web/views/farmos/__init__.py | 2 +- src/wuttafarm/web/views/farmos/quantities.py | 278 +++++++++++++++++ .../web/views/farmos/quantity_types.py | 125 -------- src/wuttafarm/web/views/quantities.py | 205 +++++++++++- src/wuttafarm/web/views/units.py | 40 ++- 13 files changed, 1225 insertions(+), 131 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py create mode 100644 src/wuttafarm/web/views/farmos/quantities.py delete mode 100644 src/wuttafarm/web/views/farmos/quantity_types.py diff --git a/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py b/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py new file mode 100644 index 0000000..a6aab9d --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py @@ -0,0 +1,293 @@ +"""add Standard Quantities + +Revision ID: 5b6c87d8cddf +Revises: 1f98d27cabeb +Create Date: 2026-02-19 15:42:19.691148 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "5b6c87d8cddf" +down_revision: Union[str, None] = "1f98d27cabeb" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # measure + op.create_table( + "measure", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("drupal_id", sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_measure")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_measure_drupal_id")), + sa.UniqueConstraint("name", name=op.f("uq_measure_name")), + ) + op.create_table( + "measure_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "drupal_id", sa.String(length=20), 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_measure_version") + ), + ) + op.create_index( + op.f("ix_measure_version_end_transaction_id"), + "measure_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_measure_version_operation_type"), + "measure_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_measure_version_pk_transaction_id", + "measure_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_measure_version_pk_validity", + "measure_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_measure_version_transaction_id"), + "measure_version", + ["transaction_id"], + unique=False, + ) + + # quantity + op.create_table( + "quantity", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("quantity_type_id", sa.String(length=50), nullable=False), + sa.Column("measure_id", sa.String(length=20), nullable=False), + sa.Column("value_numerator", sa.Integer(), nullable=False), + sa.Column("value_denominator", sa.Integer(), nullable=False), + sa.Column("units_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("label", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["measure_id"], + ["measure.drupal_id"], + name=op.f("fk_quantity_measure_id_measure"), + ), + sa.ForeignKeyConstraint( + ["quantity_type_id"], + ["quantity_type.drupal_id"], + name=op.f("fk_quantity_quantity_type_id_quantity_type"), + ), + sa.ForeignKeyConstraint( + ["units_uuid"], ["unit.uuid"], name=op.f("fk_quantity_units_uuid_unit") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_quantity_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_quantity_farmos_uuid")), + ) + op.create_table( + "quantity_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "quantity_type_id", sa.String(length=50), autoincrement=False, nullable=True + ), + sa.Column( + "measure_id", sa.String(length=20), autoincrement=False, nullable=True + ), + sa.Column("value_numerator", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "value_denominator", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "units_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("label", sa.String(length=255), autoincrement=False, nullable=True), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_id", sa.Integer(), 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_quantity_version") + ), + ) + op.create_index( + op.f("ix_quantity_version_end_transaction_id"), + "quantity_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_version_operation_type"), + "quantity_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_quantity_version_pk_transaction_id", + "quantity_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_quantity_version_pk_validity", + "quantity_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_version_transaction_id"), + "quantity_version", + ["transaction_id"], + unique=False, + ) + + # quantity_standard + op.create_table( + "quantity_standard", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["uuid"], ["quantity.uuid"], name=op.f("fk_quantity_standard_uuid_quantity") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_standard")), + ) + op.create_table( + "quantity_standard_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + 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_quantity_standard_version") + ), + ) + op.create_index( + op.f("ix_quantity_standard_version_end_transaction_id"), + "quantity_standard_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_standard_version_operation_type"), + "quantity_standard_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_quantity_standard_version_pk_transaction_id", + "quantity_standard_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_quantity_standard_version_pk_validity", + "quantity_standard_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_standard_version_transaction_id"), + "quantity_standard_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # quantity_standard + op.drop_index( + op.f("ix_quantity_standard_version_transaction_id"), + table_name="quantity_standard_version", + ) + op.drop_index( + "ix_quantity_standard_version_pk_validity", + table_name="quantity_standard_version", + ) + op.drop_index( + "ix_quantity_standard_version_pk_transaction_id", + table_name="quantity_standard_version", + ) + op.drop_index( + op.f("ix_quantity_standard_version_operation_type"), + table_name="quantity_standard_version", + ) + op.drop_index( + op.f("ix_quantity_standard_version_end_transaction_id"), + table_name="quantity_standard_version", + ) + op.drop_table("quantity_standard_version") + op.drop_table("quantity_standard") + + # quantity + op.drop_index( + op.f("ix_quantity_version_transaction_id"), table_name="quantity_version" + ) + op.drop_index("ix_quantity_version_pk_validity", table_name="quantity_version") + op.drop_index( + "ix_quantity_version_pk_transaction_id", table_name="quantity_version" + ) + op.drop_index( + op.f("ix_quantity_version_operation_type"), table_name="quantity_version" + ) + op.drop_index( + op.f("ix_quantity_version_end_transaction_id"), table_name="quantity_version" + ) + op.drop_table("quantity_version") + op.drop_table("quantity") + + # measure + op.drop_index( + op.f("ix_measure_version_transaction_id"), table_name="measure_version" + ) + op.drop_index("ix_measure_version_pk_validity", table_name="measure_version") + op.drop_index("ix_measure_version_pk_transaction_id", table_name="measure_version") + op.drop_index( + op.f("ix_measure_version_operation_type"), table_name="measure_version" + ) + op.drop_index( + op.f("ix_measure_version_end_transaction_id"), table_name="measure_version" + ) + op.drop_table("measure_version") + op.drop_table("measure") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 827fc70..68695e5 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,8 +30,8 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models -from .unit import Unit -from .quantities import QuantityType +from .unit import Unit, Measure +from .quantities import QuantityType, Quantity, StandardQuantity from .asset import AssetType, Asset, AssetParent from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py index b66f9bb..4f537b9 100644 --- a/src/wuttafarm/db/model/quantities.py +++ b/src/wuttafarm/db/model/quantities.py @@ -24,6 +24,8 @@ Model definition for Quantities """ import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr from wuttjamaican.db import model @@ -79,3 +81,141 @@ class QuantityType(model.Base): def __str__(self): return self.name or "" + + +class Quantity(model.Base): + """ + Represents a base quantity record from farmOS + """ + + __tablename__ = "quantity" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Quantity", + "model_title_plural": "All Quantities", + } + + uuid = model.uuid_column() + + quantity_type_id = sa.Column( + sa.String(length=50), + sa.ForeignKey("quantity_type.drupal_id"), + nullable=False, + ) + + quantity_type = orm.relationship(QuantityType) + + measure_id = sa.Column( + sa.String(length=20), + sa.ForeignKey("measure.drupal_id"), + nullable=False, + doc=""" + Measure for the quantity. + """, + ) + + measure = orm.relationship("Measure") + + value_numerator = sa.Column( + sa.Integer(), + nullable=False, + doc=""" + Numerator for the quantity value. + """, + ) + + value_denominator = sa.Column( + sa.Integer(), + nullable=False, + doc=""" + Denominator for the quantity value. + """, + ) + + units_uuid = model.uuid_fk_column("unit.uuid", nullable=False) + units = orm.relationship("Unit") + + label = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional label for the quantity. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the quantity within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the quantity. + """, + ) + + def render_as_text(self, config=None): + measure = str(self.measure or self.measure_id or "") + value = self.value_numerator / self.value_denominator + if config: + app = config.get_app() + value = app.render_quantity(value) + units = str(self.units or "") + return f"( {measure} ) {value} {units}" + + def __str__(self): + return self.render_as_text() + + +class QuantityMixin: + + uuid = model.uuid_fk_column("quantity.uuid", nullable=False, primary_key=True) + + @declared_attr + def quantity(cls): + return orm.relationship(Quantity) + + def render_as_text(self, config=None): + return self.quantity.render_as_text(config) + + def __str__(self): + return self.render_as_text() + + +def add_quantity_proxies(subclass): + Quantity.make_proxy(subclass, "quantity", "farmos_uuid") + Quantity.make_proxy(subclass, "quantity", "drupal_id") + Quantity.make_proxy(subclass, "quantity", "quantity_type") + Quantity.make_proxy(subclass, "quantity", "quantity_type_id") + Quantity.make_proxy(subclass, "quantity", "measure") + Quantity.make_proxy(subclass, "quantity", "measure_id") + Quantity.make_proxy(subclass, "quantity", "value_numerator") + Quantity.make_proxy(subclass, "quantity", "value_denominator") + Quantity.make_proxy(subclass, "quantity", "value_decimal") + Quantity.make_proxy(subclass, "quantity", "units_uuid") + Quantity.make_proxy(subclass, "quantity", "units") + Quantity.make_proxy(subclass, "quantity", "label") + + +class StandardQuantity(QuantityMixin, model.Base): + """ + Represents a Standard Quantity from farmOS + """ + + __tablename__ = "quantity_standard" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Standard Quantity", + "model_title_plural": "Standard Quantities", + "farmos_quantity_type": "standard", + } + + +add_quantity_proxies(StandardQuantity) diff --git a/src/wuttafarm/db/model/unit.py b/src/wuttafarm/db/model/unit.py index 8cbdd5a..e9c6e70 100644 --- a/src/wuttafarm/db/model/unit.py +++ b/src/wuttafarm/db/model/unit.py @@ -28,6 +28,42 @@ import sqlalchemy as sa from wuttjamaican.db import model +class Measure(model.Base): + """ + Represents a "measure" option (for quantities) from farmOS + """ + + __tablename__ = "measure" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Measure", + "model_title_plural": "Measures", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the measure. + """, + ) + + drupal_id = sa.Column( + sa.String(length=20), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the measure. + """, + ) + + def __str__(self): + return self.name or "" + + class Unit(model.Base): """ Represents an "unit" (taxonomy term) from farmOS diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 90a4a7c..5cf2242 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -106,8 +106,10 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["GroupAsset"] = GroupAssetImporter importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter + importers["Measure"] = MeasureImporter importers["Unit"] = UnitImporter importers["QuantityType"] = QuantityTypeImporter + importers["StandardQuantity"] = StandardQuantityImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter @@ -823,6 +825,37 @@ class UserImporter(FromFarmOS, ToWutta): ############################## +class MeasureImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Measures + """ + + model_class = model.Measure + + key = "drupal_id" + + supported_fields = [ + "drupal_id", + "name", + ] + + def get_source_objects(self): + """ """ + response = self.farmos_client.session.get( + self.app.get_farmos_url("/api/quantity/standard/resource/schema") + ) + response.raise_for_status() + data = response.json() + return data["definitions"]["attributes"]["properties"]["measure"]["oneOf"] + + def normalize_source_object(self, measure): + """ """ + return { + "drupal_id": measure["const"], + "name": measure["title"], + } + + class UnitImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Units @@ -1100,3 +1133,114 @@ class ObservationLogImporter(LogImporterBase): "status", "assets", ] + + +class QuantityImporterBase(FromFarmOS, ToWutta): + """ + Base class for farmOS API → WuttaFarm quantity importers + """ + + def get_farmos_quantity_type(self): + return self.model_class.__wutta_hint__["farmos_quantity_type"] + + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare proxy fields + fields.extend( + [ + "farmos_uuid", + "drupal_id", + "quantity_type_id", + "measure_id", + "value_numerator", + "value_denominator", + "units_uuid", + "label", + ] + ) + return fields + + def setup(self): + super().setup() + model = self.app.model + + self.quantity_types_by_farmos_uuid = {} + for quantity_type in self.target_session.query(model.QuantityType): + if quantity_type.farmos_uuid: + self.quantity_types_by_farmos_uuid[quantity_type.farmos_uuid] = ( + quantity_type + ) + + self.units_by_farmos_uuid = {} + for unit in self.target_session.query(model.Unit): + if unit.farmos_uuid: + self.units_by_farmos_uuid[unit.farmos_uuid] = unit + + def get_source_objects(self): + """ """ + quantity_type = self.get_farmos_quantity_type() + result = self.farmos_client.resource.get("quantity", quantity_type) + return result["data"] + + def normalize_source_object(self, quantity): + """ """ + quantity_type_id = None + units_uuid = None + if relationships := quantity.get("relationships"): + + if quantity_type := relationships.get("quantity_type"): + if quantity_type["data"]: + if wf_quantity_type := self.quantity_types_by_farmos_uuid.get( + UUID(quantity_type["data"]["id"]) + ): + quantity_type_id = wf_quantity_type.drupal_id + + if units := relationships.get("units"): + if units["data"]: + if wf_unit := self.units_by_farmos_uuid.get( + UUID(units["data"]["id"]) + ): + units_uuid = wf_unit.uuid + + if not quantity_type_id: + log.warning( + "missing/invalid quantity_type for farmOS Quantity: %s", quantity + ) + return None + + if not units_uuid: + log.warning("missing/invalid units for farmOS Quantity: %s", quantity) + return None + + value = quantity["attributes"]["value"] + + return { + "farmos_uuid": UUID(quantity["id"]), + "drupal_id": quantity["attributes"]["drupal_internal__id"], + "quantity_type_id": quantity_type_id, + "measure_id": quantity["attributes"]["measure"], + "value_numerator": value["numerator"], + "value_denominator": value["denominator"], + "units_uuid": units_uuid, + "label": quantity["attributes"]["label"], + } + + +class StandardQuantityImporter(QuantityImporterBase): + """ + farmOS API → WuttaFarm importer for Standard Quantities + """ + + model_class = model.StandardQuantity + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "quantity_type_id", + "measure_id", + "value_numerator", + "value_denominator", + "units_uuid", + "label", + ] diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 123f662..df2a45c 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -55,6 +55,26 @@ class AnimalTypeRef(ObjectRef): return self.request.route_url("animal_types.view", uuid=animal_type.uuid) +class FarmOSRef(colander.SchemaType): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + """ """ + from wuttafarm.web.forms.widgets import FarmOSRefWidget + + return FarmOSRefWidget(self.request, self.route_prefix, **kwargs) + + class AnimalTypeType(colander.SchemaType): def __init__(self, request, *args, **kwargs): @@ -179,6 +199,27 @@ class StructureTypeRef(ObjectRef): return self.request.route_url("structure_types.view", uuid=structure_type.uuid) +class UnitRef(ObjectRef): + """ + Custom schema type for a :class:`~wuttafarm.db.model.units.Unit` + reference field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): + model = self.app.model + return model.Unit + + def sort_query(self, query): + return query.order_by(self.model_class.name) + + def get_object_url(self, unit): + return self.request.route_url("units.view", uuid=unit.uuid) + + class UsersType(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 d5bf5c2..24c33eb 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -54,6 +54,33 @@ class ImageWidget(Widget): return super().serialize(field, cstruct, **kw) +class FarmOSRefWidget(Widget): + """ + Generic widget to display "any reference field" - as a link to + view the farmOS record it references. Only used by the farmOS + direct API views. + """ + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + obj = json.loads(cstruct) + return tags.link_to( + obj["name"], + self.request.route_url(f"{self.route_prefix}.view", uuid=obj["uuid"]), + ) + + return super().serialize(field, cstruct, **kw) + + class AnimalTypeWidget(Widget): """ Widget to display an "animal type" field. diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 01e0f07..448fb8d 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -134,11 +134,27 @@ class WuttaFarmMenuHandler(base.MenuHandler): "perm": "logs_observation.list", }, {"type": "sep"}, + { + "title": "All Quantities", + "route": "quantities", + "perm": "quantities.list", + }, + { + "title": "Standard Quantities", + "route": "quantities_standard", + "perm": "quantities_standard.list", + }, + {"type": "sep"}, { "title": "Log Types", "route": "log_types", "perm": "log_types.list", }, + { + "title": "Measures", + "route": "measures", + "perm": "measures.list", + }, { "title": "Quantity Types", "route": "quantity_types", @@ -248,6 +264,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_quantity_types", "perm": "farmos_quantity_types.list", }, + { + "title": "Standard Quantities", + "route": "farmos_quantities_standard", + "perm": "farmos_quantities_standard.list", + }, { "title": "Units", "route": "farmos_units", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index cfedfb1..e59ac1f 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,7 +28,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") - config.include("wuttafarm.web.views.farmos.quantity_types") + config.include("wuttafarm.web.views.farmos.quantities") config.include("wuttafarm.web.views.farmos.asset_types") config.include("wuttafarm.web.views.farmos.units") config.include("wuttafarm.web.views.farmos.land_types") diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py new file mode 100644 index 0000000..414474b --- /dev/null +++ b/src/wuttafarm/web/views/farmos/quantities.py @@ -0,0 +1,278 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Quantity Types +""" + +import datetime + +import colander + +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.forms.schema import FarmOSRef + + +class QuantityTypeView(FarmOSMasterView): + """ + View for farmOS Quantity Types + """ + + model_name = "farmos_quantity_type" + model_title = "farmOS Quantity Type" + model_title_plural = "farmOS Quantity Types" + + route_prefix = "farmos_quantity_types" + url_prefix = "/farmOS/quantity-types" + + grid_columns = [ + "label", + "description", + ] + + sort_defaults = "label" + + form_fields = [ + "label", + "description", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get("quantity_type") + return [self.normalize_quantity_type(t) for t in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # label + g.set_link("label") + g.set_searchable("label") + + # description + g.set_searchable("description") + + def get_instance(self): + result = self.farmos_client.resource.get_id( + "quantity_type", "quantity_type", self.request.matchdict["uuid"] + ) + self.raw_json = result + return self.normalize_quantity_type(result["data"]) + + def get_instance_title(self, quantity_type): + return quantity_type["label"] + + def normalize_quantity_type(self, quantity_type): + return { + "uuid": quantity_type["id"], + "drupal_id": quantity_type["attributes"]["drupal_internal__id"], + "label": quantity_type["attributes"]["label"], + "description": quantity_type["attributes"]["description"], + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + def get_xref_buttons(self, quantity_type): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_quantity_type := ( + session.query(model.QuantityType) + .filter(model.QuantityType.farmos_uuid == quantity_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "quantity_types.view", uuid=wf_quantity_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +class QuantityMasterView(FarmOSMasterView): + """ + Base class for Quantity views + """ + + farmos_quantity_type = None + + grid_columns = [ + "measure", + "value", + "label", + "changed", + ] + + sort_defaults = ("changed", "desc") + + form_fields = [ + "measure", + "value", + "units", + "label", + "created", + "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 configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # value + g.set_link("value") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + quantity = self.farmos_client.resource.get_id( + "quantity", self.farmos_quantity_type, self.request.matchdict["uuid"] + ) + self.raw_json = quantity + + data = self.normalize_quantity(quantity["data"]) + + if relationships := quantity["data"].get("relationships"): + + # add units + if units := relationships.get("units"): + if units["data"]: + unit = self.farmos_client.resource.get_id( + "taxonomy_term", "unit", units["data"]["id"] + ) + data["units"] = { + "uuid": unit["data"]["id"], + "name": unit["data"]["attributes"]["name"], + } + + return data + + def get_instance_title(self, quantity): + return quantity["value"] + + def normalize_quantity(self, quantity): + + if created := quantity["attributes"]["created"]: + created = datetime.datetime.fromisoformat(created) + created = self.app.localtime(created) + + if changed := quantity["attributes"]["changed"]: + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + return { + "uuid": quantity["id"], + "drupal_id": quantity["attributes"]["drupal_internal__id"], + "measure": quantity["attributes"]["measure"], + "value": quantity["attributes"]["value"], + "label": quantity["attributes"]["label"] or colander.null, + "created": created, + "changed": changed, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # created + f.set_node("created", WuttaDateTime(self.request)) + f.set_widget("created", WuttaDateTimeWidget(self.request)) + + # changed + f.set_node("changed", WuttaDateTime(self.request)) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + # units + f.set_node("units", FarmOSRef(self.request, "farmos_units")) + + +class StandardQuantityView(QuantityMasterView): + """ + View for farmOS Standard Quantities + """ + + model_name = "farmos_standard_quantity" + model_title = "farmOS Standard Quantity" + model_title_plural = "farmOS Standard Quantities" + + route_prefix = "farmos_quantities_standard" + url_prefix = "/farmOS/quantities/standard" + + farmos_quantity_type = "standard" + + def get_xref_buttons(self, standard_quantity): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_standard_quantity := ( + session.query(model.StandardQuantity) + .join(model.Quantity) + .filter(model.Quantity.farmos_uuid == standard_quantity["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "quantities_standard.view", uuid=wf_standard_quantity.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) + QuantityTypeView.defaults(config) + + StandardQuantityView = kwargs.get( + "StandardQuantityView", base["StandardQuantityView"] + ) + StandardQuantityView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/quantity_types.py b/src/wuttafarm/web/views/farmos/quantity_types.py deleted file mode 100644 index 2b10a0a..0000000 --- a/src/wuttafarm/web/views/farmos/quantity_types.py +++ /dev/null @@ -1,125 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaFarm --Web app to integrate with and extend farmOS -# Copyright © 2026 Lance Edgar -# -# This file is part of WuttaFarm. -# -# WuttaFarm is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# WuttaFarm. If not, see . -# -################################################################################ -""" -View for farmOS Quantity Types -""" - -from wuttafarm.web.views.farmos import FarmOSMasterView - - -class QuantityTypeView(FarmOSMasterView): - """ - View for farmOS Quantity Types - """ - - model_name = "farmos_quantity_type" - model_title = "farmOS Quantity Type" - model_title_plural = "farmOS Quantity Types" - - route_prefix = "farmos_quantity_types" - url_prefix = "/farmOS/quantity-types" - - grid_columns = [ - "label", - "description", - ] - - sort_defaults = "label" - - form_fields = [ - "label", - "description", - ] - - def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.resource.get("quantity_type") - return [self.normalize_quantity_type(t) for t in result["data"]] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # label - g.set_link("label") - g.set_searchable("label") - - # description - g.set_searchable("description") - - def get_instance(self): - result = self.farmos_client.resource.get_id( - "quantity_type", "quantity_type", self.request.matchdict["uuid"] - ) - self.raw_json = result - return self.normalize_quantity_type(result["data"]) - - def get_instance_title(self, quantity_type): - return quantity_type["label"] - - def normalize_quantity_type(self, quantity_type): - return { - "uuid": quantity_type["id"], - "drupal_id": quantity_type["attributes"]["drupal_internal__id"], - "label": quantity_type["attributes"]["label"], - "description": quantity_type["attributes"]["description"], - } - - def configure_form(self, form): - f = form - super().configure_form(f) - - # description - f.set_widget("description", "notes") - - def get_xref_buttons(self, quantity_type): - model = self.app.model - session = self.Session() - buttons = [] - - if wf_quantity_type := ( - session.query(model.QuantityType) - .filter(model.QuantityType.farmos_uuid == quantity_type["uuid"]) - .first() - ): - buttons.append( - self.make_button( - f"View {self.app.get_title()} record", - primary=True, - url=self.request.route_url( - "quantity_types.view", uuid=wf_quantity_type.uuid - ), - icon_left="eye", - ) - ) - - return buttons - - -def defaults(config, **kwargs): - base = globals() - - QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) - QuantityTypeView.defaults(config) - - -def includeme(config): - defaults(config) diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py index 1291791..7d75290 100644 --- a/src/wuttafarm/web/views/quantities.py +++ b/src/wuttafarm/web/views/quantities.py @@ -23,8 +23,24 @@ Master view for Quantities """ +from collections import OrderedDict + +from wuttaweb.db import Session + from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.db.model import QuantityType +from wuttafarm.db.model import QuantityType, Quantity, StandardQuantity +from wuttafarm.web.forms.schema import UnitRef + + +def get_quantity_type_enum(config): + app = config.get_app() + model = app.model + session = Session() + quantity_types = OrderedDict() + query = session.query(model.QuantityType).order_by(model.QuantityType.name) + for quantity_type in query: + quantity_types[quantity_type.drupal_id] = quantity_type.name + return quantity_types class QuantityTypeView(WuttaFarmMasterView): @@ -79,12 +95,199 @@ class QuantityTypeView(WuttaFarmMasterView): return buttons +class QuantityMasterView(WuttaFarmMasterView): + """ + Base class for Quantity master views + """ + + grid_columns = [ + "drupal_id", + "as_text", + "quantity_type", + "measure", + "value", + "units", + "label", + ] + + sort_defaults = ("drupal_id", "desc") + + form_fields = [ + "quantity_type", + "as_text", + "measure", + "value", + "units", + "label", + "farmos_uuid", + "drupal_id", + ] + + def get_query(self, session=None): + """ """ + model = self.app.model + model_class = self.get_model_class() + session = session or self.Session() + query = session.query(model_class) + if model_class is not model.Quantity: + query = query.join(model.Quantity) + query = query.join(model.Measure).join(model.Unit) + return query + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + model_class = self.get_model_class() + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", model.Quantity.drupal_id) + + # as_text + g.set_renderer("as_text", self.render_as_text_for_grid) + g.set_link("as_text") + + # quantity_type + if model_class is not model.Quantity: + g.remove("quantity_type") + else: + g.set_enum("quantity_type", get_quantity_type_enum(self.config)) + + # measure + g.set_sorter("measure", model.Measure.name) + + # value + g.set_renderer("value", self.render_value_for_grid) + + # units + g.set_sorter("units", model.Unit.name) + + # label + g.set_sorter("label", model.Quantity.label) + + # view action links to final quantity record + if model_class is model.Quantity: + + def quantity_url(quantity, i): + return self.request.route_url( + f"quantities_{quantity.quantity_type_id}.view", uuid=quantity.uuid + ) + + g.add_action("view", icon="eye", url=quantity_url) + + 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_instance_title(self, quantity): + return quantity.render_as_text(self.config) + + def configure_form(self, form): + f = form + super().configure_form(f) + quantity = form.model_instance + + # as_text + if self.creating or self.editing: + f.remove("as_text") + else: + f.set_default("as_text", quantity.render_as_text(self.config)) + + # quantity_type + if self.creating: + f.remove("quantity_type") + else: + f.set_readonly("quantity_type") + f.set_default("quantity_type", quantity.quantity_type.name) + + # measure + if self.creating: + f.remove("measure") + else: + f.set_readonly("measure") + f.set_default("measure", quantity.measure.name) + + # value + if self.creating: + f.remove("value") + else: + value = quantity.value_numerator / quantity.value_denominator + value = self.app.render_quantity(value) + f.set_default( + "value", + f"{value} ({quantity.value_numerator} / {quantity.value_denominator})", + ) + + # units + if self.creating: + f.remove("units") + else: + f.set_readonly("units") + f.set_node("units", UnitRef(self.request)) + # TODO: ugh + f.set_default("units", quantity.quantity.units) + + def get_xref_buttons(self, quantity): + buttons = super().get_xref_buttons(quantity) + + if quantity.farmos_uuid: + url = self.request.route_url( + f"farmos_quantities_{quantity.quantity_type_id}.view", + uuid=quantity.farmos_uuid, + ) + buttons.append( + self.make_button( + "View farmOS record", primary=True, url=url, icon_left="eye" + ) + ) + + return buttons + + +class QuantityView(QuantityMasterView): + """ + Master view for All Quantities + """ + + model_class = Quantity + route_prefix = "quantities" + url_prefix = "/quantities" + + viewable = False + creatable = False + editable = False + deletable = False + model_is_versioned = False + + +class StandardQuantityView(QuantityMasterView): + """ + Master view for Standard Quantities + """ + + model_class = StandardQuantity + route_prefix = "quantities_standard" + url_prefix = "/quantities/standard" + + def defaults(config, **kwargs): base = globals() QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) QuantityTypeView.defaults(config) + QuantityView = kwargs.get("QuantityView", base["QuantityView"]) + QuantityView.defaults(config) + + StandardQuantityView = kwargs.get( + "StandardQuantityView", base["StandardQuantityView"] + ) + StandardQuantityView.defaults(config) + def includeme(config): defaults(config) diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index 28570d8..3b86426 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -24,7 +24,40 @@ Master view for Units """ from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.db.model import Unit +from wuttafarm.db.model import Measure, Unit + + +class MeasureView(WuttaFarmMasterView): + """ + Master view for Measures + """ + + model_class = Measure + route_prefix = "measures" + url_prefix = "/measures" + + grid_columns = [ + "name", + "drupal_id", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") class UnitView(WuttaFarmMasterView): @@ -34,7 +67,7 @@ class UnitView(WuttaFarmMasterView): model_class = Unit route_prefix = "units" - url_prefix = "/animal-types" + url_prefix = "/units" farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" @@ -87,6 +120,9 @@ class UnitView(WuttaFarmMasterView): def defaults(config, **kwargs): base = globals() + MeasureView = kwargs.get("MeasureView", base["MeasureView"]) + MeasureView.defaults(config) + UnitView = kwargs.get("UnitView", base["UnitView"]) UnitView.defaults(config) From d884a761ad0048430361fbfad680b141a2d1f700 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 18:56:38 -0600 Subject: [PATCH 07/18] fix: expose farmOS integration mode, URL in app settings although as of now changing the integration mode setting will not actually change any behavior.. but it will refs: #3 --- src/wuttafarm/enum.py | 13 ++++ .../web/templates/appinfo/configure.mako | 28 +++++++ src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/settings.py | 78 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 src/wuttafarm/web/templates/appinfo/configure.mako create mode 100644 src/wuttafarm/web/views/settings.py diff --git a/src/wuttafarm/enum.py b/src/wuttafarm/enum.py index 03181b9..870e4cd 100644 --- a/src/wuttafarm/enum.py +++ b/src/wuttafarm/enum.py @@ -28,6 +28,19 @@ from collections import OrderedDict from wuttjamaican.enum import * +FARMOS_INTEGRATION_MODE_WRAPPER = "wrapper" +FARMOS_INTEGRATION_MODE_MIRROR = "mirror" +FARMOS_INTEGRATION_MODE_NONE = "none" + +FARMOS_INTEGRATION_MODE = OrderedDict( + [ + (FARMOS_INTEGRATION_MODE_WRAPPER, "wrapper (API only)"), + (FARMOS_INTEGRATION_MODE_MIRROR, "mirror (2-way sync)"), + (FARMOS_INTEGRATION_MODE_NONE, "none (standalone)"), + ] +) + + ANIMAL_SEX = OrderedDict( [ ("M", "Male"), diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako new file mode 100644 index 0000000..d9e448f --- /dev/null +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -0,0 +1,28 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> + +<%def name="form_content()"> + ${parent.form_content()} + +

farmOS

+
+ + + + % for value, label in enum.FARMOS_INTEGRATION_MODE.items(): + + % endfor + + + + + + + + +
+ diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 21dcbad..5e31d84 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -36,6 +36,7 @@ def includeme(config): **{ "wuttaweb.views.auth": "wuttafarm.web.views.auth", "wuttaweb.views.common": "wuttafarm.web.views.common", + "wuttaweb.views.settings": "wuttafarm.web.views.settings", "wuttaweb.views.users": "wuttafarm.web.views.users", } ) diff --git a/src/wuttafarm/web/views/settings.py b/src/wuttafarm/web/views/settings.py new file mode 100644 index 0000000..3b2c858 --- /dev/null +++ b/src/wuttafarm/web/views/settings.py @@ -0,0 +1,78 @@ +# -*- 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 . +# +################################################################################ +""" +Custom views for Settings +""" + +from webhelpers2.html import tags + +from wuttaweb.views import settings as base + + +class AppInfoView(base.AppInfoView): + """ + Custom appinfo view + """ + + def get_appinfo_dict(self): + info = super().get_appinfo_dict() + enum = self.app.enum + + mode = self.config.get( + f"{self.app.appname}.farmos_integration_mode", default="wrapper" + ) + + info["farmos_integration"] = { + "label": "farmOS Integration", + "value": enum.FARMOS_INTEGRATION_MODE.get(mode, mode), + } + + url = self.app.get_farmos_url() + info["farmos_url"] = { + "label": "farmOS URL", + "value": tags.link_to(url, url, target="_blank"), + } + + return info + + def configure_get_simple_settings(self): # pylint: disable=empty-docstring + simple_settings = super().configure_get_simple_settings() + simple_settings.extend( + [ + {"name": "farmos.url.base"}, + { + "name": f"{self.app.appname}.farmos_integration_mode", + "default": "wrapper", + }, + ] + ) + return simple_settings + + +def defaults(config, **kwargs): + local = globals() + AppInfoView = kwargs.get("AppInfoView", local["AppInfoView"]) + base.defaults(config, **{"AppInfoView": AppInfoView}) + + +def includeme(config): + defaults(config) From 1f254ca77557333e266334f9804a08be17840b4d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 19:08:15 -0600 Subject: [PATCH 08/18] fix: set *default* instead of configured menu handler --- src/wuttafarm/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttafarm/config.py b/src/wuttafarm/config.py index 831698f..16a7578 100644 --- a/src/wuttafarm/config.py +++ b/src/wuttafarm/config.py @@ -52,7 +52,7 @@ class WuttaFarmConfig(WuttaConfigExtension): # web app menu config.setdefault( - f"{config.appname}.web.menus.handler.spec", + f"{config.appname}.web.menus.handler.default_spec", "wuttafarm.web.menus:WuttaFarmMenuHandler", ) From 87101d6b0451e84638518f61f774ee417231ea33 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 19:21:23 -0600 Subject: [PATCH 09/18] feat: include/exclude certain views, menus based on integration mode refs: #3 --- src/wuttafarm/app.py | 24 ++++ src/wuttafarm/farmos/handler.py | 29 +++++ src/wuttafarm/web/menus.py | 167 ++++++++++++++++++++++++++-- src/wuttafarm/web/views/__init__.py | 36 +++--- src/wuttafarm/web/views/settings.py | 2 +- 5 files changed, 235 insertions(+), 23 deletions(-) diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 087c48a..2df38e9 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -51,6 +51,30 @@ class WuttaFarmAppHandler(base.AppHandler): self.handlers["farmos"] = factory(self.config) return self.handlers["farmos"] + def get_farmos_integration_mode(self): + """ + Returns the integration mode for farmOS, i.e. to control the + app's behavior regarding that. + """ + handler = self.get_farmos_handler() + return handler.get_farmos_integration_mode() + + def is_farmos_mirror(self): + """ + Returns ``True`` if the app is configured in "mirror" + integration mode with regard to farmOS. + """ + handler = self.get_farmos_handler() + return handler.is_farmos_mirror() + + def is_farmos_wrapper(self): + """ + Returns ``True`` if the app is configured in "wrapper" + integration mode with regard to farmOS. + """ + handler = self.get_farmos_handler() + return handler.is_farmos_wrapper() + def get_farmos_url(self, *args, **kwargs): """ Get a farmOS URL. This is a convenience wrapper around diff --git a/src/wuttafarm/farmos/handler.py b/src/wuttafarm/farmos/handler.py index 6eee14f..393d121 100644 --- a/src/wuttafarm/farmos/handler.py +++ b/src/wuttafarm/farmos/handler.py @@ -34,6 +34,35 @@ class FarmOSHandler(GenericHandler): :term:`handler`. """ + def get_farmos_integration_mode(self): + """ + Returns the integration mode for farmOS, i.e. to control the + app's behavior regarding that. + """ + enum = self.app.enum + return self.config.get( + f"{self.app.appname}.farmos_integration_mode", + default=enum.FARMOS_INTEGRATION_MODE_WRAPPER, + ) + + def is_farmos_mirror(self): + """ + Returns ``True`` if the app is configured in "mirror" + integration mode with regard to farmOS. + """ + enum = self.app.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR + + def is_farmos_wrapper(self): + """ + Returns ``True`` if the app is configured in "wrapper" + integration mode with regard to farmOS. + """ + enum = self.app.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER + def get_farmos_client(self, hostname=None, **kwargs): """ Returns a new farmOS API client. diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 448fb8d..408fd2e 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -32,12 +32,31 @@ class WuttaFarmMenuHandler(base.MenuHandler): """ def make_menus(self, request, **kwargs): - return [ - self.make_asset_menu(request), - self.make_log_menu(request), - self.make_farmos_menu(request), - self.make_admin_menu(request, include_people=True), - ] + enum = self.app.enum + mode = self.app.get_farmos_integration_mode() + + if mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER: + return [ + self.make_farmos_asset_menu(request), + self.make_farmos_log_menu(request), + self.make_farmos_other_menu(request), + self.make_admin_menu(request, include_people=True), + ] + + elif mode == enum.FARMOS_INTEGRATION_MODE_MIRROR: + return [ + self.make_asset_menu(request), + self.make_log_menu(request), + self.make_farmos_full_menu(request), + self.make_admin_menu(request, include_people=True), + ] + + else: # FARMOS_INTEGRATION_MODE_NONE + return [ + self.make_asset_menu(request), + self.make_log_menu(request), + self.make_admin_menu(request, include_people=True), + ] def make_asset_menu(self, request): return { @@ -168,7 +187,7 @@ class WuttaFarmMenuHandler(base.MenuHandler): ], } - def make_farmos_menu(self, request): + def make_farmos_full_menu(self, request): config = request.wutta_config app = config.get_app() return { @@ -282,3 +301,137 @@ class WuttaFarmMenuHandler(base.MenuHandler): }, ], } + + def make_farmos_asset_menu(self, request): + config = request.wutta_config + app = config.get_app() + return { + "title": "Assets", + "type": "menu", + "items": [ + { + "title": "Animal", + "route": "farmos_animals", + "perm": "farmos_animals.list", + }, + { + "title": "Group", + "route": "farmos_groups", + "perm": "farmos_groups.list", + }, + { + "title": "Land", + "route": "farmos_land_assets", + "perm": "farmos_land_assets.list", + }, + { + "title": "Plant", + "route": "farmos_asset_plant", + "perm": "farmos_asset_plant.list", + }, + { + "title": "Structure", + "route": "farmos_structures", + "perm": "farmos_structures.list", + }, + {"type": "sep"}, + { + "title": "Animal Types", + "route": "farmos_animal_types", + "perm": "farmos_animal_types.list", + }, + { + "title": "Land Types", + "route": "farmos_land_types", + "perm": "farmos_land_types.list", + }, + { + "title": "Plant Types", + "route": "farmos_plant_types", + "perm": "farmos_plant_types.list", + }, + { + "title": "Structure Types", + "route": "farmos_structure_types", + "perm": "farmos_structure_types.list", + }, + {"type": "sep"}, + { + "title": "Asset Types", + "route": "farmos_asset_types", + "perm": "farmos_asset_types.list", + }, + ], + } + + def make_farmos_log_menu(self, request): + config = request.wutta_config + app = config.get_app() + return { + "title": "Logs", + "type": "menu", + "items": [ + { + "title": "Activity", + "route": "farmos_logs_activity", + "perm": "farmos_logs_activity.list", + }, + { + "title": "Harvest", + "route": "farmos_logs_harvest", + "perm": "farmos_logs_harvest.list", + }, + { + "title": "Medical", + "route": "farmos_logs_medical", + "perm": "farmos_logs_medical.list", + }, + { + "title": "Observation", + "route": "farmos_logs_observation", + "perm": "farmos_logs_observation.list", + }, + {"type": "sep"}, + { + "title": "Log Types", + "route": "farmos_log_types", + "perm": "farmos_log_types.list", + }, + { + "title": "Quantity Types", + "route": "farmos_quantity_types", + "perm": "farmos_quantity_types.list", + }, + { + "title": "Standard Quantities", + "route": "farmos_quantities_standard", + "perm": "farmos_quantities_standard.list", + }, + { + "title": "Units", + "route": "farmos_units", + "perm": "farmos_units.list", + }, + ], + } + + def make_farmos_other_menu(self, request): + config = request.wutta_config + app = config.get_app() + return { + "title": "farmOS", + "type": "menu", + "items": [ + { + "title": "Go to farmOS", + "url": app.get_farmos_url(), + "target": "_blank", + }, + {"type": "sep"}, + { + "title": "Users", + "route": "farmos_users", + "perm": "farmos_users.list", + }, + ], + } diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 5e31d84..6f77e57 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -29,6 +29,10 @@ from .master import WuttaFarmMasterView def includeme(config): + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + enum = app.enum + mode = app.get_farmos_integration_mode() # wuttaweb core essential.defaults( @@ -42,20 +46,22 @@ def includeme(config): ) # native table views - config.include("wuttafarm.web.views.units") - config.include("wuttafarm.web.views.quantities") - config.include("wuttafarm.web.views.asset_types") - config.include("wuttafarm.web.views.assets") - config.include("wuttafarm.web.views.land") - config.include("wuttafarm.web.views.structures") - config.include("wuttafarm.web.views.animals") - config.include("wuttafarm.web.views.groups") - config.include("wuttafarm.web.views.plants") - config.include("wuttafarm.web.views.logs") - config.include("wuttafarm.web.views.logs_activity") - config.include("wuttafarm.web.views.logs_harvest") - config.include("wuttafarm.web.views.logs_medical") - config.include("wuttafarm.web.views.logs_observation") + if mode != enum.FARMOS_INTEGRATION_MODE_WRAPPER: + config.include("wuttafarm.web.views.units") + config.include("wuttafarm.web.views.quantities") + config.include("wuttafarm.web.views.asset_types") + config.include("wuttafarm.web.views.assets") + config.include("wuttafarm.web.views.land") + config.include("wuttafarm.web.views.structures") + config.include("wuttafarm.web.views.animals") + config.include("wuttafarm.web.views.groups") + config.include("wuttafarm.web.views.plants") + config.include("wuttafarm.web.views.logs") + config.include("wuttafarm.web.views.logs_activity") + config.include("wuttafarm.web.views.logs_harvest") + config.include("wuttafarm.web.views.logs_medical") + config.include("wuttafarm.web.views.logs_observation") # views for farmOS - config.include("wuttafarm.web.views.farmos") + if mode != enum.FARMOS_INTEGRATION_MODE_NONE: + config.include("wuttafarm.web.views.farmos") diff --git a/src/wuttafarm/web/views/settings.py b/src/wuttafarm/web/views/settings.py index 3b2c858..6372c40 100644 --- a/src/wuttafarm/web/views/settings.py +++ b/src/wuttafarm/web/views/settings.py @@ -61,7 +61,7 @@ class AppInfoView(base.AppInfoView): {"name": "farmos.url.base"}, { "name": f"{self.app.appname}.farmos_integration_mode", - "default": "wrapper", + "default": self.app.get_farmos_integration_mode(), }, ] ) From 9cfa91e091d8579e5333a410589a69b5ce068655 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 19:56:44 -0600 Subject: [PATCH 10/18] fix: standardize a bit more for the farmOS Animal Assets view --- src/wuttafarm/web/menus.py | 8 +- src/wuttafarm/web/views/animals.py | 2 +- src/wuttafarm/web/views/assets.py | 2 +- src/wuttafarm/web/views/common.py | 4 +- src/wuttafarm/web/views/farmos/animals.py | 176 +++++----------------- src/wuttafarm/web/views/farmos/assets.py | 175 +++++++++++++++++++++ 6 files changed, 222 insertions(+), 145 deletions(-) create mode 100644 src/wuttafarm/web/views/farmos/assets.py diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 408fd2e..c79acec 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -202,8 +202,8 @@ class WuttaFarmMenuHandler(base.MenuHandler): {"type": "sep"}, { "title": "Animal Assets", - "route": "farmos_animals", - "perm": "farmos_animals.list", + "route": "farmos_assets_animal", + "perm": "farmos_assets_animal.list", }, { "title": "Group Assets", @@ -311,8 +311,8 @@ class WuttaFarmMenuHandler(base.MenuHandler): "items": [ { "title": "Animal", - "route": "farmos_animals", - "perm": "farmos_animals.list", + "route": "farmos_assets_animal", + "perm": "farmos_assets_animal.list", }, { "title": "Group", diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 72a05ee..09162b2 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -153,11 +153,11 @@ class AnimalAssetView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", + "produces_eggs", "animal_type", "birthdate", "is_sterile", "sex", - "produces_eggs", "archived", ] diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b918839..85835f9 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -282,7 +282,7 @@ class AssetMasterView(WuttaFarmMasterView): # TODO route = None if asset.asset_type == "animal": - route = "farmos_animals.view" + route = "farmos_assets_animal.view" elif asset.asset_type == "group": route = "farmos_groups.view" elif asset.asset_type == "land": diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 44a9598..2efd5c6 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -67,8 +67,8 @@ class CommonView(base.CommonView): "asset_types.versions", "farmos_animal_types.list", "farmos_animal_types.view", - "farmos_animals.list", - "farmos_animals.view", + "farmos_assets_animal.list", + "farmos_assets_animal.view", "farmos_asset_types.list", "farmos_asset_types.view", "farmos_groups.list", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index c9c2887..3a79c8c 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -30,28 +30,27 @@ import colander from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget -from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType, AnimalTypeType, StructureType -from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.web.views.farmos.assets import AssetMasterView +from wuttafarm.web.forms.schema import AnimalTypeType -class AnimalView(FarmOSMasterView): +class AnimalView(AssetMasterView): """ Master view for Farm Animals """ - model_name = "farmos_animal" - model_title = "farmOS Animal" - model_title_plural = "farmOS Animals" + model_name = "farmos_animal_asset" + model_title = "farmOS Animal Asset" + model_title_plural = "farmOS Animal Assets" - route_prefix = "farmos_animals" - url_prefix = "/farmOS/animals" + route_prefix = "farmos_assets_animal" + url_prefix = "/farmOS/assets/animal" + farmos_asset_type = "animal" farmos_refurl_path = "/assets/animal" labels = { "animal_type": "Species / Breed", - "location": "Current Location", } grid_columns = [ @@ -62,8 +61,6 @@ class AnimalView(FarmOSMasterView): "archived", ] - sort_defaults = "name" - form_fields = [ "name", "animal_type", @@ -80,38 +77,21 @@ class AnimalView(FarmOSMasterView): "image", ] - def get_grid_data(self, columns=None, session=None): - animals = self.farmos_client.resource.get("asset", "animal") - return [self.normalize_animal(a) for a in animals["data"]] - def configure_grid(self, grid): g = grid super().configure_grid(g) - # name - g.set_link("name") - g.set_searchable("name") - # birthdate g.set_renderer("birthdate", "date") # is_sterile g.set_renderer("is_sterile", "boolean") - # archived - g.set_renderer("archived", "boolean") - def get_instance(self): - animal = self.farmos_client.resource.get_id( - "asset", "animal", self.request.matchdict["uuid"] - ) - self.raw_json = animal + data = super().get_instance() - # instance data - data = self.normalize_animal(animal["data"]) - - if relationships := animal["data"].get("relationships"): + if relationships := self.raw_json["data"].get("relationships"): # add animal type if animal_type := relationships.get("animal_type"): @@ -124,54 +104,11 @@ class AnimalView(FarmOSMasterView): "name": animal_type["data"]["attributes"]["name"], } - # add location - if location := relationships.get("location"): - if location["data"]: - location = self.farmos_client.resource.get_id( - "asset", "structure", location["data"][0]["id"] - ) - data["location"] = { - "uuid": location["data"]["id"], - "name": location["data"]["attributes"]["name"], - } - - # add owners - if owner := relationships.get("owner"): - data["owners"] = [] - for owner_data in owner["data"]: - owner = self.farmos_client.resource.get_id( - "user", "user", owner_data["id"] - ) - data["owners"].append( - { - "uuid": owner["data"]["id"], - "display_name": owner["data"]["attributes"]["display_name"], - } - ) - - # add image urls - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - data["raw_image_url"] = self.app.get_farmos_url( - image["data"]["attributes"]["uri"]["url"] - ) - # nb. other styles available: medium, wide - data["large_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["large"] - data["thumbnail_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["thumbnail"] - return data - def get_instance_title(self, animal): - return animal["name"] + def normalize_asset(self, animal): - def normalize_animal(self, animal): + normal = super().normalize_asset(animal) birthdate = animal["attributes"]["birthdate"] if birthdate: @@ -184,30 +121,19 @@ class AnimalView(FarmOSMasterView): else: sterile = animal["attributes"]["is_castrated"] - if notes := animal["attributes"]["notes"]: - notes = notes["value"] + normal.update( + { + "birthdate": birthdate, + "sex": animal["attributes"]["sex"] or colander.null, + "is_sterile": sterile, + } + ) - if self.farmos_4x: - archived = animal["attributes"]["archived"] - else: - archived = animal["attributes"]["status"] == "archived" - - return { - "uuid": animal["id"], - "drupal_id": animal["attributes"]["drupal_internal__id"], - "name": animal["attributes"]["name"], - "birthdate": birthdate, - "sex": animal["attributes"]["sex"] or colander.null, - "is_sterile": sterile, - "location": colander.null, # TODO - "archived": archived, - "notes": notes or colander.null, - } + return normal def configure_form(self, form): f = form super().configure_form(f) - animal = f.model_instance # animal_type f.set_node("animal_type", AnimalTypeType(self.request)) @@ -219,52 +145,28 @@ class AnimalView(FarmOSMasterView): # is_sterile f.set_node("is_sterile", colander.Boolean()) - # location - f.set_node("location", StructureType(self.request)) - - # owners - f.set_node("owners", UsersType(self.request)) - - # notes - f.set_widget("notes", "notes") - - # archived - f.set_node("archived", colander.Boolean()) - - # image - if url := animal.get("large_image_url"): - f.set_widget("image", ImageWidget("animal image")) - f.set_default("image", url) - def get_xref_buttons(self, animal): - model = self.app.model - session = self.Session() + buttons = super().get_xref_buttons(animal) - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url(f"/asset/{animal['drupal_id']}"), - target="_blank", - icon_left="external-link-alt", - ), - ] + if self.app.is_farmos_mirror(): + model = self.app.model + session = self.Session() - if wf_animal := ( - session.query(model.Asset) - .filter(model.Asset.farmos_uuid == animal["uuid"]) - .first() - ): - buttons.append( - self.make_button( - f"View {self.app.get_title()} record", - primary=True, - url=self.request.route_url( - "animal_assets.view", uuid=wf_animal.uuid - ), - icon_left="eye", + if wf_animal := ( + session.query(model.Asset) + .filter(model.Asset.farmos_uuid == animal["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "animal_assets.view", uuid=wf_animal.uuid + ), + icon_left="eye", + ) ) - ) return buttons diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py new file mode 100644 index 0000000..31f21c9 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -0,0 +1,175 @@ +# -*- 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 . +# +################################################################################ +""" +Base class for Asset master views +""" + +import colander + +from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.forms.schema import UsersType, StructureType +from wuttafarm.web.forms.widgets import ImageWidget + + +class AssetMasterView(FarmOSMasterView): + """ + Base class for Asset master views + """ + + farmos_asset_type = None + + labels = { + "name": "Asset Name", + "location": "Current Location", + } + + grid_columns = [ + "name", + "archived", + ] + + sort_defaults = "name" + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.asset.get(self.farmos_asset_type) + return [self.normalize_asset(a) for a in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # archived + g.set_renderer("archived", "boolean") + + def get_instance(self): + asset = self.farmos_client.resource.get_id( + "asset", self.farmos_asset_type, self.request.matchdict["uuid"] + ) + self.raw_json = asset + + # instance data + data = self.normalize_asset(asset["data"]) + + if relationships := asset["data"].get("relationships"): + + # add location + if location := relationships.get("location"): + if location["data"]: + location = self.farmos_client.resource.get_id( + "asset", "structure", location["data"][0]["id"] + ) + data["location"] = { + "uuid": location["data"]["id"], + "name": location["data"]["attributes"]["name"], + } + + # add owners + if owner := relationships.get("owner"): + data["owners"] = [] + for owner_data in owner["data"]: + owner = self.farmos_client.resource.get_id( + "user", "user", owner_data["id"] + ) + data["owners"].append( + { + "uuid": owner["data"]["id"], + "display_name": owner["data"]["attributes"]["display_name"], + } + ) + + # add image urls + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + data["raw_image_url"] = self.app.get_farmos_url( + image["data"]["attributes"]["uri"]["url"] + ) + # nb. other styles available: medium, wide + data["large_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["large"] + data["thumbnail_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["thumbnail"] + + return data + + def get_instance_title(self, asset): + return asset["name"] + + def normalize_asset(self, asset): + + if notes := asset["attributes"]["notes"]: + notes = notes["value"] + + if self.farmos_4x: + archived = asset["attributes"]["archived"] + else: + archived = asset["attributes"]["status"] == "archived" + + return { + "uuid": asset["id"], + "drupal_id": asset["attributes"]["drupal_internal__id"], + "name": asset["attributes"]["name"], + "location": colander.null, # TODO + "notes": notes or colander.null, + "archived": archived, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + animal = f.model_instance + + # location + f.set_node("location", StructureType(self.request)) + + # owners + f.set_node("owners", UsersType(self.request)) + + # notes + f.set_widget("notes", "notes") + + # archived + f.set_node("archived", colander.Boolean()) + + # image + if url := animal.get("large_image_url"): + f.set_widget("image", ImageWidget("animal image")) + f.set_default("image", url) + + def get_xref_buttons(self, asset): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/asset/{asset['drupal_id']}"), + target="_blank", + icon_left="external-link-alt", + ), + ] From bbb1207b271070f6b6c77c9567f1997a2b1c39fe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 13:23:20 -0600 Subject: [PATCH 11/18] feat: add backend filters, sorting for farmOS animal types, assets could not add pagination due to quirks with how Drupal JSONAPI works for that. but so far it looks like we can add filter/sort to all of the farmOS grids..now just need to do it --- src/wuttafarm/web/grids.py | 200 ++++++++++++++++++++++ src/wuttafarm/web/views/farmos/animals.py | 30 +++- src/wuttafarm/web/views/farmos/assets.py | 39 ++++- src/wuttafarm/web/views/farmos/master.py | 19 +- 4 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 src/wuttafarm/web/grids.py diff --git a/src/wuttafarm/web/grids.py b/src/wuttafarm/web/grids.py new file mode 100644 index 0000000..198d591 --- /dev/null +++ b/src/wuttafarm/web/grids.py @@ -0,0 +1,200 @@ +# -*- 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 . +# +################################################################################ +""" +Custom grid stuff for use with farmOS / JSONAPI +""" + +from wuttaweb.grids.filters import GridFilter + + +class SimpleFilter(GridFilter): + + default_verbs = ["equal", "not_equal"] + + def __init__(self, request, key, path=None, **kwargs): + super().__init__(request, key, **kwargs) + self.path = path or key + + def filter_equal(self, data, value): + if value: + data.add_filter(self.path, "=", value) + return data + + def filter_not_equal(self, data, value): + if value: + data.add_filter(self.path, "<>", value) + return data + + def filter_is_null(self, data, value): + data.add_filter(self.path, "IS NULL", None) + return data + + def filter_is_not_null(self, data, value): + data.add_filter(self.path, "IS NOT NULL", None) + return data + + +class StringFilter(SimpleFilter): + + default_verbs = ["contains", "equal", "not_equal"] + + def filter_contains(self, data, value): + if value: + data.add_filter(self.path, "CONTAINS", value) + return data + + +class NullableStringFilter(StringFilter): + + default_verbs = ["contains", "equal", "not_equal", "is_null", "is_not_null"] + + +class IntegerFilter(SimpleFilter): + + default_verbs = [ + "equal", + "not_equal", + "less_than", + "less_equal", + "greater_than", + "greater_equal", + ] + + def filter_less_than(self, data, value): + if value: + data.add_filter(self.path, "<", value) + return data + + def filter_less_equal(self, data, value): + if value: + data.add_filter(self.path, "<=", value) + return data + + def filter_greater_than(self, data, value): + if value: + data.add_filter(self.path, ">", value) + return data + + def filter_greater_equal(self, data, value): + if value: + data.add_filter(self.path, ">=", value) + return data + + +class NullableIntegerFilter(IntegerFilter): + + default_verbs = ["equal", "not_equal", "is_null", "is_not_null"] + + +class BooleanFilter(SimpleFilter): + + default_verbs = ["is_true", "is_false"] + + def filter_is_true(self, data, value): + data.add_filter(self.path, "=", 1) + return data + + def filter_is_false(self, data, value): + data.add_filter(self.path, "=", 0) + return data + + +class NullableBooleanFilter(BooleanFilter): + + default_verbs = ["is_true", "is_false", "is_null", "is_not_null"] + + +class SimpleSorter: + + def __init__(self, key): + self.key = key + + def __call__(self, data, sortdir): + data.add_sorter(self.key, sortdir) + return data + + +class ResourceData: + + def __init__(self, config, farmos_client, content_type, normalizer=None): + self.config = config + self.farmos_client = farmos_client + self.entity, self.bundle = content_type.split("--") + self.filters = [] + self.sorters = [] + self.normalizer = normalizer + self._data = None + + def __bool__(self): + return True + + def __getitem__(self, subscript): + return self.get_data()[subscript] + + def __len__(self): + return len(self._data) + + def add_filter(self, path, operator, value): + self.filters.append((path, operator, value)) + + def add_sorter(self, path, sortdir): + self.sorters.append((path, sortdir)) + + def get_data(self): + if self._data is None: + params = {} + + for path, operator, value in self.filters: + params[f"filter[{path}][condition][path]"] = path + params[f"filter[{path}][condition][operator]"] = operator + params[f"filter[{path}][condition][value]"] = value + + sorters = [] + for path, sortdir in self.sorters: + prefix = "-" if sortdir == "desc" else "" + sorters.append(f"{prefix}{path}") + if sorters: + params["sort"] = ",".join(sorters) + + # nb. while the API allows for pagination, it does not + # tell me how many total records there are (IIUC). also + # if i ask for e.g. items 21-40 (page 2 @ 20/page) i am + # not guaranteed to get 20 items even if there are plenty + # in the DB, since Drupal may filter some out based on + # permissions. (granted that may not be an issue in + # practice, but can't rule it out.) so the punchline is, + # we fetch "all" (sic) data and send it to the frontend, + # and pagination happens there. + + # TODO: if we ever try again, this sort of works... + # params["page[offset]"] = start + # params["page[limit]"] = stop - start + + result = self.farmos_client.resource.get( + self.entity, self.bundle, params=params + ) + data = result["data"] + if self.normalizer: + data = [self.normalizer(d) for d in data] + + self._data = data + return self._data diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 3a79c8c..ce5cd40 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -31,6 +31,12 @@ from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos.assets import AssetMasterView +from wuttafarm.web.grids import ( + SimpleSorter, + StringFilter, + BooleanFilter, + NullableBooleanFilter, +) from wuttafarm.web.forms.schema import AnimalTypeType @@ -51,13 +57,16 @@ class AnimalView(AssetMasterView): labels = { "animal_type": "Species / Breed", + "is_sterile": "Sterile", } grid_columns = [ + "drupal_id", "name", + "produces_eggs", "birthdate", - "sex", "is_sterile", + "sex", "archived", ] @@ -65,6 +74,7 @@ class AnimalView(AssetMasterView): "name", "animal_type", "birthdate", + "produces_eggs", "sex", "is_sterile", "archived", @@ -80,12 +90,26 @@ class AnimalView(AssetMasterView): def configure_grid(self, grid): g = grid super().configure_grid(g) + enum = self.app.enum + + # produces_eggs + g.set_renderer("produces_eggs", "boolean") + g.set_sorter("produces_eggs", SimpleSorter("produces_eggs")) + g.set_filter("produces_eggs", NullableBooleanFilter) # birthdate g.set_renderer("birthdate", "date") + g.set_sorter("birthdate", SimpleSorter("birthdate")) + + # sex + g.set_enum("sex", enum.ANIMAL_SEX) + g.set_sorter("sex", SimpleSorter("sex")) + g.set_filter("sex", StringFilter) # is_sterile g.set_renderer("is_sterile", "boolean") + g.set_sorter("is_sterile", SimpleSorter("is_sterile")) + g.set_filter("is_sterile", BooleanFilter) def get_instance(self): @@ -126,6 +150,7 @@ class AnimalView(AssetMasterView): "birthdate": birthdate, "sex": animal["attributes"]["sex"] or colander.null, "is_sterile": sterile, + "produces_eggs": animal["attributes"].get("produces_eggs"), } ) @@ -142,6 +167,9 @@ class AnimalView(AssetMasterView): f.set_node("birthdate", WuttaDateTime()) f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + # produces_eggs + f.set_node("produces_eggs", colander.Boolean()) + # is_sterile f.set_node("is_sterile", colander.Boolean()) diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 31f21c9..06f9563 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -28,6 +28,13 @@ import colander from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.schema import UsersType, StructureType from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.web.grids import ( + ResourceData, + StringFilter, + IntegerFilter, + BooleanFilter, + SimpleSorter, +) class AssetMasterView(FarmOSMasterView): @@ -36,6 +43,8 @@ class AssetMasterView(FarmOSMasterView): """ farmos_asset_type = None + filterable = True + sort_on_backend = True labels = { "name": "Asset Name", @@ -43,26 +52,50 @@ class AssetMasterView(FarmOSMasterView): } grid_columns = [ + "drupal_id", "name", "archived", ] sort_defaults = "name" + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + "archived": {"active": True, "verb": "is_false"}, + } + def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.asset.get(self.farmos_asset_type) - return [self.normalize_asset(a) for a in result["data"]] + return ResourceData( + self.config, + self.farmos_client, + f"asset--{self.farmos_asset_type}", + normalizer=self.normalize_asset, + ) def configure_grid(self, grid): g = grid super().configure_grid(g) + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", SimpleSorter("drupal_internal__id")) + g.set_filter("drupal_id", IntegerFilter, path="drupal_internal__id") + # name g.set_link("name") - g.set_searchable("name") + g.set_sorter("name", SimpleSorter("name")) + g.set_filter("name", StringFilter) # archived g.set_renderer("archived", "boolean") + g.set_sorter("archived", SimpleSorter("archived")) + g.set_filter("archived", BooleanFilter) + + def grid_row_class(self, asset, data, i): + """ """ + if asset["archived"]: + return "has-background-warning" + return None def get_instance(self): asset = self.farmos_client.resource.get_id( diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 56d70b6..90e8549 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -33,6 +33,7 @@ from wuttaweb.views import MasterView from wuttaweb.forms.schema import WuttaDateTime from wuttafarm.web.util import save_farmos_oauth2_token +from wuttafarm.web.grids import ResourceData, StringFilter, SimpleSorter class FarmOSMasterView(MasterView): @@ -53,6 +54,7 @@ class FarmOSMasterView(MasterView): farmos_refurl_path = None labels = { + "drupal_id": "Drupal ID", "raw_image_url": "Raw Image URL", "large_image_url": "Large Image URL", "thumbnail_image_url": "Thumbnail Image URL", @@ -111,6 +113,8 @@ class TaxonomyMasterView(FarmOSMasterView): """ farmos_taxonomy_type = None + filterable = True + sort_on_backend = True grid_columns = [ "name", @@ -120,6 +124,10 @@ class TaxonomyMasterView(FarmOSMasterView): sort_defaults = "name" + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + form_fields = [ "name", "description", @@ -127,10 +135,12 @@ class TaxonomyMasterView(FarmOSMasterView): ] def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.resource.get( - "taxonomy_term", self.farmos_taxonomy_type + return ResourceData( + self.config, + self.farmos_client, + f"taxonomy_term--{self.farmos_taxonomy_type}", + normalizer=self.normalize_taxonomy_term, ) - return [self.normalize_taxonomy_term(t) for t in result["data"]] def normalize_taxonomy_term(self, term): @@ -155,7 +165,8 @@ class TaxonomyMasterView(FarmOSMasterView): # name g.set_link("name") - g.set_searchable("name") + g.set_sorter("name", SimpleSorter("name")) + g.set_filter("name", StringFilter) # changed g.set_renderer("changed", "datetime") From 1af2b695dca81ac0e0e9342d5932866eecac25b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 19:02:36 -0600 Subject: [PATCH 12/18] feat: use 'include' API param for better Animal Assets grid data this commit also renames all farmOS asset routes, for some reason. at least now they are consistent --- src/wuttafarm/web/forms/widgets.py | 2 +- src/wuttafarm/web/grids.py | 17 +++- src/wuttafarm/web/menus.py | 32 +++---- src/wuttafarm/web/views/assets.py | 29 ++----- src/wuttafarm/web/views/common.py | 12 +-- src/wuttafarm/web/views/farmos/animals.py | 88 ++++++++++++++++---- src/wuttafarm/web/views/farmos/assets.py | 69 +++++++++++++-- src/wuttafarm/web/views/farmos/groups.py | 2 +- src/wuttafarm/web/views/farmos/plants.py | 4 +- src/wuttafarm/web/views/farmos/structures.py | 4 +- 10 files changed, 188 insertions(+), 71 deletions(-) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 24c33eb..7c807fa 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -189,7 +189,7 @@ class StructureWidget(Widget): return tags.link_to( structure["name"], self.request.route_url( - "farmos_structures.view", uuid=structure["uuid"] + "farmos_structure_assets.view", uuid=structure["uuid"] ), ) diff --git a/src/wuttafarm/web/grids.py b/src/wuttafarm/web/grids.py index 198d591..5e5e87c 100644 --- a/src/wuttafarm/web/grids.py +++ b/src/wuttafarm/web/grids.py @@ -135,12 +135,20 @@ class SimpleSorter: class ResourceData: - def __init__(self, config, farmos_client, content_type, normalizer=None): + def __init__( + self, + config, + farmos_client, + content_type, + include=None, + normalizer=None, + ): self.config = config self.farmos_client = farmos_client self.entity, self.bundle = content_type.split("--") self.filters = [] self.sorters = [] + self.include = include self.normalizer = normalizer self._data = None @@ -189,12 +197,17 @@ class ResourceData: # params["page[offset]"] = start # params["page[limit]"] = stop - start + if self.include: + params["include"] = self.include + result = self.farmos_client.resource.get( self.entity, self.bundle, params=params ) data = result["data"] + included = {obj["id"]: obj for obj in result.get("included", [])} + if self.normalizer: - data = [self.normalizer(d) for d in data] + data = [self.normalizer(d, included) for d in data] self._data = data return self._data diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index c79acec..be59006 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -202,13 +202,13 @@ class WuttaFarmMenuHandler(base.MenuHandler): {"type": "sep"}, { "title": "Animal Assets", - "route": "farmos_assets_animal", - "perm": "farmos_assets_animal.list", + "route": "farmos_animal_assets", + "perm": "farmos_animal_assets.list", }, { "title": "Group Assets", - "route": "farmos_groups", - "perm": "farmos_groups.list", + "route": "farmos_group_assets", + "perm": "farmos_group_assets.list", }, { "title": "Land Assets", @@ -217,13 +217,13 @@ class WuttaFarmMenuHandler(base.MenuHandler): }, { "title": "Plant Assets", - "route": "farmos_asset_plant", - "perm": "farmos_asset_plant.list", + "route": "farmos_plant_assets", + "perm": "farmos_plant_assets.list", }, { "title": "Structure Assets", - "route": "farmos_structures", - "perm": "farmos_structures.list", + "route": "farmos_structure_assets", + "perm": "farmos_structure_assets.list", }, {"type": "sep"}, { @@ -311,13 +311,13 @@ class WuttaFarmMenuHandler(base.MenuHandler): "items": [ { "title": "Animal", - "route": "farmos_assets_animal", - "perm": "farmos_assets_animal.list", + "route": "farmos_animal_assets", + "perm": "farmos_animal_assets.list", }, { "title": "Group", - "route": "farmos_groups", - "perm": "farmos_groups.list", + "route": "farmos_group_assets", + "perm": "farmos_group_assets.list", }, { "title": "Land", @@ -326,13 +326,13 @@ class WuttaFarmMenuHandler(base.MenuHandler): }, { "title": "Plant", - "route": "farmos_asset_plant", - "perm": "farmos_asset_plant.list", + "route": "farmos_plant_assets", + "perm": "farmos_plant_assets.list", }, { "title": "Structure", - "route": "farmos_structures", - "perm": "farmos_structures.list", + "route": "farmos_structure_assets", + "perm": "farmos_structure_assets.list", }, {"type": "sep"}, { diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 85835f9..b78f149 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -278,29 +278,14 @@ class AssetMasterView(WuttaFarmMasterView): buttons = super().get_xref_buttons(asset) if asset.farmos_uuid: - - # TODO - route = None - if asset.asset_type == "animal": - route = "farmos_assets_animal.view" - elif asset.asset_type == "group": - route = "farmos_groups.view" - elif asset.asset_type == "land": - route = "farmos_land_assets.view" - elif asset.asset_type == "plant": - route = "farmos_asset_plant.view" - elif asset.asset_type == "structure": - route = "farmos_structures.view" - - if route: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url(route, uuid=asset.farmos_uuid), - icon_left="eye", - ) + asset_type = self.get_model_class().__wutta_hint__["farmos_asset_type"] + route = f"farmos_{asset_type}_assets.view" + url = self.request.route_url(route, uuid=asset.farmos_uuid) + buttons.append( + self.make_button( + "View farmOS record", primary=True, url=url, icon_left="eye" ) + ) return buttons diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 2efd5c6..f15e92b 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -65,14 +65,14 @@ class CommonView(base.CommonView): "asset_types.list", "asset_types.view", "asset_types.versions", + "farmos_animal_assets.list", + "farmos_animal_assets.view", "farmos_animal_types.list", "farmos_animal_types.view", - "farmos_assets_animal.list", - "farmos_assets_animal.view", "farmos_asset_types.list", "farmos_asset_types.view", - "farmos_groups.list", - "farmos_groups.view", + "farmos_group_assets.list", + "farmos_group_assets.view", "farmos_land_assets.list", "farmos_land_assets.view", "farmos_land_types.list", @@ -87,10 +87,10 @@ class CommonView(base.CommonView): "farmos_logs_medical.view", "farmos_logs_observation.list", "farmos_logs_observation.view", + "farmos_structure_assets.list", + "farmos_structure_assets.view", "farmos_structure_types.list", "farmos_structure_types.view", - "farmos_structures.list", - "farmos_structures.view", "farmos_users.list", "farmos_users.view", "group_assets.create", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index ce5cd40..3f329f0 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -26,6 +26,7 @@ Master view for Farm Animals import datetime import colander +from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget @@ -45,11 +46,11 @@ class AnimalView(AssetMasterView): Master view for Farm Animals """ - model_name = "farmos_animal_asset" + model_name = "farmos_animal_assets" model_title = "farmOS Animal Asset" model_title_plural = "farmOS Animal Assets" - route_prefix = "farmos_assets_animal" + route_prefix = "farmos_animal_assets" url_prefix = "/farmOS/assets/animal" farmos_asset_type = "animal" @@ -57,6 +58,7 @@ class AnimalView(AssetMasterView): labels = { "animal_type": "Species / Breed", + "animal_type_name": "Species / Breed", "is_sterile": "Sterile", } @@ -64,9 +66,13 @@ class AnimalView(AssetMasterView): "drupal_id", "name", "produces_eggs", + "animal_type_name", "birthdate", "is_sterile", "sex", + "groups", + "owners", + "locations", "archived", ] @@ -78,6 +84,7 @@ class AnimalView(AssetMasterView): "sex", "is_sterile", "archived", + "groups", "owners", "location", "notes", @@ -87,6 +94,10 @@ class AnimalView(AssetMasterView): "image", ] + def get_grid_data(self, **kwargs): + kwargs.setdefault("include", "animal_type,group,owner,location") + return super().get_grid_data(**kwargs) + def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -97,6 +108,11 @@ class AnimalView(AssetMasterView): g.set_sorter("produces_eggs", SimpleSorter("produces_eggs")) g.set_filter("produces_eggs", NullableBooleanFilter) + # animal_type_name + g.set_renderer("animal_type_name", self.render_animal_type_for_grid) + g.set_sorter("animal_type_name", SimpleSorter("animal_type.name")) + g.set_filter("animal_type_name", StringFilter, path="animal_type.name") + # birthdate g.set_renderer("birthdate", "date") g.set_sorter("birthdate", SimpleSorter("birthdate")) @@ -106,11 +122,27 @@ class AnimalView(AssetMasterView): g.set_sorter("sex", SimpleSorter("sex")) g.set_filter("sex", StringFilter) + # groups + g.set_label("groups", "Group Membership") + g.set_renderer("groups", self.render_groups_for_grid) + # is_sterile g.set_renderer("is_sterile", "boolean") g.set_sorter("is_sterile", SimpleSorter("is_sterile")) g.set_filter("is_sterile", BooleanFilter) + def render_animal_type_for_grid(self, animal, field, value): + uuid = animal["animal_type"]["uuid"] + url = self.request.route_url("farmos_animal_types.view", uuid=uuid) + return tags.link_to(value, url) + + def render_groups_for_grid(self, animal, field, value): + links = [] + for group in animal["group_objects"]: + url = self.request.route_url("farmos_group_assets.view", uuid=group["uuid"]) + links.append(tags.link_to(group["name"], url)) + return ", ".join(links) + def get_instance(self): data = super().get_instance() @@ -118,21 +150,21 @@ class AnimalView(AssetMasterView): if relationships := self.raw_json["data"].get("relationships"): # add animal type - if animal_type := relationships.get("animal_type"): - if animal_type["data"]: - animal_type = self.farmos_client.resource.get_id( - "taxonomy_term", "animal_type", animal_type["data"]["id"] - ) - data["animal_type"] = { - "uuid": animal_type["data"]["id"], - "name": animal_type["data"]["attributes"]["name"], - } + if not data.get("animal_type"): + if animal_type := relationships.get("animal_type"): + if animal_type["data"]: + animal_type = self.farmos_client.resource.get_id( + "taxonomy_term", "animal_type", animal_type["data"]["id"] + ) + data["animal_type"] = { + "uuid": animal_type["data"]["id"], + "name": animal_type["data"]["attributes"]["name"], + } return data - def normalize_asset(self, animal): - - normal = super().normalize_asset(animal) + def normalize_asset(self, animal, included): + normal = super().normalize_asset(animal, included) birthdate = animal["attributes"]["birthdate"] if birthdate: @@ -145,8 +177,36 @@ class AnimalView(AssetMasterView): else: sterile = animal["attributes"]["is_castrated"] + animal_type = None + animal_type_name = None + group_objects = [] + group_names = [] + if relationships := animal.get("relationships"): + + if animal_type := relationships.get("animal_type"): + if animal_type := included.get(animal_type["data"]["id"]): + animal_type = { + "uuid": animal_type["id"], + "name": animal_type["attributes"]["name"], + } + animal_type_name = animal_type["name"] + + if groups := relationships.get("group"): + for group in groups["data"]: + if group := included.get(group["id"]): + group = { + "uuid": group["id"], + "name": group["attributes"]["name"], + } + group_objects.append(group) + group_names.append(group["name"]) + normal.update( { + "animal_type": animal_type, + "animal_type_name": animal_type_name, + "group_objects": group_objects, + "group_names": group_names, "birthdate": birthdate, "sex": animal["attributes"]["sex"] or colander.null, "is_sterile": sterile, diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 06f9563..1a61d42 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -24,6 +24,7 @@ Base class for Asset master views """ import colander +from webhelpers2.html import tags from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.schema import UsersType, StructureType @@ -48,12 +49,15 @@ class AssetMasterView(FarmOSMasterView): labels = { "name": "Asset Name", - "location": "Current Location", + "owners": "Owner", + "locations": "Location", } grid_columns = [ "drupal_id", "name", + "owners", + "locations", "archived", ] @@ -64,12 +68,14 @@ class AssetMasterView(FarmOSMasterView): "archived": {"active": True, "verb": "is_false"}, } - def get_grid_data(self, columns=None, session=None): + def get_grid_data(self, columns=None, session=None, **kwargs): + kwargs.setdefault("include", "owner,location") + kwargs.setdefault("normalizer", self.normalize_asset) return ResourceData( self.config, self.farmos_client, f"asset--{self.farmos_asset_type}", - normalizer=self.normalize_asset, + **kwargs, ) def configure_grid(self, grid): @@ -86,11 +92,33 @@ class AssetMasterView(FarmOSMasterView): g.set_sorter("name", SimpleSorter("name")) g.set_filter("name", StringFilter) + # owners + g.set_renderer("owners", self.render_owners_for_grid) + + # locations + g.set_renderer("locations", self.render_locations_for_grid) + # archived g.set_renderer("archived", "boolean") g.set_sorter("archived", SimpleSorter("archived")) g.set_filter("archived", BooleanFilter) + def render_owners_for_grid(self, asset, field, value): + links = [] + for user in value: + url = self.request.route_url("farmos_users.view", uuid=user["uuid"]) + links.append(tags.link_to(user["name"], url)) + return ", ".join(links) + + def render_locations_for_grid(self, asset, field, value): + links = [] + for location in value: + asset_type = location["type"].split("--")[1] + route = f"farmos_{asset_type}_assets.view" + url = self.request.route_url(route, uuid=location["uuid"]) + links.append(tags.link_to(location["name"], url)) + return ", ".join(links) + def grid_row_class(self, asset, data, i): """ """ if asset["archived"]: @@ -104,7 +132,7 @@ class AssetMasterView(FarmOSMasterView): self.raw_json = asset # instance data - data = self.normalize_asset(asset["data"]) + data = self.normalize_asset(asset["data"], {}) if relationships := asset["data"].get("relationships"): @@ -155,7 +183,7 @@ class AssetMasterView(FarmOSMasterView): def get_instance_title(self, asset): return asset["name"] - def normalize_asset(self, asset): + def normalize_asset(self, asset, included): if notes := asset["attributes"]["notes"]: notes = notes["value"] @@ -165,12 +193,43 @@ class AssetMasterView(FarmOSMasterView): else: archived = asset["attributes"]["status"] == "archived" + owner_objects = [] + owner_names = [] + location_objects = [] + location_names = [] + if relationships := asset.get("relationships"): + + if owners := relationships.get("owner"): + for user in owners["data"]: + if user := included.get(user["id"]): + user = { + "uuid": user["id"], + "name": user["attributes"]["name"], + } + owner_objects.append(user) + owner_names.append(user["name"]) + + if locations := relationships.get("location"): + for location in locations["data"]: + if location := included.get(location["id"]): + location = { + "uuid": location["id"], + "type": location["type"], + "name": location["attributes"]["name"], + } + location_objects.append(location) + location_names.append(location["name"]) + return { "uuid": asset["id"], "drupal_id": asset["attributes"]["drupal_internal__id"], "name": asset["attributes"]["name"], "location": colander.null, # TODO "notes": notes or colander.null, + "owners": owner_objects, + "owner_names": owner_names, + "locations": location_objects, + "location_names": location_names, "archived": archived, } diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index ddb7278..8794965 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -41,7 +41,7 @@ class GroupView(FarmOSMasterView): model_title = "farmOS Group" model_title_plural = "farmOS Groups" - route_prefix = "farmos_groups" + route_prefix = "farmos_group_assets" url_prefix = "/farmOS/groups" farmos_refurl_path = "/assets/group" diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py index 95a2dab..57bf2d4 100644 --- a/src/wuttafarm/web/views/farmos/plants.py +++ b/src/wuttafarm/web/views/farmos/plants.py @@ -80,11 +80,11 @@ class PlantAssetView(FarmOSMasterView): Master view for farmOS Plant Assets """ - model_name = "farmos_asset_plant" + model_name = "farmos_plant_assets" model_title = "farmOS Plant Asset" model_title_plural = "farmOS Plant Assets" - route_prefix = "farmos_asset_plant" + route_prefix = "farmos_plant_assets" url_prefix = "/farmOS/assets/plant" farmos_refurl_path = "/assets/plant" diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py index 550f432..b6dc97b 100644 --- a/src/wuttafarm/web/views/farmos/structures.py +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -39,11 +39,11 @@ class StructureView(FarmOSMasterView): View for farmOS Structures """ - model_name = "farmos_structure" + model_name = "farmos_structure_asset" model_title = "farmOS Structure" model_title_plural = "farmOS Structures" - route_prefix = "farmos_structures" + route_prefix = "farmos_structure_assets" url_prefix = "/farmOS/structures" farmos_refurl_path = "/assets/structure" From e5e3d3836547077424004ab975d1842155e4bf04 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 20:22:48 -0600 Subject: [PATCH 13/18] fix: add setting to toggle "farmOS-style grid links" not sure yet if users prefer farmOS style, but will assume so by default just to be safe. but i want the "traditional" behavior myself, so setting is needed either way --- .../web/templates/appinfo/configure.mako | 33 +++++++++++++++---- src/wuttafarm/web/util.py | 4 +++ src/wuttafarm/web/views/animals.py | 10 ++++++ src/wuttafarm/web/views/farmos/animals.py | 18 +++++++--- src/wuttafarm/web/views/farmos/assets.py | 26 +++++++++------ src/wuttafarm/web/views/farmos/master.py | 3 +- src/wuttafarm/web/views/master.py | 6 ++++ src/wuttafarm/web/views/settings.py | 7 ++++ 8 files changed, 85 insertions(+), 22 deletions(-) diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako index d9e448f..3760577 100644 --- a/src/wuttafarm/web/templates/appinfo/configure.mako +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -7,6 +7,13 @@

farmOS

+ + + + + - - - - + + Use farmOS-style grid links + + <${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}"> + + +
diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py index 65d637d..cd4ec0d 100644 --- a/src/wuttafarm/web/util.py +++ b/src/wuttafarm/web/util.py @@ -38,3 +38,7 @@ def save_farmos_oauth2_token(request, token): # save token to user session request.session["farmos.oauth2.token"] = token + + +def use_farmos_style_grid_links(config): + return config.get_bool(f"{config.appname}.farmos_style_grid_links", default=True) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 09162b2..7fa6a09 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -23,6 +23,8 @@ Master view for Animals """ +from webhelpers2.html import tags + from wuttaweb.forms.schema import WuttaDictEnum from wuttafarm.db.model import AnimalType, AnimalAsset @@ -189,6 +191,10 @@ class AnimalAssetView(AssetMasterView): g.set_joiner("animal_type", lambda q: q.join(model.AnimalType)) g.set_sorter("animal_type", model.AnimalType.name) g.set_filter("animal_type", model.AnimalType.name) + if self.farmos_style_grid_links: + g.set_renderer("animal_type", self.render_animal_type_for_grid) + else: + g.set_link("animal_type") # birthdate g.set_renderer("birthdate", "date") @@ -196,6 +202,10 @@ class AnimalAssetView(AssetMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) + def render_animal_type_for_grid(self, animal, field, value): + url = self.request.route_url("animal_types.view", uuid=animal.animal_type_uuid) + return tags.link_to(value, url) + def configure_form(self, form): f = form super().configure_form(f) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 3f329f0..44a1cdc 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -109,7 +109,10 @@ class AnimalView(AssetMasterView): g.set_filter("produces_eggs", NullableBooleanFilter) # animal_type_name - g.set_renderer("animal_type_name", self.render_animal_type_for_grid) + if self.farmos_style_grid_links: + g.set_renderer("animal_type_name", self.render_animal_type_for_grid) + else: + g.set_link("animal_type_name") g.set_sorter("animal_type_name", SimpleSorter("animal_type.name")) g.set_filter("animal_type_name", StringFilter, path="animal_type.name") @@ -137,11 +140,16 @@ class AnimalView(AssetMasterView): return tags.link_to(value, url) def render_groups_for_grid(self, animal, field, value): - links = [] + groups = [] for group in animal["group_objects"]: - url = self.request.route_url("farmos_group_assets.view", uuid=group["uuid"]) - links.append(tags.link_to(group["name"], url)) - return ", ".join(links) + if self.farmos_style_grid_links: + url = self.request.route_url( + "farmos_group_assets.view", uuid=group["uuid"] + ) + groups.append(tags.link_to(group["name"], url)) + else: + groups.append(group["name"]) + return ", ".join(groups) def get_instance(self): diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 1a61d42..1b81b35 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -104,20 +104,26 @@ class AssetMasterView(FarmOSMasterView): g.set_filter("archived", BooleanFilter) def render_owners_for_grid(self, asset, field, value): - links = [] + owners = [] for user in value: - url = self.request.route_url("farmos_users.view", uuid=user["uuid"]) - links.append(tags.link_to(user["name"], url)) - return ", ".join(links) + if self.farmos_style_grid_links: + url = self.request.route_url("farmos_users.view", uuid=user["uuid"]) + owners.append(tags.link_to(user["name"], url)) + else: + owners.append(user["name"]) + return ", ".join(owners) def render_locations_for_grid(self, asset, field, value): - links = [] + locations = [] for location in value: - asset_type = location["type"].split("--")[1] - route = f"farmos_{asset_type}_assets.view" - url = self.request.route_url(route, uuid=location["uuid"]) - links.append(tags.link_to(location["name"], url)) - return ", ".join(links) + if self.farmos_style_grid_links: + asset_type = location["type"].split("--")[1] + route = f"farmos_{asset_type}_assets.view" + url = self.request.route_url(route, uuid=location["uuid"]) + locations.append(tags.link_to(location["name"], url)) + else: + locations.append(location["name"]) + return ", ".join(locations) def grid_row_class(self, asset, data, i): """ """ diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 90e8549..ae4b97e 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -32,7 +32,7 @@ import markdown from wuttaweb.views import MasterView from wuttaweb.forms.schema import WuttaDateTime -from wuttafarm.web.util import save_farmos_oauth2_token +from wuttafarm.web.util import save_farmos_oauth2_token, use_farmos_style_grid_links from wuttafarm.web.grids import ResourceData, StringFilter, SimpleSorter @@ -65,6 +65,7 @@ class FarmOSMasterView(MasterView): self.farmos_client = self.get_farmos_client() self.farmos_4x = self.app.is_farmos_4x(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") diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 0e25a30..82d64bc 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -27,6 +27,8 @@ from webhelpers2.html import tags from wuttaweb.views import MasterView +from wuttafarm.web.util import use_farmos_style_grid_links + class WuttaFarmMasterView(MasterView): """ @@ -49,6 +51,10 @@ class WuttaFarmMasterView(MasterView): "thumbnail_url": "Thumbnail URL", } + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.farmos_style_grid_links = use_farmos_style_grid_links(self.config) + def get_farmos_url(self, obj): return None diff --git a/src/wuttafarm/web/views/settings.py b/src/wuttafarm/web/views/settings.py index 6372c40..86d7a0c 100644 --- a/src/wuttafarm/web/views/settings.py +++ b/src/wuttafarm/web/views/settings.py @@ -27,6 +27,8 @@ from webhelpers2.html import tags from wuttaweb.views import settings as base +from wuttafarm.web.util import use_farmos_style_grid_links + class AppInfoView(base.AppInfoView): """ @@ -63,6 +65,11 @@ class AppInfoView(base.AppInfoView): "name": f"{self.app.appname}.farmos_integration_mode", "default": self.app.get_farmos_integration_mode(), }, + { + "name": f"{self.app.appname}.farmos_style_grid_links", + "type": bool, + "default": use_farmos_style_grid_links(self.config), + }, ] ) return simple_settings From 5d7dea5a843e0ddfb594404f2dd372dd91bb57cb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 20:52:08 -0600 Subject: [PATCH 14/18] fix: add thumbnail to farmOS asset base view --- src/wuttafarm/web/views/farmos/animals.py | 3 ++- src/wuttafarm/web/views/farmos/assets.py | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 44a1cdc..e11ff59 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -63,6 +63,7 @@ class AnimalView(AssetMasterView): } grid_columns = [ + "thumbnail", "drupal_id", "name", "produces_eggs", @@ -95,7 +96,7 @@ class AnimalView(AssetMasterView): ] def get_grid_data(self, **kwargs): - kwargs.setdefault("include", "animal_type,group,owner,location") + kwargs.setdefault("include", "image,animal_type,group,owner,location") return super().get_grid_data(**kwargs) def configure_grid(self, grid): diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 1b81b35..c662cf8 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -54,6 +54,7 @@ class AssetMasterView(FarmOSMasterView): } grid_columns = [ + "thumbnail", "drupal_id", "name", "owners", @@ -69,7 +70,7 @@ class AssetMasterView(FarmOSMasterView): } def get_grid_data(self, columns=None, session=None, **kwargs): - kwargs.setdefault("include", "owner,location") + kwargs.setdefault("include", "image,owner,location") kwargs.setdefault("normalizer", self.normalize_asset) return ResourceData( self.config, @@ -82,6 +83,11 @@ class AssetMasterView(FarmOSMasterView): 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) g.set_sorter("drupal_id", SimpleSorter("drupal_internal__id")) @@ -103,6 +109,11 @@ class AssetMasterView(FarmOSMasterView): g.set_sorter("archived", SimpleSorter("archived")) g.set_filter("archived", BooleanFilter) + def render_grid_thumbnail(self, obj, field, value): + if url := obj.get("thumbnail_url"): + return tags.image(url, f"thumbnail for {self.get_model_title()}") + return None + def render_owners_for_grid(self, asset, field, value): owners = [] for user in value: @@ -203,6 +214,7 @@ class AssetMasterView(FarmOSMasterView): owner_names = [] location_objects = [] location_names = [] + thumbnail_url = None if relationships := asset.get("relationships"): if owners := relationships.get("owner"): @@ -226,6 +238,13 @@ class AssetMasterView(FarmOSMasterView): location_objects.append(location) location_names.append(location["name"]) + if images := relationships.get("image"): + for image in images["data"]: + if image := included.get(image["id"]): + thumbnail_url = image["attributes"]["image_style_uri"][ + "thumbnail" + ] + return { "uuid": asset["id"], "drupal_id": asset["attributes"]["drupal_internal__id"], @@ -237,6 +256,7 @@ class AssetMasterView(FarmOSMasterView): "locations": location_objects, "location_names": location_names, "archived": archived, + "thumbnail_url": thumbnail_url, } def configure_form(self, form): From c976d94bdda18db7db07f6b387d64a6e0919b48e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 21:37:57 -0600 Subject: [PATCH 15/18] fix: add grid filter for animal birthdate --- src/wuttafarm/web/grids.py | 107 ++++++++++++++++++++-- src/wuttafarm/web/views/farmos/animals.py | 2 + 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/src/wuttafarm/web/grids.py b/src/wuttafarm/web/grids.py index 5e5e87c..8f4cde5 100644 --- a/src/wuttafarm/web/grids.py +++ b/src/wuttafarm/web/grids.py @@ -23,6 +23,8 @@ Custom grid stuff for use with farmOS / JSONAPI """ +import datetime + from wuttaweb.grids.filters import GridFilter @@ -35,12 +37,12 @@ class SimpleFilter(GridFilter): self.path = path or key def filter_equal(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "=", value) return data def filter_not_equal(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "<>", value) return data @@ -58,7 +60,7 @@ class StringFilter(SimpleFilter): default_verbs = ["contains", "equal", "not_equal"] def filter_contains(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "CONTAINS", value) return data @@ -80,22 +82,22 @@ class IntegerFilter(SimpleFilter): ] def filter_less_than(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "<", value) return data def filter_less_equal(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "<=", value) return data def filter_greater_than(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, ">", value) return data def filter_greater_equal(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, ">=", value) return data @@ -123,6 +125,88 @@ class NullableBooleanFilter(BooleanFilter): default_verbs = ["is_true", "is_false", "is_null", "is_not_null"] +# TODO: this may not work, it's not used anywhere yet +class DateFilter(SimpleFilter): + + data_type = "date" + + default_verbs = [ + "equal", + "not_equal", + "greater_than", + "greater_equal", + "less_than", + "less_equal", + # 'between', + ] + + default_verb_labels = { + "equal": "on", + "not_equal": "not on", + "greater_than": "after", + "greater_equal": "on or after", + "less_than": "before", + "less_equal": "on or before", + # "between": "between", + "is_null": "is null", + "is_not_null": "is not null", + "is_any": "is any", + } + + def coerce_value(self, value): + if value: + if isinstance(value, datetime.date): + return value + + try: + dt = datetime.datetime.strptime(value, "%Y-%m-%d") + except ValueError: + log.warning("invalid date value: %s", value) + else: + return dt.date() + + return None + + +# TODO: this is not very complete yet, so far used only for animal birthdate +class DateTimeFilter(DateFilter): + + default_verbs = ["equal", "is_null", "is_not_null"] + + def coerce_value(self, value): + """ + Convert user input to a proper ``datetime.date`` object. + """ + if value: + if isinstance(value, datetime.date): + return value + + try: + dt = datetime.datetime.strptime(value, "%Y-%m-%d") + except ValueError: + log.warning("invalid date value: %s", value) + else: + return dt.date() + + return None + + def filter_equal(self, data, value): + if value := self.coerce_value(value): + + start = datetime.datetime.combine(value, datetime.time(0)) + start = self.app.localtime(start, from_utc=False) + + stop = datetime.datetime.combine( + value + datetime.timedelta(days=1), datetime.time(0) + ) + stop = self.app.localtime(stop, from_utc=False) + + data.add_filter(self.path, ">=", int(start.timestamp())) + data.add_filter(self.path, "<", int(stop.timestamp())) + + return data + + class SimpleSorter: def __init__(self, key): @@ -171,10 +255,13 @@ class ResourceData: if self._data is None: params = {} + i = 0 for path, operator, value in self.filters: - params[f"filter[{path}][condition][path]"] = path - params[f"filter[{path}][condition][operator]"] = operator - params[f"filter[{path}][condition][value]"] = value + i += 1 + key = f"{i:03d}" + params[f"filter[{key}][condition][path]"] = path + params[f"filter[{key}][condition][operator]"] = operator + params[f"filter[{key}][condition][value]"] = value sorters = [] for path, sortdir in self.sorters: diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index e11ff59..5389d8f 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -37,6 +37,7 @@ from wuttafarm.web.grids import ( StringFilter, BooleanFilter, NullableBooleanFilter, + DateTimeFilter, ) from wuttafarm.web.forms.schema import AnimalTypeType @@ -120,6 +121,7 @@ class AnimalView(AssetMasterView): # birthdate g.set_renderer("birthdate", "date") g.set_sorter("birthdate", SimpleSorter("birthdate")) + g.set_filter("birthdate", DateTimeFilter) # sex g.set_enum("sex", enum.ANIMAL_SEX) From ad6ac13d503337501edb8420a7105b9760113d60 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Feb 2026 18:36:26 -0600 Subject: [PATCH 16/18] feat: add basic CRUD for direct API views: animal types, animal assets --- src/wuttafarm/web/forms/schema.py | 59 +++++++- src/wuttafarm/web/forms/widgets.py | 64 ++++++++- src/wuttafarm/web/views/animals.py | 2 +- src/wuttafarm/web/views/farmos/animals.py | 101 +++++++++++--- src/wuttafarm/web/views/farmos/assets.py | 163 +++++++++++++--------- src/wuttafarm/web/views/farmos/master.py | 59 +++++++- 6 files changed, 351 insertions(+), 97 deletions(-) diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index df2a45c..469a466 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -58,10 +58,50 @@ class AnimalTypeRef(ObjectRef): class FarmOSRef(colander.SchemaType): def __init__(self, request, route_prefix, *args, **kwargs): + self.values = kwargs.pop("values", None) super().__init__(*args, **kwargs) self.request = request self.route_prefix = route_prefix + def get_values(self): + if callable(self.values): + self.values = self.values() + return self.values + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + # nb. keep a ref to this for later use + node.model_instance = appstruct + + # serialize to PK as string + return appstruct["uuid"] + + def deserialize(self, node, cstruct): + if not cstruct: + return colander.null + + # nb. deserialize to PK string, not dict + return cstruct + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSRefWidget + + if not kwargs.get("readonly"): + if "values" not in kwargs: + if values := self.get_values(): + kwargs["values"] = values + + return FarmOSRefWidget(self.request, self.route_prefix, **kwargs) + + +class FarmOSRefs(WuttaSet): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(request, *args, **kwargs) + self.route_prefix = route_prefix + def serialize(self, node, appstruct): if appstruct is colander.null: return colander.null @@ -69,10 +109,23 @@ class FarmOSRef(colander.SchemaType): return json.dumps(appstruct) def widget_maker(self, **kwargs): - """ """ - from wuttafarm.web.forms.widgets import FarmOSRefWidget + from wuttafarm.web.forms.widgets import FarmOSRefsWidget - return FarmOSRefWidget(self.request, self.route_prefix, **kwargs) + return FarmOSRefsWidget(self.request, self.route_prefix, **kwargs) + + +class FarmOSLocationRefs(WuttaSet): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSLocationRefsWidget + + return FarmOSLocationRefsWidget(self.request, **kwargs) class AnimalTypeType(colander.SchemaType): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 7c807fa..dfbaefe 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 +from deform.widget import Widget, SelectWidget from webhelpers2.html import HTML, tags from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget @@ -54,7 +54,7 @@ class ImageWidget(Widget): return super().serialize(field, cstruct, **kw) -class FarmOSRefWidget(Widget): +class FarmOSRefWidget(SelectWidget): """ Generic widget to display "any reference field" - as a link to view the farmOS record it references. Only used by the farmOS @@ -72,7 +72,12 @@ class FarmOSRefWidget(Widget): if cstruct in (colander.null, None): return HTML.tag("span") - obj = json.loads(cstruct) + try: + obj = json.loads(cstruct) + except json.JSONDecodeError: + name = dict(self.values)[cstruct] + obj = {"uuid": cstruct, "name": name} + return tags.link_to( obj["name"], self.request.route_url(f"{self.route_prefix}.view", uuid=obj["uuid"]), @@ -81,6 +86,59 @@ class FarmOSRefWidget(Widget): return super().serialize(field, cstruct, **kw) +class FarmOSRefsWidget(Widget): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + links = [] + for obj in json.loads(cstruct): + url = self.request.route_url( + f"{self.route_prefix}.view", uuid=obj["uuid"] + ) + links.append(HTML.tag("li", c=tags.link_to(obj["name"], url))) + + return HTML.tag("ul", c=links) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSLocationRefsWidget(Widget): + """ + Widget to display a "Locations" field for an asset. + """ + + 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") + + locations = [] + for location in json.loads(cstruct): + asset_type = location["type"].split("--")[1] + url = self.request.route_url( + f"farmos_{asset_type}_assets.view", uuid=location["uuid"] + ) + locations.append(HTML.tag("li", c=tags.link_to(location["name"], url))) + + return HTML.tag("ul", c=locations) + + return super().serialize(field, cstruct, **kw) + + class AnimalTypeWidget(Widget): """ Widget to display an "animal type" field. diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 7fa6a09..76e0335 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -167,9 +167,9 @@ class AnimalAssetView(AssetMasterView): "asset_name", "animal_type", "birthdate", + "produces_eggs", "sex", "is_sterile", - "produces_eggs", "notes", "asset_type", "archived", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 5389d8f..690e7ee 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -28,7 +28,7 @@ import datetime import colander from webhelpers2.html import tags -from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos.assets import AssetMasterView @@ -39,7 +39,7 @@ from wuttafarm.web.grids import ( NullableBooleanFilter, DateTimeFilter, ) -from wuttafarm.web.forms.schema import AnimalTypeType +from wuttafarm.web.forms.schema import FarmOSRef class AnimalView(AssetMasterView): @@ -85,20 +85,23 @@ class AnimalView(AssetMasterView): "produces_eggs", "sex", "is_sterile", - "archived", + "notes", + "asset_type_name", "groups", "owners", - "location", - "notes", - "raw_image_url", - "large_image_url", - "thumbnail_image_url", + "locations", + "archived", + "thumbnail_url", + "image_url", + "thumbnail", "image", ] - def get_grid_data(self, **kwargs): - kwargs.setdefault("include", "image,animal_type,group,owner,location") - return super().get_grid_data(**kwargs) + def get_farmos_api_includes(self): + includes = super().get_farmos_api_includes() + includes.add("animal_type") + includes.add("group") + return includes def configure_grid(self, grid): g = grid @@ -188,19 +191,17 @@ class AnimalView(AssetMasterView): else: sterile = animal["attributes"]["is_castrated"] - animal_type = None - animal_type_name = None + animal_type_object = None group_objects = [] group_names = [] if relationships := animal.get("relationships"): if animal_type := relationships.get("animal_type"): if animal_type := included.get(animal_type["data"]["id"]): - animal_type = { + animal_type_object = { "uuid": animal_type["id"], "name": animal_type["attributes"]["name"], } - animal_type_name = animal_type["name"] if groups := relationships.get("group"): for group in groups["data"]: @@ -214,8 +215,9 @@ class AnimalView(AssetMasterView): normal.update( { - "animal_type": animal_type, - "animal_type_name": animal_type_name, + "animal_type": animal_type_object, + "animal_type_uuid": animal_type_object["uuid"], + "animal_type_name": animal_type_object["name"], "group_objects": group_objects, "group_names": group_names, "birthdate": birthdate, @@ -227,23 +229,78 @@ class AnimalView(AssetMasterView): return normal + def get_animal_types(self): + animal_types = [] + result = self.farmos_client.resource.get( + "taxonomy_term", "animal_type", params={"sort": "name"} + ) + for animal_type in result["data"]: + animal_types.append((animal_type["id"], animal_type["attributes"]["name"])) + return animal_types + def configure_form(self, form): f = form super().configure_form(f) + enum = self.app.enum + animal = f.model_instance # animal_type - f.set_node("animal_type", AnimalTypeType(self.request)) - - # birthdate - f.set_node("birthdate", WuttaDateTime()) - f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + f.set_node( + "animal_type", + FarmOSRef( + self.request, "farmos_animal_types", values=self.get_animal_types + ), + ) # produces_eggs f.set_node("produces_eggs", colander.Boolean()) + # birthdate + f.set_node("birthdate", WuttaDateTime()) + f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + f.set_required("birthdate", False) + + # sex + if not (self.creating or self.editing) and not animal["sex"]: + pass # TODO: dict enum widget does not handle null values well + else: + f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) + f.set_required("sex", False) + # is_sterile f.set_node("is_sterile", colander.Boolean()) + # groups + if self.creating or self.editing: + f.remove("groups") # TODO + + def get_api_payload(self, animal): + payload = super().get_api_payload(animal) + + birthdate = None + if animal["birthdate"]: + birthdate = self.app.localtime(animal["birthdate"]).timestamp() + + attrs = { + "sex": animal["sex"] or None, + "is_sterile": animal["is_sterile"], + "produces_eggs": animal["produces_eggs"], + "birthdate": birthdate, + } + + rels = { + "animal_type": { + "data": { + "id": animal["animal_type"], + "type": "taxonomy_term--animal_type", + } + } + } + + payload["attributes"].update(attrs) + payload.setdefault("relationships", {}).update(rels) + return payload + def get_xref_buttons(self, animal): buttons = super().get_xref_buttons(animal) diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index c662cf8..f985c6b 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -27,7 +27,7 @@ import colander from webhelpers2.html import tags from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType, StructureType +from wuttafarm.web.forms.schema import FarmOSRefs, FarmOSLocationRefs from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.grids import ( ResourceData, @@ -44,13 +44,19 @@ class AssetMasterView(FarmOSMasterView): """ farmos_asset_type = None + creatable = True + editable = True + deletable = True filterable = True sort_on_backend = True labels = { "name": "Asset Name", + "asset_type_name": "Asset Type", "owners": "Owner", "locations": "Location", + "thumbnail_url": "Thumbnail URL", + "image_url": "Image URL", } grid_columns = [ @@ -69,14 +75,13 @@ class AssetMasterView(FarmOSMasterView): "archived": {"active": True, "verb": "is_false"}, } - def get_grid_data(self, columns=None, session=None, **kwargs): - kwargs.setdefault("include", "image,owner,location") - kwargs.setdefault("normalizer", self.normalize_asset) + def get_grid_data(self, **kwargs): return ResourceData( self.config, self.farmos_client, f"asset--{self.farmos_asset_type}", - **kwargs, + include=",".join(self.get_farmos_api_includes()), + normalizer=self.normalize_asset, ) def configure_grid(self, grid): @@ -142,60 +147,19 @@ class AssetMasterView(FarmOSMasterView): return "has-background-warning" return None + def get_farmos_api_includes(self): + return {"asset_type", "location", "owner", "image"} + def get_instance(self): asset = self.farmos_client.resource.get_id( - "asset", self.farmos_asset_type, self.request.matchdict["uuid"] + "asset", + self.farmos_asset_type, + self.request.matchdict["uuid"], + params={"include": ",".join(self.get_farmos_api_includes())}, ) self.raw_json = asset - - # instance data - data = self.normalize_asset(asset["data"], {}) - - if relationships := asset["data"].get("relationships"): - - # add location - if location := relationships.get("location"): - if location["data"]: - location = self.farmos_client.resource.get_id( - "asset", "structure", location["data"][0]["id"] - ) - data["location"] = { - "uuid": location["data"]["id"], - "name": location["data"]["attributes"]["name"], - } - - # add owners - if owner := relationships.get("owner"): - data["owners"] = [] - for owner_data in owner["data"]: - owner = self.farmos_client.resource.get_id( - "user", "user", owner_data["id"] - ) - data["owners"].append( - { - "uuid": owner["data"]["id"], - "display_name": owner["data"]["attributes"]["display_name"], - } - ) - - # add image urls - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - data["raw_image_url"] = self.app.get_farmos_url( - image["data"]["attributes"]["uri"]["url"] - ) - # nb. other styles available: medium, wide - data["large_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["large"] - data["thumbnail_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["thumbnail"] - - return data + included = {obj["id"]: obj for obj in asset.get("included", [])} + return self.normalize_asset(asset["data"], included) def get_instance_title(self, asset): return asset["name"] @@ -210,13 +174,24 @@ class AssetMasterView(FarmOSMasterView): else: archived = asset["attributes"]["status"] == "archived" + asset_type_object = {} + asset_type_name = None owner_objects = [] owner_names = [] location_objects = [] location_names = [] thumbnail_url = None + image_url = None if relationships := asset.get("relationships"): + if asset_type := relationships.get("asset_type"): + if asset_type := included.get(asset_type["data"]["id"]): + asset_type_object = { + "uuid": asset_type["id"], + "name": asset_type["attributes"]["label"], + } + asset_type_name = asset_type_object["name"] + if owners := relationships.get("owner"): for user in owners["data"]: if user := included.get(user["id"]): @@ -244,42 +219,102 @@ class AssetMasterView(FarmOSMasterView): thumbnail_url = image["attributes"]["image_style_uri"][ "thumbnail" ] + image_url = image["attributes"]["image_style_uri"]["large"] return { "uuid": asset["id"], "drupal_id": asset["attributes"]["drupal_internal__id"], "name": asset["attributes"]["name"], - "location": colander.null, # TODO + "asset_type": asset_type_object, + "asset_type_name": asset_type_name, "notes": notes or colander.null, "owners": owner_objects, "owner_names": owner_names, "locations": location_objects, "location_names": location_names, "archived": archived, - "thumbnail_url": thumbnail_url, + "thumbnail_url": thumbnail_url or colander.null, + "image_url": image_url or colander.null, } def configure_form(self, form): f = form super().configure_form(f) - animal = f.model_instance + asset = f.model_instance - # location - f.set_node("location", StructureType(self.request)) + # asset_type_name + if self.creating or self.editing: + f.remove("asset_type_name") + + # locations + if self.creating or self.editing: + f.remove("locations") + else: + f.set_node("locations", FarmOSLocationRefs(self.request)) # owners - f.set_node("owners", UsersType(self.request)) + if self.creating or self.editing: + f.remove("owners") # TODO + else: + f.set_node("owners", FarmOSRefs(self.request, "farmos_users")) # notes f.set_widget("notes", "notes") + f.set_required("notes", False) # archived f.set_node("archived", colander.Boolean()) + # thumbnail_url + if self.creating or self.editing: + f.remove("thumbnail_url") + + # image_url + if self.creating or self.editing: + f.remove("image_url") + + # thumbnail + if self.creating or self.editing: + f.remove("thumbnail") + elif asset.get("thumbnail_url"): + f.set_widget("thumbnail", ImageWidget("asset thumbnail")) + f.set_default("thumbnail", asset["thumbnail_url"]) + # image - if url := animal.get("large_image_url"): - f.set_widget("image", ImageWidget("animal image")) - f.set_default("image", url) + if self.creating or self.editing: + f.remove("image") + elif asset.get("image_url"): + f.set_widget("image", ImageWidget("asset image")) + f.set_default("image", asset["image_url"]) + + def persist(self, asset, session=None): + payload = self.get_api_payload(asset) + if self.editing: + payload["id"] = asset["uuid"] + + result = self.farmos_client.asset.send(self.farmos_asset_type, payload) + + if self.creating: + asset["uuid"] = result["data"]["id"] + + def get_api_payload(self, asset): + + attrs = { + "name": asset["name"], + "notes": {"value": asset["notes"] or None}, + "archived": asset["archived"], + } + + if "is_location" in asset: + attrs["is_location"] = asset["is_location"] + + if "is_fixed" in asset: + attrs["is_fixed"] = asset["is_fixed"] + + return {"attributes": attrs} + + def delete_instance(self, asset): + self.farmos_client.asset.delete(self.farmos_asset_type, asset["uuid"]) def get_xref_buttons(self, asset): return [ diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index ae4b97e..5c4c635 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -31,9 +31,16 @@ import markdown 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.grids import ResourceData, StringFilter, SimpleSorter +from wuttafarm.web.grids import ( + ResourceData, + StringFilter, + NullableStringFilter, + DateTimeFilter, + SimpleSorter, +) class FarmOSMasterView(MasterView): @@ -114,6 +121,9 @@ class TaxonomyMasterView(FarmOSMasterView): """ farmos_taxonomy_type = None + creatable = True + editable = True + deletable = True filterable = True sort_on_backend = True @@ -143,7 +153,7 @@ class TaxonomyMasterView(FarmOSMasterView): normalizer=self.normalize_taxonomy_term, ) - def normalize_taxonomy_term(self, term): + def normalize_taxonomy_term(self, term, included): if changed := term["attributes"]["changed"]: changed = datetime.datetime.fromisoformat(changed) @@ -169,15 +179,21 @@ class TaxonomyMasterView(FarmOSMasterView): g.set_sorter("name", SimpleSorter("name")) g.set_filter("name", StringFilter) + # description + g.set_sorter("description", SimpleSorter("description.value")) + g.set_filter("description", NullableStringFilter, path="description.value") + # changed g.set_renderer("changed", "datetime") + g.set_sorter("changed", SimpleSorter("changed")) + g.set_filter("changed", DateTimeFilter) def get_instance(self): result = self.farmos_client.resource.get_id( "taxonomy_term", self.farmos_taxonomy_type, self.request.matchdict["uuid"] ) self.raw_json = result - return self.normalize_taxonomy_term(result["data"]) + return self.normalize_taxonomy_term(result["data"], {}) def get_instance_title(self, term): return term["name"] @@ -188,9 +204,44 @@ class TaxonomyMasterView(FarmOSMasterView): # description f.set_widget("description", "notes") + f.set_required("description", False) # changed - f.set_node("changed", WuttaDateTime()) + if self.creating or self.editing: + f.remove("changed") + else: + f.set_node("changed", WuttaDateTime()) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + def get_api_payload(self, term): + + attrs = { + "name": term["name"], + } + + if description := term["description"]: + attrs["description"] = {"value": description} + else: + attrs["description"] = None + + return {"attributes": attrs} + + def persist(self, term, session=None): + payload = self.get_api_payload(term) + if self.editing: + payload["id"] = term["uuid"] + + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + + if self.creating: + term["uuid"] = result["data"]["id"] + + def delete_instance(self, term): + self.farmos_client.resource.delete( + "taxonomy_term", self.farmos_taxonomy_type, term["uuid"] + ) def get_xref_buttons(self, term): return [ From 1a6870b8fef63306ca02c9940122acbc297d75d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 22 Feb 2026 14:51:15 -0600 Subject: [PATCH 17/18] feat: overhaul farmOS log views; add Eggs quick form probably a few other changes...i'm tired and need a savepoint --- src/wuttafarm/web/forms/schema.py | 56 ++++ src/wuttafarm/web/forms/widgets.py | 83 ++++++ src/wuttafarm/web/menus.py | 25 +- src/wuttafarm/web/templates/quick/form.mako | 14 + src/wuttafarm/web/util.py | 17 ++ src/wuttafarm/web/views/__init__.py | 4 + src/wuttafarm/web/views/farmos/assets.py | 19 +- src/wuttafarm/web/views/farmos/logs.py | 281 +++++++++++++++++- .../web/views/farmos/logs_harvest.py | 11 + src/wuttafarm/web/views/farmos/master.py | 11 + src/wuttafarm/web/views/farmos/quantities.py | 4 +- src/wuttafarm/web/views/logs.py | 8 +- src/wuttafarm/web/views/quick/__init__.py | 30 ++ src/wuttafarm/web/views/quick/base.py | 155 ++++++++++ src/wuttafarm/web/views/quick/eggs.py | 232 +++++++++++++++ 15 files changed, 914 insertions(+), 36 deletions(-) create mode 100644 src/wuttafarm/web/templates/quick/form.mako create mode 100644 src/wuttafarm/web/views/quick/__init__.py create mode 100644 src/wuttafarm/web/views/quick/base.py create mode 100644 src/wuttafarm/web/views/quick/eggs.py diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 469a466..a5c396b 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -55,6 +55,34 @@ class AnimalTypeRef(ObjectRef): return self.request.route_url("animal_types.view", uuid=animal_type.uuid) +class LogQuick(WuttaSet): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import LogQuickWidget + + return LogQuickWidget(**kwargs) + + +class FarmOSUnitRef(colander.SchemaType): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSUnitRefWidget + + return FarmOSUnitRefWidget(**kwargs) + + class FarmOSRef(colander.SchemaType): def __init__(self, request, route_prefix, *args, **kwargs): @@ -114,6 +142,20 @@ class FarmOSRefs(WuttaSet): return FarmOSRefsWidget(self.request, self.route_prefix, **kwargs) +class FarmOSAssetRefs(WuttaSet): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSAssetRefsWidget + + return FarmOSAssetRefsWidget(self.request, **kwargs) + + class FarmOSLocationRefs(WuttaSet): def serialize(self, node, appstruct): @@ -128,6 +170,20 @@ class FarmOSLocationRefs(WuttaSet): return FarmOSLocationRefsWidget(self.request, **kwargs) +class FarmOSQuantityRefs(WuttaSet): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSQuantityRefsWidget + + return FarmOSQuantityRefsWidget(**kwargs) + + class AnimalTypeType(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 dfbaefe..5fc9d55 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -32,6 +32,8 @@ from webhelpers2.html import HTML, tags from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget from wuttaweb.db import Session +from wuttafarm.web.util import render_quantity_objects + class ImageWidget(Widget): """ @@ -54,6 +56,26 @@ class ImageWidget(Widget): return super().serialize(field, cstruct, **kw) +class LogQuickWidget(Widget): + """ + Widget to display an image URL for a record. + """ + + def serialize(self, field, cstruct, **kw): + """ """ + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + items = [] + for quick in json.loads(cstruct): + items.append(HTML.tag("li", c=quick)) + return HTML.tag("ul", c=items) + + return super().serialize(field, cstruct, **kw) + + class FarmOSRefWidget(SelectWidget): """ Generic widget to display "any reference field" - as a link to @@ -111,6 +133,33 @@ class FarmOSRefsWidget(Widget): return super().serialize(field, cstruct, **kw) +class FarmOSAssetRefsWidget(Widget): + """ + Widget to display a "Assets" field for an asset. + """ + + 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") + + assets = [] + for asset in json.loads(cstruct): + url = self.request.route_url( + f"farmos_{asset['asset_type']}_assets.view", uuid=asset["uuid"] + ) + assets.append(HTML.tag("li", c=tags.link_to(asset["name"], url))) + + return HTML.tag("ul", c=assets) + + return super().serialize(field, cstruct, **kw) + + class FarmOSLocationRefsWidget(Widget): """ Widget to display a "Locations" field for an asset. @@ -139,6 +188,40 @@ class FarmOSLocationRefsWidget(Widget): return super().serialize(field, cstruct, **kw) +class FarmOSQuantityRefsWidget(Widget): + """ + Widget to display a "Quantities" field for a log. + """ + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + quantities = json.loads(cstruct) + return render_quantity_objects(quantities) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSUnitRefWidget(Widget): + """ + Widget to display a "Units" field for a quantity. + """ + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + unit = json.loads(cstruct) + return unit["name"] + + return super().serialize(field, cstruct, **kw) + + class AnimalTypeWidget(Widget): """ Widget to display an "animal type" field. diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index be59006..6ce4a8d 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -35,29 +35,48 @@ class WuttaFarmMenuHandler(base.MenuHandler): enum = self.app.enum mode = self.app.get_farmos_integration_mode() + quick_menu = self.make_quick_menu(request) + admin_menu = self.make_admin_menu(request, include_people=True) + if mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER: return [ + quick_menu, self.make_farmos_asset_menu(request), self.make_farmos_log_menu(request), self.make_farmos_other_menu(request), - self.make_admin_menu(request, include_people=True), + admin_menu, ] elif mode == enum.FARMOS_INTEGRATION_MODE_MIRROR: return [ + quick_menu, self.make_asset_menu(request), self.make_log_menu(request), self.make_farmos_full_menu(request), - self.make_admin_menu(request, include_people=True), + admin_menu, ] else: # FARMOS_INTEGRATION_MODE_NONE return [ + quick_menu, self.make_asset_menu(request), self.make_log_menu(request), - self.make_admin_menu(request, include_people=True), + admin_menu, ] + def make_quick_menu(self, request): + return { + "title": "Quick", + "type": "menu", + "items": [ + { + "title": "Eggs", + "route": "quick.eggs", + # "perm": "assets.list", + }, + ], + } + def make_asset_menu(self, request): return { "title": "Assets", diff --git a/src/wuttafarm/web/templates/quick/form.mako b/src/wuttafarm/web/templates/quick/form.mako new file mode 100644 index 0000000..4a4f75c --- /dev/null +++ b/src/wuttafarm/web/templates/quick/form.mako @@ -0,0 +1,14 @@ +<%inherit file="/form.mako" /> + +<%def name="title()">${index_title} » ${form_title} + +<%def name="content_title()">${form_title} + +<%def name="render_form_tag()"> + +

+ ${help_text} +

+ + ${parent.render_form_tag()} + diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py index cd4ec0d..2d51851 100644 --- a/src/wuttafarm/web/util.py +++ b/src/wuttafarm/web/util.py @@ -23,6 +23,8 @@ Misc. utilities for web app """ +from webhelpers2.html import HTML + def save_farmos_oauth2_token(request, token): """ @@ -42,3 +44,18 @@ def save_farmos_oauth2_token(request, token): def use_farmos_style_grid_links(config): return config.get_bool(f"{config.appname}.farmos_style_grid_links", default=True) + + +def render_quantity_objects(quantities): + items = [] + for quantity in quantities: + text = render_quantity_object(quantity) + items.append(HTML.tag("li", c=text)) + return HTML.tag("ul", c=items) + + +def render_quantity_object(quantity): + measure = quantity["measure_name"] + value = quantity["value_decimal"] + unit = quantity["unit_name"] + return f"( {measure} ) {value} {unit}" diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 6f77e57..0d58a72 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -62,6 +62,10 @@ def includeme(config): config.include("wuttafarm.web.views.logs_medical") config.include("wuttafarm.web.views.logs_observation") + # quick form views + # (nb. these work with all integration modes) + config.include("wuttafarm.web.views.quick") + # views for farmOS if mode != enum.FARMOS_INTEGRATION_MODE_NONE: config.include("wuttafarm.web.views.farmos") diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index f985c6b..d1ae226 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -119,16 +119,6 @@ class AssetMasterView(FarmOSMasterView): return tags.image(url, f"thumbnail for {self.get_model_title()}") return None - def render_owners_for_grid(self, asset, field, value): - owners = [] - for user in value: - if self.farmos_style_grid_links: - url = self.request.route_url("farmos_users.view", uuid=user["uuid"]) - owners.append(tags.link_to(user["name"], url)) - else: - owners.append(user["name"]) - return ", ".join(owners) - def render_locations_for_grid(self, asset, field, value): locations = [] for location in value: @@ -151,15 +141,14 @@ class AssetMasterView(FarmOSMasterView): return {"asset_type", "location", "owner", "image"} def get_instance(self): - asset = self.farmos_client.resource.get_id( - "asset", + result = self.farmos_client.asset.get_id( self.farmos_asset_type, self.request.matchdict["uuid"], params={"include": ",".join(self.get_farmos_api_includes())}, ) - self.raw_json = asset - included = {obj["id"]: obj for obj in asset.get("included", [])} - return self.normalize_asset(asset["data"], included) + self.raw_json = result + included = {obj["id"]: obj for obj in result.get("included", [])} + return self.normalize_asset(result["data"], included) def get_instance_title(self, asset): return asset["name"] diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index a3e804f..4c704ce 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -26,11 +26,27 @@ View for farmOS Harvest Logs import datetime import colander +from webhelpers2.html import tags -from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.grids import ( + ResourceData, + SimpleSorter, + StringFilter, + IntegerFilter, + DateTimeFilter, + NullableBooleanFilter, +) +from wuttafarm.web.forms.schema import ( + FarmOSQuantityRefs, + FarmOSAssetRefs, + FarmOSRefs, + LogQuick, +) +from wuttafarm.web.util import render_quantity_objects class LogMasterView(FarmOSMasterView): @@ -39,48 +55,183 @@ class LogMasterView(FarmOSMasterView): """ farmos_log_type = None + filterable = True + sort_on_backend = True + + _farmos_units = None + _farmos_measures = None + + labels = { + "name": "Log Name", + "log_type_name": "Log Type", + "quantities": "Quantity", + } grid_columns = [ - "name", - "timestamp", "status", + "drupal_id", + "timestamp", + "name", + "assets", + "quantities", + "is_group_assignment", + "owners", ] sort_defaults = ("timestamp", "desc") + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + "status": {"active": True, "verb": "not_equal", "value": "abandoned"}, + } + form_fields = [ "name", "timestamp", - "status", + "assets", + "quantities", "notes", + "status", + "log_type_name", + "owners", + "quick", + "drupal_id", ] - def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.log.get(self.farmos_log_type) - return [self.normalize_log(l) for l in result["data"]] + def get_farmos_api_includes(self): + return {"log_type", "quantity", "asset", "owner"} + + def get_grid_data(self, **kwargs): + return ResourceData( + self.config, + self.farmos_client, + f"log--{self.farmos_log_type}", + include=",".join(self.get_farmos_api_includes()), + normalizer=self.normalize_log, + ) def configure_grid(self, grid): g = grid super().configure_grid(g) + enum = self.app.enum + + # status + g.set_enum("status", enum.LOG_STATUS) + g.set_sorter("status", SimpleSorter("status")) + g.set_filter( + "status", + StringFilter, + choices=enum.LOG_STATUS, + verbs=["equal", "not_equal"], + ) + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", SimpleSorter("drupal_internal__id")) + g.set_filter("drupal_id", IntegerFilter, path="drupal_internal__id") + + # timestamp + g.set_renderer("timestamp", "date") + g.set_link("timestamp") + g.set_sorter("timestamp", SimpleSorter("timestamp")) + g.set_filter("timestamp", DateTimeFilter) # name g.set_link("name") - g.set_searchable("name") + g.set_sorter("name", SimpleSorter("name")) + g.set_filter("name", StringFilter) - # timestamp - g.set_renderer("timestamp", "datetime") + # assets + g.set_renderer("assets", 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", SimpleSorter("is_group_assignment")) + g.set_filter("is_group_assignment", NullableBooleanFilter) + + # owners + g.set_label("owners", "Owner") + g.set_renderer("owners", self.render_owners_for_grid) + + def render_assets_for_grid(self, log, field, value): + assets = [] + for asset in value: + if self.farmos_style_grid_links: + url = self.request.route_url( + f"farmos_{asset['asset_type']}_assets.view", uuid=asset["uuid"] + ) + assets.append(tags.link_to(asset["name"], url)) + else: + assets.append(asset["name"]) + return ", ".join(assets) + + def render_quantities_for_grid(self, log, field, value): + if not value: + return None + return render_quantity_objects(value) + + 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 get_instance(self): - log = self.farmos_client.log.get_id( - self.farmos_log_type, self.request.matchdict["uuid"] + result = self.farmos_client.log.get_id( + self.farmos_log_type, + self.request.matchdict["uuid"], + params={"include": ",".join(self.get_farmos_api_includes())}, ) - self.raw_json = log - return self.normalize_log(log["data"]) + self.raw_json = result + included = {obj["id"]: obj for obj in result.get("included", [])} + return self.normalize_log(result["data"], included) def get_instance_title(self, log): return log["name"] - def normalize_log(self, log): + def get_farmos_units(self): + if self._farmos_units: + return self._farmos_units + + units = {} + result = self.farmos_client.resource.get("taxonomy_term", "unit") + for unit in result["data"]: + units[unit["id"]] = unit + + self._farmos_units = units + return self._farmos_units + + def get_farmos_unit(self, uuid): + units = self.get_farmos_units() + return units[uuid] + + def get_farmos_measures(self): + if self._farmos_measures: + return self._farmos_measures + + measures = {} + response = self.farmos_client.session.get( + self.app.get_farmos_url("/api/quantity/standard/resource/schema") + ) + response.raise_for_status() + data = response.json() + for measure in data["definitions"]["attributes"]["properties"]["measure"][ + "oneOf" + ]: + measures[measure["const"]] = measure["title"] + + self._farmos_measures = measures + return self._farmos_measures + + def get_farmos_measure_name(self, measure_id): + measures = self.get_farmos_measures() + return measures[measure_id] + + def normalize_log(self, log, included): if timestamp := log["attributes"]["timestamp"]: timestamp = datetime.datetime.fromisoformat(timestamp) @@ -89,26 +240,126 @@ class LogMasterView(FarmOSMasterView): if notes := log["attributes"]["notes"]: notes = notes["value"] + log_type_object = {} + log_type_name = None + asset_objects = [] + quantity_objects = [] + owner_objects = [] + if relationships := log.get("relationships"): + + if log_type := relationships.get("log_type"): + log_type = included[log_type["data"]["id"]] + log_type_object = { + "uuid": log_type["id"], + "name": log_type["attributes"]["label"], + } + log_type_name = log_type_object["name"] + + if assets := relationships.get("asset"): + for asset in assets["data"]: + asset = included[asset["id"]] + attrs = asset["attributes"] + rels = asset["relationships"] + asset_objects.append( + { + "uuid": asset["id"], + "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"], + "asset_type": asset["type"].split("--")[1], + } + ) + + if quantities := relationships.get("quantity"): + for quantity in quantities["data"]: + quantity = included[quantity["id"]] + attrs = quantity["attributes"] + rels = quantity["relationships"] + value = attrs["value"] + + unit_uuid = rels["units"]["data"]["id"] + unit = self.get_farmos_unit(unit_uuid) + + measure_id = attrs["measure"] + + quantity_objects.append( + { + "uuid": quantity["id"], + "drupal_id": attrs["drupal_internal__id"], + "quantity_type_uuid": rels["quantity_type"]["data"]["id"], + "quantity_type_id": rels["quantity_type"]["data"]["meta"][ + "drupal_internal__target_id" + ], + "measure_id": measure_id, + "measure_name": self.get_farmos_measure_name(measure_id), + "value_numerator": value["numerator"], + "value_decimal": value["decimal"], + "value_denominator": value["denominator"], + "unit_uuid": unit_uuid, + "unit_name": unit["attributes"]["name"], + } + ) + + if owners := relationships.get("owner"): + for user in owners["data"]: + user = included[user["id"]] + owner_objects.append( + { + "uuid": user["id"], + "name": user["attributes"]["name"], + } + ) + return { "uuid": log["id"], "drupal_id": log["attributes"]["drupal_internal__id"], + "log_type": log_type_object, + "log_type_name": log_type_name, "name": log["attributes"]["name"], "timestamp": timestamp, + "assets": asset_objects, + "quantities": quantity_objects, + "is_group_assignment": log["attributes"]["is_group_assignment"], + "quick": log["attributes"]["quick"], "status": log["attributes"]["status"], "notes": notes or colander.null, + "owners": owner_objects, } def configure_form(self, form): f = form super().configure_form(f) + enum = self.app.enum + log = f.model_instance # timestamp f.set_node("timestamp", WuttaDateTime()) f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) + # assets + f.set_node("assets", FarmOSAssetRefs(self.request)) + + # quantities + f.set_node("quantities", FarmOSQuantityRefs(self.request)) + # notes f.set_widget("notes", "notes") + # status + f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) + + # owners + if self.creating or self.editing: + f.remove("owners") # TODO + else: + f.set_node("owners", FarmOSRefs(self.request, "farmos_users")) + + # quick + f.set_node("quick", LogQuick(self.request)) + def get_xref_buttons(self, log): model = self.app.model session = self.Session() diff --git a/src/wuttafarm/web/views/farmos/logs_harvest.py b/src/wuttafarm/web/views/farmos/logs_harvest.py index 0f39a5a..08b2629 100644 --- a/src/wuttafarm/web/views/farmos/logs_harvest.py +++ b/src/wuttafarm/web/views/farmos/logs_harvest.py @@ -41,6 +41,17 @@ class HarvestLogView(LogMasterView): farmos_log_type = "harvest" farmos_refurl_path = "/logs/harvest" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "name", + "assets", + "quantities", + "is_group_assignment", + "owners", + ] + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 5c4c635..36d1778 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -28,6 +28,7 @@ import json import colander import markdown +from webhelpers2.html import tags from wuttaweb.views import MasterView from wuttaweb.forms.schema import WuttaDateTime @@ -99,6 +100,16 @@ class FarmOSMasterView(MasterView): return templates + def render_owners_for_grid(self, obj, field, value): + owners = [] + for user in value: + if self.farmos_style_grid_links: + url = self.request.route_url("farmos_users.view", uuid=user["uuid"]) + owners.append(tags.link_to(user["name"], url)) + else: + owners.append(user["name"]) + return ", ".join(owners) + def get_template_context(self, context): if self.listing and self.farmos_refurl_path: diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py index 414474b..8aafeea 100644 --- a/src/wuttafarm/web/views/farmos/quantities.py +++ b/src/wuttafarm/web/views/farmos/quantities.py @@ -31,7 +31,7 @@ from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import FarmOSRef +from wuttafarm.web.forms.schema import FarmOSUnitRef class QuantityTypeView(FarmOSMasterView): @@ -220,7 +220,7 @@ class QuantityMasterView(FarmOSMasterView): f.set_widget("changed", WuttaDateTimeWidget(self.request)) # units - f.set_node("units", FarmOSRef(self.request, "farmos_units")) + f.set_node("units", FarmOSUnitRef()) class StandardQuantityView(QuantityMasterView): diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index cf77967..eeef49e 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -175,15 +175,21 @@ class LogMasterView(WuttaFarmMasterView): Base class for Asset master views """ + labels = { + "message": "Log Name", + "owners": "Owner", + } + grid_columns = [ "status", "drupal_id", "timestamp", "message", "assets", - "location", + # "location", "quantity", "is_group_assignment", + "owners", ] sort_defaults = ("timestamp", "desc") diff --git a/src/wuttafarm/web/views/quick/__init__.py b/src/wuttafarm/web/views/quick/__init__.py new file mode 100644 index 0000000..92595e1 --- /dev/null +++ b/src/wuttafarm/web/views/quick/__init__.py @@ -0,0 +1,30 @@ +# -*- 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 . +# +################################################################################ +""" +Quick Form views for farmOS +""" + +from .base import QuickFormView + + +def includeme(config): + 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 new file mode 100644 index 0000000..29ba7ef --- /dev/null +++ b/src/wuttafarm/web/views/quick/base.py @@ -0,0 +1,155 @@ +# -*- 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 . +# +################################################################################ +""" +Base class for Quick Form views +""" + +import logging + +from pyramid.renderers import render_to_response + +from wuttaweb.views import View + +from wuttafarm.web.util import save_farmos_oauth2_token + + +log = logging.getLogger(__name__) + + +class QuickFormView(View): + """ + Base class for quick form views. + """ + + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.farmos_client = self.get_farmos_client() + self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + + @classmethod + def get_route_slug(cls): + return cls.route_slug + + @classmethod + def get_url_slug(cls): + return cls.url_slug + + @classmethod + def get_form_title(cls): + return cls.form_title + + def __call__(self): + form = self.make_quick_form() + + if form.validate(): + try: + result = self.save_quick_form(form) + except Exception as err: + log.warning("failed to save 'edit' form", exc_info=True) + self.request.session.flash( + f"Save failed: {self.app.render_error(err)}", "error" + ) + else: + return self.redirect_after_save(result) + + return self.render_to_response({"form": form}) + + def make_quick_form(self): + raise NotImplementedError + + def save_quick_form(self, form): + raise NotImplementedError + + def redirect_after_save(self, result): + return self.redirect(self.request.current_route_url()) + + def render_to_response(self, context): + + defaults = { + "index_title": "Quick Form", + "form_title": self.get_form_title(), + "help_text": self.__doc__.strip(), + } + + defaults.update(context) + context = defaults + + # supplement context further if needed + context = self.get_template_context(context) + + page_templates = self.get_page_templates() + mako_path = page_templates[0] + try: + render_to_response(mako_path, context, request=self.request) + except IOError: + + # try one or more fallback templates + for fallback in page_templates[1:]: + try: + return render_to_response(fallback, context, request=self.request) + except IOError: + pass + + # if we made it all the way here, then we found no + # templates at all, in which case re-attempt the first and + # let that error raise on up + return render_to_response(mako_path, context, request=self.request) + + def get_page_templates(self): + route_slug = self.get_route_slug() + page_templates = [f"/quick/{route_slug}.mako"] + page_templates.extend(self.get_fallback_templates()) + return page_templates + + def get_fallback_templates(self): + return ["/quick/form.mako"] + + 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) + + @classmethod + def _defaults(cls, config): + route_slug = cls.get_route_slug() + url_slug = cls.get_url_slug() + + config.add_route(f"quick.{route_slug}", f"/quick/{url_slug}") + config.add_view(cls, route_name=f"quick.{route_slug}") diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py new file mode 100644 index 0000000..c505381 --- /dev/null +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -0,0 +1,232 @@ +# -*- 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 . +# +################################################################################ +""" +Quick Form for "Eggs" +""" + +import json + +import colander +from deform.widget import SelectWidget + +from farmOS.subrequests import Action, Subrequest, SubrequestsBlueprint, Format + +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views.quick import QuickFormView + + +class EggsQuickForm(QuickFormView): + """ + Use this form to record an egg harvest. A harvest log will be + created with standard details filled in. + """ + + form_title = "Eggs" + route_slug = "eggs" + url_slug = "eggs" + + _layer_assets = None + + def make_quick_form(self): + f = self.make_form( + fields=[ + "timestamp", + "count", + "asset", + "notes", + ], + labels={ + "timestamp": "Date", + "count": "Quantity", + "asset": "Layer Asset", + }, + ) + + # timestamp + f.set_node("timestamp", WuttaDateTime()) + f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) + f.set_default("timestamp", self.app.make_utc()) + + # count + f.set_node("count", colander.Integer()) + + # asset + assets = self.get_layer_assets() + values = [(a["uuid"], a["name"]) for a in assets] + f.set_widget("asset", SelectWidget(values=values)) + if len(assets) == 1: + f.set_default("asset", assets[0]["uuid"]) + + # notes + f.set_widget("notes", "notes") + f.set_required("notes", False) + + return f + + def get_layer_assets(self): + if self._layer_assets is not None: + return self._layer_assets + + assets = [] + params = { + "filter[produces_eggs]": 1, + "sort": "name", + } + + def normalize(asset): + return { + "uuid": asset["id"], + "name": asset["attributes"]["name"], + "type": asset["type"], + } + + result = self.farmos_client.asset.get("animal", params=params) + assets.extend([normalize(a) for a in result["data"]]) + + 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): + data = form.validated + + assets = self.get_layer_assets() + assets = {a["uuid"]: a for a in assets} + asset = assets[data["asset"]] + + # TODO: make this configurable? + unit_name = "egg(s)" + + unit = {"data": {"type": "taxonomy_term--unit"}} + new_unit = None + + result = self.farmos_client.resource.get( + "taxonomy_term", + "unit", + params={ + "filter[name]": unit_name, + }, + ) + if result["data"]: + unit["data"]["id"] = result["data"][0]["id"] + else: + payload = dict(unit) + payload["data"]["attributes"] = {"name": unit_name} + new_unit = Subrequest( + action=Action.create, + requestId="create-unit", + endpoint="api/taxonomy_term/unit", + body=payload, + ) + unit["data"]["id"] = "{{create-unit.body@$.data.id}}" + + quantity = { + "data": { + "type": "quantity--standard", + "attributes": { + "measure": "count", + "value": { + "numerator": data["count"], + "denominator": 1, + }, + }, + "relationships": { + "units": unit, + }, + }, + } + + kw = {} + if new_unit: + kw["waitFor"] = ["create-unit"] + new_quantity = Subrequest( + action=Action.create, + requestId="create-quantity", + endpoint="api/quantity/standard", + body=quantity, + **kw, + ) + + notes = None + if data["notes"]: + notes = {"value": data["notes"]} + + log = { + "data": { + "type": "log--harvest", + "attributes": { + "name": f"Collected {data['count']} {unit_name}", + "notes": notes, + "quick": ["eggs"], + }, + "relationships": { + "asset": { + "data": [ + { + "id": asset["uuid"], + "type": asset["type"], + }, + ], + }, + "quantity": { + "data": [ + { + "id": "{{create-quantity.body@$.data.id}}", + "type": "quantity--standard", + }, + ], + }, + }, + }, + } + + new_log = Subrequest( + action=Action.create, + requestId="create-log", + waitFor=["create-quantity"], + endpoint="api/log/harvest", + body=log, + ) + + blueprints = [new_quantity, new_log] + if new_unit: + blueprints.insert(0, new_unit) + blueprint = SubrequestsBlueprint.parse_obj(blueprints) + response = self.farmos_client.subrequests.send(blueprint, format=Format.json) + result = json.loads(response["create-log#body{0}"]["body"]) + return result + + def redirect_after_save(self, result): + return self.redirect( + self.request.route_url( + "farmos_logs_harvest.view", uuid=result["data"]["id"] + ) + ) + + +def includeme(config): + EggsQuickForm.defaults(config) From e7ef5c3d32255edeadd7789e27a7340ee355de4f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 22 Feb 2026 19:20:46 -0600 Subject: [PATCH 18/18] feat: add common normalizer to simplify code in view, importer etc. only the "log" normalizer exists so far, but will add more.. --- src/wuttafarm/app.py | 75 ++++++++- src/wuttafarm/farmos/handler.py | 29 ---- src/wuttafarm/importing/farmos.py | 66 +++++--- src/wuttafarm/normal.py | 199 +++++++++++++++++++++++ src/wuttafarm/web/views/farmos/logs.py | 151 +---------------- src/wuttafarm/web/views/farmos/master.py | 1 + src/wuttafarm/web/views/master.py | 2 +- src/wuttafarm/web/views/quick/base.py | 1 + src/wuttafarm/web/views/quick/eggs.py | 15 +- 9 files changed, 331 insertions(+), 208 deletions(-) create mode 100644 src/wuttafarm/normal.py diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 2df38e9..d0ca392 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -56,24 +56,38 @@ class WuttaFarmAppHandler(base.AppHandler): Returns the integration mode for farmOS, i.e. to control the app's behavior regarding that. """ - handler = self.get_farmos_handler() - return handler.get_farmos_integration_mode() + enum = self.enum + return self.config.get( + f"{self.appname}.farmos_integration_mode", + default=enum.FARMOS_INTEGRATION_MODE_WRAPPER, + ) def is_farmos_mirror(self): """ Returns ``True`` if the app is configured in "mirror" integration mode with regard to farmOS. """ - handler = self.get_farmos_handler() - return handler.is_farmos_mirror() + enum = self.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR def is_farmos_wrapper(self): """ Returns ``True`` if the app is configured in "wrapper" integration mode with regard to farmOS. """ - handler = self.get_farmos_handler() - return handler.is_farmos_wrapper() + enum = self.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER + + def is_standalone(self): + """ + Returns ``True`` if the app is configured in "standalone" mode + with regard to farmOS. + """ + enum = self.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_NONE def get_farmos_url(self, *args, **kwargs): """ @@ -109,7 +123,20 @@ class WuttaFarmAppHandler(base.AppHandler): handler = self.get_farmos_handler() return handler.is_farmos_4x(*args, **kwargs) - def export_to_farmos(self, obj, require=True): + def get_normalizer(self, farmos_client=None): + """ + Get the configured farmOS integration handler. + + :rtype: :class:`~wuttafarm.farmos.FarmOSHandler` + """ + spec = self.config.get( + f"{self.appname}.normalizer_spec", + default="wuttafarm.normal:Normalizer", + ) + factory = self.load_object(spec) + return factory(self.config, farmos_client) + + def auto_sync_to_farmos(self, obj, model_name=None, require=True): """ Export the given object to farmOS, using configured handler. @@ -127,7 +154,8 @@ class WuttaFarmAppHandler(base.AppHandler): """ handler = self.app.get_import_handler("export.to_farmos.from_wuttafarm") - model_name = type(obj).__name__ + if not model_name: + model_name = type(obj).__name__ if model_name not in handler.importers: if require: raise ValueError(f"no exporter found for {model_name}") @@ -141,6 +169,37 @@ class WuttaFarmAppHandler(base.AppHandler): normal = importer.normalize_source_object(obj) importer.process_data(source_data=[normal]) + def auto_sync_from_farmos(self, obj, model_name, require=True): + """ + Import the given object from farmOS, using configured handler. + + :param obj: Any data record from farmOS. + + :param model_name': Model name for the importer to use, + e.g. ``"AnimalAsset"``. + + :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 + there is no such importer. + """ + handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos") + + if model_name not in handler.importers: + if require: + raise ValueError(f"no importer found for {model_name}") + 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() + with self.short_session(commit=True) as session: + handler.target_session = session + importer = handler.get_importer(model_name, caches_target=False) + normal = importer.normalize_source_object(obj) + importer.process_data(source_data=[normal]) + class WuttaFarmAppProvider(base.AppProvider): """ diff --git a/src/wuttafarm/farmos/handler.py b/src/wuttafarm/farmos/handler.py index 393d121..6eee14f 100644 --- a/src/wuttafarm/farmos/handler.py +++ b/src/wuttafarm/farmos/handler.py @@ -34,35 +34,6 @@ class FarmOSHandler(GenericHandler): :term:`handler`. """ - def get_farmos_integration_mode(self): - """ - Returns the integration mode for farmOS, i.e. to control the - app's behavior regarding that. - """ - enum = self.app.enum - return self.config.get( - f"{self.app.appname}.farmos_integration_mode", - default=enum.FARMOS_INTEGRATION_MODE_WRAPPER, - ) - - def is_farmos_mirror(self): - """ - Returns ``True`` if the app is configured in "mirror" - integration mode with regard to farmOS. - """ - enum = self.app.enum - mode = self.get_farmos_integration_mode() - return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR - - def is_farmos_wrapper(self): - """ - Returns ``True`` if the app is configured in "wrapper" - integration mode with regard to farmOS. - """ - enum = self.app.enum - mode = self.get_farmos_integration_mode() - return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER - def get_farmos_client(self, hostname=None, **kwargs): """ Returns a new farmOS API client. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 5cf2242..e17825b 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -53,6 +53,7 @@ class FromFarmOSHandler(ImportHandler): 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) def get_farmos_oauth2_token(self): @@ -76,6 +77,7 @@ class FromFarmOSHandler(ImportHandler): kwargs = super().get_importer_kwargs(key, **kwargs) kwargs["farmos_client"] = self.farmos_client kwargs["farmos_4x"] = self.farmos_4x + kwargs["normal"] = self.normal return kwargs @@ -981,33 +983,25 @@ class LogImporterBase(FromFarmOS, ToWutta): def get_source_objects(self): """ """ log_type = self.get_farmos_log_type() - result = self.farmos_client.log.get(log_type) - return result["data"] - - def get_asset_type(self, asset): - return asset["type"].split("--")[1] + return list(self.farmos_client.log.iterate(log_type)) def normalize_source_object(self, log): """ """ - if notes := log["attributes"]["notes"]: - notes = notes["value"] + data = self.normal.normalize_farmos_log(log) + + data["farmos_uuid"] = UUID(data.pop("uuid")) + data["message"] = data.pop("name") + data["timestamp"] = self.app.make_utc(data["timestamp"]) + + # TODO + data["log_type"] = self.get_farmos_log_type() - assets = None if "assets" in self.fields: - assets = [] - for asset in log["relationships"]["asset"]["data"]: - assets.append((self.get_asset_type(asset), UUID(asset["id"]))) + data["assets"] = [ + (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] + ] - return { - "farmos_uuid": UUID(log["id"]), - "drupal_id": log["attributes"]["drupal_internal__id"], - "log_type": self.get_farmos_log_type(), - "message": log["attributes"]["name"], - "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), - "notes": notes, - "status": log["attributes"]["status"], - "assets": assets, - } + return data def normalize_target_object(self, log): data = super().normalize_target_object(log) @@ -1183,6 +1177,28 @@ class QuantityImporterBase(FromFarmOS, ToWutta): result = self.farmos_client.resource.get("quantity", quantity_type) return result["data"] + def get_quantity_type_by_farmos_uuid(self, uuid): + if hasattr(self, "quantity_types_by_farmos_uuid"): + return self.quantity_types_by_farmos_uuid.get(UUID(uuid)) + + model = self.app.model + return ( + self.target_session.query(model.QuantityType) + .filter(model.QuantityType.farmos_uuid == uuid) + .one() + ) + + def get_unit_by_farmos_uuid(self, uuid): + if hasattr(self, "units_by_farmos_uuid"): + return self.units_by_farmos_uuid.get(UUID(uuid)) + + model = self.app.model + return ( + self.target_session.query(model.Unit) + .filter(model.Unit.farmos_uuid == uuid) + .one() + ) + def normalize_source_object(self, quantity): """ """ quantity_type_id = None @@ -1191,16 +1207,14 @@ class QuantityImporterBase(FromFarmOS, ToWutta): if quantity_type := relationships.get("quantity_type"): if quantity_type["data"]: - if wf_quantity_type := self.quantity_types_by_farmos_uuid.get( - UUID(quantity_type["data"]["id"]) + if wf_quantity_type := self.get_quantity_type_by_farmos_uuid( + quantity_type["data"]["id"] ): quantity_type_id = wf_quantity_type.drupal_id if units := relationships.get("units"): if units["data"]: - if wf_unit := self.units_by_farmos_uuid.get( - UUID(units["data"]["id"]) - ): + if wf_unit := self.get_unit_by_farmos_uuid(units["data"]["id"]): units_uuid = wf_unit.uuid if not quantity_type_id: diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py new file mode 100644 index 0000000..ca7be39 --- /dev/null +++ b/src/wuttafarm/normal.py @@ -0,0 +1,199 @@ +# -*- 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 . +# +################################################################################ +""" +Data normalizer for WuttaFarm / farmOS +""" + +import datetime + +from wuttjamaican.app import GenericHandler + + +class Normalizer(GenericHandler): + """ + Base class and default implementation for the global data + normalizer. This should be used for normalizing records from + WuttaFarm and/or farmOS. + + The point here is to have a single place to put the normalization + logic, and let it be another thing which can be customized via + subclass. + """ + + _farmos_units = None + _farmos_measures = None + + def __init__(self, config, farmos_client=None): + super().__init__(config) + self.farmos_client = farmos_client + + def get_farmos_measures(self): + if self._farmos_measures: + return self._farmos_measures + + measures = {} + response = self.farmos_client.session.get( + self.app.get_farmos_url("/api/quantity/standard/resource/schema") + ) + response.raise_for_status() + data = response.json() + for measure in data["definitions"]["attributes"]["properties"]["measure"][ + "oneOf" + ]: + measures[measure["const"]] = measure["title"] + + self._farmos_measures = measures + return self._farmos_measures + + def get_farmos_measure_name(self, measure_id): + measures = self.get_farmos_measures() + return measures[measure_id] + + def get_farmos_unit(self, uuid): + units = self.get_farmos_units() + return units[uuid] + + def get_farmos_units(self): + if self._farmos_units: + return self._farmos_units + + units = {} + result = self.farmos_client.resource.get("taxonomy_term", "unit") + for unit in result["data"]: + units[unit["id"]] = unit + + self._farmos_units = units + return self._farmos_units + + def normalize_farmos_log(self, log, included={}): + + if timestamp := log["attributes"]["timestamp"]: + timestamp = datetime.datetime.fromisoformat(timestamp) + timestamp = self.app.localtime(timestamp) + + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + log_type_object = {} + log_type_uuid = None + asset_objects = [] + quantity_objects = [] + quantity_uuids = [] + owner_objects = [] + owner_uuids = [] + if relationships := log.get("relationships"): + + if log_type := relationships.get("log_type"): + log_type_uuid = log_type["data"]["id"] + if log_type := included.get(log_type_uuid): + log_type_object = { + "uuid": log_type["id"], + "name": log_type["attributes"]["label"], + } + + if assets := relationships.get("asset"): + for asset in assets["data"]: + asset_object = { + "uuid": asset["id"], + "type": asset["type"], + "asset_type": asset["type"].split("--")[1], + } + if asset := included.get(asset["id"]): + attrs = asset["attributes"] + rels = asset["relationships"] + asset_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"], + } + ) + asset_objects.append(asset_object) + + if quantities := relationships.get("quantity"): + for quantity in quantities["data"]: + quantity_uuid = quantity["id"] + quantity_uuids.append(quantity_uuid) + if quantity := included.get(quantity_uuid): + attrs = quantity["attributes"] + rels = quantity["relationships"] + value = attrs["value"] + + unit_uuid = rels["units"]["data"]["id"] + unit = self.get_farmos_unit(unit_uuid) + + measure_id = attrs["measure"] + + quantity_objects.append( + { + "uuid": quantity["id"], + "drupal_id": attrs["drupal_internal__id"], + "quantity_type_uuid": rels["quantity_type"]["data"][ + "id" + ], + "quantity_type_id": rels["quantity_type"]["data"][ + "meta" + ]["drupal_internal__target_id"], + "measure_id": measure_id, + "measure_name": self.get_farmos_measure_name( + measure_id + ), + "value_numerator": value["numerator"], + "value_decimal": value["decimal"], + "value_denominator": value["denominator"], + "unit_uuid": unit_uuid, + "unit_name": unit["attributes"]["name"], + } + ) + + 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": log["id"], + "drupal_id": log["attributes"]["drupal_internal__id"], + "log_type_uuid": log_type_uuid, + "log_type": log_type_object, + "name": log["attributes"]["name"], + "timestamp": timestamp, + "assets": asset_objects, + "quantities": quantity_objects, + "quantity_uuids": quantity_uuids, + "is_group_assignment": log["attributes"]["is_group_assignment"], + "quick": log["attributes"]["quick"], + "status": log["attributes"]["status"], + "notes": notes, + "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 4c704ce..f20eb0e 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -23,12 +23,9 @@ View for farmOS Harvest Logs """ -import datetime - -import colander from webhelpers2.html import tags -from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum +from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum, Notes from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos import FarmOSMasterView @@ -58,9 +55,6 @@ class LogMasterView(FarmOSMasterView): filterable = True sort_on_backend = True - _farmos_units = None - _farmos_measures = None - labels = { "name": "Log Name", "log_type_name": "Log Type", @@ -193,141 +187,14 @@ class LogMasterView(FarmOSMasterView): def get_instance_title(self, log): return log["name"] - def get_farmos_units(self): - if self._farmos_units: - return self._farmos_units - - units = {} - result = self.farmos_client.resource.get("taxonomy_term", "unit") - for unit in result["data"]: - units[unit["id"]] = unit - - self._farmos_units = units - return self._farmos_units - - def get_farmos_unit(self, uuid): - units = self.get_farmos_units() - return units[uuid] - - def get_farmos_measures(self): - if self._farmos_measures: - return self._farmos_measures - - measures = {} - response = self.farmos_client.session.get( - self.app.get_farmos_url("/api/quantity/standard/resource/schema") - ) - response.raise_for_status() - data = response.json() - for measure in data["definitions"]["attributes"]["properties"]["measure"][ - "oneOf" - ]: - measures[measure["const"]] = measure["title"] - - self._farmos_measures = measures - return self._farmos_measures - - def get_farmos_measure_name(self, measure_id): - measures = self.get_farmos_measures() - return measures[measure_id] - def normalize_log(self, log, included): - - if timestamp := log["attributes"]["timestamp"]: - timestamp = datetime.datetime.fromisoformat(timestamp) - timestamp = self.app.localtime(timestamp) - - if notes := log["attributes"]["notes"]: - notes = notes["value"] - - log_type_object = {} - log_type_name = None - asset_objects = [] - quantity_objects = [] - owner_objects = [] - if relationships := log.get("relationships"): - - if log_type := relationships.get("log_type"): - log_type = included[log_type["data"]["id"]] - log_type_object = { - "uuid": log_type["id"], - "name": log_type["attributes"]["label"], - } - log_type_name = log_type_object["name"] - - if assets := relationships.get("asset"): - for asset in assets["data"]: - asset = included[asset["id"]] - attrs = asset["attributes"] - rels = asset["relationships"] - asset_objects.append( - { - "uuid": asset["id"], - "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"], - "asset_type": asset["type"].split("--")[1], - } - ) - - if quantities := relationships.get("quantity"): - for quantity in quantities["data"]: - quantity = included[quantity["id"]] - attrs = quantity["attributes"] - rels = quantity["relationships"] - value = attrs["value"] - - unit_uuid = rels["units"]["data"]["id"] - unit = self.get_farmos_unit(unit_uuid) - - measure_id = attrs["measure"] - - quantity_objects.append( - { - "uuid": quantity["id"], - "drupal_id": attrs["drupal_internal__id"], - "quantity_type_uuid": rels["quantity_type"]["data"]["id"], - "quantity_type_id": rels["quantity_type"]["data"]["meta"][ - "drupal_internal__target_id" - ], - "measure_id": measure_id, - "measure_name": self.get_farmos_measure_name(measure_id), - "value_numerator": value["numerator"], - "value_decimal": value["decimal"], - "value_denominator": value["denominator"], - "unit_uuid": unit_uuid, - "unit_name": unit["attributes"]["name"], - } - ) - - if owners := relationships.get("owner"): - for user in owners["data"]: - user = included[user["id"]] - owner_objects.append( - { - "uuid": user["id"], - "name": user["attributes"]["name"], - } - ) - - return { - "uuid": log["id"], - "drupal_id": log["attributes"]["drupal_internal__id"], - "log_type": log_type_object, - "log_type_name": log_type_name, - "name": log["attributes"]["name"], - "timestamp": timestamp, - "assets": asset_objects, - "quantities": quantity_objects, - "is_group_assignment": log["attributes"]["is_group_assignment"], - "quick": log["attributes"]["quick"], - "status": log["attributes"]["status"], - "notes": notes or colander.null, - "owners": owner_objects, - } + data = self.normal.normalize_farmos_log(log, included) + data.update( + { + "log_type_name": data["log_type"].get("name"), + } + ) + return data def configure_form(self, form): f = form @@ -346,7 +213,7 @@ class LogMasterView(FarmOSMasterView): f.set_node("quantities", FarmOSQuantityRefs(self.request)) # notes - f.set_widget("notes", "notes") + f.set_node("notes", Notes()) # status f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 36d1778..742ce14 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -72,6 +72,7 @@ class FarmOSMasterView(MasterView): super().__init__(request, context=context) self.farmos_client = self.get_farmos_client() 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) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 82d64bc..2250d1b 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -105,4 +105,4 @@ class WuttaFarmMasterView(MasterView): def persist(self, obj, session=None): super().persist(obj, session) - self.app.export_to_farmos(obj, require=False) + self.app.auto_sync_to_farmos(obj, require=False) diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py index 29ba7ef..2fb73e4 100644 --- a/src/wuttafarm/web/views/quick/base.py +++ b/src/wuttafarm/web/views/quick/base.py @@ -44,6 +44,7 @@ class QuickFormView(View): super().__init__(request, context=context) self.farmos_client = self.get_farmos_client() self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + self.normal = self.app.get_normalizer(self.farmos_client) @classmethod def get_route_slug(cls): diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index c505381..aa663b6 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -112,6 +112,18 @@ class EggsQuickForm(QuickFormView): 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_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") + + return log + + def save_to_farmos(self, form): data = form.validated assets = self.get_layer_assets() @@ -217,8 +229,7 @@ class EggsQuickForm(QuickFormView): blueprints.insert(0, new_unit) blueprint = SubrequestsBlueprint.parse_obj(blueprints) response = self.farmos_client.subrequests.send(blueprint, format=Format.json) - result = json.loads(response["create-log#body{0}"]["body"]) - return result + return response def redirect_after_save(self, result): return self.redirect(