diff --git a/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py b/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py
new file mode 100644
index 0000000..a6aab9d
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py
@@ -0,0 +1,293 @@
+"""add Standard Quantities
+
+Revision ID: 5b6c87d8cddf
+Revises: 1f98d27cabeb
+Create Date: 2026-02-19 15:42:19.691148
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "5b6c87d8cddf"
+down_revision: Union[str, None] = "1f98d27cabeb"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # measure
+ op.create_table(
+ "measure",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("drupal_id", sa.String(length=20), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_measure")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_measure_drupal_id")),
+ sa.UniqueConstraint("name", name=op.f("uq_measure_name")),
+ )
+ op.create_table(
+ "measure_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
+ sa.Column(
+ "drupal_id", sa.String(length=20), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_measure_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_measure_version_end_transaction_id"),
+ "measure_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_measure_version_operation_type"),
+ "measure_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_measure_version_pk_transaction_id",
+ "measure_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_measure_version_pk_validity",
+ "measure_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_measure_version_transaction_id"),
+ "measure_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # quantity
+ op.create_table(
+ "quantity",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("quantity_type_id", sa.String(length=50), nullable=False),
+ sa.Column("measure_id", sa.String(length=20), nullable=False),
+ sa.Column("value_numerator", sa.Integer(), nullable=False),
+ sa.Column("value_denominator", sa.Integer(), nullable=False),
+ sa.Column("units_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("label", sa.String(length=255), nullable=True),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["measure_id"],
+ ["measure.drupal_id"],
+ name=op.f("fk_quantity_measure_id_measure"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["quantity_type_id"],
+ ["quantity_type.drupal_id"],
+ name=op.f("fk_quantity_quantity_type_id_quantity_type"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["units_uuid"], ["unit.uuid"], name=op.f("fk_quantity_units_uuid_unit")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_quantity_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_quantity_farmos_uuid")),
+ )
+ op.create_table(
+ "quantity_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "quantity_type_id", sa.String(length=50), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "measure_id", sa.String(length=20), autoincrement=False, nullable=True
+ ),
+ sa.Column("value_numerator", sa.Integer(), autoincrement=False, nullable=True),
+ sa.Column(
+ "value_denominator", sa.Integer(), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "units_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column("label", sa.String(length=255), autoincrement=False, nullable=True),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_quantity_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_quantity_version_end_transaction_id"),
+ "quantity_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_quantity_version_operation_type"),
+ "quantity_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_quantity_version_pk_transaction_id",
+ "quantity_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_quantity_version_pk_validity",
+ "quantity_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_quantity_version_transaction_id"),
+ "quantity_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # quantity_standard
+ op.create_table(
+ "quantity_standard",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["quantity.uuid"], name=op.f("fk_quantity_standard_uuid_quantity")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_standard")),
+ )
+ op.create_table(
+ "quantity_standard_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_quantity_standard_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_quantity_standard_version_end_transaction_id"),
+ "quantity_standard_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_quantity_standard_version_operation_type"),
+ "quantity_standard_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_quantity_standard_version_pk_transaction_id",
+ "quantity_standard_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_quantity_standard_version_pk_validity",
+ "quantity_standard_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_quantity_standard_version_transaction_id"),
+ "quantity_standard_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # quantity_standard
+ op.drop_index(
+ op.f("ix_quantity_standard_version_transaction_id"),
+ table_name="quantity_standard_version",
+ )
+ op.drop_index(
+ "ix_quantity_standard_version_pk_validity",
+ table_name="quantity_standard_version",
+ )
+ op.drop_index(
+ "ix_quantity_standard_version_pk_transaction_id",
+ table_name="quantity_standard_version",
+ )
+ op.drop_index(
+ op.f("ix_quantity_standard_version_operation_type"),
+ table_name="quantity_standard_version",
+ )
+ op.drop_index(
+ op.f("ix_quantity_standard_version_end_transaction_id"),
+ table_name="quantity_standard_version",
+ )
+ op.drop_table("quantity_standard_version")
+ op.drop_table("quantity_standard")
+
+ # quantity
+ op.drop_index(
+ op.f("ix_quantity_version_transaction_id"), table_name="quantity_version"
+ )
+ op.drop_index("ix_quantity_version_pk_validity", table_name="quantity_version")
+ op.drop_index(
+ "ix_quantity_version_pk_transaction_id", table_name="quantity_version"
+ )
+ op.drop_index(
+ op.f("ix_quantity_version_operation_type"), table_name="quantity_version"
+ )
+ op.drop_index(
+ op.f("ix_quantity_version_end_transaction_id"), table_name="quantity_version"
+ )
+ op.drop_table("quantity_version")
+ op.drop_table("quantity")
+
+ # measure
+ op.drop_index(
+ op.f("ix_measure_version_transaction_id"), table_name="measure_version"
+ )
+ op.drop_index("ix_measure_version_pk_validity", table_name="measure_version")
+ op.drop_index("ix_measure_version_pk_transaction_id", table_name="measure_version")
+ op.drop_index(
+ op.f("ix_measure_version_operation_type"), table_name="measure_version"
+ )
+ op.drop_index(
+ op.f("ix_measure_version_end_transaction_id"), table_name="measure_version"
+ )
+ op.drop_table("measure_version")
+ op.drop_table("measure")
diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py
index 827fc70..68695e5 100644
--- a/src/wuttafarm/db/model/__init__.py
+++ b/src/wuttafarm/db/model/__init__.py
@@ -30,8 +30,8 @@ from wuttjamaican.db.model import *
from .users import WuttaFarmUser
# wuttafarm proper models
-from .unit import Unit
-from .quantities import QuantityType
+from .unit import Unit, Measure
+from .quantities import QuantityType, Quantity, StandardQuantity
from .asset import AssetType, Asset, AssetParent
from .asset_land import LandType, LandAsset
from .asset_structure import StructureType, StructureAsset
diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py
index b66f9bb..4f537b9 100644
--- a/src/wuttafarm/db/model/quantities.py
+++ b/src/wuttafarm/db/model/quantities.py
@@ -24,6 +24,8 @@ Model definition for Quantities
"""
import sqlalchemy as sa
+from sqlalchemy import orm
+from sqlalchemy.ext.declarative import declared_attr
from wuttjamaican.db import model
@@ -79,3 +81,141 @@ class QuantityType(model.Base):
def __str__(self):
return self.name or ""
+
+
+class Quantity(model.Base):
+ """
+ Represents a base quantity record from farmOS
+ """
+
+ __tablename__ = "quantity"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Quantity",
+ "model_title_plural": "All Quantities",
+ }
+
+ uuid = model.uuid_column()
+
+ quantity_type_id = sa.Column(
+ sa.String(length=50),
+ sa.ForeignKey("quantity_type.drupal_id"),
+ nullable=False,
+ )
+
+ quantity_type = orm.relationship(QuantityType)
+
+ measure_id = sa.Column(
+ sa.String(length=20),
+ sa.ForeignKey("measure.drupal_id"),
+ nullable=False,
+ doc="""
+ Measure for the quantity.
+ """,
+ )
+
+ measure = orm.relationship("Measure")
+
+ value_numerator = sa.Column(
+ sa.Integer(),
+ nullable=False,
+ doc="""
+ Numerator for the quantity value.
+ """,
+ )
+
+ value_denominator = sa.Column(
+ sa.Integer(),
+ nullable=False,
+ doc="""
+ Denominator for the quantity value.
+ """,
+ )
+
+ units_uuid = model.uuid_fk_column("unit.uuid", nullable=False)
+ units = orm.relationship("Unit")
+
+ label = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Optional label for the quantity.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the quantity within farmOS.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the quantity.
+ """,
+ )
+
+ def render_as_text(self, config=None):
+ measure = str(self.measure or self.measure_id or "")
+ value = self.value_numerator / self.value_denominator
+ if config:
+ app = config.get_app()
+ value = app.render_quantity(value)
+ units = str(self.units or "")
+ return f"( {measure} ) {value} {units}"
+
+ def __str__(self):
+ return self.render_as_text()
+
+
+class QuantityMixin:
+
+ uuid = model.uuid_fk_column("quantity.uuid", nullable=False, primary_key=True)
+
+ @declared_attr
+ def quantity(cls):
+ return orm.relationship(Quantity)
+
+ def render_as_text(self, config=None):
+ return self.quantity.render_as_text(config)
+
+ def __str__(self):
+ return self.render_as_text()
+
+
+def add_quantity_proxies(subclass):
+ Quantity.make_proxy(subclass, "quantity", "farmos_uuid")
+ Quantity.make_proxy(subclass, "quantity", "drupal_id")
+ Quantity.make_proxy(subclass, "quantity", "quantity_type")
+ Quantity.make_proxy(subclass, "quantity", "quantity_type_id")
+ Quantity.make_proxy(subclass, "quantity", "measure")
+ Quantity.make_proxy(subclass, "quantity", "measure_id")
+ Quantity.make_proxy(subclass, "quantity", "value_numerator")
+ Quantity.make_proxy(subclass, "quantity", "value_denominator")
+ Quantity.make_proxy(subclass, "quantity", "value_decimal")
+ Quantity.make_proxy(subclass, "quantity", "units_uuid")
+ Quantity.make_proxy(subclass, "quantity", "units")
+ Quantity.make_proxy(subclass, "quantity", "label")
+
+
+class StandardQuantity(QuantityMixin, model.Base):
+ """
+ Represents a Standard Quantity from farmOS
+ """
+
+ __tablename__ = "quantity_standard"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Standard Quantity",
+ "model_title_plural": "Standard Quantities",
+ "farmos_quantity_type": "standard",
+ }
+
+
+add_quantity_proxies(StandardQuantity)
diff --git a/src/wuttafarm/db/model/unit.py b/src/wuttafarm/db/model/unit.py
index 8cbdd5a..e9c6e70 100644
--- a/src/wuttafarm/db/model/unit.py
+++ b/src/wuttafarm/db/model/unit.py
@@ -28,6 +28,42 @@ import sqlalchemy as sa
from wuttjamaican.db import model
+class Measure(model.Base):
+ """
+ Represents a "measure" option (for quantities) from farmOS
+ """
+
+ __tablename__ = "measure"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Measure",
+ "model_title_plural": "Measures",
+ }
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ unique=True,
+ doc="""
+ Name of the measure.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.String(length=20),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the measure.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
+
+
class Unit(model.Base):
"""
Represents an "unit" (taxonomy term) from farmOS
diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py
index 90a4a7c..5cf2242 100644
--- a/src/wuttafarm/importing/farmos.py
+++ b/src/wuttafarm/importing/farmos.py
@@ -106,8 +106,10 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["GroupAsset"] = GroupAssetImporter
importers["PlantType"] = PlantTypeImporter
importers["PlantAsset"] = PlantAssetImporter
+ importers["Measure"] = MeasureImporter
importers["Unit"] = UnitImporter
importers["QuantityType"] = QuantityTypeImporter
+ importers["StandardQuantity"] = StandardQuantityImporter
importers["LogType"] = LogTypeImporter
importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter
@@ -823,6 +825,37 @@ class UserImporter(FromFarmOS, ToWutta):
##############################
+class MeasureImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Measures
+ """
+
+ model_class = model.Measure
+
+ key = "drupal_id"
+
+ supported_fields = [
+ "drupal_id",
+ "name",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ response = self.farmos_client.session.get(
+ self.app.get_farmos_url("/api/quantity/standard/resource/schema")
+ )
+ response.raise_for_status()
+ data = response.json()
+ return data["definitions"]["attributes"]["properties"]["measure"]["oneOf"]
+
+ def normalize_source_object(self, measure):
+ """ """
+ return {
+ "drupal_id": measure["const"],
+ "name": measure["title"],
+ }
+
+
class UnitImporter(FromFarmOS, ToWutta):
"""
farmOS API → WuttaFarm importer for Units
@@ -1100,3 +1133,114 @@ class ObservationLogImporter(LogImporterBase):
"status",
"assets",
]
+
+
+class QuantityImporterBase(FromFarmOS, ToWutta):
+ """
+ Base class for farmOS API → WuttaFarm quantity importers
+ """
+
+ def get_farmos_quantity_type(self):
+ return self.model_class.__wutta_hint__["farmos_quantity_type"]
+
+ def get_simple_fields(self):
+ """ """
+ fields = list(super().get_simple_fields())
+ # nb. must explicitly declare proxy fields
+ fields.extend(
+ [
+ "farmos_uuid",
+ "drupal_id",
+ "quantity_type_id",
+ "measure_id",
+ "value_numerator",
+ "value_denominator",
+ "units_uuid",
+ "label",
+ ]
+ )
+ return fields
+
+ def setup(self):
+ super().setup()
+ model = self.app.model
+
+ self.quantity_types_by_farmos_uuid = {}
+ for quantity_type in self.target_session.query(model.QuantityType):
+ if quantity_type.farmos_uuid:
+ self.quantity_types_by_farmos_uuid[quantity_type.farmos_uuid] = (
+ quantity_type
+ )
+
+ self.units_by_farmos_uuid = {}
+ for unit in self.target_session.query(model.Unit):
+ if unit.farmos_uuid:
+ self.units_by_farmos_uuid[unit.farmos_uuid] = unit
+
+ def get_source_objects(self):
+ """ """
+ quantity_type = self.get_farmos_quantity_type()
+ result = self.farmos_client.resource.get("quantity", quantity_type)
+ return result["data"]
+
+ def normalize_source_object(self, quantity):
+ """ """
+ quantity_type_id = None
+ units_uuid = None
+ if relationships := quantity.get("relationships"):
+
+ if quantity_type := relationships.get("quantity_type"):
+ if quantity_type["data"]:
+ if wf_quantity_type := self.quantity_types_by_farmos_uuid.get(
+ UUID(quantity_type["data"]["id"])
+ ):
+ quantity_type_id = wf_quantity_type.drupal_id
+
+ if units := relationships.get("units"):
+ if units["data"]:
+ if wf_unit := self.units_by_farmos_uuid.get(
+ UUID(units["data"]["id"])
+ ):
+ units_uuid = wf_unit.uuid
+
+ if not quantity_type_id:
+ log.warning(
+ "missing/invalid quantity_type for farmOS Quantity: %s", quantity
+ )
+ return None
+
+ if not units_uuid:
+ log.warning("missing/invalid units for farmOS Quantity: %s", quantity)
+ return None
+
+ value = quantity["attributes"]["value"]
+
+ return {
+ "farmos_uuid": UUID(quantity["id"]),
+ "drupal_id": quantity["attributes"]["drupal_internal__id"],
+ "quantity_type_id": quantity_type_id,
+ "measure_id": quantity["attributes"]["measure"],
+ "value_numerator": value["numerator"],
+ "value_denominator": value["denominator"],
+ "units_uuid": units_uuid,
+ "label": quantity["attributes"]["label"],
+ }
+
+
+class StandardQuantityImporter(QuantityImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Standard Quantities
+ """
+
+ model_class = model.StandardQuantity
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "quantity_type_id",
+ "measure_id",
+ "value_numerator",
+ "value_denominator",
+ "units_uuid",
+ "label",
+ ]
diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py
index 123f662..df2a45c 100644
--- a/src/wuttafarm/web/forms/schema.py
+++ b/src/wuttafarm/web/forms/schema.py
@@ -55,6 +55,26 @@ class AnimalTypeRef(ObjectRef):
return self.request.route_url("animal_types.view", uuid=animal_type.uuid)
+class FarmOSRef(colander.SchemaType):
+
+ def __init__(self, request, route_prefix, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+ self.route_prefix = route_prefix
+
+ def serialize(self, node, appstruct):
+ if appstruct is colander.null:
+ return colander.null
+
+ return json.dumps(appstruct)
+
+ def widget_maker(self, **kwargs):
+ """ """
+ from wuttafarm.web.forms.widgets import FarmOSRefWidget
+
+ return FarmOSRefWidget(self.request, self.route_prefix, **kwargs)
+
+
class AnimalTypeType(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
@@ -179,6 +199,27 @@ class StructureTypeRef(ObjectRef):
return self.request.route_url("structure_types.view", uuid=structure_type.uuid)
+class UnitRef(ObjectRef):
+ """
+ Custom schema type for a :class:`~wuttafarm.db.model.units.Unit`
+ reference field.
+
+ This is a subclass of
+ :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
+ """
+
+ @property
+ def model_class(self):
+ model = self.app.model
+ return model.Unit
+
+ def sort_query(self, query):
+ return query.order_by(self.model_class.name)
+
+ def get_object_url(self, unit):
+ return self.request.route_url("units.view", uuid=unit.uuid)
+
+
class UsersType(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py
index d5bf5c2..24c33eb 100644
--- a/src/wuttafarm/web/forms/widgets.py
+++ b/src/wuttafarm/web/forms/widgets.py
@@ -54,6 +54,33 @@ class ImageWidget(Widget):
return super().serialize(field, cstruct, **kw)
+class FarmOSRefWidget(Widget):
+ """
+ Generic widget to display "any reference field" - as a link to
+ view the farmOS record it references. Only used by the farmOS
+ direct API views.
+ """
+
+ def __init__(self, request, route_prefix, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+ self.route_prefix = route_prefix
+
+ def serialize(self, field, cstruct, **kw):
+ readonly = kw.get("readonly", self.readonly)
+ if readonly:
+ if cstruct in (colander.null, None):
+ return HTML.tag("span")
+
+ obj = json.loads(cstruct)
+ return tags.link_to(
+ obj["name"],
+ self.request.route_url(f"{self.route_prefix}.view", uuid=obj["uuid"]),
+ )
+
+ return super().serialize(field, cstruct, **kw)
+
+
class AnimalTypeWidget(Widget):
"""
Widget to display an "animal type" field.
diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py
index 01e0f07..448fb8d 100644
--- a/src/wuttafarm/web/menus.py
+++ b/src/wuttafarm/web/menus.py
@@ -134,11 +134,27 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"perm": "logs_observation.list",
},
{"type": "sep"},
+ {
+ "title": "All Quantities",
+ "route": "quantities",
+ "perm": "quantities.list",
+ },
+ {
+ "title": "Standard Quantities",
+ "route": "quantities_standard",
+ "perm": "quantities_standard.list",
+ },
+ {"type": "sep"},
{
"title": "Log Types",
"route": "log_types",
"perm": "log_types.list",
},
+ {
+ "title": "Measures",
+ "route": "measures",
+ "perm": "measures.list",
+ },
{
"title": "Quantity Types",
"route": "quantity_types",
@@ -248,6 +264,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_quantity_types",
"perm": "farmos_quantity_types.list",
},
+ {
+ "title": "Standard Quantities",
+ "route": "farmos_quantities_standard",
+ "perm": "farmos_quantities_standard.list",
+ },
{
"title": "Units",
"route": "farmos_units",
diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py
index cfedfb1..e59ac1f 100644
--- a/src/wuttafarm/web/views/farmos/__init__.py
+++ b/src/wuttafarm/web/views/farmos/__init__.py
@@ -28,7 +28,7 @@ from .master import FarmOSMasterView
def includeme(config):
config.include("wuttafarm.web.views.farmos.users")
- config.include("wuttafarm.web.views.farmos.quantity_types")
+ config.include("wuttafarm.web.views.farmos.quantities")
config.include("wuttafarm.web.views.farmos.asset_types")
config.include("wuttafarm.web.views.farmos.units")
config.include("wuttafarm.web.views.farmos.land_types")
diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py
new file mode 100644
index 0000000..414474b
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/quantities.py
@@ -0,0 +1,278 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaFarm --Web app to integrate with and extend farmOS
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaFarm.
+#
+# WuttaFarm is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# WuttaFarm. If not, see .
+#
+################################################################################
+"""
+View for farmOS Quantity Types
+"""
+
+import datetime
+
+import colander
+
+from wuttaweb.forms.schema import WuttaDateTime
+from wuttaweb.forms.widgets import WuttaDateTimeWidget
+
+from wuttafarm.web.views.farmos import FarmOSMasterView
+from wuttafarm.web.forms.schema import FarmOSRef
+
+
+class QuantityTypeView(FarmOSMasterView):
+ """
+ View for farmOS Quantity Types
+ """
+
+ model_name = "farmos_quantity_type"
+ model_title = "farmOS Quantity Type"
+ model_title_plural = "farmOS Quantity Types"
+
+ route_prefix = "farmos_quantity_types"
+ url_prefix = "/farmOS/quantity-types"
+
+ grid_columns = [
+ "label",
+ "description",
+ ]
+
+ sort_defaults = "label"
+
+ form_fields = [
+ "label",
+ "description",
+ ]
+
+ def get_grid_data(self, columns=None, session=None):
+ result = self.farmos_client.resource.get("quantity_type")
+ return [self.normalize_quantity_type(t) for t in result["data"]]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # label
+ g.set_link("label")
+ g.set_searchable("label")
+
+ # description
+ g.set_searchable("description")
+
+ def get_instance(self):
+ result = self.farmos_client.resource.get_id(
+ "quantity_type", "quantity_type", self.request.matchdict["uuid"]
+ )
+ self.raw_json = result
+ return self.normalize_quantity_type(result["data"])
+
+ def get_instance_title(self, quantity_type):
+ return quantity_type["label"]
+
+ def normalize_quantity_type(self, quantity_type):
+ return {
+ "uuid": quantity_type["id"],
+ "drupal_id": quantity_type["attributes"]["drupal_internal__id"],
+ "label": quantity_type["attributes"]["label"],
+ "description": quantity_type["attributes"]["description"],
+ }
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # description
+ f.set_widget("description", "notes")
+
+ def get_xref_buttons(self, quantity_type):
+ model = self.app.model
+ session = self.Session()
+ buttons = []
+
+ if wf_quantity_type := (
+ session.query(model.QuantityType)
+ .filter(model.QuantityType.farmos_uuid == quantity_type["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "quantity_types.view", uuid=wf_quantity_type.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+class QuantityMasterView(FarmOSMasterView):
+ """
+ Base class for Quantity views
+ """
+
+ farmos_quantity_type = None
+
+ grid_columns = [
+ "measure",
+ "value",
+ "label",
+ "changed",
+ ]
+
+ sort_defaults = ("changed", "desc")
+
+ form_fields = [
+ "measure",
+ "value",
+ "units",
+ "label",
+ "created",
+ "changed",
+ ]
+
+ def get_grid_data(self, columns=None, session=None):
+ result = self.farmos_client.resource.get("quantity", self.farmos_quantity_type)
+ return [self.normalize_quantity(t) for t in result["data"]]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # value
+ g.set_link("value")
+
+ # changed
+ g.set_renderer("changed", "datetime")
+
+ def get_instance(self):
+ quantity = self.farmos_client.resource.get_id(
+ "quantity", self.farmos_quantity_type, self.request.matchdict["uuid"]
+ )
+ self.raw_json = quantity
+
+ data = self.normalize_quantity(quantity["data"])
+
+ if relationships := quantity["data"].get("relationships"):
+
+ # add units
+ if units := relationships.get("units"):
+ if units["data"]:
+ unit = self.farmos_client.resource.get_id(
+ "taxonomy_term", "unit", units["data"]["id"]
+ )
+ data["units"] = {
+ "uuid": unit["data"]["id"],
+ "name": unit["data"]["attributes"]["name"],
+ }
+
+ return data
+
+ def get_instance_title(self, quantity):
+ return quantity["value"]
+
+ def normalize_quantity(self, quantity):
+
+ if created := quantity["attributes"]["created"]:
+ created = datetime.datetime.fromisoformat(created)
+ created = self.app.localtime(created)
+
+ if changed := quantity["attributes"]["changed"]:
+ changed = datetime.datetime.fromisoformat(changed)
+ changed = self.app.localtime(changed)
+
+ return {
+ "uuid": quantity["id"],
+ "drupal_id": quantity["attributes"]["drupal_internal__id"],
+ "measure": quantity["attributes"]["measure"],
+ "value": quantity["attributes"]["value"],
+ "label": quantity["attributes"]["label"] or colander.null,
+ "created": created,
+ "changed": changed,
+ }
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # created
+ f.set_node("created", WuttaDateTime(self.request))
+ f.set_widget("created", WuttaDateTimeWidget(self.request))
+
+ # changed
+ f.set_node("changed", WuttaDateTime(self.request))
+ f.set_widget("changed", WuttaDateTimeWidget(self.request))
+
+ # units
+ f.set_node("units", FarmOSRef(self.request, "farmos_units"))
+
+
+class StandardQuantityView(QuantityMasterView):
+ """
+ View for farmOS Standard Quantities
+ """
+
+ model_name = "farmos_standard_quantity"
+ model_title = "farmOS Standard Quantity"
+ model_title_plural = "farmOS Standard Quantities"
+
+ route_prefix = "farmos_quantities_standard"
+ url_prefix = "/farmOS/quantities/standard"
+
+ farmos_quantity_type = "standard"
+
+ def get_xref_buttons(self, standard_quantity):
+ model = self.app.model
+ session = self.Session()
+ buttons = []
+
+ if wf_standard_quantity := (
+ session.query(model.StandardQuantity)
+ .join(model.Quantity)
+ .filter(model.Quantity.farmos_uuid == standard_quantity["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "quantities_standard.view", uuid=wf_standard_quantity.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"])
+ QuantityTypeView.defaults(config)
+
+ StandardQuantityView = kwargs.get(
+ "StandardQuantityView", base["StandardQuantityView"]
+ )
+ StandardQuantityView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/quantity_types.py b/src/wuttafarm/web/views/farmos/quantity_types.py
deleted file mode 100644
index 2b10a0a..0000000
--- a/src/wuttafarm/web/views/farmos/quantity_types.py
+++ /dev/null
@@ -1,125 +0,0 @@
-# -*- coding: utf-8; -*-
-################################################################################
-#
-# WuttaFarm --Web app to integrate with and extend farmOS
-# Copyright © 2026 Lance Edgar
-#
-# This file is part of WuttaFarm.
-#
-# WuttaFarm is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along with
-# WuttaFarm. If not, see .
-#
-################################################################################
-"""
-View for farmOS Quantity Types
-"""
-
-from wuttafarm.web.views.farmos import FarmOSMasterView
-
-
-class QuantityTypeView(FarmOSMasterView):
- """
- View for farmOS Quantity Types
- """
-
- model_name = "farmos_quantity_type"
- model_title = "farmOS Quantity Type"
- model_title_plural = "farmOS Quantity Types"
-
- route_prefix = "farmos_quantity_types"
- url_prefix = "/farmOS/quantity-types"
-
- grid_columns = [
- "label",
- "description",
- ]
-
- sort_defaults = "label"
-
- form_fields = [
- "label",
- "description",
- ]
-
- def get_grid_data(self, columns=None, session=None):
- result = self.farmos_client.resource.get("quantity_type")
- return [self.normalize_quantity_type(t) for t in result["data"]]
-
- def configure_grid(self, grid):
- g = grid
- super().configure_grid(g)
-
- # label
- g.set_link("label")
- g.set_searchable("label")
-
- # description
- g.set_searchable("description")
-
- def get_instance(self):
- result = self.farmos_client.resource.get_id(
- "quantity_type", "quantity_type", self.request.matchdict["uuid"]
- )
- self.raw_json = result
- return self.normalize_quantity_type(result["data"])
-
- def get_instance_title(self, quantity_type):
- return quantity_type["label"]
-
- def normalize_quantity_type(self, quantity_type):
- return {
- "uuid": quantity_type["id"],
- "drupal_id": quantity_type["attributes"]["drupal_internal__id"],
- "label": quantity_type["attributes"]["label"],
- "description": quantity_type["attributes"]["description"],
- }
-
- def configure_form(self, form):
- f = form
- super().configure_form(f)
-
- # description
- f.set_widget("description", "notes")
-
- def get_xref_buttons(self, quantity_type):
- model = self.app.model
- session = self.Session()
- buttons = []
-
- if wf_quantity_type := (
- session.query(model.QuantityType)
- .filter(model.QuantityType.farmos_uuid == quantity_type["uuid"])
- .first()
- ):
- buttons.append(
- self.make_button(
- f"View {self.app.get_title()} record",
- primary=True,
- url=self.request.route_url(
- "quantity_types.view", uuid=wf_quantity_type.uuid
- ),
- icon_left="eye",
- )
- )
-
- return buttons
-
-
-def defaults(config, **kwargs):
- base = globals()
-
- QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"])
- QuantityTypeView.defaults(config)
-
-
-def includeme(config):
- defaults(config)
diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py
index 1291791..7d75290 100644
--- a/src/wuttafarm/web/views/quantities.py
+++ b/src/wuttafarm/web/views/quantities.py
@@ -23,8 +23,24 @@
Master view for Quantities
"""
+from collections import OrderedDict
+
+from wuttaweb.db import Session
+
from wuttafarm.web.views import WuttaFarmMasterView
-from wuttafarm.db.model import QuantityType
+from wuttafarm.db.model import QuantityType, Quantity, StandardQuantity
+from wuttafarm.web.forms.schema import UnitRef
+
+
+def get_quantity_type_enum(config):
+ app = config.get_app()
+ model = app.model
+ session = Session()
+ quantity_types = OrderedDict()
+ query = session.query(model.QuantityType).order_by(model.QuantityType.name)
+ for quantity_type in query:
+ quantity_types[quantity_type.drupal_id] = quantity_type.name
+ return quantity_types
class QuantityTypeView(WuttaFarmMasterView):
@@ -79,12 +95,199 @@ class QuantityTypeView(WuttaFarmMasterView):
return buttons
+class QuantityMasterView(WuttaFarmMasterView):
+ """
+ Base class for Quantity master views
+ """
+
+ grid_columns = [
+ "drupal_id",
+ "as_text",
+ "quantity_type",
+ "measure",
+ "value",
+ "units",
+ "label",
+ ]
+
+ sort_defaults = ("drupal_id", "desc")
+
+ form_fields = [
+ "quantity_type",
+ "as_text",
+ "measure",
+ "value",
+ "units",
+ "label",
+ "farmos_uuid",
+ "drupal_id",
+ ]
+
+ def get_query(self, session=None):
+ """ """
+ model = self.app.model
+ model_class = self.get_model_class()
+ session = session or self.Session()
+ query = session.query(model_class)
+ if model_class is not model.Quantity:
+ query = query.join(model.Quantity)
+ query = query.join(model.Measure).join(model.Unit)
+ return query
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+ model = self.app.model
+ model_class = self.get_model_class()
+
+ # drupal_id
+ g.set_label("drupal_id", "ID", column_only=True)
+ g.set_sorter("drupal_id", model.Quantity.drupal_id)
+
+ # as_text
+ g.set_renderer("as_text", self.render_as_text_for_grid)
+ g.set_link("as_text")
+
+ # quantity_type
+ if model_class is not model.Quantity:
+ g.remove("quantity_type")
+ else:
+ g.set_enum("quantity_type", get_quantity_type_enum(self.config))
+
+ # measure
+ g.set_sorter("measure", model.Measure.name)
+
+ # value
+ g.set_renderer("value", self.render_value_for_grid)
+
+ # units
+ g.set_sorter("units", model.Unit.name)
+
+ # label
+ g.set_sorter("label", model.Quantity.label)
+
+ # view action links to final quantity record
+ if model_class is model.Quantity:
+
+ def quantity_url(quantity, i):
+ return self.request.route_url(
+ f"quantities_{quantity.quantity_type_id}.view", uuid=quantity.uuid
+ )
+
+ g.add_action("view", icon="eye", url=quantity_url)
+
+ def render_as_text_for_grid(self, quantity, field, value):
+ return quantity.render_as_text(self.config)
+
+ def render_value_for_grid(self, quantity, field, value):
+ value = quantity.value_numerator / quantity.value_denominator
+ return self.app.render_quantity(value)
+
+ def get_instance_title(self, quantity):
+ return quantity.render_as_text(self.config)
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ quantity = form.model_instance
+
+ # as_text
+ if self.creating or self.editing:
+ f.remove("as_text")
+ else:
+ f.set_default("as_text", quantity.render_as_text(self.config))
+
+ # quantity_type
+ if self.creating:
+ f.remove("quantity_type")
+ else:
+ f.set_readonly("quantity_type")
+ f.set_default("quantity_type", quantity.quantity_type.name)
+
+ # measure
+ if self.creating:
+ f.remove("measure")
+ else:
+ f.set_readonly("measure")
+ f.set_default("measure", quantity.measure.name)
+
+ # value
+ if self.creating:
+ f.remove("value")
+ else:
+ value = quantity.value_numerator / quantity.value_denominator
+ value = self.app.render_quantity(value)
+ f.set_default(
+ "value",
+ f"{value} ({quantity.value_numerator} / {quantity.value_denominator})",
+ )
+
+ # units
+ if self.creating:
+ f.remove("units")
+ else:
+ f.set_readonly("units")
+ f.set_node("units", UnitRef(self.request))
+ # TODO: ugh
+ f.set_default("units", quantity.quantity.units)
+
+ def get_xref_buttons(self, quantity):
+ buttons = super().get_xref_buttons(quantity)
+
+ if quantity.farmos_uuid:
+ url = self.request.route_url(
+ f"farmos_quantities_{quantity.quantity_type_id}.view",
+ uuid=quantity.farmos_uuid,
+ )
+ buttons.append(
+ self.make_button(
+ "View farmOS record", primary=True, url=url, icon_left="eye"
+ )
+ )
+
+ return buttons
+
+
+class QuantityView(QuantityMasterView):
+ """
+ Master view for All Quantities
+ """
+
+ model_class = Quantity
+ route_prefix = "quantities"
+ url_prefix = "/quantities"
+
+ viewable = False
+ creatable = False
+ editable = False
+ deletable = False
+ model_is_versioned = False
+
+
+class StandardQuantityView(QuantityMasterView):
+ """
+ Master view for Standard Quantities
+ """
+
+ model_class = StandardQuantity
+ route_prefix = "quantities_standard"
+ url_prefix = "/quantities/standard"
+
+
def defaults(config, **kwargs):
base = globals()
QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"])
QuantityTypeView.defaults(config)
+ QuantityView = kwargs.get("QuantityView", base["QuantityView"])
+ QuantityView.defaults(config)
+
+ StandardQuantityView = kwargs.get(
+ "StandardQuantityView", base["StandardQuantityView"]
+ )
+ StandardQuantityView.defaults(config)
+
def includeme(config):
defaults(config)
diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py
index 28570d8..3b86426 100644
--- a/src/wuttafarm/web/views/units.py
+++ b/src/wuttafarm/web/views/units.py
@@ -24,7 +24,40 @@ Master view for Units
"""
from wuttafarm.web.views import WuttaFarmMasterView
-from wuttafarm.db.model import Unit
+from wuttafarm.db.model import Measure, Unit
+
+
+class MeasureView(WuttaFarmMasterView):
+ """
+ Master view for Measures
+ """
+
+ model_class = Measure
+ route_prefix = "measures"
+ url_prefix = "/measures"
+
+ grid_columns = [
+ "name",
+ "drupal_id",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "drupal_id",
+ ]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
class UnitView(WuttaFarmMasterView):
@@ -34,7 +67,7 @@ class UnitView(WuttaFarmMasterView):
model_class = Unit
route_prefix = "units"
- url_prefix = "/animal-types"
+ url_prefix = "/units"
farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview"
@@ -87,6 +120,9 @@ class UnitView(WuttaFarmMasterView):
def defaults(config, **kwargs):
base = globals()
+ MeasureView = kwargs.get("MeasureView", base["MeasureView"])
+ MeasureView.defaults(config)
+
UnitView = kwargs.get("UnitView", base["UnitView"])
UnitView.defaults(config)