diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1eedfc..794220d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,33 @@ All notable changes to WuttaFarm will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.9.0 (2026-03-10)
+
+### Feat
+
+- add schema, edit/sync support for Seeding Logs
+- add schema, edit/sync support for Equipment Assets
+- add schema, edit/sync support for Equipment Types
+- add schema, edit/sync support for Water Assets
+- add edit/sync support for Material Types + Material Quantities
+- add edit/sync support for Material Types
+- add edit/sync support for Log Quantities
+- add edit/sync support for `Log.groups`
+- add edit/sync support for `Log.locations`
+- expose Assets field when editing a Log record
+- add edit/sync support for Plant Seasons
+- add edit/sync support for asset parents
+
+### Fix
+
+- avoid error when material type is unknown
+- improve behavior when deleting a Standard Quantity
+- cleanup grid views for All, Standard Quantities
+- add ordinal for sorting Measures
+- expose `is_location` and `is_fixed` for editing on Animal Asset
+- allow "N/A" option for animal sex
+- fix Assets column for All Logs subgrid when viewing asset
+
## v0.8.0 (2026-03-04)
### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 1bb1dda..51737ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaFarm"
-version = "0.8.0"
+version = "0.9.0"
description = "Web app to integrate with and extend farmOS"
readme = "README.md"
authors = [
@@ -34,7 +34,7 @@ dependencies = [
"pyramid_exclog",
"uvicorn[standard]",
"WuttaSync",
- "WuttaWeb[continuum]>=0.29.0",
+ "WuttaWeb[continuum]>=0.29.2",
]
diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py
index cb9aed3..decd44f 100644
--- a/src/wuttafarm/app.py
+++ b/src/wuttafarm/app.py
@@ -151,6 +151,74 @@ class WuttaFarmAppHandler(base.AppHandler):
factory = self.load_object(spec)
return factory(self.config, farmos_client)
+ def get_quantity_types(self, session=None):
+ """
+ Returns a list of all known quantity types.
+ """
+ model = self.model
+ with self.short_session(session=session) as sess:
+ return (
+ sess.query(model.QuantityType).order_by(model.QuantityType.name).all()
+ )
+
+ def get_measures(self, session=None):
+ """
+ Returns a list of all known measures.
+ """
+ model = self.model
+ with self.short_session(session=session) as sess:
+ return sess.query(model.Measure).order_by(model.Measure.ordinal).all()
+
+ def get_units(self, session=None):
+ """
+ Returns a list of all known units.
+ """
+ model = self.model
+ with self.short_session(session=session) as sess:
+ return sess.query(model.Unit).order_by(model.Unit.name).all()
+
+ def get_material_types(self, session=None):
+ """
+ Returns a list of all known material types.
+ """
+ model = self.model
+ with self.short_session(session=session) as sess:
+ return (
+ sess.query(model.MaterialType).order_by(model.MaterialType.name).all()
+ )
+
+ def get_quantity_models(self):
+ model = self.model
+ return {
+ "standard": model.StandardQuantity,
+ "material": model.MaterialQuantity,
+ }
+
+ def get_true_quantity(self, quantity, require=True):
+ model = self.model
+ if not isinstance(quantity, model.Quantity):
+ if require and not quantity:
+ raise ValueError(f"quantity is not valid: {quantity}")
+ return quantity
+
+ session = self.get_session(quantity)
+ models = self.get_quantity_models()
+ if require and quantity.quantity_type_id not in models:
+ raise ValueError(
+ f"quantity has invalid quantity_type_id: {quantity.quantity_type_id}"
+ )
+
+ true_quantity = session.get(models[quantity.quantity_type_id], quantity.uuid)
+ if require and not true_quantity:
+ raise ValueError(f"quantity has no true/typed quantity record: {quantity}")
+
+ return true_quantity
+
+ def make_true_quantity(self, quantity_type_id, **kwargs):
+ models = self.get_quantity_models()
+ kwargs["quantity_type_id"] = quantity_type_id
+ return models[quantity_type_id](**kwargs)
+
def auto_sync_to_farmos(self, obj, model_name=None, client=None, require=True):
"""
Export the given object to farmOS, using configured handler.
diff --git a/src/wuttafarm/db/alembic/versions/1c89f3fbb521_add_materialtype.py b/src/wuttafarm/db/alembic/versions/1c89f3fbb521_add_materialtype.py
new file mode 100644
index 0000000..0d4c0f5
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/1c89f3fbb521_add_materialtype.py
@@ -0,0 +1,116 @@
+"""add MaterialType
+
+Revision ID: 1c89f3fbb521
+Revises: 82a497e30a97
+Create Date: 2026-03-08 14:38:04.538621
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "1c89f3fbb521"
+down_revision: Union[str, None] = "82a497e30a97"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # material_type
+ op.create_table(
+ "material_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.Integer(), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_material_type")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_material_type_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_material_type_farmos_uuid")),
+ )
+ op.create_table(
+ "material_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.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_material_type_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_material_type_version_end_transaction_id"),
+ "material_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_material_type_version_operation_type"),
+ "material_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_material_type_version_pk_transaction_id",
+ "material_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_material_type_version_pk_validity",
+ "material_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_material_type_version_transaction_id"),
+ "material_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # material_type
+ op.drop_index(
+ op.f("ix_material_type_version_transaction_id"),
+ table_name="material_type_version",
+ )
+ op.drop_index(
+ "ix_material_type_version_pk_validity", table_name="material_type_version"
+ )
+ op.drop_index(
+ "ix_material_type_version_pk_transaction_id", table_name="material_type_version"
+ )
+ op.drop_index(
+ op.f("ix_material_type_version_operation_type"),
+ table_name="material_type_version",
+ )
+ op.drop_index(
+ op.f("ix_material_type_version_end_transaction_id"),
+ table_name="material_type_version",
+ )
+ op.drop_table("material_type_version")
+ op.drop_table("material_type")
diff --git a/src/wuttafarm/db/alembic/versions/82a497e30a97_add_measure_ordinal.py b/src/wuttafarm/db/alembic/versions/82a497e30a97_add_measure_ordinal.py
new file mode 100644
index 0000000..2cd8057
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/82a497e30a97_add_measure_ordinal.py
@@ -0,0 +1,37 @@
+"""add Measure.ordinal
+
+Revision ID: 82a497e30a97
+Revises: c5183b781d34
+Create Date: 2026-03-08 13:15:36.917747
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "82a497e30a97"
+down_revision: Union[str, None] = "c5183b781d34"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # measure
+ op.add_column("measure", sa.Column("ordinal", sa.Integer(), nullable=True))
+ op.add_column(
+ "measure_version",
+ sa.Column("ordinal", sa.Integer(), autoincrement=False, nullable=True),
+ )
+
+
+def downgrade() -> None:
+
+ # measure
+ op.drop_column("measure_version", "ordinal")
+ op.drop_column("measure", "ordinal")
diff --git a/src/wuttafarm/db/alembic/versions/9c53513f8862_add_materialquantity.py b/src/wuttafarm/db/alembic/versions/9c53513f8862_add_materialquantity.py
new file mode 100644
index 0000000..6f28989
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/9c53513f8862_add_materialquantity.py
@@ -0,0 +1,211 @@
+"""add MaterialQuantity
+
+Revision ID: 9c53513f8862
+Revises: 1c89f3fbb521
+Create Date: 2026-03-08 18:14:05.587678
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "9c53513f8862"
+down_revision: Union[str, None] = "1c89f3fbb521"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # quantity_material
+ op.create_table(
+ "quantity_material",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["quantity.uuid"], name=op.f("fk_quantity_material_uuid_quantity")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_material")),
+ )
+ op.create_table(
+ "quantity_material_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_material_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_quantity_material_version_end_transaction_id"),
+ "quantity_material_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_quantity_material_version_operation_type"),
+ "quantity_material_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_quantity_material_version_pk_transaction_id",
+ "quantity_material_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_quantity_material_version_pk_validity",
+ "quantity_material_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_quantity_material_version_transaction_id"),
+ "quantity_material_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # quantity_material_material_type
+ op.create_table(
+ "quantity_material_material_type",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("quantity_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("material_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["material_type_uuid"],
+ ["material_type.uuid"],
+ name=op.f(
+ "fk_quantity_material_material_type_material_type_uuid_material_type"
+ ),
+ ),
+ sa.ForeignKeyConstraint(
+ ["quantity_uuid"],
+ ["quantity_material.uuid"],
+ name=op.f(
+ "fk_quantity_material_material_type_quantity_uuid_quantity_material"
+ ),
+ ),
+ sa.PrimaryKeyConstraint(
+ "uuid", name=op.f("pk_quantity_material_material_type")
+ ),
+ )
+ op.create_table(
+ "quantity_material_material_type_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "quantity_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "material_type_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid",
+ "transaction_id",
+ name=op.f("pk_quantity_material_material_type_version"),
+ ),
+ )
+ op.create_index(
+ op.f("ix_quantity_material_material_type_version_end_transaction_id"),
+ "quantity_material_material_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_quantity_material_material_type_version_operation_type"),
+ "quantity_material_material_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_quantity_material_material_type_version_pk_transaction_id",
+ "quantity_material_material_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_quantity_material_material_type_version_pk_validity",
+ "quantity_material_material_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_quantity_material_material_type_version_transaction_id"),
+ "quantity_material_material_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # quantity_material_material_type
+ op.drop_index(
+ op.f("ix_quantity_material_material_type_version_transaction_id"),
+ table_name="quantity_material_material_type_version",
+ )
+ op.drop_index(
+ "ix_quantity_material_material_type_version_pk_validity",
+ table_name="quantity_material_material_type_version",
+ )
+ op.drop_index(
+ "ix_quantity_material_material_type_version_pk_transaction_id",
+ table_name="quantity_material_material_type_version",
+ )
+ op.drop_index(
+ op.f("ix_quantity_material_material_type_version_operation_type"),
+ table_name="quantity_material_material_type_version",
+ )
+ op.drop_index(
+ op.f("ix_quantity_material_material_type_version_end_transaction_id"),
+ table_name="quantity_material_material_type_version",
+ )
+ op.drop_table("quantity_material_material_type_version")
+ op.drop_table("quantity_material_material_type")
+
+ # quantity_material
+ op.drop_index(
+ op.f("ix_quantity_material_version_transaction_id"),
+ table_name="quantity_material_version",
+ )
+ op.drop_index(
+ "ix_quantity_material_version_pk_validity",
+ table_name="quantity_material_version",
+ )
+ op.drop_index(
+ "ix_quantity_material_version_pk_transaction_id",
+ table_name="quantity_material_version",
+ )
+ op.drop_index(
+ op.f("ix_quantity_material_version_operation_type"),
+ table_name="quantity_material_version",
+ )
+ op.drop_index(
+ op.f("ix_quantity_material_version_end_transaction_id"),
+ table_name="quantity_material_version",
+ )
+ op.drop_table("quantity_material_version")
+ op.drop_table("quantity_material")
diff --git a/src/wuttafarm/db/alembic/versions/c5183b781d34_add_plant_seasons.py b/src/wuttafarm/db/alembic/versions/c5183b781d34_add_plant_seasons.py
new file mode 100644
index 0000000..406ad64
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/c5183b781d34_add_plant_seasons.py
@@ -0,0 +1,205 @@
+"""add plant seasons
+
+Revision ID: c5183b781d34
+Revises: 5f474125a80e
+Create Date: 2026-03-06 20:18:40.160531
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "c5183b781d34"
+down_revision: Union[str, None] = "5f474125a80e"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # season
+ op.create_table(
+ "season",
+ 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_season")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_season_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_season_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_season_name")),
+ )
+ op.create_table(
+ "season_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_season_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_season_version_end_transaction_id"),
+ "season_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_season_version_operation_type"),
+ "season_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_season_version_pk_transaction_id",
+ "season_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_season_version_pk_validity",
+ "season_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_season_version_transaction_id"),
+ "season_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # asset_plant_season
+ op.create_table(
+ "asset_plant_season",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("plant_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("season_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["plant_asset_uuid"],
+ ["asset_plant.uuid"],
+ name=op.f("fk_asset_plant_season_plant_asset_uuid_asset_plant"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["season_uuid"],
+ ["season.uuid"],
+ name=op.f("fk_asset_plant_season_season_uuid_season"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_plant_season")),
+ )
+ op.create_table(
+ "asset_plant_season_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "plant_asset_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "season_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_asset_plant_season_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_plant_season_version_end_transaction_id"),
+ "asset_plant_season_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_plant_season_version_operation_type"),
+ "asset_plant_season_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_plant_season_version_pk_transaction_id",
+ "asset_plant_season_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_plant_season_version_pk_validity",
+ "asset_plant_season_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_plant_season_version_transaction_id"),
+ "asset_plant_season_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # asset_plant_season
+ op.drop_index(
+ op.f("ix_asset_plant_season_version_transaction_id"),
+ table_name="asset_plant_season_version",
+ )
+ op.drop_index(
+ "ix_asset_plant_season_version_pk_validity",
+ table_name="asset_plant_season_version",
+ )
+ op.drop_index(
+ "ix_asset_plant_season_version_pk_transaction_id",
+ table_name="asset_plant_season_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_plant_season_version_operation_type"),
+ table_name="asset_plant_season_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_plant_season_version_end_transaction_id"),
+ table_name="asset_plant_season_version",
+ )
+ op.drop_table("asset_plant_season_version")
+ op.drop_table("asset_plant_season")
+
+ # season
+ op.drop_index(op.f("ix_season_version_transaction_id"), table_name="season_version")
+ op.drop_index("ix_season_version_pk_validity", table_name="season_version")
+ op.drop_index("ix_season_version_pk_transaction_id", table_name="season_version")
+ op.drop_index(op.f("ix_season_version_operation_type"), table_name="season_version")
+ op.drop_index(
+ op.f("ix_season_version_end_transaction_id"), table_name="season_version"
+ )
+ op.drop_table("season_version")
+ op.drop_table("season")
diff --git a/src/wuttafarm/db/alembic/versions/dca5b48a5562_add_seedinglog.py b/src/wuttafarm/db/alembic/versions/dca5b48a5562_add_seedinglog.py
new file mode 100644
index 0000000..6a374b4
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/dca5b48a5562_add_seedinglog.py
@@ -0,0 +1,108 @@
+"""add SeedingLog
+
+Revision ID: dca5b48a5562
+Revises: e9b8664e1f39
+Create Date: 2026-03-10 09:52:13.999777
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "dca5b48a5562"
+down_revision: Union[str, None] = "e9b8664e1f39"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # log_seeding
+ op.create_table(
+ "log_seeding",
+ sa.Column("source", sa.String(length=255), nullable=True),
+ sa.Column("purchase_date", sa.DateTime(), nullable=True),
+ sa.Column("lot_number", sa.String(length=255), nullable=True),
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["log.uuid"], name=op.f("fk_log_seeding_uuid_log")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_seeding")),
+ )
+ op.create_table(
+ "log_seeding_version",
+ sa.Column("source", sa.String(length=255), autoincrement=False, nullable=True),
+ sa.Column("purchase_date", sa.DateTime(), autoincrement=False, nullable=True),
+ sa.Column(
+ "lot_number", sa.String(length=255), autoincrement=False, nullable=True
+ ),
+ 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_log_seeding_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_log_seeding_version_end_transaction_id"),
+ "log_seeding_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_seeding_version_operation_type"),
+ "log_seeding_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_seeding_version_pk_transaction_id",
+ "log_seeding_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_seeding_version_pk_validity",
+ "log_seeding_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_seeding_version_transaction_id"),
+ "log_seeding_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # log_seeding
+ op.drop_index(
+ op.f("ix_log_seeding_version_transaction_id"), table_name="log_seeding_version"
+ )
+ op.drop_index(
+ "ix_log_seeding_version_pk_validity", table_name="log_seeding_version"
+ )
+ op.drop_index(
+ "ix_log_seeding_version_pk_transaction_id", table_name="log_seeding_version"
+ )
+ op.drop_index(
+ op.f("ix_log_seeding_version_operation_type"), table_name="log_seeding_version"
+ )
+ op.drop_index(
+ op.f("ix_log_seeding_version_end_transaction_id"),
+ table_name="log_seeding_version",
+ )
+ op.drop_table("log_seeding_version")
+ op.drop_table("log_seeding")
diff --git a/src/wuttafarm/db/alembic/versions/de1197d24485_add_waterasset.py b/src/wuttafarm/db/alembic/versions/de1197d24485_add_waterasset.py
new file mode 100644
index 0000000..e123fc3
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/de1197d24485_add_waterasset.py
@@ -0,0 +1,100 @@
+"""add WaterAsset
+
+Revision ID: de1197d24485
+Revises: 9c53513f8862
+Create Date: 2026-03-09 14:59:12.032318
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "de1197d24485"
+down_revision: Union[str, None] = "9c53513f8862"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # asset_water
+ op.create_table(
+ "asset_water",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["asset.uuid"], name=op.f("fk_asset_water_uuid_asset")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_water")),
+ )
+ op.create_table(
+ "asset_water_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_asset_water_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_water_version_end_transaction_id"),
+ "asset_water_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_water_version_operation_type"),
+ "asset_water_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_water_version_pk_transaction_id",
+ "asset_water_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_water_version_pk_validity",
+ "asset_water_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_water_version_transaction_id"),
+ "asset_water_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # asset_water
+ op.drop_index(
+ op.f("ix_asset_water_version_transaction_id"), table_name="asset_water_version"
+ )
+ op.drop_index(
+ "ix_asset_water_version_pk_validity", table_name="asset_water_version"
+ )
+ op.drop_index(
+ "ix_asset_water_version_pk_transaction_id", table_name="asset_water_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_water_version_operation_type"), table_name="asset_water_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_water_version_end_transaction_id"),
+ table_name="asset_water_version",
+ )
+ op.drop_table("asset_water_version")
+ op.drop_table("asset_water")
diff --git a/src/wuttafarm/db/alembic/versions/e5b27eac471c_add_equipmenttype.py b/src/wuttafarm/db/alembic/versions/e5b27eac471c_add_equipmenttype.py
new file mode 100644
index 0000000..a436725
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/e5b27eac471c_add_equipmenttype.py
@@ -0,0 +1,118 @@
+"""add EquipmentType
+
+Revision ID: e5b27eac471c
+Revises: de1197d24485
+Create Date: 2026-03-09 15:45:35.047694
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "e5b27eac471c"
+down_revision: Union[str, None] = "de1197d24485"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # equipment_type
+ op.create_table(
+ "equipment_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("drupal_id", sa.Integer(), nullable=True),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_equipment_type")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_equipment_type_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_equipment_type_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_equipment_type_name")),
+ )
+ op.create_table(
+ "equipment_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("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_equipment_type_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_equipment_type_version_end_transaction_id"),
+ "equipment_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_equipment_type_version_operation_type"),
+ "equipment_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_equipment_type_version_pk_transaction_id",
+ "equipment_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_equipment_type_version_pk_validity",
+ "equipment_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_equipment_type_version_transaction_id"),
+ "equipment_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # equipment_type
+ op.drop_index(
+ op.f("ix_equipment_type_version_transaction_id"),
+ table_name="equipment_type_version",
+ )
+ op.drop_index(
+ "ix_equipment_type_version_pk_validity", table_name="equipment_type_version"
+ )
+ op.drop_index(
+ "ix_equipment_type_version_pk_transaction_id",
+ table_name="equipment_type_version",
+ )
+ op.drop_index(
+ op.f("ix_equipment_type_version_operation_type"),
+ table_name="equipment_type_version",
+ )
+ op.drop_index(
+ op.f("ix_equipment_type_version_end_transaction_id"),
+ table_name="equipment_type_version",
+ )
+ op.drop_table("equipment_type_version")
+ op.drop_table("equipment_type")
diff --git a/src/wuttafarm/db/alembic/versions/e9b8664e1f39_add_equipmentasset.py b/src/wuttafarm/db/alembic/versions/e9b8664e1f39_add_equipmentasset.py
new file mode 100644
index 0000000..2a8ed15
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/e9b8664e1f39_add_equipmentasset.py
@@ -0,0 +1,218 @@
+"""add EquipmentAsset
+
+Revision ID: e9b8664e1f39
+Revises: e5b27eac471c
+Create Date: 2026-03-09 18:05:54.917562
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "e9b8664e1f39"
+down_revision: Union[str, None] = "e5b27eac471c"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # asset_equipment
+ op.create_table(
+ "asset_equipment",
+ sa.Column("manufacturer", sa.String(length=255), nullable=True),
+ sa.Column("model", sa.String(length=255), nullable=True),
+ sa.Column("serial_number", sa.String(length=255), nullable=True),
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["asset.uuid"], name=op.f("fk_asset_equipment_uuid_asset")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_equipment")),
+ )
+ op.create_table(
+ "asset_equipment_version",
+ sa.Column(
+ "manufacturer", sa.String(length=255), autoincrement=False, nullable=True
+ ),
+ sa.Column("model", sa.String(length=255), autoincrement=False, nullable=True),
+ sa.Column(
+ "serial_number", sa.String(length=255), autoincrement=False, nullable=True
+ ),
+ 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_asset_equipment_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_equipment_version_end_transaction_id"),
+ "asset_equipment_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_equipment_version_operation_type"),
+ "asset_equipment_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_equipment_version_pk_transaction_id",
+ "asset_equipment_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_equipment_version_pk_validity",
+ "asset_equipment_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_equipment_version_transaction_id"),
+ "asset_equipment_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # asset_equipment_equipment_type
+ op.create_table(
+ "asset_equipment_equipment_type",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("equipment_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("equipment_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["equipment_asset_uuid"],
+ ["asset_equipment.uuid"],
+ name=op.f(
+ "fk_asset_equipment_equipment_type_equipment_asset_uuid_asset_equipment"
+ ),
+ ),
+ sa.ForeignKeyConstraint(
+ ["equipment_type_uuid"],
+ ["equipment_type.uuid"],
+ name=op.f(
+ "fk_asset_equipment_equipment_type_equipment_type_uuid_equipment_type"
+ ),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_equipment_equipment_type")),
+ )
+ op.create_table(
+ "asset_equipment_equipment_type_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "equipment_asset_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "equipment_type_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid",
+ "transaction_id",
+ name=op.f("pk_asset_equipment_equipment_type_version"),
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_equipment_equipment_type_version_end_transaction_id"),
+ "asset_equipment_equipment_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_equipment_equipment_type_version_operation_type"),
+ "asset_equipment_equipment_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_equipment_equipment_type_version_pk_transaction_id",
+ "asset_equipment_equipment_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_equipment_equipment_type_version_pk_validity",
+ "asset_equipment_equipment_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_equipment_equipment_type_version_transaction_id"),
+ "asset_equipment_equipment_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # asset_equipment_equipment_type
+ op.drop_index(
+ op.f("ix_asset_equipment_equipment_type_version_transaction_id"),
+ table_name="asset_equipment_equipment_type_version",
+ )
+ op.drop_index(
+ "ix_asset_equipment_equipment_type_version_pk_validity",
+ table_name="asset_equipment_equipment_type_version",
+ )
+ op.drop_index(
+ "ix_asset_equipment_equipment_type_version_pk_transaction_id",
+ table_name="asset_equipment_equipment_type_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_equipment_equipment_type_version_operation_type"),
+ table_name="asset_equipment_equipment_type_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_equipment_equipment_type_version_end_transaction_id"),
+ table_name="asset_equipment_equipment_type_version",
+ )
+ op.drop_table("asset_equipment_equipment_type_version")
+ op.drop_table("asset_equipment_equipment_type")
+
+ # asset_equipment
+ op.drop_index(
+ op.f("ix_asset_equipment_version_transaction_id"),
+ table_name="asset_equipment_version",
+ )
+ op.drop_index(
+ "ix_asset_equipment_version_pk_validity", table_name="asset_equipment_version"
+ )
+ op.drop_index(
+ "ix_asset_equipment_version_pk_transaction_id",
+ table_name="asset_equipment_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_equipment_version_operation_type"),
+ table_name="asset_equipment_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_equipment_version_end_transaction_id"),
+ table_name="asset_equipment_version",
+ )
+ op.drop_table("asset_equipment_version")
+ op.drop_table("asset_equipment")
diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py
index 15514fb..d90272b 100644
--- a/src/wuttafarm/db/model/__init__.py
+++ b/src/wuttafarm/db/model/__init__.py
@@ -31,15 +31,31 @@ from .users import WuttaFarmUser
# wuttafarm proper models
from .unit import Unit, Measure
-from .quantities import QuantityType, Quantity, StandardQuantity
+from .material_type import MaterialType
+from .quantities import (
+ QuantityType,
+ Quantity,
+ StandardQuantity,
+ MaterialQuantity,
+ MaterialQuantityMaterialType,
+)
from .asset import AssetType, Asset, AssetParent
from .asset_land import LandType, LandAsset
from .asset_structure import StructureType, StructureAsset
+from .asset_equipment import EquipmentType, EquipmentAsset, EquipmentAssetEquipmentType
from .asset_animal import AnimalType, AnimalAsset
from .asset_group import GroupAsset
-from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType
+from .asset_plant import (
+ PlantType,
+ Season,
+ PlantAsset,
+ PlantAssetPlantType,
+ PlantAssetSeason,
+)
+from .asset_water import WaterAsset
from .log import LogType, Log, LogAsset, LogGroup, LogLocation, LogQuantity, LogOwner
from .log_activity import ActivityLog
from .log_harvest import HarvestLog
from .log_medical import MedicalLog
from .log_observation import ObservationLog
+from .log_seeding import SeedingLog
diff --git a/src/wuttafarm/db/model/asset_equipment.py b/src/wuttafarm/db/model/asset_equipment.py
new file mode 100644
index 0000000..51af9ee
--- /dev/null
+++ b/src/wuttafarm/db/model/asset_equipment.py
@@ -0,0 +1,133 @@
+# -*- 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 Equipment
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+from sqlalchemy.ext.associationproxy import association_proxy
+
+from wuttjamaican.db import model
+
+from wuttafarm.db.model.taxonomy import TaxonomyMixin
+from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies
+
+
+class EquipmentType(TaxonomyMixin, model.Base):
+ """
+ Represents an "equipment type" (taxonomy term) from farmOS
+ """
+
+ __tablename__ = "equipment_type"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Equipment Type",
+ "model_title_plural": "Equipment Types",
+ }
+
+ _equipment_assets = orm.relationship(
+ "EquipmentAssetEquipmentType",
+ cascade_backrefs=False,
+ back_populates="equipment_type",
+ )
+
+
+class EquipmentAsset(AssetMixin, model.Base):
+ """
+ Represents an equipment asset from farmOS
+ """
+
+ __tablename__ = "asset_equipment"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Equipment Asset",
+ "model_title_plural": "Equipment Assets",
+ "farmos_asset_type": "equipment",
+ }
+
+ manufacturer = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Name of the manufacturer, if applicable.
+ """,
+ )
+
+ model = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Model name for the equipment, if applicable.
+ """,
+ )
+
+ serial_number = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Serial number for the equipment, if applicable.
+ """,
+ )
+
+ _equipment_types = orm.relationship(
+ "EquipmentAssetEquipmentType",
+ cascade="all, delete-orphan",
+ cascade_backrefs=False,
+ back_populates="equipment_asset",
+ )
+
+ equipment_types = association_proxy(
+ "_equipment_types",
+ "equipment_type",
+ creator=lambda pt: EquipmentAssetEquipmentType(equipment_type=pt),
+ )
+
+
+add_asset_proxies(EquipmentAsset)
+
+
+class EquipmentAssetEquipmentType(model.Base):
+ """
+ Associates one or more equipment types with an equipment asset.
+ """
+
+ __tablename__ = "asset_equipment_equipment_type"
+ __versioned__ = {}
+
+ uuid = model.uuid_column()
+
+ equipment_asset_uuid = model.uuid_fk_column("asset_equipment.uuid", nullable=False)
+ equipment_asset = orm.relationship(
+ EquipmentAsset,
+ foreign_keys=equipment_asset_uuid,
+ back_populates="_equipment_types",
+ )
+
+ equipment_type_uuid = model.uuid_fk_column("equipment_type.uuid", nullable=False)
+ equipment_type = orm.relationship(
+ EquipmentType,
+ doc="""
+ Reference to the equipment type.
+ """,
+ back_populates="_equipment_assets",
+ )
diff --git a/src/wuttafarm/db/model/asset_plant.py b/src/wuttafarm/db/model/asset_plant.py
index 62f7e9b..fa1be03 100644
--- a/src/wuttafarm/db/model/asset_plant.py
+++ b/src/wuttafarm/db/model/asset_plant.py
@@ -91,6 +91,65 @@ class PlantType(model.Base):
return self.name or ""
+class Season(model.Base):
+ """
+ Represents a "season" (taxonomy term) from farmOS
+ """
+
+ __tablename__ = "season"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Season",
+ "model_title_plural": "Seasons",
+ }
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ unique=True,
+ doc="""
+ Name of the season.
+ """,
+ )
+
+ description = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Optional description for the season.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the season within farmOS.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the season.
+ """,
+ )
+
+ _plant_assets = orm.relationship(
+ "PlantAssetSeason",
+ cascade_backrefs=False,
+ back_populates="season",
+ )
+
+ def __str__(self):
+ return self.name or ""
+
+
class PlantAsset(AssetMixin, model.Base):
"""
Represents a plant asset from farmOS
@@ -117,6 +176,19 @@ class PlantAsset(AssetMixin, model.Base):
creator=lambda pt: PlantAssetPlantType(plant_type=pt),
)
+ _seasons = orm.relationship(
+ "PlantAssetSeason",
+ cascade="all, delete-orphan",
+ cascade_backrefs=False,
+ back_populates="plant_asset",
+ )
+
+ seasons = association_proxy(
+ "_seasons",
+ "season",
+ creator=lambda s: PlantAssetSeason(season=s),
+ )
+
add_asset_proxies(PlantAsset)
@@ -146,3 +218,30 @@ class PlantAssetPlantType(model.Base):
""",
back_populates="_plant_assets",
)
+
+
+class PlantAssetSeason(model.Base):
+ """
+ Associates one or more seasons with a plant asset.
+ """
+
+ __tablename__ = "asset_plant_season"
+ __versioned__ = {}
+
+ uuid = model.uuid_column()
+
+ plant_asset_uuid = model.uuid_fk_column("asset_plant.uuid", nullable=False)
+ plant_asset = orm.relationship(
+ PlantAsset,
+ foreign_keys=plant_asset_uuid,
+ back_populates="_seasons",
+ )
+
+ season_uuid = model.uuid_fk_column("season.uuid", nullable=False)
+ season = orm.relationship(
+ Season,
+ doc="""
+ Reference to the season.
+ """,
+ back_populates="_plant_assets",
+ )
diff --git a/src/wuttafarm/db/model/asset_water.py b/src/wuttafarm/db/model/asset_water.py
new file mode 100644
index 0000000..046c899
--- /dev/null
+++ b/src/wuttafarm/db/model/asset_water.py
@@ -0,0 +1,45 @@
+# -*- 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 Water Assets
+"""
+
+from wuttjamaican.db import model
+
+from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies
+
+
+class WaterAsset(AssetMixin, model.Base):
+ """
+ Represents a water asset from farmOS
+ """
+
+ __tablename__ = "asset_water"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Water Asset",
+ "model_title_plural": "Water Assets",
+ "farmos_asset_type": "water",
+ }
+
+
+add_asset_proxies(WaterAsset)
diff --git a/src/wuttafarm/db/model/log_seeding.py b/src/wuttafarm/db/model/log_seeding.py
new file mode 100644
index 0000000..7f68923
--- /dev/null
+++ b/src/wuttafarm/db/model/log_seeding.py
@@ -0,0 +1,71 @@
+# -*- 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 Seeding Logs
+"""
+
+import sqlalchemy as sa
+
+from wuttjamaican.db import model
+
+from wuttafarm.db.model.log import LogMixin, add_log_proxies
+
+
+class SeedingLog(LogMixin, model.Base):
+ """
+ Represents a Seeding Log from farmOS
+ """
+
+ __tablename__ = "log_seeding"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Seeding Log",
+ "model_title_plural": "Seeding Logs",
+ "farmos_log_type": "seeding",
+ }
+
+ source = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Where the seed was obtained, if applicable.
+ """,
+ )
+
+ purchase_date = sa.Column(
+ sa.DateTime(),
+ nullable=True,
+ doc="""
+ When the seed was purchased, if applicable.
+ """,
+ )
+
+ lot_number = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Lot number for the seed, if applicable.
+ """,
+ )
+
+
+add_log_proxies(SeedingLog)
diff --git a/src/wuttafarm/db/model/material_type.py b/src/wuttafarm/db/model/material_type.py
new file mode 100644
index 0000000..a124451
--- /dev/null
+++ b/src/wuttafarm/db/model/material_type.py
@@ -0,0 +1,100 @@
+# -*- 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 Material Types
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+from sqlalchemy.ext.associationproxy import association_proxy
+
+from wuttjamaican.db import model
+
+
+class MaterialType(model.Base):
+ """
+ Represents a "material type" (taxonomy term) from farmOS
+ """
+
+ __tablename__ = "material_type"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Material Type",
+ "model_title_plural": "Material Types",
+ }
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ doc="""
+ Name of the material type.
+ """,
+ )
+
+ description = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Optional description for the material type.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the material type within farmOS.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the material type.
+ """,
+ )
+
+ _quantities = orm.relationship(
+ "MaterialQuantityMaterialType",
+ cascade="all, delete-orphan",
+ cascade_backrefs=False,
+ back_populates="material_type",
+ )
+
+ def _make_material_quantity(qty):
+ from wuttafarm.db.model import MaterialQuantityMaterialType
+
+ return MaterialQuantityMaterialType(quantity=qty)
+
+ quantities = association_proxy(
+ "_quantities",
+ "quantity",
+ creator=_make_material_quantity,
+ )
+
+ def __str__(self):
+ return self.name or ""
diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py
index 4bed6a0..4fa92af 100644
--- a/src/wuttafarm/db/model/quantities.py
+++ b/src/wuttafarm/db/model/quantities.py
@@ -181,9 +181,13 @@ class Quantity(model.Base):
creator=make_log_quantity,
)
+ def get_value_decimal(self):
+ # TODO: should actually return a decimal here?
+ return self.value_numerator / self.value_denominator
+
def render_as_text(self, config=None):
measure = str(self.measure or self.measure_id or "")
- value = self.value_numerator / self.value_denominator
+ value = self.get_value_decimal()
if config:
app = config.get_app()
value = app.render_quantity(value)
@@ -200,7 +204,15 @@ class QuantityMixin:
@declared_attr
def quantity(cls):
- return orm.relationship(Quantity)
+ return orm.relationship(
+ Quantity,
+ single_parent=True,
+ cascade="all, delete-orphan",
+ cascade_backrefs=False,
+ )
+
+ def get_value_decimal(self):
+ return self.quantity.get_value_decimal()
def render_as_text(self, config=None):
return self.quantity.render_as_text(config)
@@ -240,3 +252,64 @@ class StandardQuantity(QuantityMixin, model.Base):
add_quantity_proxies(StandardQuantity)
+
+
+class MaterialQuantity(QuantityMixin, model.Base):
+ """
+ Represents a Material Quantity from farmOS
+ """
+
+ __tablename__ = "quantity_material"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Material Quantity",
+ "model_title_plural": "Material Quantities",
+ "farmos_quantity_type": "material",
+ }
+
+ _material_types = orm.relationship(
+ "MaterialQuantityMaterialType",
+ cascade="all, delete-orphan",
+ cascade_backrefs=False,
+ back_populates="quantity",
+ )
+
+ material_types = association_proxy(
+ "_material_types",
+ "material_type",
+ creator=lambda mtype: MaterialQuantityMaterialType(material_type=mtype),
+ )
+
+ def render_as_text(self, config=None):
+ text = super().render_as_text(config)
+ mtypes = ", ".join([str(mt) for mt in self.material_types])
+ return f"{mtypes} {text}"
+
+
+add_quantity_proxies(MaterialQuantity)
+
+
+class MaterialQuantityMaterialType(model.Base):
+ """
+ Represents a "material quantity's material type relationship" from
+ farmOS.
+ """
+
+ __tablename__ = "quantity_material_material_type"
+ __versioned__ = {}
+
+ uuid = model.uuid_column()
+
+ quantity_uuid = model.uuid_fk_column("quantity_material.uuid", nullable=False)
+ quantity = orm.relationship(
+ MaterialQuantity,
+ foreign_keys=quantity_uuid,
+ back_populates="_material_types",
+ )
+
+ material_type_uuid = model.uuid_fk_column("material_type.uuid", nullable=False)
+ material_type = orm.relationship(
+ "MaterialType",
+ foreign_keys=material_type_uuid,
+ back_populates="_quantities",
+ )
diff --git a/src/wuttafarm/db/model/taxonomy.py b/src/wuttafarm/db/model/taxonomy.py
new file mode 100644
index 0000000..3d84197
--- /dev/null
+++ b/src/wuttafarm/db/model/taxonomy.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 .
+#
+################################################################################
+"""
+Base logic for taxonomy term models
+"""
+
+import sqlalchemy as sa
+
+from wuttjamaican.db import model
+
+
+class TaxonomyMixin:
+ """
+ Mixin for taxonomy term models
+ """
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ unique=True,
+ doc="""
+ Name for the taxonomy term.
+ """,
+ )
+
+ description = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Optional description for the taxonomy term.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the taxonomy term.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the taxonomy term within farmOS.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
diff --git a/src/wuttafarm/db/model/unit.py b/src/wuttafarm/db/model/unit.py
index e9c6e70..a376e2c 100644
--- a/src/wuttafarm/db/model/unit.py
+++ b/src/wuttafarm/db/model/unit.py
@@ -42,6 +42,14 @@ class Measure(model.Base):
uuid = model.uuid_column()
+ ordinal = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ doc="""
+ Ordinal (sequence number) for the measure.
+ """,
+ )
+
name = sa.Column(
sa.String(length=100),
nullable=False,
diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py
index ad1cb38..011a170 100644
--- a/src/wuttafarm/farmos/importing/model.py
+++ b/src/wuttafarm/farmos/importing/model.py
@@ -71,13 +71,15 @@ class ToFarmOSTaxonomy(ToFarmOS):
supported_fields = [
"uuid",
"name",
+ "description",
]
def get_target_objects(self, **kwargs):
- result = self.farmos_client.resource.get(
- "taxonomy_term", self.farmos_taxonomy_type
+ return list(
+ self.farmos_client.resource.iterate(
+ "taxonomy_term", self.farmos_taxonomy_type
+ )
)
- return result["data"]
def get_target_object(self, key):
@@ -101,17 +103,24 @@ class ToFarmOSTaxonomy(ToFarmOS):
return result["data"]
def normalize_target_object(self, obj):
+ if description := obj["attributes"]["description"]:
+ description = description["value"]
return {
"uuid": UUID(obj["id"]),
"name": obj["attributes"]["name"],
+ "description": description,
}
def get_term_payload(self, source_data):
- return {
- "attributes": {
- "name": source_data["name"],
- }
- }
+
+ attrs = {}
+ if "name" in self.fields:
+ attrs["name"] = source_data["name"]
+ if "description" in self.fields:
+ attrs["description"] = {"value": source_data["description"]}
+
+ payload = {"attributes": attrs}
+ return payload
def create_target_object(self, key, source_data):
if source_data.get("__ignoreme__"):
@@ -127,9 +136,9 @@ class ToFarmOSTaxonomy(ToFarmOS):
normal["_new_object"] = result["data"]
return normal
- def update_target_object(self, asset, source_data, target_data=None):
+ def update_target_object(self, term, source_data, target_data=None):
if self.dry_run:
- return asset
+ return term
payload = self.get_term_payload(source_data)
payload["id"] = str(source_data["uuid"])
@@ -146,9 +155,12 @@ class ToFarmOSAsset(ToFarmOS):
farmos_asset_type = None
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.normal = self.app.get_normalizer(self.farmos_client)
+
def get_target_objects(self, **kwargs):
- assets = self.farmos_client.asset.get(self.farmos_asset_type)
- return assets["data"]
+ return list(self.farmos_client.asset.iterate(self.farmos_asset_type))
def get_target_object(self, key):
@@ -191,18 +203,17 @@ class ToFarmOSAsset(ToFarmOS):
return self.normalize_target_object(result["data"])
def normalize_target_object(self, asset):
-
- if notes := asset["attributes"]["notes"]:
- notes = notes["value"]
-
+ normal = self.normal.normalize_farmos_asset(asset)
return {
- "uuid": UUID(asset["id"]),
- "asset_name": asset["attributes"]["name"],
- "is_location": asset["attributes"]["is_location"],
- "is_fixed": asset["attributes"]["is_fixed"],
- "produces_eggs": asset["attributes"].get("produces_eggs"),
- "notes": notes,
- "archived": asset["attributes"]["archived"],
+ "uuid": UUID(normal["uuid"]),
+ "asset_name": normal["asset_name"],
+ "is_location": normal["is_location"],
+ "is_fixed": normal["is_fixed"],
+ # nb. this is only used for certain asset types
+ "produces_eggs": normal["produces_eggs"],
+ "parents": [(p["asset_type"], UUID(p["uuid"])) for p in normal["parents"]],
+ "notes": normal["notes"],
+ "archived": normal["archived"],
}
def get_asset_payload(self, source_data):
@@ -221,8 +232,18 @@ class ToFarmOSAsset(ToFarmOS):
if "archived" in self.fields:
attrs["archived"] = source_data["archived"]
- payload = {"attributes": attrs}
+ rels = {}
+ if "parents" in self.fields:
+ rels["parent"] = {"data": []}
+ for asset_type, uuid in source_data["parents"]:
+ rels["parent"]["data"].append(
+ {
+ "id": str(uuid),
+ "type": f"asset--{asset_type}",
+ }
+ )
+ payload = {"attributes": attrs, "relationships": rels}
return payload
@@ -245,6 +266,8 @@ class AnimalAssetImporter(ToFarmOSAsset):
"is_sterile",
"produces_eggs",
"birthdate",
+ "is_location",
+ "is_fixed",
"notes",
"archived",
]
@@ -296,6 +319,80 @@ class AnimalTypeImporter(ToFarmOSTaxonomy):
farmos_taxonomy_type = "animal_type"
+class EquipmentTypeImporter(ToFarmOSTaxonomy):
+
+ model_title = "EquipmentType"
+ farmos_taxonomy_type = "equipment_type"
+
+
+class EquipmentAssetImporter(ToFarmOSAsset):
+
+ model_title = "EquipmentAsset"
+ farmos_asset_type = "equipment"
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "manufacturer",
+ "model",
+ "serial_number",
+ "equipment_type_uuids",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "archived",
+ ]
+
+ def normalize_target_object(self, equipment):
+ data = super().normalize_target_object(equipment)
+ data.update(
+ {
+ "manufacturer": equipment["attributes"]["manufacturer"],
+ "model": equipment["attributes"]["model"],
+ "serial_number": equipment["attributes"]["serial_number"],
+ "equipment_type_uuids": [
+ UUID(etype["id"])
+ for etype in equipment["relationships"]["equipment_type"]["data"]
+ ],
+ }
+ )
+ return data
+
+ def get_asset_payload(self, source_data):
+ payload = super().get_asset_payload(source_data)
+
+ attrs = {}
+ if "manufacturer" in self.fields:
+ attrs["manufacturer"] = source_data["manufacturer"]
+ if "model" in self.fields:
+ attrs["model"] = source_data["model"]
+ if "serial_number" in self.fields:
+ attrs["serial_number"] = source_data["serial_number"]
+
+ rels = {}
+ if "equipment_type_uuids" in self.fields:
+ rels["equipment_type"] = {"data": []}
+ for uuid in source_data["equipment_type_uuids"]:
+ rels["equipment_type"]["data"].append(
+ {
+ "id": str(uuid),
+ "type": "taxonomy_term--equipment_type",
+ }
+ )
+
+ payload["attributes"].update(attrs)
+ if rels:
+ payload.setdefault("relationships", {}).update(rels)
+
+ return payload
+
+
+class MaterialTypeImporter(ToFarmOSTaxonomy):
+
+ model_title = "MaterialType"
+ farmos_taxonomy_type = "material_type"
+
+
class GroupAssetImporter(ToFarmOSAsset):
model_title = "GroupAsset"
@@ -353,6 +450,12 @@ class PlantTypeImporter(ToFarmOSTaxonomy):
farmos_taxonomy_type = "plant_type"
+class SeasonImporter(ToFarmOSTaxonomy):
+
+ model_title = "Season"
+ farmos_taxonomy_type = "season"
+
+
class PlantAssetImporter(ToFarmOSAsset):
model_title = "PlantAsset"
@@ -362,6 +465,7 @@ class PlantAssetImporter(ToFarmOSAsset):
"uuid",
"asset_name",
"plant_type_uuids",
+ "season_uuids",
"notes",
"archived",
]
@@ -373,6 +477,9 @@ class PlantAssetImporter(ToFarmOSAsset):
"plant_type_uuids": [
UUID(p["id"]) for p in plant["relationships"]["plant_type"]["data"]
],
+ "season_uuids": [
+ UUID(p["id"]) for p in plant["relationships"]["season"]["data"]
+ ],
}
)
return data
@@ -398,6 +505,15 @@ class PlantAssetImporter(ToFarmOSAsset):
"type": "taxonomy_term--plant_type",
}
)
+ if "season_uuids" in self.fields:
+ rels["season"] = {"data": []}
+ for uuid in source_data["season_uuids"]:
+ rels["season"]["data"].append(
+ {
+ "id": str(uuid),
+ "type": "taxonomy_term--season",
+ }
+ )
payload["attributes"].update(attrs)
if rels:
@@ -443,6 +559,21 @@ class StructureAssetImporter(ToFarmOSAsset):
return payload
+class WaterAssetImporter(ToFarmOSAsset):
+
+ model_title = "WaterAsset"
+ farmos_asset_type = "water"
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "archived",
+ ]
+
+
##############################
# quantity importers
##############################
@@ -569,6 +700,49 @@ class ToFarmOSQuantity(ToFarmOS):
return payload
+class MaterialQuantityImporter(ToFarmOSQuantity):
+
+ model_title = "MaterialQuantity"
+ farmos_quantity_type = "material"
+
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "material_types",
+ ]
+ )
+ return fields
+
+ def normalize_target_object(self, quantity):
+ data = super().normalize_target_object(quantity)
+
+ if "material_types" in self.fields:
+ data["material_types"] = [
+ UUID(mtype["id"])
+ for mtype in quantity["relationships"]["material_type"]["data"]
+ ]
+
+ return data
+
+ def get_quantity_payload(self, source_data):
+ payload = super().get_quantity_payload(source_data)
+
+ rels = {}
+ if "material_types" in self.fields:
+ rels["material_type"] = {"data": []}
+ for uuid in source_data["material_types"]:
+ rels["material_type"]["data"].append(
+ {
+ "id": str(uuid),
+ "type": "taxonomy_term--material_type",
+ }
+ )
+
+ payload.setdefault("relationships", {}).update(rels)
+ return payload
+
+
class StandardQuantityImporter(ToFarmOSQuantity):
model_title = "StandardQuantity"
@@ -597,6 +771,8 @@ class ToFarmOSLog(ToFarmOS):
"notes",
"quick",
"assets",
+ "locations",
+ "groups",
"quantities",
]
@@ -605,8 +781,7 @@ class ToFarmOSLog(ToFarmOS):
self.normal = self.app.get_normalizer(self.farmos_client)
def get_target_objects(self, **kwargs):
- result = self.farmos_client.log.get(self.farmos_log_type)
- return result["data"]
+ return list(self.farmos_client.log.iterate(self.farmos_log_type))
def get_target_object(self, key):
@@ -660,6 +835,10 @@ class ToFarmOSLog(ToFarmOS):
"notes": normal["notes"],
"quick": normal["quick"],
"assets": [(a["asset_type"], UUID(a["uuid"])) for a in normal["assets"]],
+ "locations": [
+ (l["asset_type"], UUID(l["uuid"])) for l in normal["locations"]
+ ],
+ "groups": [(g["asset_type"], UUID(g["uuid"])) for g in normal["groups"]],
"quantities": [UUID(uuid) for uuid in normal["quantity_uuids"]],
}
@@ -692,6 +871,26 @@ class ToFarmOSLog(ToFarmOS):
}
)
rels["asset"] = {"data": assets}
+ if "locations" in self.fields:
+ locations = []
+ for asset_type, uuid in source_data["locations"]:
+ locations.append(
+ {
+ "type": f"asset--{asset_type}",
+ "id": str(uuid),
+ }
+ )
+ rels["location"] = {"data": locations}
+ if "groups" in self.fields:
+ groups = []
+ for asset_type, uuid in source_data["groups"]:
+ groups.append(
+ {
+ "type": f"asset--{asset_type}",
+ "id": str(uuid),
+ }
+ )
+ rels["group"] = {"data": groups}
if "quantities" in self.fields:
quantities = []
for uuid in source_data["quantities"]:
@@ -756,3 +955,48 @@ class ObservationLogImporter(ToFarmOSLog):
model_title = "ObservationLog"
farmos_log_type = "observation"
+
+
+class SeedingLogImporter(ToFarmOSLog):
+
+ model_title = "SeedingLog"
+ farmos_log_type = "seeding"
+
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "source",
+ "purchase_date",
+ "lot_number",
+ ]
+ )
+ return fields
+
+ def normalize_target_object(self, log):
+ data = super().normalize_target_object(log)
+ data.update(
+ {
+ "source": log["attributes"]["source"],
+ "purchase_date": self.normalize_datetime(
+ log["attributes"]["purchase_date"]
+ ),
+ "lot_number": log["attributes"]["lot_number"],
+ }
+ )
+ return data
+
+ def get_log_payload(self, source_data):
+ payload = super().get_log_payload(source_data)
+
+ attrs = {}
+ if "source" in self.fields:
+ attrs["source"] = source_data["source"]
+ if "purchase_date" in self.fields:
+ attrs["purchase_date"] = self.format_datetime(source_data["purchase_date"])
+ if "lot_number" in self.fields:
+ attrs["lot_number"] = source_data["lot_number"]
+
+ if attrs:
+ payload["attributes"].update(attrs)
+ return payload
diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py
index 8394e4c..746f761 100644
--- a/src/wuttafarm/farmos/importing/wuttafarm.py
+++ b/src/wuttafarm/farmos/importing/wuttafarm.py
@@ -98,17 +98,24 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
importers = super().define_importers()
importers["LandAsset"] = LandAssetImporter
importers["StructureAsset"] = StructureAssetImporter
+ importers["WaterAsset"] = WaterAssetImporter
+ importers["EquipmentType"] = EquipmentTypeImporter
+ importers["EquipmentAsset"] = EquipmentAssetImporter
importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter
importers["PlantType"] = PlantTypeImporter
+ importers["Season"] = SeasonImporter
importers["PlantAsset"] = PlantAssetImporter
importers["Unit"] = UnitImporter
+ importers["MaterialType"] = MaterialTypeImporter
+ importers["MaterialQuantity"] = MaterialQuantityImporter
importers["StandardQuantity"] = StandardQuantityImporter
importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter
importers["MedicalLog"] = MedicalLogImporter
importers["ObservationLog"] = ObservationLogImporter
+ importers["SeedingLog"] = SeedingLogImporter
return importers
@@ -134,60 +141,158 @@ class FromWuttaFarm(FromWutta):
return obj
-class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImporter):
+class FromWuttaFarmAsset(FromWuttaFarm):
+ """
+ Base class for WuttaFarm → farmOS API asset exporters
+ """
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "is_location",
+ "is_fixed",
+ "parents",
+ "notes",
+ "archived",
+ ]
+
+ def normalize_source_object(self, asset):
+ return {
+ "uuid": asset.farmos_uuid or self.app.make_true_uuid(),
+ "asset_name": asset.asset_name,
+ "is_location": asset.is_location,
+ "is_fixed": asset.is_fixed,
+ "parents": [(p.asset_type, p.farmos_uuid) for p in asset.parents],
+ "notes": asset.notes,
+ "archived": asset.archived,
+ "_src_object": asset,
+ }
+
+
+class AnimalAssetImporter(
+ FromWuttaFarmAsset, farmos_importing.model.AnimalAssetImporter
+):
"""
WuttaFarm → farmOS API exporter for Animal Assets
"""
source_model_class = model.AnimalAsset
- supported_fields = [
- "uuid",
- "asset_name",
- "animal_type_uuid",
- "sex",
- "is_sterile",
- "produces_eggs",
- "birthdate",
- "notes",
- "archived",
- ]
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "animal_type_uuid",
+ "sex",
+ "is_sterile",
+ "produces_eggs",
+ "birthdate",
+ ]
+ )
+ return fields
def normalize_source_object(self, animal):
+ data = super().normalize_source_object(animal)
+ data.update(
+ {
+ "animal_type_uuid": animal.animal_type.farmos_uuid,
+ "sex": animal.sex,
+ "is_sterile": animal.is_sterile,
+ "produces_eggs": animal.produces_eggs,
+ "birthdate": animal.birthdate,
+ }
+ )
+ return data
+
+
+class FromWuttaFarmTaxonomy(FromWuttaFarm):
+ """
+ Base class for taxonomy term exporters
+ """
+
+ supported_fields = [
+ "uuid",
+ "name",
+ "description",
+ ]
+
+ drupal_internal_id_field = "drupal_internal__tid"
+
+ def normalize_source_object(self, term):
return {
- "uuid": animal.farmos_uuid or self.app.make_true_uuid(),
- "asset_name": animal.asset_name,
- "animal_type_uuid": animal.animal_type.farmos_uuid,
- "sex": animal.sex,
- "is_sterile": animal.is_sterile,
- "produces_eggs": animal.produces_eggs,
- "birthdate": animal.birthdate,
- "notes": animal.notes,
- "archived": animal.archived,
- "_src_object": animal,
+ "uuid": term.farmos_uuid or self.app.make_true_uuid(),
+ "name": term.name,
+ "description": term.description,
+ "_src_object": term,
}
-class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporter):
+class EquipmentTypeImporter(
+ FromWuttaFarmTaxonomy, farmos_importing.model.EquipmentTypeImporter
+):
+ """
+ WuttaFarm → farmOS API exporter for Equipment Types
+ """
+
+ source_model_class = model.EquipmentType
+
+
+class EquipmentAssetImporter(
+ FromWuttaFarmAsset, farmos_importing.model.EquipmentAssetImporter
+):
+ """
+ WuttaFarm → farmOS API exporter for Equipment Assets
+ """
+
+ source_model_class = model.EquipmentAsset
+
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+
+ print(fields)
+ fields.extend(
+ [
+ "manufacturer",
+ "model",
+ "serial_number",
+ "equipment_type_uuids",
+ ]
+ )
+ return fields
+
+ def normalize_source_object(self, equipment):
+ data = super().normalize_source_object(equipment)
+ data.update(
+ {
+ "manufacturer": equipment.manufacturer,
+ "model": equipment.model,
+ "serial_number": equipment.serial_number,
+ "equipment_type_uuids": [
+ etype.farmos_uuid for etype in equipment.equipment_types
+ ],
+ }
+ )
+ return data
+
+
+class AnimalTypeImporter(
+ FromWuttaFarmTaxonomy, farmos_importing.model.AnimalTypeImporter
+):
"""
WuttaFarm → farmOS API exporter for Animal Types
"""
source_model_class = model.AnimalType
- supported_fields = [
- "uuid",
- "name",
- ]
- drupal_internal_id_field = "drupal_internal__tid"
+class MaterialTypeImporter(
+ FromWuttaFarmTaxonomy, farmos_importing.model.MaterialTypeImporter
+):
+ """
+ WuttaFarm → farmOS API exporter for Material Types
+ """
- def normalize_source_object(self, animal_type):
- return {
- "uuid": animal_type.farmos_uuid or self.app.make_true_uuid(),
- "name": animal_type.name,
- "_src_object": animal_type,
- }
+ source_model_class = model.MaterialType
class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter):
@@ -212,60 +317,56 @@ class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter):
}
-class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter):
+class GroupAssetImporter(FromWuttaFarmAsset, farmos_importing.model.GroupAssetImporter):
"""
WuttaFarm → farmOS API exporter for Group Assets
"""
source_model_class = model.GroupAsset
- supported_fields = [
- "uuid",
- "asset_name",
- "produces_eggs",
- "notes",
- "archived",
- ]
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "produces_eggs",
+ ]
+ )
+ return fields
def normalize_source_object(self, group):
- return {
- "uuid": group.farmos_uuid or self.app.make_true_uuid(),
- "asset_name": group.asset_name,
- "produces_eggs": group.produces_eggs,
- "notes": group.notes,
- "archived": group.archived,
- "_src_object": group,
- }
+ data = super().normalize_source_object(group)
+ data.update(
+ {
+ "produces_eggs": group.produces_eggs,
+ }
+ )
+ return data
-class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter):
+class LandAssetImporter(FromWuttaFarmAsset, farmos_importing.model.LandAssetImporter):
"""
WuttaFarm → farmOS API exporter for Land Assets
"""
source_model_class = model.LandAsset
- supported_fields = [
- "uuid",
- "asset_name",
- "land_type_id",
- "is_location",
- "is_fixed",
- "notes",
- "archived",
- ]
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "land_type_id",
+ ]
+ )
+ return fields
def normalize_source_object(self, land):
- return {
- "uuid": land.farmos_uuid or self.app.make_true_uuid(),
- "asset_name": land.asset_name,
- "land_type_id": land.land_type.drupal_id,
- "is_location": land.is_location,
- "is_fixed": land.is_fixed,
- "notes": land.notes,
- "archived": land.archived,
- "_src_object": land,
- }
+ data = super().normalize_source_object(land)
+ data.update(
+ {
+ "land_type_id": land.land_type.drupal_id,
+ }
+ )
+ return data
class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter):
@@ -290,34 +391,58 @@ class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter)
}
-class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter):
+class SeasonImporter(FromWuttaFarm, farmos_importing.model.SeasonImporter):
+ """
+ WuttaFarm → farmOS API exporter for Seasons
+ """
+
+ source_model_class = model.Season
+
+ supported_fields = [
+ "uuid",
+ "name",
+ ]
+
+ drupal_internal_id_field = "drupal_internal__tid"
+
+ def normalize_source_object(self, season):
+ return {
+ "uuid": season.farmos_uuid or self.app.make_true_uuid(),
+ "name": season.name,
+ "_src_object": season,
+ }
+
+
+class PlantAssetImporter(FromWuttaFarmAsset, 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 get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "plant_type_uuids",
+ "season_uuids",
+ ]
+ )
+ return fields
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,
- }
+ data = super().normalize_source_object(plant)
+ data.update(
+ {
+ "plant_type_uuids": [pt.farmos_uuid for pt in plant.plant_types],
+ "season_uuids": [s.farmos_uuid for s in plant.seasons],
+ }
+ )
+ return data
class StructureAssetImporter(
- FromWuttaFarm, farmos_importing.model.StructureAssetImporter
+ FromWuttaFarmAsset, farmos_importing.model.StructureAssetImporter
):
"""
WuttaFarm → farmOS API exporter for Structure Assets
@@ -325,27 +450,31 @@ class StructureAssetImporter(
source_model_class = model.StructureAsset
- supported_fields = [
- "uuid",
- "asset_name",
- "structure_type_id",
- "is_location",
- "is_fixed",
- "notes",
- "archived",
- ]
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "structure_type_id",
+ ]
+ )
+ return fields
def normalize_source_object(self, structure):
- return {
- "uuid": structure.farmos_uuid or self.app.make_true_uuid(),
- "asset_name": structure.asset_name,
- "structure_type_id": structure.structure_type.drupal_id,
- "is_location": structure.is_location,
- "is_fixed": structure.is_fixed,
- "notes": structure.notes,
- "archived": structure.archived,
- "_src_object": structure,
- }
+ data = super().normalize_source_object(structure)
+ data.update(
+ {
+ "structure_type_id": structure.structure_type.drupal_id,
+ }
+ )
+ return data
+
+
+class WaterAssetImporter(FromWuttaFarmAsset, farmos_importing.model.WaterAssetImporter):
+ """
+ WuttaFarm → farmOS API exporter for Water Assets
+ """
+
+ source_model_class = model.WaterAsset
##############################
@@ -381,6 +510,24 @@ class FromWuttaFarmQuantity(FromWuttaFarm):
}
+class MaterialQuantityImporter(
+ FromWuttaFarmQuantity, farmos_importing.model.MaterialQuantityImporter
+):
+ """
+ WuttaFarm → farmOS API exporter for Material Quantities
+ """
+
+ source_model_class = model.MaterialQuantity
+
+ def normalize_source_object(self, quantity):
+ data = super().normalize_source_object(quantity)
+
+ if "material_types" in self.fields:
+ data["material_types"] = [mt.farmos_uuid for mt in quantity.material_types]
+
+ return data
+
+
class StandardQuantityImporter(
FromWuttaFarmQuantity, farmos_importing.model.StandardQuantityImporter
):
@@ -411,6 +558,8 @@ class FromWuttaFarmLog(FromWuttaFarm):
"notes",
"quick",
"assets",
+ "locations",
+ "groups",
"quantities",
]
@@ -425,6 +574,8 @@ class FromWuttaFarmLog(FromWuttaFarm):
"notes": log.notes,
"quick": self.config.parse_list(log.quick) if log.quick else [],
"assets": [(a.asset_type, a.farmos_uuid) for a in log.assets],
+ "locations": [(l.asset_type, l.farmos_uuid) for l in log.locations],
+ "groups": [(g.asset_type, g.farmos_uuid) for g in log.groups],
"quantities": [qty.farmos_uuid for qty in log.quantities],
"_src_object": log,
}
@@ -480,3 +631,22 @@ class ObservationLogImporter(
"""
source_model_class = model.ObservationLog
+
+
+class SeedingLogImporter(FromWuttaFarmLog, farmos_importing.model.SeedingLogImporter):
+ """
+ WuttaFarm → farmOS API exporter for Seeding Logs
+ """
+
+ source_model_class = model.SeedingLog
+
+ def normalize_source_object(self, log):
+ data = super().normalize_source_object(log)
+ data.update(
+ {
+ "source": log.source,
+ "purchase_date": log.purchase_date,
+ "lot_number": log.lot_number,
+ }
+ )
+ return data
diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py
index 6b21090..c739bad 100644
--- a/src/wuttafarm/importing/farmos.py
+++ b/src/wuttafarm/importing/farmos.py
@@ -106,20 +106,27 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["LandAsset"] = LandAssetImporter
importers["StructureType"] = StructureTypeImporter
importers["StructureAsset"] = StructureAssetImporter
+ importers["WaterAsset"] = WaterAssetImporter
+ importers["EquipmentType"] = EquipmentTypeImporter
+ importers["EquipmentAsset"] = EquipmentAssetImporter
importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter
importers["PlantType"] = PlantTypeImporter
+ importers["Season"] = SeasonImporter
importers["PlantAsset"] = PlantAssetImporter
importers["Measure"] = MeasureImporter
importers["Unit"] = UnitImporter
+ importers["MaterialType"] = MaterialTypeImporter
importers["QuantityType"] = QuantityTypeImporter
importers["StandardQuantity"] = StandardQuantityImporter
+ importers["MaterialQuantity"] = MaterialQuantityImporter
importers["LogType"] = LogTypeImporter
importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter
importers["MedicalLog"] = MedicalLogImporter
importers["ObservationLog"] = ObservationLogImporter
+ importers["SeedingLog"] = SeedingLogImporter
return importers
@@ -149,6 +156,8 @@ class FromFarmOS(Importer):
:returns: Equivalent naive UTC ``datetime``
"""
+ if not dt:
+ return None
dt = datetime.datetime.fromisoformat(dt)
return self.app.make_utc(dt)
@@ -330,21 +339,18 @@ class AnimalAssetImporter(AssetImporterBase):
model_class = model.AnimalAsset
- supported_fields = [
- "farmos_uuid",
- "drupal_id",
- "asset_type",
- "asset_name",
- "animal_type_uuid",
- "sex",
- "is_sterile",
- "produces_eggs",
- "birthdate",
- "notes",
- "archived",
- "image_url",
- "thumbnail_url",
- ]
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "animal_type_uuid",
+ "sex",
+ "is_sterile",
+ "produces_eggs",
+ "birthdate",
+ ]
+ )
+ return fields
def setup(self):
super().setup()
@@ -399,12 +405,12 @@ class AnimalAssetImporter(AssetImporterBase):
return data
-class AnimalTypeImporter(FromFarmOS, ToWutta):
+class TaxonomyImporterBase(FromFarmOS, ToWutta):
"""
- farmOS API → WuttaFarm importer for Animal Types
+ farmOS API → WuttaFarm importer for taxonomy terms
"""
- model_class = model.AnimalType
+ taxonomy_type = None
supported_fields = [
"farmos_uuid",
@@ -415,19 +421,50 @@ class AnimalTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type")
- return animal_types["data"]
+ return list(
+ self.farmos_client.resource.iterate("taxonomy_term", self.taxonomy_type)
+ )
- def normalize_source_object(self, animal_type):
+ def normalize_source_object(self, term):
""" """
+ if description := term["attributes"]["description"]:
+ description = description["value"]
+
return {
- "farmos_uuid": UUID(animal_type["id"]),
- "drupal_id": animal_type["attributes"]["drupal_internal__tid"],
- "name": animal_type["attributes"]["name"],
- "description": animal_type["attributes"]["description"],
+ "farmos_uuid": UUID(term["id"]),
+ "drupal_id": term["attributes"]["drupal_internal__tid"],
+ "name": term["attributes"]["name"],
+ "description": description,
}
+class AnimalTypeImporter(TaxonomyImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Animal Types
+ """
+
+ model_class = model.AnimalType
+ taxonomy_type = "animal_type"
+
+
+class MaterialTypeImporter(TaxonomyImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Material Types
+ """
+
+ model_class = model.MaterialType
+ taxonomy_type = "material_type"
+
+
+class EquipmentTypeImporter(TaxonomyImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Equipment Types
+ """
+
+ model_class = model.EquipmentType
+ taxonomy_type = "equipment_type"
+
+
class AssetTypeImporter(FromFarmOS, ToWutta):
"""
farmOS API → WuttaFarm importer for Asset Types
@@ -444,8 +481,7 @@ class AssetTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- asset_types = self.farmos_client.resource.get("asset_type")
- return asset_types["data"]
+ return list(self.farmos_client.resource.iterate("asset_type"))
def normalize_source_object(self, asset_type):
""" """
@@ -457,6 +493,111 @@ class AssetTypeImporter(FromFarmOS, ToWutta):
}
+class EquipmentAssetImporter(AssetImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Equipment Assets
+ """
+
+ model_class = model.EquipmentAsset
+
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "equipment_types",
+ ]
+ )
+ return fields
+
+ def setup(self):
+ super().setup()
+ model = self.app.model
+
+ self.equipment_types_by_farmos_uuid = {}
+ for equipment_type in self.target_session.query(model.EquipmentType):
+ if equipment_type.farmos_uuid:
+ self.equipment_types_by_farmos_uuid[equipment_type.farmos_uuid] = (
+ equipment_type
+ )
+
+ def normalize_source_object(self, equipment):
+ """ """
+ data = super().normalize_source_object(equipment)
+
+ equipment_types = []
+ if relationships := equipment.get("relationships"):
+
+ if equipment_type := relationships.get("equipment_type"):
+ equipment_types = []
+ for equipment_type in equipment_type["data"]:
+ if wf_equipment_type := self.equipment_types_by_farmos_uuid.get(
+ UUID(equipment_type["id"])
+ ):
+ equipment_types.append(wf_equipment_type.uuid)
+ else:
+ log.warning(
+ "equipment type not found: %s", equipment_type["id"]
+ )
+
+ data.update(
+ {
+ "manufacturer": equipment["attributes"]["manufacturer"],
+ "model": equipment["attributes"]["model"],
+ "serial_number": equipment["attributes"]["serial_number"],
+ "equipment_types": set(equipment_types),
+ }
+ )
+ return data
+
+ def normalize_target_object(self, equipment):
+ data = super().normalize_target_object(equipment)
+
+ if "equipment_types" in self.fields:
+ data["equipment_types"] = set(
+ [etype.uuid for etype in equipment.equipment_types]
+ )
+
+ return data
+
+ def update_target_object(self, equipment, source_data, target_data=None):
+ model = self.app.model
+ equipment = super().update_target_object(equipment, source_data, target_data)
+
+ if "equipment_types" in self.fields:
+ if (
+ not target_data
+ or target_data["equipment_types"] != source_data["equipment_types"]
+ ):
+
+ for uuid in source_data["equipment_types"]:
+ if not target_data or uuid not in target_data["equipment_types"]:
+ self.target_session.flush()
+ equipment._equipment_types.append(
+ model.EquipmentAssetEquipmentType(equipment_type_uuid=uuid)
+ )
+
+ if target_data:
+ for uuid in target_data["equipment_types"]:
+ if uuid not in source_data["equipment_types"]:
+ equipment_type = (
+ self.target_session.query(
+ model.EquipmentAssetEquipmentType
+ )
+ .filter(
+ model.EquipmentAssetEquipmentType.equipment_asset
+ == equipment
+ )
+ .filter(
+ model.EquipmentAssetEquipmentType.equipment_type_uuid
+ == uuid
+ )
+ .one()
+ )
+ self.target_session.delete(equipment_type)
+
+ return equipment
+
+
class GroupAssetImporter(AssetImporterBase):
"""
farmOS API → WuttaFarm importer for Group Assets
@@ -464,20 +605,14 @@ class GroupAssetImporter(AssetImporterBase):
model_class = model.GroupAsset
- supported_fields = [
- "farmos_uuid",
- "drupal_id",
- "asset_type",
- "asset_name",
- "is_location",
- "is_fixed",
- "produces_eggs",
- "notes",
- "archived",
- "image_url",
- "thumbnail_url",
- "parents",
- ]
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "produces_eggs",
+ ]
+ )
+ return fields
def normalize_source_object(self, group):
""" """
@@ -497,18 +632,14 @@ class LandAssetImporter(AssetImporterBase):
model_class = model.LandAsset
- supported_fields = [
- "farmos_uuid",
- "drupal_id",
- "asset_type",
- "asset_name",
- "land_type_uuid",
- "is_location",
- "is_fixed",
- "notes",
- "archived",
- "parents",
- ]
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "land_type_uuid",
+ ]
+ )
+ return fields
def setup(self):
""" """
@@ -553,8 +684,7 @@ class LandTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- land_types = self.farmos_client.resource.get("land_type")
- return land_types["data"]
+ return list(self.farmos_client.resource.iterate("land_type"))
def normalize_source_object(self, land_type):
""" """
@@ -581,8 +711,7 @@ class PlantTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- result = self.farmos_client.resource.get("taxonomy_term", "plant_type")
- return result["data"]
+ return list(self.farmos_client.resource.iterate("taxonomy_term", "plant_type"))
def normalize_source_object(self, plant_type):
""" """
@@ -594,6 +723,34 @@ class PlantTypeImporter(FromFarmOS, ToWutta):
}
+class SeasonImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Seasons
+ """
+
+ model_class = model.Season
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ "description",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ return list(self.farmos_client.resource.iterate("taxonomy_term", "season"))
+
+ def normalize_source_object(self, season):
+ """ """
+ return {
+ "farmos_uuid": UUID(season["id"]),
+ "drupal_id": season["attributes"]["drupal_internal__tid"],
+ "name": season["attributes"]["name"],
+ "description": season["attributes"]["description"],
+ }
+
+
class PlantAssetImporter(AssetImporterBase):
"""
farmOS API → WuttaFarm importer for Plant Assets
@@ -601,17 +758,15 @@ class PlantAssetImporter(AssetImporterBase):
model_class = model.PlantAsset
- supported_fields = [
- "farmos_uuid",
- "drupal_id",
- "asset_type",
- "asset_name",
- "plant_types",
- "notes",
- "archived",
- "image_url",
- "thumbnail_url",
- ]
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "plant_types",
+ "seasons",
+ ]
+ )
+ return fields
def setup(self):
super().setup()
@@ -622,9 +777,17 @@ class PlantAssetImporter(AssetImporterBase):
if plant_type.farmos_uuid:
self.plant_types_by_farmos_uuid[plant_type.farmos_uuid] = plant_type
+ self.seasons_by_farmos_uuid = {}
+ for season in self.target_session.query(model.Season):
+ if season.farmos_uuid:
+ self.seasons_by_farmos_uuid[season.farmos_uuid] = season
+
def normalize_source_object(self, plant):
""" """
+ data = super().normalize_source_object(plant)
+
plant_types = []
+ seasons = []
if relationships := plant.get("relationships"):
if plant_type := relationships.get("plant_type"):
@@ -637,10 +800,18 @@ class PlantAssetImporter(AssetImporterBase):
else:
log.warning("plant type not found: %s", plant_type["id"])
- data = super().normalize_source_object(plant)
+ if season := relationships.get("season"):
+ seasons = []
+ for season in season["data"]:
+ if wf_season := self.seasons_by_farmos_uuid.get(UUID(season["id"])):
+ seasons.append(wf_season.uuid)
+ else:
+ log.warning("season not found: %s", season["id"])
+
data.update(
{
"plant_types": set(plant_types),
+ "seasons": set(seasons),
}
)
return data
@@ -651,6 +822,9 @@ class PlantAssetImporter(AssetImporterBase):
if "plant_types" in self.fields:
data["plant_types"] = set([pt.uuid for pt in plant.plant_types])
+ if "seasons" in self.fields:
+ data["seasons"] = set([s.uuid for s in plant.seasons])
+
return data
def update_target_object(self, plant, source_data, target_data=None):
@@ -683,6 +857,25 @@ class PlantAssetImporter(AssetImporterBase):
)
self.target_session.delete(plant_type)
+ if "seasons" in self.fields:
+ if not target_data or target_data["seasons"] != source_data["seasons"]:
+
+ for uuid in source_data["seasons"]:
+ if not target_data or uuid not in target_data["seasons"]:
+ self.target_session.flush()
+ plant._seasons.append(model.PlantAssetSeason(season_uuid=uuid))
+
+ if target_data:
+ for uuid in target_data["seasons"]:
+ if uuid not in source_data["seasons"]:
+ season = (
+ self.target_session.query(model.PlantAssetSeason)
+ .filter(model.PlantAssetSeason.plant_asset == plant)
+ .filter(model.PlantAssetSeason.season_uuid == uuid)
+ .one()
+ )
+ self.target_session.delete(season)
+
return plant
@@ -693,20 +886,14 @@ class StructureAssetImporter(AssetImporterBase):
model_class = model.StructureAsset
- supported_fields = [
- "farmos_uuid",
- "drupal_id",
- "asset_type",
- "asset_name",
- "structure_type_uuid",
- "is_location",
- "is_fixed",
- "notes",
- "archived",
- "image_url",
- "thumbnail_url",
- "parents",
- ]
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "structure_type_uuid",
+ ]
+ )
+ return fields
def setup(self):
super().setup()
@@ -752,8 +939,7 @@ class StructureTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- structure_types = self.farmos_client.resource.get("structure_type")
- return structure_types["data"]
+ return list(self.farmos_client.resource.iterate("structure_type"))
def normalize_source_object(self, structure_type):
""" """
@@ -764,6 +950,14 @@ class StructureTypeImporter(FromFarmOS, ToWutta):
}
+class WaterAssetImporter(AssetImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Water Assets
+ """
+
+ model_class = model.WaterAsset
+
+
class UserImporter(FromFarmOS, ToWutta):
"""
farmOS API → WuttaFarm importer for Users
@@ -791,8 +985,7 @@ class UserImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- users = self.farmos_client.resource.get("user")
- return users["data"]
+ return list(self.farmos_client.resource.iterate("user"))
def normalize_source_object(self, user):
""" """
@@ -833,6 +1026,7 @@ class MeasureImporter(FromFarmOS, ToWutta):
supported_fields = [
"drupal_id",
+ "ordinal",
"name",
]
@@ -843,12 +1037,15 @@ class MeasureImporter(FromFarmOS, ToWutta):
)
response.raise_for_status()
data = response.json()
+ self.ordinal = 0
return data["definitions"]["attributes"]["properties"]["measure"]["oneOf"]
def normalize_source_object(self, measure):
""" """
+ self.ordinal += 1
return {
"drupal_id": measure["const"],
+ "ordinal": self.ordinal,
"name": measure["title"],
}
@@ -869,8 +1066,7 @@ class UnitImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- result = self.farmos_client.resource.get("taxonomy_term", "unit")
- return result["data"]
+ return list(self.farmos_client.resource.iterate("taxonomy_term", "unit"))
def normalize_source_object(self, unit):
""" """
@@ -898,8 +1094,7 @@ class QuantityTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- result = self.farmos_client.resource.get("quantity_type")
- return result["data"]
+ return list(self.farmos_client.resource.iterate("quantity_type"))
def normalize_source_object(self, quantity_type):
""" """
@@ -927,8 +1122,7 @@ class LogTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- log_types = self.farmos_client.resource.get("log_type")
- return log_types["data"]
+ return list(self.farmos_client.resource.iterate("log_type"))
def normalize_source_object(self, log_type):
""" """
@@ -1226,6 +1420,41 @@ class ObservationLogImporter(LogImporterBase):
model_class = model.ObservationLog
+class SeedingLogImporter(LogImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Seeding Logs
+ """
+
+ model_class = model.SeedingLog
+
+ def get_simple_fields(self):
+ """ """
+ fields = list(super().get_simple_fields())
+ # nb. must explicitly declare proxy fields
+ fields.extend(
+ [
+ "source",
+ "purchase_date",
+ "lot_number",
+ ]
+ )
+ return fields
+
+ def normalize_source_object(self, log):
+ """ """
+ data = super().normalize_source_object(log)
+ data.update(
+ {
+ "source": log["attributes"]["source"],
+ "purchase_date": self.normalize_datetime(
+ log["attributes"]["purchase_date"]
+ ),
+ "lot_number": log["attributes"]["lot_number"],
+ }
+ )
+ return data
+
+
class QuantityImporterBase(FromFarmOS, ToWutta):
"""
Base class for farmOS API → WuttaFarm quantity importers
@@ -1271,8 +1500,7 @@ class QuantityImporterBase(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
quantity_type = self.get_farmos_quantity_type()
- result = self.farmos_client.resource.get("quantity", quantity_type)
- return result["data"]
+ return list(self.farmos_client.resource.iterate("quantity", quantity_type))
def get_quantity_type_by_farmos_uuid(self, uuid):
if hasattr(self, "quantity_types_by_farmos_uuid"):
@@ -1355,3 +1583,76 @@ class StandardQuantityImporter(QuantityImporterBase):
"units_uuid",
"label",
]
+
+
+class MaterialQuantityImporter(QuantityImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Material Quantities
+ """
+
+ model_class = model.MaterialQuantity
+
+ def get_supported_fields(self):
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "material_types",
+ ]
+ )
+ return fields
+
+ def normalize_source_object(self, quantity):
+ """ """
+ data = super().normalize_source_object(quantity)
+
+ if "material_types" in self.fields:
+ data["material_types"] = [
+ UUID(mtype["id"])
+ for mtype in quantity["relationships"]["material_type"]["data"]
+ ]
+
+ return data
+
+ def normalize_target_object(self, quantity):
+ data = super().normalize_target_object(quantity)
+
+ if "material_types" in self.fields:
+ data["material_types"] = [
+ mtype.farmos_uuid for mtype in quantity.material_types
+ ]
+
+ return data
+
+ def update_target_object(self, quantity, source_data, target_data=None):
+ model = self.app.model
+ quantity = super().update_target_object(quantity, source_data, target_data)
+
+ if "material_types" in self.fields:
+ if (
+ not target_data
+ or target_data["material_types"] != source_data["material_types"]
+ ):
+
+ for farmos_uuid in source_data["material_types"]:
+ if (
+ not target_data
+ or farmos_uuid not in target_data["material_types"]
+ ):
+ mtype = (
+ self.target_session.query(model.MaterialType)
+ .filter(model.MaterialType.farmos_uuid == farmos_uuid)
+ .one()
+ )
+ quantity.material_types.append(mtype)
+
+ if target_data:
+ for farmos_uuid in target_data["material_types"]:
+ if farmos_uuid not in source_data["material_types"]:
+ mtype = (
+ self.target_session.query(model.MaterialType)
+ .filter(model.MaterialType.farmos_uuid == farmos_uuid)
+ .one()
+ )
+ quantity.material_types.remove(mtype)
+
+ return quantity
diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py
index 4fc8796..c47fcc3 100644
--- a/src/wuttafarm/normal.py
+++ b/src/wuttafarm/normal.py
@@ -84,16 +84,41 @@ class Normalizer(GenericHandler):
self._farmos_units = units
return self._farmos_units
+ def normalize_datetime(self, value):
+ if not value:
+ return None
+ value = datetime.datetime.fromisoformat(value)
+ return self.app.localtime(value)
+
def normalize_farmos_asset(self, asset, included={}):
""" """
if notes := asset["attributes"]["notes"]:
notes = notes["value"]
+ parent_objects = []
+ parent_uuids = []
owner_objects = []
owner_uuids = []
if relationships := asset.get("relationships"):
+ if parents := relationships.get("parent"):
+ for parent in parents["data"]:
+ parent_uuid = parent["id"]
+ parent_uuids.append(parent_uuid)
+ parent_object = {
+ "uuid": parent_uuid,
+ "type": parent["type"],
+ "asset_type": parent["type"].split("--")[1],
+ }
+ if parent := included.get(parent_uuid):
+ parent_object.update(
+ {
+ "name": parent["attributes"]["name"],
+ }
+ )
+ parent_objects.append(parent_object)
+
if owners := relationships.get("owner"):
for user in owners["data"]:
user_uuid = user["id"]
@@ -106,6 +131,11 @@ class Normalizer(GenericHandler):
}
)
+ # if self.farmos_4x:
+ # archived = asset["attributes"]["archived"]
+ # else:
+ # archived = asset["attributes"]["status"] == "archived"
+
return {
"uuid": asset["id"],
"drupal_id": asset["attributes"]["drupal_internal__id"],
@@ -114,6 +144,10 @@ class Normalizer(GenericHandler):
"is_fixed": asset["attributes"]["is_fixed"],
"archived": asset["attributes"]["archived"],
"notes": notes,
+ # nb. this is only used for certain asset types
+ "produces_eggs": asset["attributes"].get("produces_eggs"),
+ "parents": parent_objects,
+ "parent_uuids": parent_uuids,
"owners": owner_objects,
"owner_uuids": owner_uuids,
}
@@ -121,8 +155,7 @@ class Normalizer(GenericHandler):
def normalize_farmos_log(self, log, included={}):
if timestamp := log["attributes"]["timestamp"]:
- timestamp = datetime.datetime.fromisoformat(timestamp)
- timestamp = self.app.localtime(timestamp)
+ timestamp = self.normalize_datetime(timestamp)
if notes := log["attributes"]["notes"]:
notes = notes["value"]
@@ -232,27 +265,29 @@ class Normalizer(GenericHandler):
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"],
- }
- )
+ quantity_object = {
+ "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 quantity_object["quantity_type_id"] == "material":
+ quantity_object["material_types"] = [
+ {"uuid": mtype["id"]}
+ for mtype in quantity["relationships"]["material_type"][
+ "data"
+ ]
+ ]
+ quantity_objects.append(quantity_object)
if owners := relationships.get("owner"):
for user in owners["data"]:
diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py
index 6bf434e..e24f0cf 100644
--- a/src/wuttafarm/web/forms/schema.py
+++ b/src/wuttafarm/web/forms/schema.py
@@ -28,7 +28,7 @@ import json
import colander
from wuttaweb.db import Session
-from wuttaweb.forms.schema import ObjectRef, WuttaSet
+from wuttaweb.forms.schema import ObjectRef, WuttaSet, WuttaList
from wuttaweb.forms.widgets import NotesWidget
@@ -164,10 +164,9 @@ class FarmOSRefs(WuttaSet):
self.route_prefix = route_prefix
def serialize(self, node, appstruct):
- if appstruct is colander.null:
+ if not appstruct:
return colander.null
-
- return json.dumps(appstruct)
+ return appstruct
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSRefsWidget
@@ -217,6 +216,35 @@ class FarmOSQuantityRefs(WuttaSet):
return FarmOSQuantityRefsWidget(**kwargs)
+class FarmOSTaxonomyTerms(colander.SchemaType):
+ """
+ Schema type which can represent multiple taxonomy terms.
+ """
+
+ route_prefix = None
+
+ def __init__(self, request, route_prefix=None, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+ if route_prefix:
+ self.route_prefix = route_prefix
+
+ def serialize(self, node, appstruct):
+ if not appstruct:
+ return colander.null
+ return appstruct
+
+ def widget_maker(self, **kwargs):
+ from wuttafarm.web.forms.widgets import FarmOSTaxonomyTermsWidget
+
+ return FarmOSTaxonomyTermsWidget(self.request, self.route_prefix, **kwargs)
+
+
+class FarmOSEquipmentTypeRefs(FarmOSTaxonomyTerms):
+
+ route_prefix = "farmos_equipment_types"
+
+
class FarmOSPlantTypes(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
@@ -261,6 +289,35 @@ class LandTypeRef(ObjectRef):
return self.request.route_url("land_types.view", uuid=land_type.uuid)
+class TaxonomyTermRefs(WuttaList):
+ """
+ Generic schema type for a field which can reference multiple
+ taxonomy terms.
+ """
+
+ def serialize(self, node, appstruct):
+ if not appstruct:
+ return colander.null
+
+ terms = []
+ for term in appstruct:
+ terms.append(
+ {
+ "uuid": str(term.uuid),
+ "name": term.name,
+ }
+ )
+ return terms
+
+
+class EquipmentTypeRefs(TaxonomyTermRefs):
+
+ def widget_maker(self, **kwargs):
+ from wuttafarm.web.forms.widgets import EquipmentTypeRefsWidget
+
+ return EquipmentTypeRefsWidget(self.request, **kwargs)
+
+
class PlantTypeRefs(WuttaSet):
"""
Schema type for Plant Types field (on a Plant Asset).
@@ -288,6 +345,62 @@ class PlantTypeRefs(WuttaSet):
return PlantTypeRefsWidget(self.request, **kwargs)
+class MaterialTypeRefs(colander.List):
+ """
+ Schema type for Material Types field (on a Material Asset).
+ """
+
+ def __init__(self, request):
+ super().__init__()
+ self.request = request
+ self.config = self.request.wutta_config
+ self.app = self.config.get_app()
+
+ def serialize(self, node, appstruct):
+ if not appstruct:
+ return colander.null
+
+ mtypes = []
+ for mtype in appstruct:
+ mtypes.append(
+ {
+ "uuid": mtype.uuid.hex,
+ "name": mtype.name,
+ }
+ )
+ return mtypes
+
+ def widget_maker(self, **kwargs):
+ from wuttafarm.web.forms.widgets import MaterialTypeRefsWidget
+
+ return MaterialTypeRefsWidget(self.request, **kwargs)
+
+
+class SeasonRefs(WuttaSet):
+ """
+ Schema type for Plant Types field (on a Plant Asset).
+ """
+
+ def serialize(self, node, appstruct):
+ if not appstruct:
+ return []
+
+ return [season.uuid.hex for season in appstruct]
+
+ def widget_maker(self, **kwargs):
+ from wuttafarm.web.forms.widgets import SeasonRefsWidget
+
+ model = self.app.model
+ session = Session()
+
+ if "values" not in kwargs:
+ seasons = session.query(model.Season).order_by(model.Season.name).all()
+ values = [(s.uuid.hex, str(s)) for s in seasons]
+ kwargs["values"] = values
+
+ return SeasonRefsWidget(self.request, **kwargs)
+
+
class StructureType(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
@@ -372,55 +485,100 @@ class UsersType(colander.SchemaType):
return UsersWidget(self.request, **kwargs)
-class AssetParentRefs(WuttaSet):
- """
- Schema type for Parents field which references assets.
- """
-
- def serialize(self, node, appstruct):
- if not appstruct:
- appstruct = []
- uuids = [u.hex for u in appstruct]
- return json.dumps(uuids)
-
- def widget_maker(self, **kwargs):
- from wuttafarm.web.forms.widgets import AssetParentRefsWidget
-
- return AssetParentRefsWidget(self.request, **kwargs)
-
-
class AssetRefs(WuttaSet):
"""
Schema type for Assets field (on a Log record)
"""
+ def __init__(
+ self, request, for_asset=None, is_group=None, is_location=None, **kwargs
+ ):
+ super().__init__(request, **kwargs)
+ self.is_group = is_group
+ self.is_location = is_location
+ self.for_asset = for_asset
+
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
- return {asset.uuid for asset in appstruct}
+ return {asset.uuid.hex for asset in appstruct}
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import AssetRefsWidget
+ model = self.app.model
+ session = Session()
+
+ if "values" not in kwargs:
+ query = session.query(model.Asset)
+ if self.is_group is not None:
+ query = query.join(model.GroupAsset)
+ if self.is_location is not None:
+ query = query.filter(model.Asset.is_location == self.is_location)
+ if self.for_asset:
+ query = query.filter(model.Asset.uuid != self.for_asset.uuid)
+ query = query.order_by(model.Asset.asset_name)
+ values = [(asset.uuid.hex, str(asset)) for asset in query]
+ kwargs["values"] = values
+
return AssetRefsWidget(self.request, **kwargs)
-class LogQuantityRefs(WuttaSet):
+class QuantityRefs(colander.List):
"""
Schema type for Quantities field (on a Log record)
"""
+ def __init__(self, request):
+ super().__init__()
+ self.request = request
+ self.config = self.request.wutta_config
+ self.app = self.config.get_app()
+
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
- return {qty.uuid for qty in appstruct}
+ quantities = []
+ for qty in appstruct:
+
+ quantity = {
+ "uuid": qty.uuid.hex,
+ "quantity_type": {
+ "drupal_id": qty.quantity_type_id,
+ "name": qty.quantity_type.name,
+ },
+ "measure": qty.measure_id,
+ "value": qty.get_value_decimal(),
+ "units": {
+ "uuid": qty.units.uuid.hex,
+ "name": qty.units.name,
+ },
+ "as_text": qty.render_as_text(self.config),
+ # nb. always include this regardless of quantity type,
+ # for sake of easier frontend logic
+ "material_types": [],
+ }
+
+ if qty.quantity_type_id == "material":
+ quantity["material_types"] = []
+ for mtype in qty.material_types:
+ quantity["material_types"].append(
+ {
+ "uuid": mtype.uuid.hex,
+ "name": mtype.name,
+ }
+ )
+
+ quantities.append(quantity)
+
+ return quantities
def widget_maker(self, **kwargs):
- from wuttafarm.web.forms.widgets import LogQuantityRefsWidget
+ from wuttafarm.web.forms.widgets import QuantityRefsWidget
- return LogQuantityRefsWidget(self.request, **kwargs)
+ return QuantityRefsWidget(self.request, **kwargs)
class OwnerRefs(WuttaSet):
diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py
index 0a14638..db79eae 100644
--- a/src/wuttafarm/web/forms/widgets.py
+++ b/src/wuttafarm/web/forms/widgets.py
@@ -33,6 +33,7 @@ from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget
from wuttaweb.db import Session
from wuttafarm.web.util import render_quantity_objects
+from wuttafarm.db.model import EquipmentType
class ImageWidget(Widget):
@@ -124,7 +125,7 @@ class FarmOSRefsWidget(Widget):
return HTML.tag("span")
links = []
- for obj in json.loads(cstruct):
+ for obj in cstruct:
url = self.request.route_url(
f"{self.route_prefix}.view", uuid=obj["uuid"]
)
@@ -228,6 +229,38 @@ class FarmOSUnitRefWidget(Widget):
return super().serialize(field, cstruct, **kw)
+class FarmOSTaxonomyTermsWidget(Widget):
+ """
+ Widget to display a field which can reference multiple taxonomy
+ terms.
+ """
+
+ 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 term in cstruct:
+ link = tags.link_to(
+ term["name"],
+ self.request.route_url(
+ f"{self.route_prefix}.view", uuid=term["uuid"]
+ ),
+ )
+ links.append(HTML.tag("li", c=link))
+ return HTML.tag("ul", c=links)
+
+ return super().serialize(field, cstruct, **kw)
+
+
class FarmOSPlantTypesWidget(Widget):
"""
Widget to display a farmOS "plant types" field.
@@ -258,6 +291,88 @@ class FarmOSPlantTypesWidget(Widget):
return super().serialize(field, cstruct, **kw)
+class TaxonomyTermRefsWidget(Widget):
+ """
+ Generic (incomplete) widget for fields which can reference
+ multiple taxonomy terms.
+
+ This widget can handle typical read-only scenarios but the
+ editable mode is not implemented.
+ """
+
+ route_prefix = None
+
+ def __init__(self, request, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+ self.config = self.request.wutta_config
+ self.app = self.config.get_app()
+
+ @classmethod
+ def get_route_prefix(cls):
+ return cls.route_prefix
+
+ @classmethod
+ def get_permission_prefix(cls):
+ return cls.route_prefix
+
+ def serialize(self, field, cstruct, **kw):
+ """ """
+ if not cstruct:
+ cstruct = []
+
+ if readonly := kw.get("readonly", self.readonly):
+ items = []
+ route_prefix = self.get_route_prefix()
+ for term in cstruct:
+ url = self.request.route_url(f"{route_prefix}.view", uuid=term["uuid"])
+ link = tags.link_to(term["name"], url)
+ items.append(HTML.tag("li", c=link))
+ return HTML.tag("ul", c=items)
+
+ tmpl_values = self.get_template_values(field, cstruct, kw)
+ return field.renderer(self.template, **tmpl_values)
+
+ def get_template_values(self, field, cstruct, kw):
+ values = super().get_template_values(field, cstruct, kw)
+ model = self.app.model
+ session = Session()
+
+ terms = []
+ query = session.query(self.model_class).order_by(self.model_class.name)
+ for term in query:
+ terms.append(
+ {
+ "uuid": str(term.uuid),
+ "name": term.name,
+ }
+ )
+ values["terms"] = terms
+
+ permission_prefix = self.get_permission_prefix()
+ if self.request.has_perm(f"{permission_prefix}.create"):
+ values["can_create"] = True
+
+ return values
+
+ def deserialize(self, field, pstruct):
+ """ """
+ if not pstruct:
+ return colander.null
+
+ return json.loads(pstruct)
+
+
+class EquipmentTypeRefsWidget(TaxonomyTermRefsWidget):
+ """
+ Widget for Equipment Types field.
+ """
+
+ model_class = EquipmentType
+ route_prefix = "equipment_types"
+ template = "equipmenttyperefs"
+
+
class PlantTypeRefsWidget(Widget):
"""
Widget for Plant Types field (on a Plant Asset).
@@ -332,6 +447,144 @@ class PlantTypeRefsWidget(Widget):
return set(pstruct.split(","))
+class MaterialTypeRefsWidget(Widget):
+ """
+ Widget for Material Types field (on a Material Asset).
+ """
+
+ template = "materialtyperefs"
+ values = ()
+
+ def __init__(self, request, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+ self.config = self.request.wutta_config
+ self.app = self.config.get_app()
+
+ def serialize(self, field, cstruct, **kw):
+ """ """
+ model = self.app.model
+ session = Session()
+
+ if not cstruct:
+ cstruct = []
+
+ if readonly := kw.get("readonly", self.readonly):
+ items = []
+ for mtype in cstruct:
+ items.append(
+ HTML.tag(
+ "li",
+ c=tags.link_to(
+ mtype["name"],
+ self.request.route_url(
+ "material_types.view", uuid=mtype["uuid"]
+ ),
+ ),
+ )
+ )
+ return HTML.tag("ul", c=items)
+
+ tmpl_values = self.get_template_values(field, cstruct, kw)
+ return field.renderer(self.template, **tmpl_values)
+
+ def get_template_values(self, field, cstruct, kw):
+ """ """
+ values = super().get_template_values(field, cstruct, kw)
+ session = Session()
+
+ material_types = []
+ for mtype in self.app.get_material_types(session):
+ material_types.append(
+ {
+ "uuid": mtype.uuid.hex,
+ "name": mtype.name,
+ }
+ )
+ values["material_types"] = json.dumps(material_types)
+
+ return values
+
+ def deserialize(self, field, pstruct):
+ """ """
+ if not pstruct:
+ return []
+
+ return json.loads(pstruct)
+
+
+class SeasonRefsWidget(Widget):
+ """
+ Widget for Seasons field (on a Plant Asset).
+ """
+
+ template = "seasonrefs"
+ values = ()
+
+ def __init__(self, request, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+ self.config = self.request.wutta_config
+ self.app = self.config.get_app()
+
+ def serialize(self, field, cstruct, **kw):
+ """ """
+ model = self.app.model
+ session = Session()
+
+ if cstruct in (colander.null, None):
+ cstruct = ()
+
+ if readonly := kw.get("readonly", self.readonly):
+ items = []
+
+ seasons = (
+ session.query(model.Season)
+ .filter(model.Season.uuid.in_(cstruct))
+ .order_by(model.Season.name)
+ .all()
+ )
+
+ for season in seasons:
+ items.append(
+ HTML.tag(
+ "li",
+ c=tags.link_to(
+ str(season),
+ self.request.route_url("seasons.view", uuid=season.uuid),
+ ),
+ )
+ )
+
+ return HTML.tag("ul", c=items)
+
+ values = kw.get("values", self.values)
+ if not isinstance(values, sequence_types):
+ raise TypeError("Values must be a sequence type (list, tuple, or range).")
+
+ kw["values"] = _normalize_choices(values)
+ tmpl_values = self.get_template_values(field, cstruct, kw)
+ return field.renderer(self.template, **tmpl_values)
+
+ def get_template_values(self, field, cstruct, kw):
+ """ """
+ values = super().get_template_values(field, cstruct, kw)
+
+ values["js_values"] = json.dumps(values["values"])
+
+ if self.request.has_perm("seasons.create"):
+ values["can_create"] = True
+
+ return values
+
+ def deserialize(self, field, pstruct):
+ """ """
+ if not pstruct:
+ return set()
+
+ return set(pstruct.split(","))
+
+
class StructureWidget(Widget):
"""
Widget to display a "structure" field.
@@ -393,42 +646,20 @@ class UsersWidget(Widget):
##############################
-class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
- """
- Widget for Parents field which references assets.
- """
-
- def serialize(self, field, cstruct, **kw):
- """ """
- model = self.app.model
- session = Session()
-
- readonly = kw.get("readonly", self.readonly)
- if readonly:
- parents = []
- for uuid in json.loads(cstruct):
- parent = session.get(model.Asset, uuid)
- parents.append(
- HTML.tag(
- "li",
- c=tags.link_to(
- str(parent),
- self.request.route_url(
- f"{parent.asset_type}_assets.view", uuid=parent.uuid
- ),
- ),
- )
- )
- return HTML.tag("ul", c=parents)
-
- return super().serialize(field, cstruct, **kw)
-
-
-class AssetRefsWidget(WuttaCheckboxChoiceWidget):
+class AssetRefsWidget(Widget):
"""
Widget for Assets field (of various kinds).
"""
+ template = "assetrefs"
+ values = ()
+
+ def __init__(self, request, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+ self.config = self.request.wutta_config
+ self.app = self.config.get_app()
+
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
@@ -452,14 +683,43 @@ class AssetRefsWidget(WuttaCheckboxChoiceWidget):
)
return HTML.tag("ul", c=assets)
- return super().serialize(field, cstruct, **kw)
+ values = kw.get("values", self.values)
+ if not isinstance(values, sequence_types):
+ raise TypeError("Values must be a sequence type (list, tuple, or range).")
+
+ kw["values"] = _normalize_choices(values)
+ tmpl_values = self.get_template_values(field, cstruct, kw)
+ return field.renderer(self.template, **tmpl_values)
+
+ def get_template_values(self, field, cstruct, kw):
+ """ """
+ values = super().get_template_values(field, cstruct, kw)
+
+ values["js_values"] = json.dumps(values["values"])
+
+ return values
+
+ def deserialize(self, field, pstruct):
+ """ """
+ if not pstruct:
+ return set()
+
+ return set(pstruct.split(","))
-class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget):
+class QuantityRefsWidget(Widget):
"""
Widget for Quantities field (on a Log record)
"""
+ template = "quantityrefs"
+
+ def __init__(self, request, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+ self.config = self.request.wutta_config
+ self.app = self.config.get_app()
+
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
@@ -467,24 +727,78 @@ class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget):
readonly = kw.get("readonly", self.readonly)
if readonly:
+ if not cstruct:
+ return ""
+
quantities = []
- for uuid in cstruct or []:
- qty = session.get(model.Quantity, uuid)
- quantities.append(
- HTML.tag(
- "li",
- c=tags.link_to(
- qty.render_as_text(self.config),
- # TODO
- self.request.route_url(
- "quantities_standard.view", uuid=qty.uuid
- ),
- ),
- )
+ for qty in cstruct:
+ url = self.request.route_url(
+ f"quantities_{qty['quantity_type']['drupal_id']}.view",
+ uuid=qty["uuid"],
)
+ quantities.append(HTML.tag("li", c=tags.link_to(qty["as_text"], url)))
+
return HTML.tag("ul", c=quantities)
- return super().serialize(field, cstruct, **kw)
+ tmpl_values = self.get_template_values(field, cstruct, kw)
+ return field.renderer(self.template, **tmpl_values)
+
+ def get_template_values(self, field, cstruct, kw):
+ model = self.app.model
+ session = Session()
+ values = super().get_template_values(field, cstruct, kw)
+
+ qtypes = []
+ for qtype in self.app.get_quantity_types(session):
+ qtypes.append(
+ {
+ "uuid": qtype.uuid.hex,
+ "drupal_id": qtype.drupal_id,
+ "name": qtype.name,
+ }
+ )
+ values["quantity_types"] = qtypes
+
+ material_types = []
+ for mtype in self.app.get_material_types(session):
+ material_types.append(
+ {
+ "uuid": mtype.uuid.hex,
+ "name": mtype.name,
+ }
+ )
+ values["material_types"] = material_types
+
+ measures = []
+ for measure in self.app.get_measures(session):
+ measures.append(
+ {
+ "uuid": measure.uuid.hex,
+ "drupal_id": measure.drupal_id,
+ "name": measure.name,
+ }
+ )
+ values["measures"] = measures
+
+ units = []
+ for unit in self.app.get_units(session):
+ units.append(
+ {
+ "uuid": unit.uuid.hex,
+ "drupal_id": unit.drupal_id,
+ "name": unit.name,
+ }
+ )
+ values["units"] = units
+
+ return values
+
+ def deserialize(self, field, pstruct):
+ """ """
+ if not pstruct:
+ return set()
+
+ return json.loads(pstruct)
class OwnerRefsWidget(WuttaCheckboxChoiceWidget):
diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py
index fe7719e..2756738 100644
--- a/src/wuttafarm/web/menus.py
+++ b/src/wuttafarm/web/menus.py
@@ -92,6 +92,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "animal_assets",
"perm": "animal_assets.list",
},
+ {
+ "title": "Equipment",
+ "route": "equipment_assets",
+ "perm": "equipment_assets.list",
+ },
{
"title": "Group",
"route": "group_assets",
@@ -112,12 +117,22 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "structure_assets",
"perm": "structure_assets.list",
},
+ {
+ "title": "Water",
+ "route": "water_assets",
+ "perm": "water_assets.list",
+ },
{"type": "sep"},
{
"title": "Animal Types",
"route": "animal_types",
"perm": "animal_types.list",
},
+ {
+ "title": "Equipment Types",
+ "route": "equipment_types",
+ "perm": "equipment_types.list",
+ },
{
"title": "Land Types",
"route": "land_types",
@@ -128,6 +143,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "plant_types",
"perm": "plant_types.list",
},
+ {
+ "title": "Seasons",
+ "route": "seasons",
+ "perm": "seasons.list",
+ },
{
"title": "Structure Types",
"route": "structure_types",
@@ -171,12 +191,22 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "logs_observation",
"perm": "logs_observation.list",
},
+ {
+ "title": "Seeding",
+ "route": "logs_seeding",
+ "perm": "logs_seeding.list",
+ },
{"type": "sep"},
{
"title": "All Quantities",
"route": "quantities",
"perm": "quantities.list",
},
+ {
+ "title": "Material Quantities",
+ "route": "quantities_material",
+ "perm": "quantities_material.list",
+ },
{
"title": "Standard Quantities",
"route": "quantities_standard",
@@ -188,6 +218,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "log_types",
"perm": "log_types.list",
},
+ {
+ "title": "Material Types",
+ "route": "material_types",
+ "perm": "material_types.list",
+ },
{
"title": "Measures",
"route": "measures",
@@ -224,6 +259,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_animal_assets",
"perm": "farmos_animal_assets.list",
},
+ {
+ "title": "Equipment Assets",
+ "route": "farmos_equipment_assets",
+ "perm": "farmos_equipment_assets.list",
+ },
{
"title": "Group Assets",
"route": "farmos_group_assets",
@@ -244,6 +284,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_structure_assets",
"perm": "farmos_structure_assets.list",
},
+ {
+ "title": "Water Assets",
+ "route": "farmos_water_assets",
+ "perm": "farmos_water_assets.list",
+ },
{"type": "sep"},
{
"title": "Activity Logs",
@@ -265,12 +310,22 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_logs_observation",
"perm": "farmos_logs_observation.list",
},
+ {
+ "title": "Seeding Logs",
+ "route": "farmos_logs_seeding",
+ "perm": "farmos_logs_seeding.list",
+ },
{"type": "sep"},
{
"title": "Animal Types",
"route": "farmos_animal_types",
"perm": "farmos_animal_types.list",
},
+ {
+ "title": "Equipment Types",
+ "route": "farmos_equipment_types",
+ "perm": "farmos_equipment_types.list",
+ },
{
"title": "Land Types",
"route": "farmos_land_types",
@@ -281,6 +336,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_plant_types",
"perm": "farmos_plant_types.list",
},
+ {
+ "title": "Seasons",
+ "route": "farmos_seasons",
+ "perm": "farmos_seasons.list",
+ },
{
"title": "Structure Types",
"route": "farmos_structure_types",
@@ -297,11 +357,21 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_log_types",
"perm": "farmos_log_types.list",
},
+ {
+ "title": "Material Types",
+ "route": "farmos_material_types",
+ "perm": "farmos_material_types.list",
+ },
{
"title": "Quantity Types",
"route": "farmos_quantity_types",
"perm": "farmos_quantity_types.list",
},
+ {
+ "title": "Material Quantities",
+ "route": "farmos_quantities_material",
+ "perm": "farmos_quantities_material.list",
+ },
{
"title": "Standard Quantities",
"route": "farmos_quantities_standard",
@@ -333,6 +403,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_animal_assets",
"perm": "farmos_animal_assets.list",
},
+ {
+ "title": "Equipment",
+ "route": "farmos_equipment_assets",
+ "perm": "farmos_equipment_assets.list",
+ },
{
"title": "Group",
"route": "farmos_group_assets",
@@ -353,12 +428,22 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_structure_assets",
"perm": "farmos_structure_assets.list",
},
+ {
+ "title": "Water",
+ "route": "farmos_water_assets",
+ "perm": "farmos_water_assets.list",
+ },
{"type": "sep"},
{
"title": "Animal Types",
"route": "farmos_animal_types",
"perm": "farmos_animal_types.list",
},
+ {
+ "title": "Equipment Types",
+ "route": "farmos_equipment_types",
+ "perm": "farmos_equipment_types.list",
+ },
{
"title": "Land Types",
"route": "farmos_land_types",
@@ -369,6 +454,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_plant_types",
"perm": "farmos_plant_types.list",
},
+ {
+ "title": "Seasons",
+ "route": "farmos_seasons",
+ "perm": "farmos_seasons.list",
+ },
{
"title": "Structure Types",
"route": "farmos_structure_types",
@@ -410,17 +500,32 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_logs_observation",
"perm": "farmos_logs_observation.list",
},
+ {
+ "title": "Seeding",
+ "route": "farmos_logs_seeding",
+ "perm": "farmos_logs_seeding.list",
+ },
{"type": "sep"},
{
"title": "Log Types",
"route": "farmos_log_types",
"perm": "farmos_log_types.list",
},
+ {
+ "title": "Material Types",
+ "route": "farmos_material_types",
+ "perm": "farmos_material_types.list",
+ },
{
"title": "Quantity Types",
"route": "farmos_quantity_types",
"perm": "farmos_quantity_types.list",
},
+ {
+ "title": "Material Quantities",
+ "route": "farmos_quantities_material",
+ "perm": "farmos_quantities_material.list",
+ },
{
"title": "Standard Quantities",
"route": "farmos_quantities_standard",
diff --git a/src/wuttafarm/web/templates/deform/assetrefs.pt b/src/wuttafarm/web/templates/deform/assetrefs.pt
new file mode 100644
index 0000000..b2e9660
--- /dev/null
+++ b/src/wuttafarm/web/templates/deform/assetrefs.pt
@@ -0,0 +1,11 @@
+
diff --git a/src/wuttafarm/web/templates/deform/equipmenttyperefs.pt b/src/wuttafarm/web/templates/deform/equipmenttyperefs.pt
new file mode 100644
index 0000000..4d48fd7
--- /dev/null
+++ b/src/wuttafarm/web/templates/deform/equipmenttyperefs.pt
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/src/wuttafarm/web/templates/deform/materialtyperefs.pt b/src/wuttafarm/web/templates/deform/materialtyperefs.pt
new file mode 100644
index 0000000..44ac6e8
--- /dev/null
+++ b/src/wuttafarm/web/templates/deform/materialtyperefs.pt
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/src/wuttafarm/web/templates/deform/quantityrefs.pt b/src/wuttafarm/web/templates/deform/quantityrefs.pt
new file mode 100644
index 0000000..cc65f77
--- /dev/null
+++ b/src/wuttafarm/web/templates/deform/quantityrefs.pt
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/src/wuttafarm/web/templates/deform/seasonrefs.pt b/src/wuttafarm/web/templates/deform/seasonrefs.pt
new file mode 100644
index 0000000..955241a
--- /dev/null
+++ b/src/wuttafarm/web/templates/deform/seasonrefs.pt
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako
index 37b176e..890568f 100644
--- a/src/wuttafarm/web/templates/wuttafarm-components.mako
+++ b/src/wuttafarm/web/templates/wuttafarm-components.mako
@@ -1,7 +1,330 @@
<%def name="make_wuttafarm_components()">
+ ${self.make_taxonomy_terms_picker_component()}
+ ${self.make_equipment_types_picker_component()}
+ ${self.make_assets_picker_component()}
${self.make_animal_type_picker_component()}
+ ${self.make_material_types_picker_component()}
+ ${self.make_quantity_editor_component()}
+ ${self.make_quantities_editor_component()}
${self.make_plant_types_picker_component()}
+ ${self.make_seasons_picker_component()}
+%def>
+
+<%def name="make_taxonomy_terms_picker_component()">
+
+
+%def>
+
+<%def name="make_equipment_types_picker_component()">
+
+
+%def>
+
+<%def name="make_assets_picker_component()">
+
+
%def>
<%def name="make_animal_type_picker_component()">
@@ -108,7 +431,10 @@
createSave() {
this.createSaving = true
+ ## TODO
+ % if not app.is_farmos_wrapper():
const url = "${url('animal_types.ajax_create')}"
+ % endif
const params = {name: this.createName}
this.wuttaPOST(url, params, response => {
this.internalAnimalTypes.push([response.data.uuid, response.data.name])
@@ -128,6 +454,503 @@
%def>
+<%def name="make_material_types_picker_component()">
+
+
+%def>
+
+<%def name="make_quantity_editor_component()">
+
+
+%def>
+
+<%def name="make_quantities_editor_component()">
+
+
+%def>
+
<%def name="make_plant_types_picker_component()">
%def>
+
+<%def name="make_seasons_picker_component()">
+
+
+%def>
diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py
index 977550a..ec88525 100644
--- a/src/wuttafarm/web/util.py
+++ b/src/wuttafarm/web/util.py
@@ -78,4 +78,12 @@ def render_quantity_object(quantity):
measure = quantity["measure_name"]
value = quantity["value_decimal"]
unit = quantity["unit_name"]
- return f"( {measure} ) {value} {unit}"
+ text = f"( {measure} ) {value} {unit}"
+
+ if quantity["quantity_type_id"] == "material":
+ materials = ", ".join(
+ [mtype.get("name", "???") for mtype in quantity["material_types"]]
+ )
+ return f"{materials} {text}"
+
+ return text
diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py
index 0d58a72..e66f479 100644
--- a/src/wuttafarm/web/views/__init__.py
+++ b/src/wuttafarm/web/views/__init__.py
@@ -25,7 +25,7 @@ WuttaFarm Views
from wuttaweb.views import essential
-from .master import WuttaFarmMasterView
+from .master import WuttaFarmMasterView, TaxonomyMasterView
def includeme(config):
@@ -48,19 +48,23 @@ def includeme(config):
# native table views
if mode != enum.FARMOS_INTEGRATION_MODE_WRAPPER:
config.include("wuttafarm.web.views.units")
+ config.include("wuttafarm.web.views.material_types")
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.equipment")
config.include("wuttafarm.web.views.animals")
config.include("wuttafarm.web.views.groups")
config.include("wuttafarm.web.views.plants")
+ config.include("wuttafarm.web.views.water")
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")
+ config.include("wuttafarm.web.views.logs_seeding")
# quick form views
# (nb. these work with all integration modes)
diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py
index f4c97e2..ad9f060 100644
--- a/src/wuttafarm/web/views/animals.py
+++ b/src/wuttafarm/web/views/animals.py
@@ -23,9 +23,12 @@
Master view for Animals
"""
+from collections import OrderedDict
+
from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum
+from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttaweb.util import get_form_data
from wuttafarm.db.model import AnimalType, AnimalAsset
@@ -234,27 +237,6 @@ class AnimalAssetView(AssetMasterView):
"archived",
]
- form_fields = [
- "asset_name",
- "animal_type",
- "birthdate",
- "produces_eggs",
- "sex",
- "is_sterile",
- "notes",
- "asset_type",
- "owners",
- "locations",
- "groups",
- "archived",
- "drupal_id",
- "farmos_uuid",
- "thumbnail_url",
- "image_url",
- "thumbnail",
- "image",
- ]
-
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
@@ -288,15 +270,32 @@ class AnimalAssetView(AssetMasterView):
animal = f.model_instance
# animal_type
+ f.fields.insert_after("asset_name", "animal_type")
f.set_node("animal_type", AnimalTypeRef(self.request))
+ # birthdate
+ f.fields.insert_after("animal_type", "birthdate")
+ # TODO: why must we assign the widget here? pretty sure that
+ # was not needed when we declared form_fields directly, i.e.
+ # instead of adding birthdate field in this method
+ f.set_widget("birthdate", WuttaDateTimeWidget(self.request))
+
+ # produces_eggs
+ f.fields.insert_after("birthdate", "produces_eggs")
+
# sex
+ f.fields.insert_after("produces_eggs", "sex")
if not (self.creating or self.editing) and animal.sex is None:
pass # TODO: dict enum widget does not handle null values well
else:
- f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX))
+ # nb. ensure empty option appears like we want
+ sex_enum = OrderedDict([("", "N/A")] + list(enum.ANIMAL_SEX.items()))
+ f.set_node("sex", WuttaDictEnum(self.request, sex_enum))
f.set_required("sex", False)
+ # is_sterile
+ f.fields.insert_after("sex", "is_sterile")
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py
index b4e4d31..64f4dbc 100644
--- a/src/wuttafarm/web/views/assets.py
+++ b/src/wuttafarm/web/views/assets.py
@@ -32,7 +32,7 @@ from wuttaweb.db import Session
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import Asset, Log
-from wuttafarm.web.forms.schema import AssetParentRefs, OwnerRefs, AssetRefs
+from wuttafarm.web.forms.schema import OwnerRefs, AssetRefs
from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.util import get_log_type_enum
from wuttafarm.web.util import get_farmos_client_for_user
@@ -77,6 +77,25 @@ class AssetMasterView(WuttaFarmMasterView):
"archived": {"active": True, "verb": "is_false"},
}
+ form_fields = [
+ "asset_name",
+ "parents",
+ "notes",
+ "asset_type",
+ "owners",
+ "is_location",
+ "is_fixed",
+ "locations",
+ "groups",
+ "archived",
+ "drupal_id",
+ "farmos_uuid",
+ "thumbnail_url",
+ "image_url",
+ "thumbnail",
+ "image",
+ ]
+
has_rows = True
row_model_class = Log
rows_viewable = True
@@ -261,11 +280,11 @@ class AssetMasterView(WuttaFarmMasterView):
f.set_default("groups", asset_handler.get_groups(asset))
# parents
- if self.creating or self.editing:
- f.remove("parents") # TODO: add support for this
- else:
- f.set_node("parents", AssetParentRefs(self.request))
- f.set_default("parents", [p.uuid for p in asset.parents])
+ f.set_node("parents", AssetRefs(self.request, for_asset=asset))
+ f.set_required("parents", False)
+ if not self.creating:
+ # nb. must explicity declare value for non-standard field
+ f.set_default("parents", asset.parents)
# notes
f.set_widget("notes", "notes")
@@ -293,11 +312,29 @@ class AssetMasterView(WuttaFarmMasterView):
f.set_default("image", asset.image_url)
def objectify(self, form):
+ model = self.app.model
+ session = self.Session()
asset = super().objectify(form)
+ data = form.validated
if self.creating:
asset.asset_type = self.get_asset_type()
+ current = [p.uuid for p in asset.parents]
+ desired = data["parents"] or []
+
+ for uuid in desired:
+ if uuid not in current:
+ parent = session.get(model.Asset, uuid)
+ assert parent
+ asset.parents.append(parent)
+
+ for uuid in current:
+ if uuid not in desired:
+ parent = session.get(model.Asset, uuid)
+ assert parent
+ asset.parents.remove(parent)
+
return asset
def get_asset_type(self):
@@ -373,6 +410,23 @@ class AssetMasterView(WuttaFarmMasterView):
g.set_filter("log_type", model.Log.log_type)
g.set_enum("log_type", get_log_type_enum(self.config, session=session))
+ # assets
+ g.set_renderer("assets", self.render_assets_for_grid)
+
+ def render_assets_for_grid(self, log, field, value):
+ assets = getattr(log, field)
+
+ if self.farmos_style_grid_links:
+ links = []
+ for asset in assets:
+ url = self.request.route_url(
+ f"{asset.asset_type}_assets.view", uuid=asset.uuid
+ )
+ links.append(tags.link_to(str(asset), url))
+ return ", ".join(links)
+
+ return ", ".join([str(a) for a in assets])
+
def get_row_action_url_view(self, log, i):
return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py
index 674d76e..445f810 100644
--- a/src/wuttafarm/web/views/common.py
+++ b/src/wuttafarm/web/views/common.py
@@ -95,6 +95,8 @@ class CommonView(base.CommonView):
"farmos_quantities_standard.view",
"farmos_quantity_types.list",
"farmos_quantity_types.view",
+ "farmos_seasons.list",
+ "farmos_seasons.view",
"farmos_structure_assets.list",
"farmos_structure_assets.view",
"farmos_structure_types.list",
@@ -132,6 +134,12 @@ class CommonView(base.CommonView):
"logs_observation.view",
"logs_observation.versions",
"quick.eggs",
+ "plant_types.list",
+ "plant_types.view",
+ "plant_types.versions",
+ "seasons.list",
+ "seasons.view",
+ "seasons.versions",
"structure_types.list",
"structure_types.view",
"structure_types.versions",
diff --git a/src/wuttafarm/web/views/equipment.py b/src/wuttafarm/web/views/equipment.py
new file mode 100644
index 0000000..8076556
--- /dev/null
+++ b/src/wuttafarm/web/views/equipment.py
@@ -0,0 +1,122 @@
+# -*- 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 Plants
+"""
+
+from wuttafarm.db.model import EquipmentType, EquipmentAsset
+from wuttafarm.web.views import TaxonomyMasterView
+from wuttafarm.web.views.assets import AssetMasterView
+from wuttafarm.web.forms.schema import EquipmentTypeRefs
+
+
+class EquipmentTypeView(TaxonomyMasterView):
+ """
+ Master view for Equipment Types
+ """
+
+ model_class = EquipmentType
+ route_prefix = "equipment_types"
+ url_prefix = "/equipment-types"
+
+ farmos_route_prefix = "farmos_equipment_types"
+ farmos_entity_type = "taxonomy_term"
+ farmos_bundle = "equipment_type"
+ farmos_refurl_path = "/admin/structure/taxonomy/manage/equipment_type/overview"
+
+
+class EquipmentAssetView(AssetMasterView):
+ """
+ Master view for Equipment Assets
+ """
+
+ model_class = EquipmentAsset
+ route_prefix = "equipment_assets"
+ url_prefix = "/assets/equipment"
+
+ farmos_bundle = "equipment"
+ farmos_refurl_path = "/assets/equipment"
+
+ labels = {
+ "equipment_types": "Equipment Type",
+ }
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ equipment = f.model_instance
+
+ # equipment_types
+ f.fields.insert_after("asset_name", "equipment_types")
+ f.set_node("equipment_types", EquipmentTypeRefs(self.request))
+ if not self.creating:
+ # nb. must explcitly declare value for non-standard field
+ f.set_default("equipment_types", equipment.equipment_types)
+
+ # manufacturer
+ f.fields.insert_after("equipment_types", "manufacturer")
+
+ # model
+ f.fields.insert_after("manufacturer", "model")
+
+ # serial_number
+ f.fields.insert_after("model", "serial_number")
+
+ def objectify(self, form):
+ equipment = super().objectify(form)
+ data = form.validated
+
+ self.set_equipment_types(equipment, data["equipment_types"])
+
+ return equipment
+
+ def set_equipment_types(self, equipment, desired):
+ model = self.app.model
+ session = self.Session()
+ current = [str(etype.uuid) for etype in equipment.equipment_types]
+
+ for etype in desired:
+ if etype["uuid"] not in current:
+ equipment_type = session.get(model.EquipmentType, etype["uuid"])
+ assert equipment_type
+ equipment.equipment_types.append(equipment_type)
+
+ desired = [etype["uuid"] for etype in desired]
+ for uuid in current:
+ if uuid not in desired:
+ equipment_type = session.get(model.EquipmentType, uuid)
+ assert equipment_type
+ equipment.equipment_types.remove(equipment_type)
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ EquipmentTypeView = kwargs.get("EquipmentTypeView", base["EquipmentTypeView"])
+ EquipmentTypeView.defaults(config)
+
+ EquipmentAssetView = kwargs.get("EquipmentAssetView", base["EquipmentAssetView"])
+ EquipmentAssetView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py
index e59ac1f..708b553 100644
--- a/src/wuttafarm/web/views/farmos/__init__.py
+++ b/src/wuttafarm/web/views/farmos/__init__.py
@@ -28,6 +28,7 @@ from .master import FarmOSMasterView
def includeme(config):
config.include("wuttafarm.web.views.farmos.users")
+ config.include("wuttafarm.web.views.farmos.materials")
config.include("wuttafarm.web.views.farmos.quantities")
config.include("wuttafarm.web.views.farmos.asset_types")
config.include("wuttafarm.web.views.farmos.units")
@@ -35,12 +36,15 @@ def includeme(config):
config.include("wuttafarm.web.views.farmos.land_assets")
config.include("wuttafarm.web.views.farmos.structure_types")
config.include("wuttafarm.web.views.farmos.structures")
+ config.include("wuttafarm.web.views.farmos.equipment")
config.include("wuttafarm.web.views.farmos.animal_types")
config.include("wuttafarm.web.views.farmos.animals")
config.include("wuttafarm.web.views.farmos.groups")
config.include("wuttafarm.web.views.farmos.plants")
+ config.include("wuttafarm.web.views.farmos.water")
config.include("wuttafarm.web.views.farmos.log_types")
config.include("wuttafarm.web.views.farmos.logs_activity")
config.include("wuttafarm.web.views.farmos.logs_harvest")
config.include("wuttafarm.web.views.farmos.logs_medical")
config.include("wuttafarm.web.views.farmos.logs_observation")
+ config.include("wuttafarm.web.views.farmos.logs_seeding")
diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py
index c99cc5a..6a6bc92 100644
--- a/src/wuttafarm/web/views/farmos/animals.py
+++ b/src/wuttafarm/web/views/farmos/animals.py
@@ -39,7 +39,7 @@ from wuttafarm.web.grids import (
NullableBooleanFilter,
DateTimeFilter,
)
-from wuttafarm.web.forms.schema import FarmOSRef, FarmOSAssetRefs
+from wuttafarm.web.forms.schema import FarmOSRef
class AnimalView(AssetMasterView):
@@ -99,8 +99,7 @@ class AnimalView(AssetMasterView):
def get_farmos_api_includes(self):
includes = super().get_farmos_api_includes()
- includes.add("animal_type")
- includes.add("group")
+ includes.update(["animal_type"])
return includes
def configure_grid(self, grid):
@@ -131,10 +130,6 @@ class AnimalView(AssetMasterView):
g.set_sorter("sex", SimpleSorter("sex"))
g.set_filter("sex", StringFilter)
- # groups
- g.set_label("groups", "Group Membership")
- g.set_renderer("groups", self.render_groups_for_grid)
-
# is_sterile
g.set_renderer("is_sterile", "boolean")
g.set_sorter("is_sterile", SimpleSorter("is_sterile"))
@@ -145,18 +140,6 @@ class AnimalView(AssetMasterView):
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["groups"]:
- 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):
data = super().get_instance()
@@ -192,8 +175,6 @@ class AnimalView(AssetMasterView):
sterile = animal["attributes"]["is_castrated"]
animal_type_object = None
- group_objects = []
- group_names = []
if relationships := animal.get("relationships"):
if animal_type := relationships.get("animal_type"):
@@ -203,24 +184,11 @@ class AnimalView(AssetMasterView):
"name": animal_type["attributes"]["name"],
}
- if groups := relationships.get("group"):
- for group in groups["data"]:
- if group := included.get(group["id"]):
- group = {
- "uuid": group["id"],
- "name": group["attributes"]["name"],
- "asset_type": "group",
- }
- 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"],
- "groups": group_objects,
- "group_names": group_names,
"birthdate": birthdate,
"sex": animal["attributes"]["sex"] or colander.null,
"is_sterile": sterile,
@@ -271,12 +239,6 @@ class AnimalView(AssetMasterView):
# is_sterile
f.set_node("is_sterile", colander.Boolean())
- # groups
- if self.creating or self.editing:
- f.remove("groups") # TODO
- else:
- f.set_node("groups", FarmOSAssetRefs(self.request))
-
def get_api_payload(self, animal):
payload = super().get_api_payload(animal)
diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py
index 11f744b..24dd145 100644
--- a/src/wuttafarm/web/views/farmos/assets.py
+++ b/src/wuttafarm/web/views/farmos/assets.py
@@ -24,10 +24,11 @@ Base class for Asset master views
"""
import colander
+import requests
from webhelpers2.html import tags
from wuttafarm.web.views.farmos import FarmOSMasterView
-from wuttafarm.web.forms.schema import FarmOSRefs, FarmOSLocationRefs
+from wuttafarm.web.forms.schema import FarmOSRefs, FarmOSAssetRefs, FarmOSLocationRefs
from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.grids import (
ResourceData,
@@ -75,6 +76,23 @@ class AssetMasterView(FarmOSMasterView):
"archived": {"active": True, "verb": "is_false"},
}
+ form_fields = [
+ "name",
+ "notes",
+ "asset_type_name",
+ "owners",
+ "is_location",
+ "is_fixed",
+ "locations",
+ "groups",
+ "archived",
+ "drupal_id",
+ "thumbnail_url",
+ "image_url",
+ "thumbnail",
+ "image",
+ ]
+
def get_grid_data(self, **kwargs):
return ResourceData(
self.config,
@@ -110,6 +128,10 @@ class AssetMasterView(FarmOSMasterView):
# locations
g.set_renderer("locations", self.render_locations_for_grid)
+ # groups
+ g.set_label("groups", "Group Membership")
+ g.set_renderer("groups", self.render_assets_for_grid)
+
# archived
g.set_renderer("archived", "boolean")
g.set_sorter("archived", SimpleSorter("archived"))
@@ -120,6 +142,20 @@ class AssetMasterView(FarmOSMasterView):
return tags.image(url, f"thumbnail for {self.get_model_title()}")
return None
+ def render_assets_for_grid(self, log, field, value):
+ if not value:
+ return ""
+
+ assets = []
+ for asset in value:
+ if self.farmos_style_grid_links:
+ route = f"farmos_{asset['asset_type']}_assets.view"
+ url = self.request.route_url(route, uuid=asset["uuid"])
+ assets.append(tags.link_to(asset["name"], url))
+ else:
+ assets.append(asset["name"])
+ return ", ".join(assets)
+
def render_locations_for_grid(self, asset, field, value):
locations = []
for location in value:
@@ -139,14 +175,19 @@ class AssetMasterView(FarmOSMasterView):
return None
def get_farmos_api_includes(self):
- return {"asset_type", "location", "owner", "image"}
+ return {"asset_type", "location", "group", "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())},
- )
+ try:
+ result = self.farmos_client.asset.get_id(
+ self.farmos_asset_type,
+ self.request.matchdict["uuid"],
+ params={"include": ",".join(self.get_farmos_api_includes())},
+ )
+ except requests.HTTPError as exc:
+ if exc.response.status_code == 404:
+ raise self.notfound()
+ raise
self.raw_json = result
included = {obj["id"]: obj for obj in result.get("included", [])}
return self.normalize_asset(result["data"], included)
@@ -170,6 +211,7 @@ class AssetMasterView(FarmOSMasterView):
owner_names = []
location_objects = []
location_names = []
+ group_objects = []
thumbnail_url = None
image_url = None
if relationships := asset.get("relationships"):
@@ -203,6 +245,16 @@ class AssetMasterView(FarmOSMasterView):
location_objects.append(location)
location_names.append(location["name"])
+ if groups := relationships.get("group"):
+ for group in groups["data"]:
+ if group := included.get(group["id"]):
+ group = {
+ "uuid": group["id"],
+ "name": group["attributes"]["name"],
+ "asset_type": "group",
+ }
+ group_objects.append(group)
+
if images := relationships.get("image"):
for image in images["data"]:
if image := included.get(image["id"]):
@@ -217,11 +269,14 @@ class AssetMasterView(FarmOSMasterView):
"name": asset["attributes"]["name"],
"asset_type": asset_type_object,
"asset_type_name": asset_type_name,
+ "is_location": asset["attributes"]["is_location"],
+ "is_fixed": asset["attributes"]["is_fixed"],
"notes": notes or colander.null,
"owners": owner_objects,
"owner_names": owner_names,
"locations": location_objects,
"location_names": location_names,
+ "groups": group_objects,
"archived": archived,
"thumbnail_url": thumbnail_url or colander.null,
"image_url": image_url or colander.null,
@@ -243,6 +298,12 @@ class AssetMasterView(FarmOSMasterView):
f.set_label("locations", "Current Location")
f.set_node("locations", FarmOSLocationRefs(self.request))
+ # groups
+ if self.creating or self.editing:
+ f.remove("groups") # TODO
+ else:
+ f.set_node("groups", FarmOSAssetRefs(self.request))
+
# owners
if self.creating or self.editing:
f.remove("owners") # TODO
@@ -253,6 +314,16 @@ class AssetMasterView(FarmOSMasterView):
f.set_widget("notes", "notes")
f.set_required("notes", False)
+ # is_location
+ f.set_node("is_location", colander.Boolean())
+
+ # is_fixed
+ f.set_node("is_fixed", colander.Boolean())
+
+ # groups
+ if self.creating or self.editing:
+ f.remove("groups") # TODO: add support for this?
+
# archived
f.set_node("archived", colander.Boolean())
diff --git a/src/wuttafarm/web/views/farmos/equipment.py b/src/wuttafarm/web/views/farmos/equipment.py
new file mode 100644
index 0000000..6855747
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/equipment.py
@@ -0,0 +1,211 @@
+# -*- 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 farmOS Equipment
+"""
+
+from webhelpers2.html import tags
+
+from wuttafarm.web.views.farmos.master import TaxonomyMasterView
+from wuttafarm.web.views.farmos.assets import AssetMasterView
+from wuttafarm.web.forms.schema import FarmOSEquipmentTypeRefs
+
+
+class EquipmentTypeView(TaxonomyMasterView):
+ """
+ Master view for Equipment Types in farmOS.
+ """
+
+ model_name = "farmos_equipment_type"
+ model_title = "farmOS Equipment Type"
+ model_title_plural = "farmOS Equipment Types"
+
+ route_prefix = "farmos_equipment_types"
+ url_prefix = "/farmOS/equipment-types"
+
+ farmos_taxonomy_type = "equipment_type"
+ farmos_refurl_path = "/admin/structure/taxonomy/manage/equipment_type/overview"
+
+ def get_xref_buttons(self, equipment_type):
+ buttons = super().get_xref_buttons(equipment_type)
+ model = self.app.model
+ session = self.Session()
+
+ if wf_equipment_type := (
+ session.query(model.EquipmentType)
+ .filter(model.EquipmentType.farmos_uuid == equipment_type["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "equipment_types.view", uuid=wf_equipment_type.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+class EquipmentAssetView(AssetMasterView):
+ """
+ Master view for farmOS Equipment Assets
+ """
+
+ model_name = "farmos_equipment_assets"
+ model_title = "farmOS Equipment Asset"
+ model_title_plural = "farmOS Equipment Assets"
+
+ route_prefix = "farmos_equipment_assets"
+ url_prefix = "/farmOS/assets/equipment"
+
+ farmos_asset_type = "equipment"
+ farmos_refurl_path = "/assets/equipment"
+
+ labels = {
+ "equipment_types": "Equipment Type",
+ }
+
+ grid_columns = [
+ "thumbnail",
+ "drupal_id",
+ "name",
+ "equipment_types",
+ "manufacturer",
+ "model",
+ "serial_number",
+ "groups",
+ "owners",
+ "archived",
+ ]
+
+ def get_farmos_api_includes(self):
+ includes = super().get_farmos_api_includes()
+ includes.update(["equipment_type"])
+ return includes
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # equipment_types
+ g.set_renderer("equipment_types", self.render_equipment_types_for_grid)
+
+ def render_equipment_types_for_grid(self, equipment, field, value):
+ if self.farmos_style_grid_links:
+ links = []
+ for etype in value:
+ url = self.request.route_url(
+ f"farmos_equipment_types.view", uuid=etype["uuid"]
+ )
+ links.append(tags.link_to(etype["name"], url))
+ return ", ".join(links)
+
+ return ", ".join([etype["name"] for etype in value])
+
+ def normalize_asset(self, equipment, included):
+ data = super().normalize_asset(equipment, included)
+
+ equipment_type_objects = []
+ rels = equipment["relationships"]
+ for etype in rels["equipment_type"]["data"]:
+ uuid = etype["id"]
+ equipment_type = {
+ "uuid": uuid,
+ "type": etype["type"],
+ }
+ if etype := included.get(uuid):
+ equipment_type.update(
+ {
+ "name": etype["attributes"]["name"],
+ }
+ )
+ equipment_type_objects.append(equipment_type)
+
+ data.update(
+ {
+ "manufacturer": equipment["attributes"]["manufacturer"],
+ "model": equipment["attributes"]["model"],
+ "serial_number": equipment["attributes"]["serial_number"],
+ "equipment_types": equipment_type_objects,
+ }
+ )
+
+ return data
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # equipment_types
+ f.fields.insert_after("name", "equipment_types")
+ f.set_node("equipment_types", FarmOSEquipmentTypeRefs(self.request))
+
+ # manufacturer
+ f.fields.insert_after("equipment_types", "manufacturer")
+
+ # model
+ f.fields.insert_after("manufacturer", "model")
+
+ # serial_number
+ f.fields.insert_after("model", "serial_number")
+
+ def get_xref_buttons(self, equipment):
+ model = self.app.model
+ session = self.Session()
+
+ buttons = super().get_xref_buttons(equipment)
+
+ if wf_equipment := (
+ session.query(model.Asset)
+ .filter(model.Asset.farmos_uuid == equipment["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "equipment_assets.view", uuid=wf_equipment.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ EquipmentTypeView = kwargs.get("EquipmentTypeView", base["EquipmentTypeView"])
+ EquipmentTypeView.defaults(config)
+
+ EquipmentAssetView = kwargs.get("EquipmentAssetView", base["EquipmentAssetView"])
+ EquipmentAssetView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py
index cb7a87b..74fb941 100644
--- a/src/wuttafarm/web/views/farmos/logs.py
+++ b/src/wuttafarm/web/views/farmos/logs.py
@@ -45,7 +45,7 @@ from wuttafarm.web.forms.schema import (
LogQuick,
Notes,
)
-from wuttafarm.web.util import render_quantity_objects
+from wuttafarm.web.util import render_quantity_objects, render_quantity_object
class LogMasterView(FarmOSMasterView):
@@ -199,7 +199,20 @@ class LogMasterView(FarmOSMasterView):
)
self.raw_json = result
included = {obj["id"]: obj for obj in result.get("included", [])}
- return self.normalize_log(result["data"], included)
+ instance = self.normalize_log(result["data"], included)
+
+ for qty in instance["quantities"]:
+
+ if qty["quantity_type_id"] == "material":
+ for mtype in qty["material_types"]:
+ result = self.farmos_client.resource.get_id(
+ "taxonomy_term", "material_type", mtype["uuid"]
+ )
+ mtype["name"] = result["data"]["attributes"]["name"]
+
+ qty["as_text"] = render_quantity_object(qty)
+
+ return instance
def get_instance_title(self, log):
return log["name"]
diff --git a/src/wuttafarm/web/views/farmos/logs_seeding.py b/src/wuttafarm/web/views/farmos/logs_seeding.py
new file mode 100644
index 0000000..ed967cc
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/logs_seeding.py
@@ -0,0 +1,105 @@
+# -*- 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 Seeding Logs
+"""
+
+from wuttaweb.forms.schema import WuttaDateTime
+from wuttaweb.forms.widgets import WuttaDateTimeWidget
+
+from wuttafarm.web.views.farmos.logs import LogMasterView
+from wuttafarm.web.grids import SimpleSorter, StringFilter
+
+
+class SeedingLogView(LogMasterView):
+ """
+ View for farmOS seeding logs
+ """
+
+ model_name = "farmos_seeding_log"
+ model_title = "farmOS Seeding Log"
+ model_title_plural = "farmOS Seeding Logs"
+
+ route_prefix = "farmos_logs_seeding"
+ url_prefix = "/farmOS/logs/seeding"
+
+ farmos_log_type = "seeding"
+ farmos_refurl_path = "/logs/seeding"
+
+ grid_columns = [
+ "status",
+ "drupal_id",
+ "timestamp",
+ "name",
+ "assets",
+ "locations",
+ "purchase_date",
+ "source",
+ "is_group_assignment",
+ "owners",
+ ]
+
+ def normalize_log(self, log, included):
+ data = super().normalize_log(log, included)
+ data.update(
+ {
+ "source": log["attributes"]["source"],
+ "purchase_date": self.normal.normalize_datetime(
+ log["attributes"]["purchase_date"]
+ ),
+ "lot_number": log["attributes"]["lot_number"],
+ }
+ )
+ return data
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # purchase_date
+ g.set_renderer("purchase_date", "date")
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # source
+ f.fields.insert_after("timestamp", "source")
+
+ # purchase_date
+ f.fields.insert_after("source", "purchase_date")
+ f.set_node("purchase_date", WuttaDateTime())
+ f.set_widget("purchase_date", WuttaDateTimeWidget(self.request))
+
+ # lot_number
+ f.fields.insert_after("purchase_date", "lot_number")
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ SeedingLogView = kwargs.get("SeedingLogView", base["SeedingLogView"])
+ SeedingLogView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/materials.py b/src/wuttafarm/web/views/farmos/materials.py
new file mode 100644
index 0000000..e56557d
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/materials.py
@@ -0,0 +1,76 @@
+# -*- 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 materials
+"""
+
+from wuttafarm.web.views.farmos.master import TaxonomyMasterView
+
+
+class MaterialTypeView(TaxonomyMasterView):
+ """
+ Master view for Material Types in farmOS.
+ """
+
+ model_name = "farmos_material_type"
+ model_title = "farmOS Material Type"
+ model_title_plural = "farmOS Material Types"
+
+ route_prefix = "farmos_material_types"
+ url_prefix = "/farmOS/material-types"
+
+ farmos_taxonomy_type = "material_type"
+ farmos_refurl_path = "/admin/structure/taxonomy/manage/material_type/overview"
+
+ def get_xref_buttons(self, material_type):
+ buttons = super().get_xref_buttons(material_type)
+ model = self.app.model
+ session = self.Session()
+
+ if wf_material_type := (
+ session.query(model.MaterialType)
+ .filter(model.MaterialType.farmos_uuid == material_type["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "material_types.view", uuid=wf_material_type.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ MaterialTypeView = kwargs.get("MaterialTypeView", base["MaterialTypeView"])
+ MaterialTypeView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py
index 57bf2d4..40768c4 100644
--- a/src/wuttafarm/web/views/farmos/plants.py
+++ b/src/wuttafarm/web/views/farmos/plants.py
@@ -32,7 +32,12 @@ 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.schema import (
+ UsersType,
+ StructureType,
+ FarmOSPlantTypes,
+ FarmOSRefs,
+)
from wuttafarm.web.forms.widgets import ImageWidget
@@ -75,6 +80,43 @@ class PlantTypeView(TaxonomyMasterView):
return buttons
+class SeasonView(TaxonomyMasterView):
+ """
+ Master view for Seasons in farmOS.
+ """
+
+ model_name = "farmos_season"
+ model_title = "farmOS Season"
+ model_title_plural = "farmOS Seasons"
+
+ route_prefix = "farmos_seasons"
+ url_prefix = "/farmOS/seasons"
+
+ farmos_taxonomy_type = "season"
+ farmos_refurl_path = "/admin/structure/taxonomy/manage/season/overview"
+
+ def get_xref_buttons(self, season):
+ buttons = super().get_xref_buttons(season)
+ model = self.app.model
+ session = self.Session()
+
+ if wf_season := (
+ session.query(model.Season)
+ .filter(model.Season.farmos_uuid == season["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url("seasons.view", uuid=wf_season.uuid),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
class PlantAssetView(FarmOSMasterView):
"""
Master view for farmOS Plant Assets
@@ -89,6 +131,10 @@ class PlantAssetView(FarmOSMasterView):
farmos_refurl_path = "/assets/plant"
+ labels = {
+ "seasons": "Season",
+ }
+
grid_columns = [
"name",
"archived",
@@ -99,6 +145,7 @@ class PlantAssetView(FarmOSMasterView):
form_fields = [
"name",
"plant_types",
+ "seasons",
"archived",
"owners",
"location",
@@ -151,6 +198,21 @@ class PlantAssetView(FarmOSMasterView):
}
)
+ # add seasons
+ if seasons := relationships.get("season"):
+ if seasons["data"]:
+ data["seasons"] = []
+ for season in seasons["data"]:
+ season = self.farmos_client.resource.get_id(
+ "taxonomy_term", "season", season["id"]
+ )
+ data["seasons"].append(
+ {
+ "uuid": season["data"]["id"],
+ "name": season["data"]["attributes"]["name"],
+ }
+ )
+
# add location
if location := relationships.get("location"):
if location["data"]:
@@ -199,22 +261,14 @@ class PlantAssetView(FarmOSMasterView):
return plant["name"]
def normalize_plant(self, plant):
-
- if notes := plant["attributes"]["notes"]:
- notes = notes["value"]
-
- if self.farmos_4x:
- archived = plant["attributes"]["archived"]
- else:
- archived = plant["attributes"]["status"] == "archived"
-
+ normal = self.normal.normalize_farmos_asset(plant)
return {
- "uuid": plant["id"],
- "drupal_id": plant["attributes"]["drupal_internal__id"],
- "name": plant["attributes"]["name"],
+ "uuid": normal["uuid"],
+ "drupal_id": normal["drupal_id"],
+ "name": normal["asset_name"],
"location": colander.null, # TODO
- "archived": archived,
- "notes": notes or colander.null,
+ "archived": normal["archived"],
+ "notes": normal["notes"] or colander.null,
}
def configure_form(self, form):
@@ -225,6 +279,9 @@ class PlantAssetView(FarmOSMasterView):
# plant_types
f.set_node("plant_types", FarmOSPlantTypes(self.request))
+ # seasons
+ f.set_node("seasons", FarmOSRefs(self.request, "farmos_seasons"))
+
# location
f.set_node("location", StructureType(self.request))
@@ -279,6 +336,9 @@ def defaults(config, **kwargs):
PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"])
PlantTypeView.defaults(config)
+ SeasonView = kwargs.get("SeasonView", base["SeasonView"])
+ SeasonView.defaults(config)
+
PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"])
PlantAssetView.defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py
index a388559..bd2e519 100644
--- a/src/wuttafarm/web/views/farmos/quantities.py
+++ b/src/wuttafarm/web/views/farmos/quantities.py
@@ -26,12 +26,13 @@ View for farmOS Quantity Types
import datetime
import colander
+import requests
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
+from wuttafarm.web.forms.schema import FarmOSUnitRef, FarmOSRefs
from wuttafarm.web.grids import ResourceData
@@ -142,6 +143,7 @@ class QuantityMasterView(FarmOSMasterView):
sort_defaults = ("drupal_id", "desc")
form_fields = [
+ "quantity_type_name",
"measure",
"value",
"units",
@@ -171,6 +173,7 @@ class QuantityMasterView(FarmOSMasterView):
# as_text
g.set_renderer("as_text", self.render_as_text_for_grid)
+ g.set_link("as_text")
# measure
g.set_renderer("measure", self.render_measure_for_grid)
@@ -203,14 +206,26 @@ class QuantityMasterView(FarmOSMasterView):
return qty["value"]["decimal"]
def get_instance(self):
- quantity = self.farmos_client.resource.get_id(
- "quantity", self.farmos_quantity_type, self.request.matchdict["uuid"]
- )
- self.raw_json = quantity
+ # TODO: this pattern should be repeated for other views
+ try:
+ result = self.farmos_client.resource.get_id(
+ "quantity",
+ self.farmos_quantity_type,
+ self.request.matchdict["uuid"],
+ params={"include": self.get_farmos_api_includes()},
+ )
+ except requests.HTTPError as exc:
+ if exc.response.status_code == 404:
+ raise self.notfound()
+ raise
- data = self.normalize_quantity(quantity["data"])
+ self.raw_json = result
- if relationships := quantity["data"].get("relationships"):
+ included = {obj["id"]: obj for obj in result.get("included", [])}
+ assert included
+ data = self.normalize_quantity(result["data"], included)
+
+ if relationships := result["data"].get("relationships"):
# add units
if units := relationships.get("units"):
@@ -278,6 +293,11 @@ class QuantityMasterView(FarmOSMasterView):
f = form
super().configure_form(f)
+ # quantity_type_name
+ f.set_label("quantity_type_name", "Quantity Type")
+ f.set_readonly("quantity_type_name")
+ f.set_default("quantity_type_name", self.farmos_quantity_type.capitalize())
+
# created
f.set_node("created", WuttaDateTime(self.request))
f.set_widget("created", WuttaDateTimeWidget(self.request))
@@ -303,6 +323,7 @@ class StandardQuantityView(QuantityMasterView):
url_prefix = "/farmOS/quantities/standard"
farmos_quantity_type = "standard"
+ farmos_refurl_path = "/log-quantities/standard"
def get_xref_buttons(self, standard_quantity):
model = self.app.model
@@ -329,6 +350,90 @@ class StandardQuantityView(QuantityMasterView):
return buttons
+class MaterialQuantityView(QuantityMasterView):
+ """
+ View for farmOS Material Quantities
+ """
+
+ model_name = "farmos_material_quantity"
+ model_title = "farmOS Material Quantity"
+ model_title_plural = "farmOS Material Quantities"
+
+ route_prefix = "farmos_quantities_material"
+ url_prefix = "/farmOS/quantities/material"
+
+ farmos_quantity_type = "material"
+ farmos_refurl_path = "/log-quantities/material"
+
+ def get_farmos_api_includes(self):
+ includes = super().get_farmos_api_includes()
+ includes.update({"material_type"})
+ return includes
+
+ def normalize_quantity(self, quantity, included={}):
+ normal = super().normalize_quantity(quantity, included)
+
+ material_type_objects = []
+ material_type_uuids = []
+ if relationships := quantity["relationships"]:
+
+ if material_types := relationships["material_type"]["data"]:
+ for mtype in material_types:
+ uuid = mtype["id"]
+ material_type_uuids.append(uuid)
+ material_type = {
+ "uuid": uuid,
+ "type": mtype["type"],
+ }
+ if mtype := included.get(uuid):
+ material_type.update(
+ {
+ "name": mtype["attributes"]["name"],
+ }
+ )
+ material_type_objects.append(material_type)
+
+ normal.update(
+ {
+ "material_types": material_type_objects,
+ "material_type_uuids": material_type_uuids,
+ }
+ )
+ return normal
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # material_types
+ f.fields.insert_before("measure", "material_types")
+ f.set_node("material_types", FarmOSRefs(self.request, "farmos_material_types"))
+
+ def get_xref_buttons(self, material_quantity):
+ model = self.app.model
+ session = self.Session()
+ buttons = []
+
+ if wf_material_quantity := (
+ session.query(model.MaterialQuantity)
+ .join(model.Quantity)
+ .filter(model.Quantity.farmos_uuid == material_quantity["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "quantities_material.view", uuid=wf_material_quantity.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
def defaults(config, **kwargs):
base = globals()
@@ -340,6 +445,11 @@ def defaults(config, **kwargs):
)
StandardQuantityView.defaults(config)
+ MaterialQuantityView = kwargs.get(
+ "MaterialQuantityView", base["MaterialQuantityView"]
+ )
+ MaterialQuantityView.defaults(config)
+
def includeme(config):
defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/water.py b/src/wuttafarm/web/views/farmos/water.py
new file mode 100644
index 0000000..129f22e
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/water.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaFarm --Web app to integrate with and extend farmOS
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaFarm.
+#
+# WuttaFarm is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# WuttaFarm. If not, see .
+#
+################################################################################
+"""
+Master view for Water Assets in farmOS
+"""
+
+from wuttafarm.web.views.farmos.assets import AssetMasterView
+
+
+class WaterAssetView(AssetMasterView):
+ """
+ Master view for farmOS Water Assets
+ """
+
+ model_name = "farmos_water_assets"
+ model_title = "farmOS Water Asset"
+ model_title_plural = "farmOS Water Assets"
+
+ route_prefix = "farmos_water_assets"
+ url_prefix = "/farmOS/assets/water"
+
+ farmos_asset_type = "water"
+ farmos_refurl_path = "/assets/water"
+
+ grid_columns = [
+ "thumbnail",
+ "drupal_id",
+ "name",
+ "archived",
+ ]
+
+ def get_xref_buttons(self, water):
+ model = self.app.model
+ session = self.Session()
+ buttons = super().get_xref_buttons(water)
+
+ if wf_water := (
+ session.query(model.Asset)
+ .filter(model.Asset.farmos_uuid == water["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url("water_assets.view", uuid=wf_water.uuid),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ WaterAssetView = kwargs.get("WaterAssetView", base["WaterAssetView"])
+ WaterAssetView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py
index 4331280..c8cc7f7 100644
--- a/src/wuttafarm/web/views/groups.py
+++ b/src/wuttafarm/web/views/groups.py
@@ -47,15 +47,11 @@ class GroupView(AssetMasterView):
"archived",
]
- form_fields = [
- "asset_name",
- "notes",
- "asset_type",
- "produces_eggs",
- "archived",
- "drupal_id",
- "farmos_uuid",
- ]
+ def configure_form(self, f):
+ super().configure_form(f)
+
+ # produces_eggs
+ f.fields.insert_after("asset_type", "produces_eggs")
def defaults(config, **kwargs):
diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py
index 9c983b7..3d91ba1 100644
--- a/src/wuttafarm/web/views/logs.py
+++ b/src/wuttafarm/web/views/logs.py
@@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import LogType, Log
-from wuttafarm.web.forms.schema import AssetRefs, LogQuantityRefs, OwnerRefs
+from wuttafarm.web.forms.schema import AssetRefs, QuantityRefs, OwnerRefs
from wuttafarm.util import get_log_type_enum
@@ -256,26 +256,21 @@ class LogMasterView(WuttaFarmMasterView):
f.set_default("timestamp", self.app.make_utc())
# assets
- if self.creating or self.editing:
- f.remove("assets") # TODO: need to support this
- else:
- f.set_node("assets", AssetRefs(self.request))
+ f.set_node("assets", AssetRefs(self.request))
+ f.set_required("assets", False)
+ if not self.creating:
# nb. must explicity declare value for non-standard field
f.set_default("assets", log.assets)
# groups
- if self.creating or self.editing:
- f.remove("groups") # TODO: need to support this
- else:
- f.set_node("groups", AssetRefs(self.request))
+ f.set_node("groups", AssetRefs(self.request, is_group=True))
+ if not self.creating:
# nb. must explicity declare value for non-standard field
f.set_default("groups", log.groups)
# locations
- if self.creating or self.editing:
- f.remove("locations") # TODO: need to support this
- else:
- f.set_node("locations", AssetRefs(self.request))
+ f.set_node("locations", AssetRefs(self.request, is_location=True))
+ if not self.creating:
# nb. must explicity declare value for non-standard field
f.set_default("locations", log.locations)
@@ -292,12 +287,12 @@ class LogMasterView(WuttaFarmMasterView):
f.set_readonly("log_type")
# quantities
- if self.creating or self.editing:
- f.remove("quantities") # TODO: need to support this
- else:
- f.set_node("quantities", LogQuantityRefs(self.request))
+ f.set_node("quantities", QuantityRefs(self.request))
+ if not self.creating:
# nb. must explicity declare value for non-standard field
- f.set_default("quantities", log.quantities)
+ f.set_default(
+ "quantities", [self.app.get_true_quantity(q) for q in log.quantities]
+ )
# notes
f.set_widget("notes", "notes")
@@ -324,13 +319,141 @@ class LogMasterView(WuttaFarmMasterView):
def objectify(self, form):
log = super().objectify(form)
+ data = form.validated
if self.creating:
- model_class = self.get_model_class()
+
+ # log_type
log.log_type = self.get_farmos_log_type()
+ # owner
+ log.owners = [self.request.user]
+
+ self.set_assets(log, data["assets"])
+ self.set_locations(log, data["locations"])
+ self.set_groups(log, data["groups"])
+ self.set_quantities(log, data["quantities"])
+
return log
+ def set_assets(self, log, desired):
+ model = self.app.model
+ session = self.Session()
+ current = [a.uuid for a in log.assets]
+
+ for uuid in desired:
+ if uuid not in current:
+ asset = session.get(model.Asset, uuid)
+ assert asset
+ log.assets.append(asset)
+
+ for uuid in current:
+ if uuid not in desired:
+ asset = session.get(model.Asset, uuid)
+ assert asset
+ log.assets.remove(asset)
+
+ def set_locations(self, log, desired):
+ model = self.app.model
+ session = self.Session()
+ current = [l.uuid for l in log.locations]
+
+ for uuid in desired:
+ if uuid not in current:
+ location = session.get(model.Asset, uuid)
+ assert location
+ log.locations.append(location)
+
+ for uuid in current:
+ if uuid not in desired:
+ location = session.get(model.Asset, uuid)
+ assert location
+ log.locations.remove(location)
+
+ def set_groups(self, log, desired):
+ model = self.app.model
+ session = self.Session()
+ current = [g.uuid for g in log.groups]
+
+ for uuid in desired:
+ if uuid not in current:
+ group = session.get(model.Asset, uuid)
+ assert group
+ log.groups.append(group)
+
+ for uuid in current:
+ if uuid not in desired:
+ group = session.get(model.Asset, uuid)
+ assert group
+ log.groups.remove(group)
+
+ def set_quantities(self, log, desired):
+ model = self.app.model
+ session = self.Session()
+
+ current = {
+ qty.uuid.hex: self.app.get_true_quantity(qty) for qty in log.quantities
+ }
+ for new_qty in desired:
+ units = session.get(model.Unit, new_qty["units"]["uuid"])
+ assert units
+ if new_qty["uuid"].startswith("new_"):
+ qty = self.app.make_true_quantity(
+ new_qty["quantity_type"]["drupal_id"],
+ measure_id=new_qty["measure"],
+ value_numerator=int(new_qty["value"]),
+ value_denominator=1,
+ units=units,
+ )
+ # nb. must ensure "typed" quantity record persists!
+ session.add(qty)
+ # but must add "generic" quantity record to log
+ log.quantities.append(qty.quantity)
+ else:
+ old_qty = current[new_qty["uuid"]]
+ old_qty.measure_id = new_qty["measure"]
+ old_qty.value_numerator = int(new_qty["value"])
+ old_qty.value_denominator = 1
+ old_qty.units = units
+ if old_qty.quantity_type_id == "material":
+ self.set_material_types(old_qty, new_qty["material_types"])
+
+ desired = [qty["uuid"] for qty in desired]
+ for old_qty in list(log.quantities):
+ # nb. "old_qty" may be newly-created, w/ no uuid yet
+ # (this logic may break if session gets flushed early!)
+ if old_qty.uuid and old_qty.uuid.hex not in desired:
+ log.quantities.remove(old_qty)
+
+ def set_material_types(self, quantity, desired):
+ model = self.app.model
+ session = self.Session()
+ current = {mtype.uuid: mtype for mtype in quantity.material_types}
+
+ for new_mtype in desired:
+ mtype = session.get(model.MaterialType, new_mtype["uuid"])
+ assert mtype
+ if mtype.uuid not in current:
+ quantity.material_types.append(mtype)
+
+ desired = [mtype["uuid"] for mtype in desired]
+ for old_mtype in current.values():
+ if old_mtype.uuid.hex not in desired:
+ quantity.material_types.remove(old_mtype)
+
+ def auto_sync_to_farmos(self, client, log):
+ model = self.app.model
+ session = self.Session()
+
+ # nb. ensure quantities have uuid keys
+ session.flush()
+
+ for qty in log.quantities:
+ qty = self.app.get_true_quantity(qty)
+ self.app.auto_sync_to_farmos(qty, client=client)
+
+ self.app.auto_sync_to_farmos(log, client=client)
+
def get_farmos_url(self, log):
return self.app.get_farmos_url(f"/log/{log.drupal_id}")
diff --git a/src/wuttafarm/web/views/logs_seeding.py b/src/wuttafarm/web/views/logs_seeding.py
new file mode 100644
index 0000000..8946aff
--- /dev/null
+++ b/src/wuttafarm/web/views/logs_seeding.py
@@ -0,0 +1,80 @@
+# -*- 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 Seeding Logs
+"""
+
+from wuttaweb.forms.widgets import WuttaDateTimeWidget
+
+from wuttafarm.web.views.logs import LogMasterView
+from wuttafarm.db.model import SeedingLog
+
+
+class SeedingLogView(LogMasterView):
+ """
+ Master view for Seeding Logs
+ """
+
+ model_class = SeedingLog
+ route_prefix = "logs_seeding"
+ url_prefix = "/logs/seeding"
+
+ farmos_bundle = "seeding"
+ farmos_refurl_path = "/logs/seeding"
+
+ grid_columns = [
+ "status",
+ "drupal_id",
+ "timestamp",
+ "message",
+ "assets",
+ "locations",
+ "purchase_date",
+ "source",
+ "is_group_assignment",
+ "owners",
+ ]
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # source
+ f.fields.insert_after("timestamp", "source")
+
+ # purchase_date
+ f.fields.insert_after("source", "purchase_date")
+ f.set_widget("purchase_date", WuttaDateTimeWidget(self.request))
+
+ # lot_number
+ f.fields.insert_after("purchase_date", "lot_number")
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ SeedingLogView = kwargs.get("SeedingLogView", base["SeedingLogView"])
+ SeedingLogView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py
index 747cdc5..d9fe986 100644
--- a/src/wuttafarm/web/views/master.py
+++ b/src/wuttafarm/web/views/master.py
@@ -26,6 +26,7 @@ Base class for WuttaFarm master views
from webhelpers2.html import tags
from wuttaweb.views import MasterView
+from wuttaweb.util import get_form_data
from wuttafarm.web.util import use_farmos_style_grid_links, get_farmos_client_for_user
@@ -113,7 +114,10 @@ class WuttaFarmMasterView(MasterView):
# maybe also sync change to farmOS
if self.app.is_farmos_mirror():
client = get_farmos_client_for_user(self.request)
- self.app.auto_sync_to_farmos(obj, client=client, require=False)
+ self.auto_sync_to_farmos(client, obj)
+
+ def auto_sync_to_farmos(self, client, obj):
+ self.app.auto_sync_to_farmos(obj, client=client, require=False)
def get_farmos_entity_type(self):
if self.farmos_entity_type:
@@ -145,3 +149,112 @@ class WuttaFarmMasterView(MasterView):
bundle = self.get_farmos_bundle()
client = get_farmos_client_for_user(self.request)
client.resource.delete(entity_type, bundle, farmos_uuid)
+
+
+class TaxonomyMasterView(WuttaFarmMasterView):
+ """
+ Base class for master views serving taxonomy terms.
+ """
+
+ farmos_entity_type = "taxonomy_term"
+
+ grid_columns = [
+ "name",
+ "description",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "description",
+ "drupal_id",
+ "farmos_uuid",
+ ]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # description
+ f.set_widget("description", "notes")
+
+ def get_farmos_url(self, obj):
+ return self.app.get_farmos_url(f"/taxonomy/term/{obj.drupal_id}")
+
+ def get_xref_buttons(self, term):
+ buttons = super().get_xref_buttons(term)
+
+ if term.farmos_uuid:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ f"{self.farmos_route_prefix}.view", uuid=term.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+ def ajax_create(self):
+ """
+ AJAX view to create a new taxonomy term.
+ """
+ model = self.app.model
+ session = self.Session()
+ data = get_form_data(self.request)
+
+ name = data.get("name")
+ if not name:
+ return {"error": "Name is required"}
+
+ term = self.model_class(name=name)
+ session.add(term)
+ session.flush()
+
+ if self.app.is_farmos_mirror():
+ client = get_farmos_client_for_user(self.request)
+ self.app.auto_sync_to_farmos(term, client=client)
+
+ return {
+ "uuid": term.uuid.hex,
+ "name": term.name,
+ "farmos_uuid": term.farmos_uuid.hex,
+ "drupal_id": term.drupal_id,
+ }
+
+ @classmethod
+ def defaults(cls, config):
+ """ """
+ cls._defaults(config)
+ cls._taxonomy_defaults(config)
+
+ @classmethod
+ def _taxonomy_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ url_prefix = cls.get_url_prefix()
+
+ # ajax_create
+ config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new")
+ config.add_view(
+ cls,
+ attr="ajax_create",
+ route_name=f"{route_prefix}.ajax_create",
+ permission=f"{permission_prefix}.create",
+ renderer="json",
+ )
diff --git a/src/wuttafarm/web/views/material_types.py b/src/wuttafarm/web/views/material_types.py
new file mode 100644
index 0000000..d2118a7
--- /dev/null
+++ b/src/wuttafarm/web/views/material_types.py
@@ -0,0 +1,52 @@
+# -*- 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 Material Types
+"""
+
+from wuttafarm.web.views import TaxonomyMasterView
+from wuttafarm.db.model import MaterialType
+
+
+class MaterialTypeView(TaxonomyMasterView):
+ """
+ Master view for Material Types
+ """
+
+ model_class = MaterialType
+ route_prefix = "material_types"
+ url_prefix = "/material-types"
+
+ farmos_route_prefix = "farmos_material_types"
+ farmos_bundle = "material_type"
+ farmos_refurl_path = "/admin/structure/taxonomy/manage/material_type/overview"
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ MaterialTypeView = kwargs.get("MaterialTypeView", base["MaterialTypeView"])
+ MaterialTypeView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py
index a114e07..16bd3c0 100644
--- a/src/wuttafarm/web/views/plants.py
+++ b/src/wuttafarm/web/views/plants.py
@@ -28,9 +28,9 @@ from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.util import get_form_data
-from wuttafarm.db.model import PlantType, PlantAsset
+from wuttafarm.db.model import PlantType, Season, PlantAsset
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
-from wuttafarm.web.forms.schema import PlantTypeRefs
+from wuttafarm.web.forms.schema import PlantTypeRefs, SeasonRefs
from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.util import get_farmos_client_for_user
@@ -195,6 +195,166 @@ class PlantTypeView(AssetTypeMasterView):
)
+class SeasonView(AssetTypeMasterView):
+ """
+ Master view for Seasons
+ """
+
+ model_class = Season
+ route_prefix = "seasons"
+ url_prefix = "/seasons"
+
+ farmos_entity_type = "taxonomy_term"
+ farmos_bundle = "season"
+ farmos_refurl_path = "/admin/structure/taxonomy/manage/season/overview"
+
+ grid_columns = [
+ "name",
+ "description",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "description",
+ "drupal_id",
+ "farmos_uuid",
+ ]
+
+ has_rows = True
+ row_model_class = PlantAsset
+ rows_viewable = True
+
+ row_grid_columns = [
+ "asset_name",
+ "archived",
+ ]
+
+ rows_sort_defaults = "asset_name"
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
+
+ def get_farmos_url(self, season):
+ return self.app.get_farmos_url(f"/taxonomy/term/{season.drupal_id}")
+
+ def get_xref_buttons(self, season):
+ buttons = super().get_xref_buttons(season)
+
+ if season.farmos_uuid:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ "farmos_seasons.view", uuid=season.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+ def delete(self):
+ season = self.get_instance()
+
+ if season._plant_assets:
+ self.request.session.flash(
+ "Cannot delete season which is still referenced by plant assets.",
+ "warning",
+ )
+ url = self.get_action_url("view", season)
+ return self.redirect(self.request.get_referrer(default=url))
+
+ return super().delete()
+
+ def get_row_grid_data(self, season):
+ model = self.app.model
+ session = self.Session()
+ return (
+ session.query(model.PlantAsset)
+ .join(model.Asset)
+ .outerjoin(model.PlantAssetSeason)
+ .filter(model.PlantAssetSeason.season == season)
+ )
+
+ def configure_row_grid(self, grid):
+ g = grid
+ super().configure_row_grid(g)
+ model = self.app.model
+
+ # asset_name
+ g.set_link("asset_name")
+ g.set_sorter("asset_name", model.Asset.asset_name)
+ g.set_filter("asset_name", model.Asset.asset_name)
+
+ # archived
+ g.set_renderer("archived", "boolean")
+ g.set_sorter("archived", model.Asset.archived)
+ g.set_filter("archived", model.Asset.archived)
+
+ def get_row_action_url_view(self, plant, i):
+ return self.request.route_url("plant_assets.view", uuid=plant.uuid)
+
+ def ajax_create(self):
+ """
+ AJAX view to create a new season.
+ """
+ model = self.app.model
+ session = self.Session()
+ data = get_form_data(self.request)
+
+ name = data.get("name")
+ if not name:
+ return {"error": "Name is required"}
+
+ season = model.Season(name=name)
+ session.add(season)
+ session.flush()
+
+ if self.app.is_farmos_mirror():
+ client = get_farmos_client_for_user(self.request)
+ self.app.auto_sync_to_farmos(season, client=client)
+
+ return {
+ "uuid": season.uuid.hex,
+ "name": season.name,
+ "farmos_uuid": season.farmos_uuid.hex,
+ "drupal_id": season.drupal_id,
+ }
+
+ @classmethod
+ def defaults(cls, config):
+ """ """
+ cls._defaults(config)
+ cls._season_defaults(config)
+
+ @classmethod
+ def _season_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ url_prefix = cls.get_url_prefix()
+
+ # ajax_create
+ config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new")
+ config.add_view(
+ cls,
+ attr="ajax_create",
+ route_name=f"{route_prefix}.ajax_create",
+ permission=f"{permission_prefix}.create",
+ renderer="json",
+ )
+
+
class PlantAssetView(AssetMasterView):
"""
Master view for Plant Assets
@@ -209,6 +369,7 @@ class PlantAssetView(AssetMasterView):
labels = {
"plant_types": "Crop/Variety",
+ "seasons": "Season",
}
grid_columns = [
@@ -220,21 +381,6 @@ class PlantAssetView(AssetMasterView):
"archived",
]
- form_fields = [
- "asset_name",
- "plant_types",
- "season",
- "notes",
- "asset_type",
- "archived",
- "drupal_id",
- "farmos_uuid",
- "thumbnail_url",
- "image_url",
- "thumbnail",
- "image",
- ]
-
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
@@ -262,23 +408,33 @@ class PlantAssetView(AssetMasterView):
plant = f.model_instance
# plant_types
+ f.fields.insert_after("asset_name", "plant_types")
f.set_node("plant_types", PlantTypeRefs(self.request))
if not self.creating:
# nb. must explcitly declare value for non-standard field
f.set_default("plant_types", [pt.uuid for pt in plant.plant_types])
# season
- if self.creating or self.editing:
- f.remove("season") # TODO: add support for this
+ f.fields.insert_after("plant_types", "seasons")
+ f.set_node("seasons", SeasonRefs(self.request))
+ f.set_required("seasons", False)
+ if not self.creating:
+ # nb. must explcitly declare value for non-standard field
+ f.set_default("seasons", plant.seasons)
def objectify(self, form):
- model = self.app.model
- session = self.Session()
plant = super().objectify(form)
data = form.validated
+ self.set_plant_types(plant, data["plant_types"])
+ self.set_seasons(plant, data["seasons"])
+
+ return plant
+
+ def set_plant_types(self, plant, desired):
+ model = self.app.model
+ session = self.Session()
current = [pt.uuid for pt in plant.plant_types]
- desired = data["plant_types"]
for uuid in desired:
if uuid not in current:
@@ -292,7 +448,22 @@ class PlantAssetView(AssetMasterView):
assert plant_type
plant.plant_types.remove(plant_type)
- return plant
+ def set_seasons(self, plant, desired):
+ model = self.app.model
+ session = self.Session()
+ current = [s.uuid for s in plant.seasons]
+
+ for uuid in desired:
+ if uuid not in current:
+ season = session.get(model.Season, uuid)
+ assert season
+ plant.seasons.append(season)
+
+ for uuid in current:
+ if uuid not in desired:
+ season = session.get(model.Season, uuid)
+ assert season
+ plant.seasons.remove(season)
def defaults(config, **kwargs):
@@ -301,6 +472,9 @@ def defaults(config, **kwargs):
PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"])
PlantTypeView.defaults(config)
+ SeasonView = kwargs.get("SeasonView", base["SeasonView"])
+ SeasonView.defaults(config)
+
PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"])
PlantAssetView.defaults(config)
diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py
index d4112cf..9a91941 100644
--- a/src/wuttafarm/web/views/quantities.py
+++ b/src/wuttafarm/web/views/quantities.py
@@ -25,11 +25,19 @@ Master view for Quantities
from collections import OrderedDict
+from webhelpers2.html import tags
+
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, LogRef
+from wuttafarm.db.model import (
+ QuantityType,
+ Quantity,
+ StandardQuantity,
+ MaterialQuantity,
+)
+from wuttafarm.web.forms.schema import UnitRef, LogRef, MaterialTypeRefs
+from wuttafarm.util import get_log_type_enum
def get_quantity_type_enum(config):
@@ -100,17 +108,28 @@ class QuantityMasterView(WuttaFarmMasterView):
Base class for Quantity master views
"""
+ farmos_entity_type = "quantity"
+
+ labels = {
+ "log_id": "Log ID",
+ }
+
grid_columns = [
"drupal_id",
- "as_text",
- "quantity_type",
+ "log_id",
+ "log_status",
+ "log_timestamp",
+ "log_type",
+ "log_name",
+ "log_assets",
"measure",
"value",
"units",
"label",
+ "quantity_type",
]
- sort_defaults = ("drupal_id", "desc")
+ sort_defaults = ("log_timestamp", "desc")
form_fields = [
"quantity_type",
@@ -129,10 +148,15 @@ class QuantityMasterView(WuttaFarmMasterView):
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)
+
+ query = query.outerjoin(model.LogQuantity).outerjoin(model.Log)
+
return query
def configure_grid(self, grid):
@@ -140,14 +164,39 @@ class QuantityMasterView(WuttaFarmMasterView):
super().configure_grid(g)
model = self.app.model
model_class = self.get_model_class()
+ session = self.Session()
# 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")
+ # log_id
+ g.set_renderer("log_id", self.render_log_id_for_grid)
+ g.set_sorter("log_id", model.Log.drupal_id)
+
+ # log_status
+ g.set_renderer("log_status", self.render_log_status_for_grid)
+ g.set_sorter("log_status", model.Log.status)
+
+ # log_timestamp
+ g.set_renderer("log_timestamp", self.render_log_timestamp_for_grid)
+ g.set_sorter("log_timestamp", model.Log.timestamp)
+
+ # log_type
+ self.log_type_enum = get_log_type_enum(self.config, session)
+ g.set_renderer("log_type", self.render_log_type_for_grid)
+ g.set_sorter("log_type", model.Log.log_type)
+
+ # log_name
+ g.set_renderer("log_name", self.render_log_name_for_grid)
+ g.set_sorter("log_name", model.Log.message)
+ if not self.farmos_style_grid_links:
+ g.set_link("log_name")
+
+ # log_assets
+ g.set_renderer("log_assets", self.render_log_assets_for_grid)
+ if not self.farmos_style_grid_links:
+ g.set_link("log_assets")
# quantity_type
if model_class is not model.Quantity:
@@ -177,8 +226,47 @@ class QuantityMasterView(WuttaFarmMasterView):
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_log_id_for_grid(self, quantity, field, value):
+ if log := quantity.log:
+ return log.drupal_id
+ return None
+
+ def render_log_status_for_grid(self, quantity, field, value):
+ enum = self.app.enum
+ if log := quantity.log:
+ return enum.LOG_STATUS.get(log.status, log.status)
+ return None
+
+ def render_log_timestamp_for_grid(self, quantity, field, value):
+ if log := quantity.log:
+ return self.app.render_date(log.timestamp)
+ return None
+
+ def render_log_type_for_grid(self, quantity, field, value):
+ if log := quantity.log:
+ return self.log_type_enum.get(log.log_type, log.log_type)
+ return None
+
+ def render_log_name_for_grid(self, quantity, field, value):
+ if log := quantity.log:
+ if self.farmos_style_grid_links:
+ url = self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
+ return tags.link_to(log.message, url)
+ return log.message
+ return None
+
+ def render_log_assets_for_grid(self, quantity, field, value):
+ if log := quantity.log:
+ if self.farmos_style_grid_links:
+ links = []
+ for asset in log.assets:
+ url = self.request.route_url(
+ f"{asset.asset_type}_assets.view", uuid=asset.uuid
+ )
+ links.append(tags.link_to(str(asset), url))
+ return ", ".join(links)
+ return [str(a) for a in log.assets]
+ return None
def render_value_for_grid(self, quantity, field, value):
value = quantity.value_numerator / quantity.value_denominator
@@ -271,6 +359,8 @@ class AllQuantityView(QuantityMasterView):
deletable = False
model_is_versioned = False
+ farmos_refurl_path = "/log-quantities"
+
class StandardQuantityView(QuantityMasterView):
"""
@@ -281,6 +371,77 @@ class StandardQuantityView(QuantityMasterView):
route_prefix = "quantities_standard"
url_prefix = "/quantities/standard"
+ farmos_bundle = "standard"
+ farmos_refurl_path = "/log-quantities/standard"
+
+
+class MaterialQuantityView(QuantityMasterView):
+ """
+ Master view for Material Quantities
+ """
+
+ model_class = MaterialQuantity
+ route_prefix = "quantities_material"
+ url_prefix = "/quantities/material"
+
+ farmos_bundle = "material"
+ farmos_refurl_path = "/log-quantities/material"
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # material_types
+ g.columns.append("material_types")
+ g.set_label("material_types", "Material Type", column_only=True)
+ g.set_renderer("material_types", self.render_material_types_for_grid)
+
+ def render_material_types_for_grid(self, quantity, field, value):
+ if self.farmos_style_grid_links:
+ links = []
+ for mtype in quantity.material_types:
+ url = self.request.route_url("material_types.view", uuid=mtype.uuid)
+ links.append(tags.link_to(str(mtype), url))
+ return ", ".join(links)
+
+ return ", ".join([str(mtype) for mtype in quantity.material_types])
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ quantity = form.model_instance
+
+ # material_types
+ f.fields.insert_after("quantity_type", "material_types")
+ f.set_node("material_types", MaterialTypeRefs(self.request))
+ if not self.creating:
+ f.set_default("material_types", quantity.material_types)
+
+ def objectify(self, form):
+ quantity = super().objectify(form)
+ data = form.validated
+
+ self.set_material_types(quantity, data["material_types"])
+
+ return quantity
+
+ def set_material_types(self, quantity, desired):
+ model = self.app.model
+ session = self.Session()
+
+ current = {mt.uuid.hex: mt for mt in quantity.material_types}
+
+ for mtype in desired:
+ if mtype["uuid"] not in current:
+ mtype = session.get(model.MaterialType, mtype["uuid"])
+ assert mtype
+ quantity.material_types.append(mtype)
+
+ desired = [mtype["uuid"] for mtype in desired]
+ for uuid, mtype in current.items():
+ if uuid not in desired:
+ quantity.material_types.remove(mtype)
+
def defaults(config, **kwargs):
base = globals()
@@ -296,6 +457,11 @@ def defaults(config, **kwargs):
)
StandardQuantityView.defaults(config)
+ MaterialQuantityView = kwargs.get(
+ "MaterialQuantityView", base["MaterialQuantityView"]
+ )
+ MaterialQuantityView.defaults(config)
+
def includeme(config):
defaults(config)
diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py
index fe8dafe..13cff36 100644
--- a/src/wuttafarm/web/views/units.py
+++ b/src/wuttafarm/web/views/units.py
@@ -37,17 +37,19 @@ class MeasureView(WuttaFarmMasterView):
url_prefix = "/measures"
grid_columns = [
+ "ordinal",
"name",
"drupal_id",
]
- sort_defaults = "name"
+ sort_defaults = "ordinal"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
+ "ordinal",
"name",
"drupal_id",
]
diff --git a/src/wuttafarm/web/views/water.py b/src/wuttafarm/web/views/water.py
new file mode 100644
index 0000000..c0d551e
--- /dev/null
+++ b/src/wuttafarm/web/views/water.py
@@ -0,0 +1,59 @@
+# -*- 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 Water Assets
+"""
+
+from wuttafarm.db.model import WaterAsset
+from wuttafarm.web.views.assets import AssetMasterView
+
+
+class WaterAssetView(AssetMasterView):
+ """
+ Master view for Plant Assets
+ """
+
+ model_class = WaterAsset
+ route_prefix = "water_assets"
+ url_prefix = "/assets/water"
+
+ farmos_bundle = "water"
+ farmos_refurl_path = "/assets/water"
+
+ grid_columns = [
+ "thumbnail",
+ "drupal_id",
+ "asset_name",
+ "parents",
+ "archived",
+ ]
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ WaterAssetView = kwargs.get("WaterAssetView", base["WaterAssetView"])
+ WaterAssetView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)