diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py
index 087c48a..d0ca392 100644
--- a/src/wuttafarm/app.py
+++ b/src/wuttafarm/app.py
@@ -51,6 +51,44 @@ 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.
+ """
+ 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.
+ """
+ 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.
+ """
+ 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):
"""
Get a farmOS URL. This is a convenience wrapper around
@@ -85,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.
@@ -103,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}")
@@ -117,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/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",
)
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/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/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..68695e5 100644
--- a/src/wuttafarm/db/model/__init__.py
+++ b/src/wuttafarm/db/model/__init__.py
@@ -30,6 +30,8 @@ from wuttjamaican.db.model import *
from .users import WuttaFarmUser
# wuttafarm proper models
+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
new file mode 100644
index 0000000..4f537b9
--- /dev/null
+++ b/src/wuttafarm/db/model/quantities.py
@@ -0,0 +1,221 @@
+# -*- 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 sqlalchemy import orm
+from sqlalchemy.ext.declarative import declared_attr
+
+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 ""
+
+
+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
new file mode 100644
index 0000000..e9c6e70
--- /dev/null
+++ b/src/wuttafarm/db/model/unit.py
@@ -0,0 +1,117 @@
+# -*- 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 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
+ """
+
+ __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/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/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py
index d20c068..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):
@@ -333,6 +347,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..e11663f 100644
--- a/src/wuttafarm/farmos/importing/wuttafarm.py
+++ b/src/wuttafarm/farmos/importing/wuttafarm.py
@@ -98,6 +98,8 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter
+ importers["PlantAsset"] = PlantAssetImporter
+ importers["Unit"] = UnitImporter
importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter
importers["MedicalLog"] = MedicalLogImporter
@@ -183,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
@@ -239,6 +263,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/importing/farmos.py b/src/wuttafarm/importing/farmos.py
index d1cac19..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
@@ -106,6 +108,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
@@ -821,6 +827,95 @@ 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
+ """
+
+ 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 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
@@ -888,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)
@@ -1040,3 +1127,134 @@ 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 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
+ units_uuid = None
+ if relationships := quantity.get("relationships"):
+
+ if quantity_type := relationships.get("quantity_type"):
+ if quantity_type["data"]:
+ 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.get_unit_by_farmos_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/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/forms/schema.py b/src/wuttafarm/web/forms/schema.py
index 123f662..a5c396b 100644
--- a/src/wuttafarm/web/forms/schema.py
+++ b/src/wuttafarm/web/forms/schema.py
@@ -55,6 +55,135 @@ 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):
+ 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
+
+ return json.dumps(appstruct)
+
+ def widget_maker(self, **kwargs):
+ from wuttafarm.web.forms.widgets import FarmOSRefsWidget
+
+ 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):
+ 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 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):
@@ -179,6 +308,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..5fc9d55 100644
--- a/src/wuttafarm/web/forms/widgets.py
+++ b/src/wuttafarm/web/forms/widgets.py
@@ -26,12 +26,14 @@ 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
from wuttaweb.db import Session
+from wuttafarm.web.util import render_quantity_objects
+
class ImageWidget(Widget):
"""
@@ -54,6 +56,172 @@ 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
+ 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")
+
+ 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"]),
+ )
+
+ 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 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.
+ """
+
+ 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 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.
@@ -162,7 +330,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
new file mode 100644
index 0000000..8f4cde5
--- /dev/null
+++ b/src/wuttafarm/web/grids.py
@@ -0,0 +1,300 @@
+# -*- 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
+"""
+
+import datetime
+
+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 := self.coerce_value(value):
+ data.add_filter(self.path, "=", value)
+ return data
+
+ def filter_not_equal(self, data, value):
+ if value := self.coerce_value(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 := self.coerce_value(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 := self.coerce_value(value):
+ data.add_filter(self.path, "<", value)
+ return data
+
+ def filter_less_equal(self, data, value):
+ if value := self.coerce_value(value):
+ data.add_filter(self.path, "<=", value)
+ return data
+
+ def filter_greater_than(self, data, value):
+ if value := self.coerce_value(value):
+ data.add_filter(self.path, ">", value)
+ return data
+
+ def filter_greater_equal(self, data, value):
+ if value := self.coerce_value(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"]
+
+
+# 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):
+ 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,
+ 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
+
+ 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 = {}
+
+ i = 0
+ for path, operator, value in self.filters:
+ 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:
+ 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
+
+ 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, 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 d52a6ca..6ce4a8d 100644
--- a/src/wuttafarm/web/menus.py
+++ b/src/wuttafarm/web/menus.py
@@ -32,12 +32,50 @@ 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()
+
+ 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),
+ 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),
+ admin_menu,
+ ]
+
+ else: # FARMOS_INTEGRATION_MODE_NONE
+ return [
+ quick_menu,
+ self.make_asset_menu(request),
+ self.make_log_menu(request),
+ 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 {
@@ -134,15 +172,41 @@ 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",
+ "perm": "quantity_types.list",
+ },
+ {
+ "title": "Units",
+ "route": "units",
+ "perm": "units.list",
+ },
],
}
- def make_farmos_menu(self, request):
+ def make_farmos_full_menu(self, request):
config = request.wutta_config
app = config.get_app()
return {
@@ -156,30 +220,30 @@ class WuttaFarmMenuHandler(base.MenuHandler):
},
{"type": "sep"},
{
- "title": "Animals",
- "route": "farmos_animals",
- "perm": "farmos_animals.list",
+ "title": "Animal Assets",
+ "route": "farmos_animal_assets",
+ "perm": "farmos_animal_assets.list",
},
{
- "title": "Groups",
- "route": "farmos_groups",
- "perm": "farmos_groups.list",
+ "title": "Group Assets",
+ "route": "farmos_group_assets",
+ "perm": "farmos_group_assets.list",
},
{
- "title": "Plants",
- "route": "farmos_asset_plant",
- "perm": "farmos_asset_plant.list",
- },
- {
- "title": "Structures",
- "route": "farmos_structures",
- "perm": "farmos_structures.list",
- },
- {
- "title": "Land",
+ "title": "Land Assets",
"route": "farmos_land_assets",
"perm": "farmos_land_assets.list",
},
+ {
+ "title": "Plant Assets",
+ "route": "farmos_plant_assets",
+ "perm": "farmos_plant_assets.list",
+ },
+ {
+ "title": "Structure Assets",
+ "route": "farmos_structure_assets",
+ "perm": "farmos_structure_assets.list",
+ },
{"type": "sep"},
{
"title": "Activity Logs",
@@ -207,6 +271,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 +286,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",
@@ -232,6 +297,155 @@ 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": "Standard Quantities",
+ "route": "farmos_quantities_standard",
+ "perm": "farmos_quantities_standard.list",
+ },
+ {
+ "title": "Units",
+ "route": "farmos_units",
+ "perm": "farmos_units.list",
+ },
+ {"type": "sep"},
+ {
+ "title": "Users",
+ "route": "farmos_users",
+ "perm": "farmos_users.list",
+ },
+ ],
+ }
+
+ 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_animal_assets",
+ "perm": "farmos_animal_assets.list",
+ },
+ {
+ "title": "Group",
+ "route": "farmos_group_assets",
+ "perm": "farmos_group_assets.list",
+ },
+ {
+ "title": "Land",
+ "route": "farmos_land_assets",
+ "perm": "farmos_land_assets.list",
+ },
+ {
+ "title": "Plant",
+ "route": "farmos_plant_assets",
+ "perm": "farmos_plant_assets.list",
+ },
+ {
+ "title": "Structure",
+ "route": "farmos_structure_assets",
+ "perm": "farmos_structure_assets.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",
diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako
new file mode 100644
index 0000000..3760577
--- /dev/null
+++ b/src/wuttafarm/web/templates/appinfo/configure.mako
@@ -0,0 +1,49 @@
+## -*- 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
+
+
+
+
+ Use farmOS-style grid links
+
+ <${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}">
+
+
+
+ If set, certain column values in a grid may link
+ to related
+ records.
+
+
+ If not set, column values will only link to view the
+ current record.
+
+
+ ${b}-tooltip>
+
+
+%def>
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>
+
+<%def name="content_title()">${form_title}%def>
+
+<%def name="render_form_tag()">
+
+
+ ${help_text}
+
+
+ ${parent.render_form_tag()}
+%def>
diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py
index 65d637d..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):
"""
@@ -38,3 +40,22 @@ 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)
+
+
+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 bb710a2..0d58a72 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(
@@ -36,23 +40,32 @@ 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",
}
)
# native table views
- 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")
+
+ # quick form views
+ # (nb. these work with all integration modes)
+ config.include("wuttafarm.web.views.quick")
# 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/animals.py b/src/wuttafarm/web/views/animals.py
index 72a05ee..76e0335 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
@@ -153,11 +155,11 @@ class AnimalAssetView(AssetMasterView):
"thumbnail",
"drupal_id",
"asset_name",
+ "produces_eggs",
"animal_type",
"birthdate",
"is_sterile",
"sex",
- "produces_eggs",
"archived",
]
@@ -165,9 +167,9 @@ class AnimalAssetView(AssetMasterView):
"asset_name",
"animal_type",
"birthdate",
+ "produces_eggs",
"sex",
"is_sterile",
- "produces_eggs",
"notes",
"asset_type",
"archived",
@@ -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/assets.py b/src/wuttafarm/web/views/assets.py
index b918839..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_animals.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 121e631..f15e92b 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",
@@ -68,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_animals.list",
- "farmos_animals.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",
@@ -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_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_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",
@@ -114,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..e59ac1f 100644
--- a/src/wuttafarm/web/views/farmos/__init__.py
+++ b/src/wuttafarm/web/views/farmos/__init__.py
@@ -28,7 +28,9 @@ from .master import FarmOSMasterView
def includeme(config):
config.include("wuttafarm.web.views.farmos.users")
+ 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")
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/animals.py b/src/wuttafarm/web/views/farmos/animals.py
index c9c2887..690e7ee 100644
--- a/src/wuttafarm/web/views/farmos/animals.py
+++ b/src/wuttafarm/web/views/farmos/animals.py
@@ -26,152 +26,159 @@ Master view for Farm Animals
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.forms.schema import UsersType, AnimalTypeType, StructureType
-from wuttafarm.web.forms.widgets import ImageWidget
+from wuttafarm.web.views.farmos.assets import AssetMasterView
+from wuttafarm.web.grids import (
+ SimpleSorter,
+ StringFilter,
+ BooleanFilter,
+ NullableBooleanFilter,
+ DateTimeFilter,
+)
+from wuttafarm.web.forms.schema import FarmOSRef
-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_assets"
+ model_title = "farmOS Animal Asset"
+ model_title_plural = "farmOS Animal Assets"
- route_prefix = "farmos_animals"
- url_prefix = "/farmOS/animals"
+ route_prefix = "farmos_animal_assets"
+ url_prefix = "/farmOS/assets/animal"
+ farmos_asset_type = "animal"
farmos_refurl_path = "/assets/animal"
labels = {
"animal_type": "Species / Breed",
- "location": "Current Location",
+ "animal_type_name": "Species / Breed",
+ "is_sterile": "Sterile",
}
grid_columns = [
+ "thumbnail",
+ "drupal_id",
"name",
+ "produces_eggs",
+ "animal_type_name",
"birthdate",
- "sex",
"is_sterile",
+ "sex",
+ "groups",
+ "owners",
+ "locations",
"archived",
]
- sort_defaults = "name"
-
form_fields = [
"name",
"animal_type",
"birthdate",
+ "produces_eggs",
"sex",
"is_sterile",
- "archived",
- "owners",
- "location",
"notes",
- "raw_image_url",
- "large_image_url",
- "thumbnail_image_url",
+ "asset_type_name",
+ "groups",
+ "owners",
+ "locations",
+ "archived",
+ "thumbnail_url",
+ "image_url",
+ "thumbnail",
"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 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
super().configure_grid(g)
+ enum = self.app.enum
- # name
- g.set_link("name")
- g.set_searchable("name")
+ # produces_eggs
+ g.set_renderer("produces_eggs", "boolean")
+ g.set_sorter("produces_eggs", SimpleSorter("produces_eggs"))
+ g.set_filter("produces_eggs", NullableBooleanFilter)
+
+ # animal_type_name
+ 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")
# 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)
+ 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)
- # archived
- g.set_renderer("archived", "boolean")
+ 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):
+ groups = []
+ for group in animal["group_objects"]:
+ 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):
- 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"):
- 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"],
- }
-
- # 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"],
+ 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"],
}
- )
-
- # 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_animal(self, animal):
+ def normalize_asset(self, animal, included):
+ normal = super().normalize_asset(animal, included)
birthdate = animal["attributes"]["birthdate"]
if birthdate:
@@ -184,87 +191,138 @@ class AnimalView(FarmOSMasterView):
else:
sterile = animal["attributes"]["is_castrated"]
- if notes := animal["attributes"]["notes"]:
- notes = notes["value"]
+ animal_type_object = None
+ group_objects = []
+ group_names = []
+ if relationships := animal.get("relationships"):
- if self.farmos_4x:
- archived = animal["attributes"]["archived"]
- else:
- archived = animal["attributes"]["status"] == "archived"
+ if animal_type := relationships.get("animal_type"):
+ if animal_type := included.get(animal_type["data"]["id"]):
+ animal_type_object = {
+ "uuid": animal_type["id"],
+ "name": animal_type["attributes"]["name"],
+ }
- 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,
- }
+ 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_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,
+ "sex": animal["attributes"]["sex"] or colander.null,
+ "is_sterile": sterile,
+ "produces_eggs": animal["attributes"].get("produces_eggs"),
+ }
+ )
+
+ 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))
+ 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())
- # location
- f.set_node("location", StructureType(self.request))
+ # groups
+ if self.creating or self.editing:
+ f.remove("groups") # TODO
- # owners
- f.set_node("owners", UsersType(self.request))
+ def get_api_payload(self, animal):
+ payload = super().get_api_payload(animal)
- # notes
- f.set_widget("notes", "notes")
+ birthdate = None
+ if animal["birthdate"]:
+ birthdate = self.app.localtime(animal["birthdate"]).timestamp()
- # archived
- f.set_node("archived", colander.Boolean())
+ attrs = {
+ "sex": animal["sex"] or None,
+ "is_sterile": animal["is_sterile"],
+ "produces_eggs": animal["produces_eggs"],
+ "birthdate": birthdate,
+ }
- # image
- if url := animal.get("large_image_url"):
- f.set_widget("image", ImageWidget("animal image"))
- f.set_default("image", url)
+ 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):
- 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..d1ae226
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/assets.py
@@ -0,0 +1,317 @@
+# -*- 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 webhelpers2.html import tags
+
+from wuttafarm.web.views.farmos import FarmOSMasterView
+from wuttafarm.web.forms.schema import FarmOSRefs, FarmOSLocationRefs
+from wuttafarm.web.forms.widgets import ImageWidget
+from wuttafarm.web.grids import (
+ ResourceData,
+ StringFilter,
+ IntegerFilter,
+ BooleanFilter,
+ SimpleSorter,
+)
+
+
+class AssetMasterView(FarmOSMasterView):
+ """
+ Base class for Asset master views
+ """
+
+ 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 = [
+ "thumbnail",
+ "drupal_id",
+ "name",
+ "owners",
+ "locations",
+ "archived",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ "archived": {"active": True, "verb": "is_false"},
+ }
+
+ def get_grid_data(self, **kwargs):
+ return ResourceData(
+ self.config,
+ self.farmos_client,
+ f"asset--{self.farmos_asset_type}",
+ include=",".join(self.get_farmos_api_includes()),
+ normalizer=self.normalize_asset,
+ )
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # thumbnail
+ g.set_renderer("thumbnail", self.render_grid_thumbnail)
+ g.set_label("thumbnail", "", column_only=True)
+ g.set_centered("thumbnail")
+
+ # drupal_id
+ g.set_label("drupal_id", "ID", column_only=True)
+ 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_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_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_locations_for_grid(self, asset, field, value):
+ locations = []
+ for location in value:
+ 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):
+ """ """
+ if asset["archived"]:
+ return "has-background-warning"
+ return None
+
+ def get_farmos_api_includes(self):
+ return {"asset_type", "location", "owner", "image"}
+
+ def get_instance(self):
+ 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 = 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"]
+
+ def normalize_asset(self, asset, included):
+
+ if notes := asset["attributes"]["notes"]:
+ notes = notes["value"]
+
+ if self.farmos_4x:
+ archived = asset["attributes"]["archived"]
+ 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"]):
+ 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"])
+
+ if images := relationships.get("image"):
+ for image in images["data"]:
+ if image := included.get(image["id"]):
+ 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"],
+ "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 or colander.null,
+ "image_url": image_url or colander.null,
+ }
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ asset = f.model_instance
+
+ # 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
+ 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 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 [
+ 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",
+ ),
+ ]
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/logs.py b/src/wuttafarm/web/views/farmos/logs.py
index a3e804f..f20eb0e 100644
--- a/src/wuttafarm/web/views/farmos/logs.py
+++ b/src/wuttafarm/web/views/farmos/logs.py
@@ -23,14 +23,27 @@
View for farmOS Harvest Logs
"""
-import datetime
+from webhelpers2.html import tags
-import colander
-
-from wuttaweb.forms.schema import WuttaDateTime
+from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum, Notes
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,75 +52,180 @@ class LogMasterView(FarmOSMasterView):
"""
farmos_log_type = None
+ filterable = True
+ sort_on_backend = True
+
+ 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):
-
- if timestamp := log["attributes"]["timestamp"]:
- timestamp = datetime.datetime.fromisoformat(timestamp)
- timestamp = self.app.localtime(timestamp)
-
- if notes := log["attributes"]["notes"]:
- notes = notes["value"]
-
- return {
- "uuid": log["id"],
- "drupal_id": log["attributes"]["drupal_internal__id"],
- "name": log["attributes"]["name"],
- "timestamp": timestamp,
- "status": log["attributes"]["status"],
- "notes": notes or colander.null,
- }
+ def normalize_log(self, log, included):
+ 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
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")
+ f.set_node("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
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 fff3d2c..742ce14 100644
--- a/src/wuttafarm/web/views/farmos/master.py
+++ b/src/wuttafarm/web/views/farmos/master.py
@@ -23,13 +23,25 @@
Base class for farmOS master views
"""
+import datetime
import json
+import colander
import markdown
+from webhelpers2.html import tags
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
+from wuttafarm.web.util import save_farmos_oauth2_token, use_farmos_style_grid_links
+from wuttafarm.web.grids import (
+ ResourceData,
+ StringFilter,
+ NullableStringFilter,
+ DateTimeFilter,
+ SimpleSorter,
+)
class FarmOSMasterView(MasterView):
@@ -50,6 +62,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",
@@ -59,7 +72,9 @@ 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)
def get_farmos_client(self):
token = self.request.session.get("farmos.oauth2.token")
@@ -86,6 +101,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:
@@ -100,3 +125,143 @@ class FarmOSMasterView(MasterView):
)
return context
+
+
+class TaxonomyMasterView(FarmOSMasterView):
+ """
+ Base class for farmOS "taxonomy term" views
+ """
+
+ farmos_taxonomy_type = None
+ creatable = True
+ editable = True
+ deletable = True
+ filterable = True
+ sort_on_backend = True
+
+ grid_columns = [
+ "name",
+ "description",
+ "changed",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "description",
+ "changed",
+ ]
+
+ def get_grid_data(self, columns=None, session=None):
+ return ResourceData(
+ self.config,
+ self.farmos_client,
+ f"taxonomy_term--{self.farmos_taxonomy_type}",
+ normalizer=self.normalize_taxonomy_term,
+ )
+
+ def normalize_taxonomy_term(self, term, included):
+
+ 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_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"], {})
+
+ 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")
+ f.set_required("description", False)
+
+ # changed
+ 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 [
+ 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..57bf2d4 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"])
@@ -155,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/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py
new file mode 100644
index 0000000..8aafeea
--- /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 FarmOSUnitRef
+
+
+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", FarmOSUnitRef())
+
+
+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/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"
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/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/master.py b/src/wuttafarm/web/views/master.py
index 0e25a30..2250d1b 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
@@ -99,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/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):
diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py
new file mode 100644
index 0000000..7d75290
--- /dev/null
+++ b/src/wuttafarm/web/views/quantities.py
@@ -0,0 +1,293 @@
+# -*- 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 collections import OrderedDict
+
+from wuttaweb.db import Session
+
+from wuttafarm.web.views import WuttaFarmMasterView
+from wuttafarm.db.model import QuantityType, Quantity, StandardQuantity
+from wuttafarm.web.forms.schema import UnitRef
+
+
+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):
+ """
+ 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
+
+
+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/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..2fb73e4
--- /dev/null
+++ b/src/wuttafarm/web/views/quick/base.py
@@ -0,0 +1,156 @@
+# -*- 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)
+ self.normal = self.app.get_normalizer(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..aa663b6
--- /dev/null
+++ b/src/wuttafarm/web/views/quick/eggs.py
@@ -0,0 +1,243 @@
+# -*- 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):
+
+ 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()
+ 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)
+ return response
+
+ 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)
diff --git a/src/wuttafarm/web/views/settings.py b/src/wuttafarm/web/views/settings.py
new file mode 100644
index 0000000..86d7a0c
--- /dev/null
+++ b/src/wuttafarm/web/views/settings.py
@@ -0,0 +1,85 @@
+# -*- 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
+
+from wuttafarm.web.util import use_farmos_style_grid_links
+
+
+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": 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
+
+
+def defaults(config, **kwargs):
+ local = globals()
+ AppInfoView = kwargs.get("AppInfoView", local["AppInfoView"])
+ base.defaults(config, **{"AppInfoView": AppInfoView})
+
+
+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..3b86426
--- /dev/null
+++ b/src/wuttafarm/web/views/units.py
@@ -0,0 +1,131 @@
+# -*- 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 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):
+ """
+ Master view for Units
+ """
+
+ model_class = Unit
+ route_prefix = "units"
+ url_prefix = "/units"
+
+ 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()
+
+ MeasureView = kwargs.get("MeasureView", base["MeasureView"])
+ MeasureView.defaults(config)
+
+ UnitView = kwargs.get("UnitView", base["UnitView"])
+ UnitView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)