diff --git a/src/wuttafarm/db/alembic/versions/11e0e46f48a6_add_plant_assets_and_more_logs.py b/src/wuttafarm/db/alembic/versions/11e0e46f48a6_add_plant_assets_and_more_logs.py
new file mode 100644
index 0000000..7df55c4
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/11e0e46f48a6_add_plant_assets_and_more_logs.py
@@ -0,0 +1,596 @@
+"""add Plant Assets and more Logs
+
+Revision ID: 11e0e46f48a6
+Revises: dd6351e69233
+Create Date: 2026-02-18 18:11:46.536930
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "11e0e46f48a6"
+down_revision: Union[str, None] = "dd6351e69233"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # plant_type
+ op.create_table(
+ "plant_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_plant_type")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_plant_type_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_plant_type_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_plant_type_name")),
+ )
+ op.create_table(
+ "plant_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_plant_type_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_plant_type_version_end_transaction_id"),
+ "plant_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_plant_type_version_operation_type"),
+ "plant_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_plant_type_version_pk_transaction_id",
+ "plant_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_plant_type_version_pk_validity",
+ "plant_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_plant_type_version_transaction_id"),
+ "plant_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # asset_plant
+ op.create_table(
+ "asset_plant",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["asset.uuid"], name=op.f("fk_asset_plant_uuid_asset")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_plant")),
+ )
+ op.create_table(
+ "asset_plant_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_plant_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_plant_version_end_transaction_id"),
+ "asset_plant_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_plant_version_operation_type"),
+ "asset_plant_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_plant_version_pk_transaction_id",
+ "asset_plant_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_plant_version_pk_validity",
+ "asset_plant_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_plant_version_transaction_id"),
+ "asset_plant_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # asset_plant_plant_type
+ op.create_table(
+ "asset_plant_plant_type",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("plant_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("plant_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["plant_asset_uuid"],
+ ["asset_plant.uuid"],
+ name=op.f("fk_asset_plant_plant_type_plant_asset_uuid_asset_plant"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["plant_type_uuid"],
+ ["plant_type.uuid"],
+ name=op.f("fk_asset_plant_plant_type_plant_type_uuid_plant_type"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_plant_plant_type")),
+ )
+ op.create_table(
+ "asset_plant_plant_type_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(
+ "plant_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_plant_plant_type_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_plant_plant_type_version_end_transaction_id"),
+ "asset_plant_plant_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_plant_plant_type_version_operation_type"),
+ "asset_plant_plant_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_plant_plant_type_version_pk_transaction_id",
+ "asset_plant_plant_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_plant_plant_type_version_pk_validity",
+ "asset_plant_plant_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_plant_plant_type_version_transaction_id"),
+ "asset_plant_plant_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # log_asset
+ op.create_table(
+ "log_asset",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["asset_uuid"], ["asset.uuid"], name=op.f("fk_log_asset_asset_uuid_asset")
+ ),
+ sa.ForeignKeyConstraint(
+ ["log_uuid"], ["log.uuid"], name=op.f("fk_log_asset_log_uuid_log")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_asset")),
+ )
+ op.create_table(
+ "log_asset_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "asset_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_log_asset_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_log_asset_version_end_transaction_id"),
+ "log_asset_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_asset_version_operation_type"),
+ "log_asset_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_asset_version_pk_transaction_id",
+ "log_asset_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_asset_version_pk_validity",
+ "log_asset_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_asset_version_transaction_id"),
+ "log_asset_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # log_harvest
+ op.create_table(
+ "log_harvest",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["log.uuid"], name=op.f("fk_log_harvest_uuid_log")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_harvest")),
+ )
+ op.create_table(
+ "log_harvest_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_log_harvest_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_log_harvest_version_end_transaction_id"),
+ "log_harvest_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_harvest_version_operation_type"),
+ "log_harvest_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_harvest_version_pk_transaction_id",
+ "log_harvest_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_harvest_version_pk_validity",
+ "log_harvest_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_harvest_version_transaction_id"),
+ "log_harvest_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # log_medical
+ op.create_table(
+ "log_medical",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["log.uuid"], name=op.f("fk_log_medical_uuid_log")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_medical")),
+ )
+ op.create_table(
+ "log_medical_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_log_medical_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_log_medical_version_end_transaction_id"),
+ "log_medical_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_medical_version_operation_type"),
+ "log_medical_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_medical_version_pk_transaction_id",
+ "log_medical_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_medical_version_pk_validity",
+ "log_medical_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_medical_version_transaction_id"),
+ "log_medical_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # log_observation
+ op.create_table(
+ "log_observation",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["log.uuid"], name=op.f("fk_log_observation_uuid_log")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_observation")),
+ )
+ op.create_table(
+ "log_observation_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_log_observation_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_log_observation_version_end_transaction_id"),
+ "log_observation_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_observation_version_operation_type"),
+ "log_observation_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_observation_version_pk_transaction_id",
+ "log_observation_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_observation_version_pk_validity",
+ "log_observation_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_observation_version_transaction_id"),
+ "log_observation_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # log_observation
+ op.drop_index(
+ op.f("ix_log_observation_version_transaction_id"),
+ table_name="log_observation_version",
+ )
+ op.drop_index(
+ "ix_log_observation_version_pk_validity", table_name="log_observation_version"
+ )
+ op.drop_index(
+ "ix_log_observation_version_pk_transaction_id",
+ table_name="log_observation_version",
+ )
+ op.drop_index(
+ op.f("ix_log_observation_version_operation_type"),
+ table_name="log_observation_version",
+ )
+ op.drop_index(
+ op.f("ix_log_observation_version_end_transaction_id"),
+ table_name="log_observation_version",
+ )
+ op.drop_table("log_observation_version")
+ op.drop_table("log_observation")
+
+ # log_medical
+ op.drop_index(
+ op.f("ix_log_medical_version_transaction_id"), table_name="log_medical_version"
+ )
+ op.drop_index(
+ "ix_log_medical_version_pk_validity", table_name="log_medical_version"
+ )
+ op.drop_index(
+ "ix_log_medical_version_pk_transaction_id", table_name="log_medical_version"
+ )
+ op.drop_index(
+ op.f("ix_log_medical_version_operation_type"), table_name="log_medical_version"
+ )
+ op.drop_index(
+ op.f("ix_log_medical_version_end_transaction_id"),
+ table_name="log_medical_version",
+ )
+ op.drop_table("log_medical_version")
+ op.drop_table("log_medical")
+
+ # log_harvest
+ op.drop_index(
+ op.f("ix_log_harvest_version_transaction_id"), table_name="log_harvest_version"
+ )
+ op.drop_index(
+ "ix_log_harvest_version_pk_validity", table_name="log_harvest_version"
+ )
+ op.drop_index(
+ "ix_log_harvest_version_pk_transaction_id", table_name="log_harvest_version"
+ )
+ op.drop_index(
+ op.f("ix_log_harvest_version_operation_type"), table_name="log_harvest_version"
+ )
+ op.drop_index(
+ op.f("ix_log_harvest_version_end_transaction_id"),
+ table_name="log_harvest_version",
+ )
+ op.drop_table("log_harvest_version")
+ op.drop_table("log_harvest")
+
+ # log_asset
+ op.drop_index(
+ op.f("ix_log_asset_version_transaction_id"), table_name="log_asset_version"
+ )
+ op.drop_index("ix_log_asset_version_pk_validity", table_name="log_asset_version")
+ op.drop_index(
+ "ix_log_asset_version_pk_transaction_id", table_name="log_asset_version"
+ )
+ op.drop_index(
+ op.f("ix_log_asset_version_operation_type"), table_name="log_asset_version"
+ )
+ op.drop_index(
+ op.f("ix_log_asset_version_end_transaction_id"), table_name="log_asset_version"
+ )
+ op.drop_table("log_asset_version")
+ op.drop_table("log_asset")
+
+ # asset_plant_plant_type
+ op.drop_index(
+ op.f("ix_asset_plant_plant_type_version_transaction_id"),
+ table_name="asset_plant_plant_type_version",
+ )
+ op.drop_index(
+ "ix_asset_plant_plant_type_version_pk_validity",
+ table_name="asset_plant_plant_type_version",
+ )
+ op.drop_index(
+ "ix_asset_plant_plant_type_version_pk_transaction_id",
+ table_name="asset_plant_plant_type_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_plant_plant_type_version_operation_type"),
+ table_name="asset_plant_plant_type_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_plant_plant_type_version_end_transaction_id"),
+ table_name="asset_plant_plant_type_version",
+ )
+ op.drop_table("asset_plant_plant_type_version")
+ op.drop_table("asset_plant_plant_type")
+
+ # asset_plant
+ op.drop_index(
+ op.f("ix_asset_plant_version_transaction_id"), table_name="asset_plant_version"
+ )
+ op.drop_index(
+ "ix_asset_plant_version_pk_validity", table_name="asset_plant_version"
+ )
+ op.drop_index(
+ "ix_asset_plant_version_pk_transaction_id", table_name="asset_plant_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_plant_version_operation_type"), table_name="asset_plant_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_plant_version_end_transaction_id"),
+ table_name="asset_plant_version",
+ )
+ op.drop_table("asset_plant_version")
+ op.drop_table("asset_plant")
+
+ # plant_type
+ op.drop_index(
+ op.f("ix_plant_type_version_transaction_id"), table_name="plant_type_version"
+ )
+ op.drop_index("ix_plant_type_version_pk_validity", table_name="plant_type_version")
+ op.drop_index(
+ "ix_plant_type_version_pk_transaction_id", table_name="plant_type_version"
+ )
+ op.drop_index(
+ op.f("ix_plant_type_version_operation_type"), table_name="plant_type_version"
+ )
+ op.drop_index(
+ op.f("ix_plant_type_version_end_transaction_id"),
+ table_name="plant_type_version",
+ )
+ op.drop_table("plant_type_version")
+ op.drop_table("plant_type")
diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py
index 978ed5d..f9eb790 100644
--- a/src/wuttafarm/db/model/__init__.py
+++ b/src/wuttafarm/db/model/__init__.py
@@ -35,5 +35,9 @@ from .asset_land import LandType, LandAsset
from .asset_structure import StructureType, StructureAsset
from .asset_animal import AnimalType, AnimalAsset
from .asset_group import GroupAsset
-from .log import LogType, Log
+from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType
+from .log import LogType, Log, LogAsset
from .log_activity import ActivityLog
+from .log_harvest import HarvestLog
+from .log_medical import MedicalLog
+from .log_observation import ObservationLog
diff --git a/src/wuttafarm/db/model/asset_plant.py b/src/wuttafarm/db/model/asset_plant.py
new file mode 100644
index 0000000..5f10e7c
--- /dev/null
+++ b/src/wuttafarm/db/model/asset_plant.py
@@ -0,0 +1,132 @@
+# -*- 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 Plant Assets
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies
+
+
+class PlantType(model.Base):
+ """
+ Represents a "plant type" (taxonomy term) from farmOS
+ """
+
+ __tablename__ = "plant_type"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Plant Type",
+ "model_title_plural": "Plant Types",
+ }
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ unique=True,
+ doc="""
+ Name of the plant type.
+ """,
+ )
+
+ description = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Optional description for the plant type.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the plant type within farmOS.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the plant type.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
+
+
+class PlantAsset(AssetMixin, model.Base):
+ """
+ Represents a plant asset from farmOS
+ """
+
+ __tablename__ = "asset_plant"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Plant Asset",
+ "model_title_plural": "Plant Assets",
+ "farmos_asset_type": "plant",
+ }
+
+ _plant_types = orm.relationship(
+ "PlantAssetPlantType",
+ back_populates="plant_asset",
+ )
+
+
+add_asset_proxies(PlantAsset)
+
+
+class PlantAssetPlantType(model.Base):
+ """
+ Associates one or more plant types with a plant asset.
+ """
+
+ __tablename__ = "asset_plant_plant_type"
+ __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="_plant_types",
+ )
+
+ plant_type_uuid = model.uuid_fk_column("plant_type.uuid", nullable=False)
+ plant_type = orm.relationship(
+ PlantType,
+ doc="""
+ Reference to the plant type.
+ """,
+ )
diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py
index 14afe3e..a86c447 100644
--- a/src/wuttafarm/db/model/log.py
+++ b/src/wuttafarm/db/model/log.py
@@ -92,7 +92,7 @@ class Log(model.Base):
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Log",
- "model_title_plural": "Logs",
+ "model_title_plural": "All Logs",
}
uuid = model.uuid_column()
@@ -153,6 +153,8 @@ class Log(model.Base):
""",
)
+ _assets = orm.relationship("LogAsset", back_populates="log")
+
def __str__(self):
return self.message or ""
@@ -177,3 +179,27 @@ def add_log_proxies(subclass):
Log.make_proxy(subclass, "log", "timestamp")
Log.make_proxy(subclass, "log", "status")
Log.make_proxy(subclass, "log", "notes")
+
+
+class LogAsset(model.Base):
+ """
+ Represents a "log's asset relationship" from farmOS.
+ """
+
+ __tablename__ = "log_asset"
+ __versioned__ = {}
+
+ uuid = model.uuid_column()
+
+ log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
+ log = orm.relationship(
+ Log,
+ foreign_keys=log_uuid,
+ back_populates="_assets",
+ )
+
+ asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
+ asset = orm.relationship(
+ "Asset",
+ foreign_keys=asset_uuid,
+ )
diff --git a/src/wuttafarm/db/model/log_activity.py b/src/wuttafarm/db/model/log_activity.py
index bbf8154..2f5f6e5 100644
--- a/src/wuttafarm/db/model/log_activity.py
+++ b/src/wuttafarm/db/model/log_activity.py
@@ -30,7 +30,7 @@ from wuttafarm.db.model.log import LogMixin, add_log_proxies
class ActivityLog(LogMixin, model.Base):
"""
- Represents an activity log from farmOS
+ Represents an Activity Log from farmOS
"""
__tablename__ = "log_activity"
diff --git a/src/wuttafarm/db/model/log_harvest.py b/src/wuttafarm/db/model/log_harvest.py
new file mode 100644
index 0000000..35c3105
--- /dev/null
+++ b/src/wuttafarm/db/model/log_harvest.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 Harvest Logs
+"""
+
+from wuttjamaican.db import model
+
+from wuttafarm.db.model.log import LogMixin, add_log_proxies
+
+
+class HarvestLog(LogMixin, model.Base):
+ """
+ Represents a Harvest Log from farmOS
+ """
+
+ __tablename__ = "log_harvest"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Harvest Log",
+ "model_title_plural": "Harvest Logs",
+ "farmos_log_type": "harvest",
+ }
+
+
+add_log_proxies(HarvestLog)
diff --git a/src/wuttafarm/db/model/log_medical.py b/src/wuttafarm/db/model/log_medical.py
new file mode 100644
index 0000000..439ee3b
--- /dev/null
+++ b/src/wuttafarm/db/model/log_medical.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 Medical Logs
+"""
+
+from wuttjamaican.db import model
+
+from wuttafarm.db.model.log import LogMixin, add_log_proxies
+
+
+class MedicalLog(LogMixin, model.Base):
+ """
+ Represents a Medical Log from farmOS
+ """
+
+ __tablename__ = "log_medical"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Medical Log",
+ "model_title_plural": "Medical Logs",
+ "farmos_log_type": "medical",
+ }
+
+
+add_log_proxies(MedicalLog)
diff --git a/src/wuttafarm/db/model/log_observation.py b/src/wuttafarm/db/model/log_observation.py
new file mode 100644
index 0000000..ab89c3f
--- /dev/null
+++ b/src/wuttafarm/db/model/log_observation.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 Observation Logs
+"""
+
+from wuttjamaican.db import model
+
+from wuttafarm.db.model.log import LogMixin, add_log_proxies
+
+
+class ObservationLog(LogMixin, model.Base):
+ """
+ Represents a Observation Log from farmOS
+ """
+
+ __tablename__ = "log_observation"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Observation Log",
+ "model_title_plural": "Observation Logs",
+ "farmos_log_type": "observation",
+ }
+
+
+add_log_proxies(ObservationLog)
diff --git a/src/wuttafarm/enum.py b/src/wuttafarm/enum.py
index 41bf597..03181b9 100644
--- a/src/wuttafarm/enum.py
+++ b/src/wuttafarm/enum.py
@@ -34,3 +34,12 @@ ANIMAL_SEX = OrderedDict(
("F", "Female"),
]
)
+
+
+LOG_STATUS = OrderedDict(
+ [
+ ("pending", "Pending"),
+ ("done", "Done"),
+ ("abandoned", "Abandoned"),
+ ]
+)
diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py
index 6c3f5a0..e114746 100644
--- a/src/wuttafarm/farmos/importing/model.py
+++ b/src/wuttafarm/farmos/importing/model.py
@@ -363,3 +363,113 @@ class StructureAssetImporter(ToFarmOSAsset):
payload["attributes"].update(attrs)
return payload
+
+
+##############################
+# log importers
+##############################
+
+
+class ToFarmOSLog(ToFarmOS):
+ """
+ Base class for log data importer targeting the farmOS API.
+ """
+
+ farmos_log_type = None
+
+ supported_fields = [
+ "uuid",
+ "name",
+ "notes",
+ ]
+
+ def get_target_objects(self, **kwargs):
+ result = self.farmos_client.log.get(self.farmos_log_type)
+ return result["data"]
+
+ def get_target_object(self, key):
+
+ # fetch from cache, if applicable
+ if self.caches_target:
+ return super().get_target_object(key)
+
+ # okay now must fetch via API
+ if self.get_keys() != ["uuid"]:
+ raise ValueError("must use uuid key for this to work")
+ uuid = key[0]
+
+ try:
+ log = self.farmos_client.log.get_id(self.farmos_log_type, str(uuid))
+ except requests.HTTPError as exc:
+ if exc.response.status_code == 404:
+ return None
+ raise
+ return log["data"]
+
+ def create_target_object(self, key, source_data):
+ if source_data.get("__ignoreme__"):
+ return None
+ if self.dry_run:
+ return source_data
+
+ payload = self.get_log_payload(source_data)
+ result = self.farmos_client.log.send(self.farmos_log_type, payload)
+ normal = self.normalize_target_object(result["data"])
+ normal["_new_object"] = result["data"]
+ return normal
+
+ def update_target_object(self, asset, source_data, target_data=None):
+ if self.dry_run:
+ return asset
+
+ payload = self.get_log_payload(source_data)
+ payload["id"] = str(source_data["uuid"])
+ result = self.farmos_client.log.send(self.farmos_log_type, payload)
+ return self.normalize_target_object(result["data"])
+
+ def normalize_target_object(self, log):
+
+ if notes := log["attributes"]["notes"]:
+ notes = notes["value"]
+
+ return {
+ "uuid": UUID(log["id"]),
+ "name": log["attributes"]["name"],
+ "notes": notes,
+ }
+
+ def get_log_payload(self, source_data):
+
+ attrs = {}
+ if "name" in self.fields:
+ attrs["name"] = source_data["name"]
+ if "notes" in self.fields:
+ attrs["notes"] = {"value": source_data["notes"]}
+
+ payload = {"attributes": attrs}
+
+ return payload
+
+
+class ActivityLogImporter(ToFarmOSLog):
+
+ model_title = "ActivityLog"
+ farmos_log_type = "activity"
+
+
+class HarvestLogImporter(ToFarmOSLog):
+
+ model_title = "HarvestLog"
+ farmos_log_type = "harvest"
+
+
+class MedicalLogImporter(ToFarmOSLog):
+
+ model_title = "MedicalLog"
+ farmos_log_type = "medical"
+
+
+class ObservationLogImporter(ToFarmOSLog):
+
+ model_title = "ObservationLog"
+ farmos_log_type = "observation"
diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py
index 8ef8a77..0718614 100644
--- a/src/wuttafarm/farmos/importing/wuttafarm.py
+++ b/src/wuttafarm/farmos/importing/wuttafarm.py
@@ -98,6 +98,10 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter
+ importers["ActivityLog"] = ActivityLogImporter
+ importers["HarvestLog"] = HarvestLogImporter
+ importers["MedicalLog"] = MedicalLogImporter
+ importers["ObservationLog"] = ObservationLogImporter
return importers
@@ -261,3 +265,62 @@ class StructureAssetImporter(
"archived": structure.archived,
"_src_object": structure,
}
+
+
+##############################
+# log importers
+##############################
+
+
+class FromWuttaFarmLog(FromWuttaFarm):
+ """
+ Base class for WuttaFarm -> farmOS log importers
+ """
+
+ supported_fields = [
+ "uuid",
+ "name",
+ "notes",
+ ]
+
+ def normalize_source_object(self, log):
+ return {
+ "uuid": log.farmos_uuid or self.app.make_true_uuid(),
+ "name": log.message,
+ "notes": log.notes,
+ "_src_object": log,
+ }
+
+
+class ActivityLogImporter(FromWuttaFarmLog, farmos_importing.model.ActivityLogImporter):
+ """
+ WuttaFarm → farmOS API exporter for Activity Logs
+ """
+
+ source_model_class = model.ActivityLog
+
+
+class HarvestLogImporter(FromWuttaFarmLog, farmos_importing.model.HarvestLogImporter):
+ """
+ WuttaFarm → farmOS API exporter for Harvest Logs
+ """
+
+ source_model_class = model.HarvestLog
+
+
+class MedicalLogImporter(FromWuttaFarmLog, farmos_importing.model.MedicalLogImporter):
+ """
+ WuttaFarm → farmOS API exporter for Medical Logs
+ """
+
+ source_model_class = model.MedicalLog
+
+
+class ObservationLogImporter(
+ FromWuttaFarmLog, farmos_importing.model.ObservationLogImporter
+):
+ """
+ WuttaFarm → farmOS API exporter for Observation Logs
+ """
+
+ source_model_class = model.ObservationLog
diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py
index e421f26..bde283f 100644
--- a/src/wuttafarm/importing/farmos.py
+++ b/src/wuttafarm/importing/farmos.py
@@ -104,8 +104,13 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter
+ importers["PlantType"] = PlantTypeImporter
+ importers["PlantAsset"] = PlantAssetImporter
importers["LogType"] = LogTypeImporter
importers["ActivityLog"] = ActivityLogImporter
+ importers["HarvestLog"] = HarvestLogImporter
+ importers["MedicalLog"] = MedicalLogImporter
+ importers["ObservationLog"] = ObservationLogImporter
return importers
@@ -144,6 +149,9 @@ class AssetImporterBase(FromFarmOS, ToWutta):
Base class for farmOS API → WuttaFarm asset importers
"""
+ def get_farmos_asset_type(self):
+ return self.model_class.__wutta_hint__["farmos_asset_type"]
+
def get_simple_fields(self):
""" """
fields = list(super().get_simple_fields())
@@ -174,6 +182,12 @@ class AssetImporterBase(FromFarmOS, ToWutta):
)
return fields
+ def get_source_objects(self):
+ """ """
+ asset_type = self.get_farmos_asset_type()
+ result = self.farmos_client.asset.get(asset_type)
+ return result["data"]
+
def normalize_source_data(self, **kwargs):
""" """
data = super().normalize_source_data(**kwargs)
@@ -283,71 +297,6 @@ class AssetImporterBase(FromFarmOS, ToWutta):
return asset
-class LogImporterBase(FromFarmOS, ToWutta):
- """
- Base class for farmOS API → WuttaFarm log importers
- """
-
- def get_farmos_log_type(self):
- return self.model_class.__wutta_hint__["farmos_log_type"]
-
- def get_simple_fields(self):
- """ """
- fields = list(super().get_simple_fields())
- # nb. must explicitly declare proxy fields
- fields.extend(
- [
- "farmos_uuid",
- "drupal_id",
- "log_type",
- "message",
- "timestamp",
- "notes",
- "status",
- ]
- )
- return fields
-
- def get_source_objects(self):
- """ """
- log_type = self.get_farmos_log_type()
- result = self.farmos_client.log.get(log_type)
- return result["data"]
-
- def normalize_source_object(self, log):
- """ """
- if notes := log["attributes"]["notes"]:
- notes = notes["value"]
-
- return {
- "farmos_uuid": UUID(log["id"]),
- "drupal_id": log["attributes"]["drupal_internal__id"],
- "log_type": self.get_farmos_log_type(),
- "message": log["attributes"]["name"],
- "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]),
- "notes": notes,
- "status": log["attributes"]["status"],
- }
-
-
-class ActivityLogImporter(LogImporterBase):
- """
- farmOS API → WuttaFarm importer for Activity Logs
- """
-
- model_class = model.ActivityLog
-
- supported_fields = [
- "farmos_uuid",
- "drupal_id",
- "log_type",
- "message",
- "timestamp",
- "notes",
- "status",
- ]
-
-
class AnimalAssetImporter(AssetImporterBase):
"""
farmOS API → WuttaFarm importer for Animals
@@ -604,12 +553,12 @@ class LandTypeImporter(FromFarmOS, ToWutta):
}
-class LogTypeImporter(FromFarmOS, ToWutta):
+class PlantTypeImporter(FromFarmOS, ToWutta):
"""
- farmOS API → WuttaFarm importer for Log Types
+ farmOS API → WuttaFarm importer for Plant Types
"""
- model_class = model.LogType
+ model_class = model.PlantType
supported_fields = [
"farmos_uuid",
@@ -620,19 +569,112 @@ class LogTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
- log_types = self.farmos_client.resource.get("log_type")
- return log_types["data"]
+ result = self.farmos_client.resource.get("taxonomy_term", "plant_type")
+ return result["data"]
- def normalize_source_object(self, log_type):
+ def normalize_source_object(self, plant_type):
""" """
return {
- "farmos_uuid": UUID(log_type["id"]),
- "drupal_id": log_type["attributes"]["drupal_internal__id"],
- "name": log_type["attributes"]["label"],
- "description": log_type["attributes"]["description"],
+ "farmos_uuid": UUID(plant_type["id"]),
+ "drupal_id": plant_type["attributes"]["drupal_internal__tid"],
+ "name": plant_type["attributes"]["name"],
+ "description": plant_type["attributes"]["description"],
}
+class PlantAssetImporter(AssetImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Plant Assets
+ """
+
+ model_class = model.PlantAsset
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "asset_type",
+ "asset_name",
+ "plant_types",
+ "notes",
+ "archived",
+ "image_url",
+ "thumbnail_url",
+ ]
+
+ def setup(self):
+ super().setup()
+ model = self.app.model
+
+ self.plant_types_by_farmos_uuid = {}
+ for plant_type in self.target_session.query(model.PlantType):
+ if plant_type.farmos_uuid:
+ self.plant_types_by_farmos_uuid[plant_type.farmos_uuid] = plant_type
+
+ def normalize_source_object(self, plant):
+ """ """
+ plant_types = None
+ if relationships := plant.get("relationships"):
+
+ if plant_type := relationships.get("plant_type"):
+ plant_types = []
+ for plant_type in plant_type["data"]:
+ if wf_plant_type := self.plant_types_by_farmos_uuid.get(
+ UUID(plant_type["id"])
+ ):
+ plant_types.append(wf_plant_type.uuid)
+ else:
+ log.warning("plant type not found: %s", plant_type["id"])
+
+ data = self.normalize_asset(plant)
+ data.update(
+ {
+ "asset_type": "plant",
+ "plant_types": plant_types,
+ }
+ )
+ return data
+
+ def normalize_target_object(self, plant):
+ data = super().normalize_target_object(plant)
+
+ if "plant_types" in self.fields:
+ data["plant_types"] = [t.plant_type_uuid for t in plant._plant_types]
+
+ return data
+
+ def update_target_object(self, plant, source_data, target_data=None):
+ model = self.app.model
+ plant = super().update_target_object(plant, source_data, target_data)
+
+ if "plant_types" in self.fields:
+ if (
+ not target_data
+ or target_data["plant_types"] != source_data["plant_types"]
+ ):
+
+ for uuid in source_data["plant_types"]:
+ if not target_data or uuid not in target_data["plant_types"]:
+ self.target_session.flush()
+ plant._plant_types.append(
+ model.PlantAssetPlantType(plant_type_uuid=uuid)
+ )
+
+ if target_data:
+ for uuid in target_data["plant_types"]:
+ if uuid not in source_data["plant_types"]:
+ plant_type = (
+ self.target_session.query(model.PlantAssetPlantType)
+ .filter(model.PlantAssetPlantType.plant_asset == plant)
+ .filter(
+ model.PlantAssetPlantType.plant_type_uuid == uuid
+ )
+ .one()
+ )
+ self.target_session.delete(plant_type)
+
+ return plant
+
+
class StructureAssetImporter(AssetImporterBase):
"""
farmOS API → WuttaFarm importer for Structure Assets
@@ -768,3 +810,229 @@ class UserImporter(FromFarmOS, ToWutta):
if not user.farmos_uuid:
return False
return True
+
+
+##############################
+# log importers
+##############################
+
+
+class LogTypeImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Log Types
+ """
+
+ model_class = model.LogType
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ "description",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ log_types = self.farmos_client.resource.get("log_type")
+ return log_types["data"]
+
+ def normalize_source_object(self, log_type):
+ """ """
+ return {
+ "farmos_uuid": UUID(log_type["id"]),
+ "drupal_id": log_type["attributes"]["drupal_internal__id"],
+ "name": log_type["attributes"]["label"],
+ "description": log_type["attributes"]["description"],
+ }
+
+
+class LogImporterBase(FromFarmOS, ToWutta):
+ """
+ Base class for farmOS API → WuttaFarm log importers
+ """
+
+ def get_farmos_log_type(self):
+ return self.model_class.__wutta_hint__["farmos_log_type"]
+
+ def get_simple_fields(self):
+ """ """
+ fields = list(super().get_simple_fields())
+ # nb. must explicitly declare proxy fields
+ fields.extend(
+ [
+ "farmos_uuid",
+ "drupal_id",
+ "log_type",
+ "message",
+ "timestamp",
+ "notes",
+ "status",
+ ]
+ )
+ return fields
+
+ def get_supported_fields(self):
+ """ """
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "assets",
+ ]
+ )
+ return fields
+
+ def get_source_objects(self):
+ """ """
+ log_type = self.get_farmos_log_type()
+ result = self.farmos_client.log.get(log_type)
+ return result["data"]
+
+ def get_asset_type(self, asset):
+ return asset["type"].split("--")[1]
+
+ def normalize_source_object(self, log):
+ """ """
+ if notes := log["attributes"]["notes"]:
+ notes = notes["value"]
+
+ assets = None
+ if "assets" in self.fields:
+ assets = []
+ for asset in log["relationships"]["asset"]["data"]:
+ assets.append((self.get_asset_type(asset), UUID(asset["id"])))
+
+ return {
+ "farmos_uuid": UUID(log["id"]),
+ "drupal_id": log["attributes"]["drupal_internal__id"],
+ "log_type": self.get_farmos_log_type(),
+ "message": log["attributes"]["name"],
+ "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]),
+ "notes": notes,
+ "status": log["attributes"]["status"],
+ "assets": assets,
+ }
+
+ def normalize_target_object(self, log):
+ data = super().normalize_target_object(log)
+
+ if "assets" in self.fields:
+ data["assets"] = [
+ (a.asset.asset_type, a.asset.farmos_uuid) for a in log.log._assets
+ ]
+
+ return data
+
+ def update_target_object(self, log, source_data, target_data=None):
+ model = self.app.model
+ log = super().update_target_object(log, source_data, target_data)
+
+ if "assets" in self.fields:
+ if not target_data or target_data["assets"] != source_data["assets"]:
+
+ for key in source_data["assets"]:
+ asset_type, farmos_uuid = key
+ if not target_data or key not in target_data["assets"]:
+ self.target_session.flush()
+ asset = (
+ self.target_session.query(model.Asset)
+ .filter(model.Asset.asset_type == asset_type)
+ .filter(model.Asset.farmos_uuid == farmos_uuid)
+ .one()
+ )
+ log.log._assets.append(model.LogAsset(asset=asset))
+
+ if target_data:
+ for key in target_data["assets"]:
+ asset_type, farmos_uuid = key
+ if key not in source_data["assets"]:
+ asset = (
+ self.target_session.query(model.Asset)
+ .filter(model.Asset.asset_type == asset_type)
+ .filter(model.Asset.farmos_uuid == farmos_uuid)
+ .one()
+ )
+ asset = (
+ self.target_session.query(model.LogAsset)
+ .filter(model.LogAsset.log == log)
+ .filter(model.LogAsset.asset == asset)
+ .one()
+ )
+ self.target_session.delete(asset)
+
+ return log
+
+
+class ActivityLogImporter(LogImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Activity Logs
+ """
+
+ model_class = model.ActivityLog
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "log_type",
+ "message",
+ "timestamp",
+ "notes",
+ "status",
+ "assets",
+ ]
+
+
+class HarvestLogImporter(LogImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Harvest Logs
+ """
+
+ model_class = model.HarvestLog
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "log_type",
+ "message",
+ "timestamp",
+ "notes",
+ "status",
+ "assets",
+ ]
+
+
+class MedicalLogImporter(LogImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Medical Logs
+ """
+
+ model_class = model.MedicalLog
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "log_type",
+ "message",
+ "timestamp",
+ "notes",
+ "status",
+ "assets",
+ ]
+
+
+class ObservationLogImporter(LogImporterBase):
+ """
+ farmOS API → WuttaFarm importer for Observation Logs
+ """
+
+ model_class = model.ObservationLog
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "log_type",
+ "message",
+ "timestamp",
+ "notes",
+ "status",
+ "assets",
+ ]
diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py
index 95b3e9d..123f662 100644
--- a/src/wuttafarm/web/forms/schema.py
+++ b/src/wuttafarm/web/forms/schema.py
@@ -74,6 +74,25 @@ class AnimalTypeType(colander.SchemaType):
return AnimalTypeWidget(self.request, **kwargs)
+class FarmOSPlantTypes(colander.SchemaType):
+
+ def __init__(self, request, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+
+ def serialize(self, node, appstruct):
+ if appstruct is colander.null:
+ return colander.null
+
+ return json.dumps(appstruct)
+
+ def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
+ """ """
+ from wuttafarm.web.forms.widgets import FarmOSPlantTypesWidget
+
+ return FarmOSPlantTypesWidget(self.request, **kwargs)
+
+
class LandTypeRef(ObjectRef):
"""
Custom schema type for a
@@ -99,6 +118,23 @@ class LandTypeRef(ObjectRef):
return self.request.route_url("land_types.view", uuid=land_type.uuid)
+class PlantTypeRefs(WuttaSet):
+ """
+ Schema type for Plant Types field (on a Plant Asset).
+ """
+
+ 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 PlantTypeRefsWidget
+
+ return PlantTypeRefsWidget(self.request, **kwargs)
+
+
class StructureType(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
@@ -177,3 +213,20 @@ class AssetParentRefs(WuttaSet):
from wuttafarm.web.forms.widgets import AssetParentRefsWidget
return AssetParentRefsWidget(self.request, **kwargs)
+
+
+class LogAssetRefs(WuttaSet):
+ """
+ Schema type for Assets field (on a Log record)
+ """
+
+ 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 LogAssetRefsWidget
+
+ return LogAssetRefsWidget(self.request, **kwargs)
diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py
index f812ccf..d5bf5c2 100644
--- a/src/wuttafarm/web/forms/widgets.py
+++ b/src/wuttafarm/web/forms/widgets.py
@@ -81,6 +81,67 @@ class AnimalTypeWidget(Widget):
return super().serialize(field, cstruct, **kw)
+class FarmOSPlantTypesWidget(Widget):
+ """
+ Widget to display a farmOS "plant types" field.
+ """
+
+ def __init__(self, request, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+
+ def serialize(self, field, cstruct, **kw):
+ """ """
+ readonly = kw.get("readonly", self.readonly)
+ if readonly:
+ if cstruct in (colander.null, None):
+ return HTML.tag("span")
+
+ links = []
+ for plant_type in json.loads(cstruct):
+ link = tags.link_to(
+ plant_type["name"],
+ self.request.route_url(
+ "farmos_plant_types.view", uuid=plant_type["uuid"]
+ ),
+ )
+ links.append(HTML.tag("li", c=link))
+ return HTML.tag("ul", c=links)
+
+ return super().serialize(field, cstruct, **kw)
+
+
+class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget):
+ """
+ Widget for Plant Types field (on a Plant Asset).
+ """
+
+ def serialize(self, field, cstruct, **kw):
+ """ """
+ model = self.app.model
+ session = Session()
+
+ readonly = kw.get("readonly", self.readonly)
+ if readonly:
+ plant_types = []
+ for uuid in json.loads(cstruct):
+ plant_type = session.get(model.PlantType, uuid)
+ plant_types.append(
+ HTML.tag(
+ "li",
+ c=tags.link_to(
+ str(plant_type),
+ self.request.route_url(
+ "plant_types.view", uuid=plant_type.uuid
+ ),
+ ),
+ )
+ )
+ return HTML.tag("ul", c=plant_types)
+
+ return super().serialize(field, cstruct, **kw)
+
+
class StructureWidget(Widget):
"""
Widget to display a "structure" field.
@@ -166,3 +227,34 @@ class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
return HTML.tag("ul", c=parents)
return super().serialize(field, cstruct, **kw)
+
+
+class LogAssetRefsWidget(WuttaCheckboxChoiceWidget):
+ """
+ Widget for Assets field (on a Log record)
+ """
+
+ def serialize(self, field, cstruct, **kw):
+ """ """
+ model = self.app.model
+ session = Session()
+
+ readonly = kw.get("readonly", self.readonly)
+ if readonly:
+ assets = []
+ for uuid in json.loads(cstruct):
+ asset = session.get(model.Asset, uuid)
+ assets.append(
+ HTML.tag(
+ "li",
+ c=tags.link_to(
+ str(asset),
+ self.request.route_url(
+ f"{asset.asset_type}_assets.view", uuid=asset.uuid
+ ),
+ ),
+ )
+ )
+ return HTML.tag("ul", c=assets)
+
+ return super().serialize(field, cstruct, **kw)
diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py
index bdd2fbf..d52a6ca 100644
--- a/src/wuttafarm/web/menus.py
+++ b/src/wuttafarm/web/menus.py
@@ -64,6 +64,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "land_assets",
"perm": "land_assets.list",
},
+ {
+ "title": "Plant",
+ "route": "plant_assets",
+ "perm": "plant_assets.list",
+ },
{
"title": "Structure",
"route": "structure_assets",
@@ -80,6 +85,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "land_types",
"perm": "land_types.list",
},
+ {
+ "title": "Plant Types",
+ "route": "plant_types",
+ "perm": "plant_types.list",
+ },
{
"title": "Structure Types",
"route": "structure_types",
@@ -99,9 +109,29 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"type": "menu",
"items": [
{
- "title": "Activity Logs",
- "route": "activity_logs",
- "perm": "activity_logs.list",
+ "title": "All Logs",
+ "route": "log",
+ "perm": "log.list",
+ },
+ {
+ "title": "Activity",
+ "route": "logs_activity",
+ "perm": "logs_activity.list",
+ },
+ {
+ "title": "Harvest",
+ "route": "logs_harvest",
+ "perm": "logs_harvest.list",
+ },
+ {
+ "title": "Medical",
+ "route": "logs_medical",
+ "perm": "logs_medical.list",
+ },
+ {
+ "title": "Observation",
+ "route": "logs_observation",
+ "perm": "logs_observation.list",
},
{"type": "sep"},
{
@@ -135,6 +165,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_groups",
"perm": "farmos_groups.list",
},
+ {
+ "title": "Plants",
+ "route": "farmos_asset_plant",
+ "perm": "farmos_asset_plant.list",
+ },
{
"title": "Structures",
"route": "farmos_structures",
@@ -151,12 +186,32 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_logs_activity",
"perm": "farmos_logs_activity.list",
},
+ {
+ "title": "Harvest Logs",
+ "route": "farmos_logs_harvest",
+ "perm": "farmos_logs_harvest.list",
+ },
+ {
+ "title": "Medical Logs",
+ "route": "farmos_logs_medical",
+ "perm": "farmos_logs_medical.list",
+ },
+ {
+ "title": "Observation Logs",
+ "route": "farmos_logs_observation",
+ "perm": "farmos_logs_observation.list",
+ },
{"type": "sep"},
{
"title": "Animal Types",
"route": "farmos_animal_types",
"perm": "farmos_animal_types.list",
},
+ {
+ "title": "Plant Types",
+ "route": "farmos_plant_types",
+ "perm": "farmos_plant_types.list",
+ },
{
"title": "Structure Types",
"route": "farmos_structure_types",
diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py
index fe42703..bb710a2 100644
--- a/src/wuttafarm/web/views/__init__.py
+++ b/src/wuttafarm/web/views/__init__.py
@@ -47,8 +47,12 @@ def includeme(config):
config.include("wuttafarm.web.views.structures")
config.include("wuttafarm.web.views.animals")
config.include("wuttafarm.web.views.groups")
+ config.include("wuttafarm.web.views.plants")
config.include("wuttafarm.web.views.logs")
config.include("wuttafarm.web.views.logs_activity")
+ config.include("wuttafarm.web.views.logs_harvest")
+ config.include("wuttafarm.web.views.logs_medical")
+ config.include("wuttafarm.web.views.logs_observation")
# views for farmOS
config.include("wuttafarm.web.views.farmos")
diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py
index dffaae7..b918839 100644
--- a/src/wuttafarm/web/views/assets.py
+++ b/src/wuttafarm/web/views/assets.py
@@ -29,7 +29,7 @@ from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.db import Session
from wuttafarm.web.views import WuttaFarmMasterView
-from wuttafarm.db.model import Asset
+from wuttafarm.db.model import Asset, Log
from wuttafarm.web.forms.schema import AssetParentRefs
from wuttafarm.web.forms.widgets import ImageWidget
@@ -140,6 +140,28 @@ class AssetMasterView(WuttaFarmMasterView):
"archived": {"active": True, "verb": "is_false"},
}
+ has_rows = True
+ row_model_class = Log
+ rows_viewable = True
+
+ row_labels = {
+ "message": "Log Name",
+ }
+
+ row_grid_columns = [
+ "status",
+ "drupal_id",
+ "timestamp",
+ "message",
+ "log_type",
+ "assets",
+ "location",
+ "quantity",
+ "is_group_assignment",
+ ]
+
+ rows_sort_defaults = ("timestamp", "desc")
+
def get_fallback_templates(self, template):
templates = super().get_fallback_templates(template)
@@ -265,6 +287,8 @@ class AssetMasterView(WuttaFarmMasterView):
route = "farmos_groups.view"
elif asset.asset_type == "land":
route = "farmos_land_assets.view"
+ elif asset.asset_type == "plant":
+ route = "farmos_asset_plant.view"
elif asset.asset_type == "structure":
route = "farmos_structures.view"
@@ -280,6 +304,39 @@ class AssetMasterView(WuttaFarmMasterView):
return buttons
+ def get_row_grid_data(self, asset):
+ model = self.app.model
+ session = self.Session()
+ return (
+ session.query(model.Log)
+ .outerjoin(model.LogAsset)
+ .filter(model.LogAsset.asset_uuid == asset.uuid)
+ )
+
+ def configure_row_grid(self, grid):
+ g = grid
+ super().configure_row_grid(g)
+ model = self.app.model
+
+ # drupal_id
+ g.set_label("drupal_id", "ID", column_only=True)
+
+ # message
+ g.set_link("message")
+ g.set_sorter("message", model.Log.message)
+ g.set_filter("message", model.Log.message)
+
+ # timestamp
+ g.set_sorter("timestamp", model.Log.timestamp)
+ g.set_filter("timestamp", model.Log.timestamp)
+
+ # log_type
+ g.set_sorter("log_type", model.Log.log_type)
+ g.set_filter("log_type", model.Log.log_type)
+
+ def get_row_action_url_view(self, log, i):
+ return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py
index deacd7d..bda5d03 100644
--- a/src/wuttafarm/web/views/farmos/__init__.py
+++ b/src/wuttafarm/web/views/farmos/__init__.py
@@ -36,5 +36,9 @@ def includeme(config):
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.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")
diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py
index c6748c4..ddb7278 100644
--- a/src/wuttafarm/web/views/farmos/groups.py
+++ b/src/wuttafarm/web/views/farmos/groups.py
@@ -115,6 +115,9 @@ class GroupView(FarmOSMasterView):
else:
archived = group["attributes"]["status"] == "archived"
+ if notes := group["attributes"]["notes"]:
+ notes = notes["value"]
+
return {
"uuid": group["id"],
"drupal_id": group["attributes"]["drupal_internal__id"],
@@ -124,7 +127,7 @@ class GroupView(FarmOSMasterView):
"is_fixed": group["attributes"]["is_fixed"],
"is_location": group["attributes"]["is_location"],
"archived": archived,
- "notes": group["attributes"]["notes"]["value"],
+ "notes": notes or colander.null,
}
def configure_form(self, form):
diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py
new file mode 100644
index 0000000..a3e804f
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/logs.py
@@ -0,0 +1,142 @@
+# -*- 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 Harvest Logs
+"""
+
+import datetime
+
+import colander
+
+from wuttaweb.forms.schema import WuttaDateTime
+from wuttaweb.forms.widgets import WuttaDateTimeWidget
+
+from wuttafarm.web.views.farmos import FarmOSMasterView
+
+
+class LogMasterView(FarmOSMasterView):
+ """
+ Base class for farmOS Log master views
+ """
+
+ farmos_log_type = None
+
+ grid_columns = [
+ "name",
+ "timestamp",
+ "status",
+ ]
+
+ sort_defaults = ("timestamp", "desc")
+
+ form_fields = [
+ "name",
+ "timestamp",
+ "status",
+ "notes",
+ ]
+
+ def get_grid_data(self, columns=None, session=None):
+ result = self.farmos_client.log.get(self.farmos_log_type)
+ return [self.normalize_log(l) for l in result["data"]]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
+ g.set_searchable("name")
+
+ # timestamp
+ g.set_renderer("timestamp", "datetime")
+
+ def get_instance(self):
+ log = self.farmos_client.log.get_id(
+ self.farmos_log_type, self.request.matchdict["uuid"]
+ )
+ self.raw_json = log
+ return self.normalize_log(log["data"])
+
+ def get_instance_title(self, log):
+ return log["name"]
+
+ def normalize_log(self, log):
+
+ if timestamp := log["attributes"]["timestamp"]:
+ timestamp = datetime.datetime.fromisoformat(timestamp)
+ timestamp = self.app.localtime(timestamp)
+
+ if notes := log["attributes"]["notes"]:
+ notes = notes["value"]
+
+ return {
+ "uuid": log["id"],
+ "drupal_id": log["attributes"]["drupal_internal__id"],
+ "name": log["attributes"]["name"],
+ "timestamp": timestamp,
+ "status": log["attributes"]["status"],
+ "notes": notes or colander.null,
+ }
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # timestamp
+ f.set_node("timestamp", WuttaDateTime())
+ f.set_widget("timestamp", WuttaDateTimeWidget(self.request))
+
+ # notes
+ f.set_widget("notes", "notes")
+
+ def get_xref_buttons(self, log):
+ model = self.app.model
+ session = self.Session()
+
+ buttons = [
+ self.make_button(
+ "View in farmOS",
+ primary=True,
+ url=self.app.get_farmos_url(f"/log/{log['drupal_id']}"),
+ target="_blank",
+ icon_left="external-link-alt",
+ ),
+ ]
+
+ if wf_log := (
+ session.query(model.Log)
+ .filter(model.Log.farmos_uuid == log["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ f"logs_{self.farmos_log_type}.view", uuid=wf_log.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
diff --git a/src/wuttafarm/web/views/farmos/logs_activity.py b/src/wuttafarm/web/views/farmos/logs_activity.py
index e966810..972ca31 100644
--- a/src/wuttafarm/web/views/farmos/logs_activity.py
+++ b/src/wuttafarm/web/views/farmos/logs_activity.py
@@ -20,20 +20,13 @@
#
################################################################################
"""
-View for farmOS activity logs
+View for farmOS Activity Logs
"""
-import datetime
-
-import colander
-
-from wuttaweb.forms.schema import WuttaDateTime
-from wuttaweb.forms.widgets import WuttaDateTimeWidget
-
-from wuttafarm.web.views.farmos import FarmOSMasterView
+from wuttafarm.web.views.farmos.logs import LogMasterView
-class ActivityLogView(FarmOSMasterView):
+class ActivityLogView(LogMasterView):
"""
View for farmOS activity logs
"""
@@ -45,105 +38,9 @@ class ActivityLogView(FarmOSMasterView):
route_prefix = "farmos_logs_activity"
url_prefix = "/farmOS/logs/activity"
+ farmos_log_type = "activity"
farmos_refurl_path = "/logs/activity"
- grid_columns = [
- "name",
- "timestamp",
- "status",
- ]
-
- sort_defaults = ("timestamp", "desc")
-
- form_fields = [
- "name",
- "timestamp",
- "status",
- "notes",
- ]
-
- def get_grid_data(self, columns=None, session=None):
- logs = self.farmos_client.log.get("activity")
- return [self.normalize_log(t) for t in logs["data"]]
-
- def configure_grid(self, grid):
- g = grid
- super().configure_grid(g)
-
- # name
- g.set_link("name")
- g.set_searchable("name")
-
- # timestamp
- g.set_renderer("timestamp", "datetime")
-
- def get_instance(self):
- log = self.farmos_client.log.get_id("activity", self.request.matchdict["uuid"])
- self.raw_json = log
- return self.normalize_log(log["data"])
-
- def get_instance_title(self, log):
- return log["name"]
-
- def normalize_log(self, log):
-
- if timestamp := log["attributes"]["timestamp"]:
- timestamp = datetime.datetime.fromisoformat(timestamp)
- timestamp = self.app.localtime(timestamp)
-
- if notes := log["attributes"]["notes"]:
- notes = notes["value"]
-
- return {
- "uuid": log["id"],
- "drupal_id": log["attributes"]["drupal_internal__id"],
- "name": log["attributes"]["name"],
- "timestamp": timestamp,
- "status": log["attributes"]["status"],
- "notes": notes or colander.null,
- }
-
- def configure_form(self, form):
- f = form
- super().configure_form(f)
-
- # timestamp
- f.set_node("timestamp", WuttaDateTime())
- f.set_widget("timestamp", WuttaDateTimeWidget(self.request))
-
- # notes
- f.set_widget("notes", "notes")
-
- def get_xref_buttons(self, log):
- model = self.app.model
- session = self.Session()
-
- buttons = [
- self.make_button(
- "View in farmOS",
- primary=True,
- url=self.app.get_farmos_url(f"/log/{log['drupal_id']}"),
- target="_blank",
- icon_left="external-link-alt",
- ),
- ]
-
- if wf_log := (
- session.query(model.ActivityLog)
- .filter(model.ActivityLog.farmos_uuid == log["uuid"])
- .first()
- ):
- buttons.append(
- self.make_button(
- f"View {self.app.get_title()} record",
- primary=True,
- url=self.request.route_url("activity_logs.view", uuid=wf_log.uuid),
- icon_left="eye",
- )
- )
-
- return buttons
-
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/logs_harvest.py b/src/wuttafarm/web/views/farmos/logs_harvest.py
new file mode 100644
index 0000000..0f39a5a
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/logs_harvest.py
@@ -0,0 +1,53 @@
+# -*- 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 Harvest Logs
+"""
+
+from wuttafarm.web.views.farmos.logs import LogMasterView
+
+
+class HarvestLogView(LogMasterView):
+ """
+ View for farmOS harvest logs
+ """
+
+ model_name = "farmos_harvest_log"
+ model_title = "farmOS Harvest Log"
+ model_title_plural = "farmOS Harvest Logs"
+
+ route_prefix = "farmos_logs_harvest"
+ url_prefix = "/farmOS/logs/harvest"
+
+ farmos_log_type = "harvest"
+ farmos_refurl_path = "/logs/harvest"
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ HarvestLogView = kwargs.get("HarvestLogView", base["HarvestLogView"])
+ HarvestLogView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/logs_medical.py b/src/wuttafarm/web/views/farmos/logs_medical.py
new file mode 100644
index 0000000..95a88c5
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/logs_medical.py
@@ -0,0 +1,53 @@
+# -*- 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 Medical Logs
+"""
+
+from wuttafarm.web.views.farmos.logs import LogMasterView
+
+
+class MedicalLogView(LogMasterView):
+ """
+ View for farmOS medical logs
+ """
+
+ model_name = "farmos_medical_log"
+ model_title = "farmOS Medical Log"
+ model_title_plural = "farmOS Medical Logs"
+
+ route_prefix = "farmos_logs_medical"
+ url_prefix = "/farmOS/logs/medical"
+
+ farmos_log_type = "medical"
+ farmos_refurl_path = "/logs/medical"
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ MedicalLogView = kwargs.get("MedicalLogView", base["MedicalLogView"])
+ MedicalLogView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/logs_observation.py b/src/wuttafarm/web/views/farmos/logs_observation.py
new file mode 100644
index 0000000..ab27b5a
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/logs_observation.py
@@ -0,0 +1,53 @@
+# -*- 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 Observation Logs
+"""
+
+from wuttafarm.web.views.farmos.logs import LogMasterView
+
+
+class ObservationLogView(LogMasterView):
+ """
+ View for farmOS observation logs
+ """
+
+ model_name = "farmos_observation_log"
+ model_title = "farmOS Observation Log"
+ model_title_plural = "farmOS Observation Logs"
+
+ route_prefix = "farmos_logs_observation"
+ url_prefix = "/farmOS/logs/observation"
+
+ farmos_log_type = "observation"
+ farmos_refurl_path = "/logs/observation"
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ ObservationLogView = kwargs.get("ObservationLogView", base["ObservationLogView"])
+ ObservationLogView.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
new file mode 100644
index 0000000..f02801f
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/plants.py
@@ -0,0 +1,362 @@
+# -*- 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 Farm Plants
+"""
+
+import datetime
+
+import colander
+
+from wuttaweb.forms.schema import WuttaDateTime
+from wuttaweb.forms.widgets import WuttaDateTimeWidget
+
+from wuttafarm.web.views.farmos import FarmOSMasterView
+from wuttafarm.web.forms.schema import UsersType, StructureType, FarmOSPlantTypes
+from wuttafarm.web.forms.widgets import ImageWidget
+
+
+class PlantTypeView(FarmOSMasterView):
+ """
+ Master view for Plant Types in farmOS.
+ """
+
+ model_name = "farmos_plant_type"
+ model_title = "farmOS Plant Type"
+ model_title_plural = "farmOS Plant Types"
+
+ route_prefix = "farmos_plant_types"
+ url_prefix = "/farmOS/plant-types"
+
+ farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview"
+
+ grid_columns = [
+ "name",
+ "description",
+ "changed",
+ ]
+
+ sort_defaults = "name"
+
+ form_fields = [
+ "name",
+ "description",
+ "changed",
+ ]
+
+ def get_grid_data(self, columns=None, session=None):
+ result = self.farmos_client.resource.get("taxonomy_term", "plant_type")
+ return [self.normalize_plant_type(t) for t in result["data"]]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
+ g.set_searchable("name")
+
+ # changed
+ g.set_renderer("changed", "datetime")
+
+ def get_instance(self):
+ plant_type = self.farmos_client.resource.get_id(
+ "taxonomy_term", "plant_type", self.request.matchdict["uuid"]
+ )
+ self.raw_json = plant_type
+ return self.normalize_plant_type(plant_type["data"])
+
+ def get_instance_title(self, plant_type):
+ return plant_type["name"]
+
+ def normalize_plant_type(self, plant_type):
+
+ if changed := plant_type["attributes"]["changed"]:
+ changed = datetime.datetime.fromisoformat(changed)
+ changed = self.app.localtime(changed)
+
+ if description := plant_type["attributes"]["description"]:
+ description = description["value"]
+
+ return {
+ "uuid": plant_type["id"],
+ "drupal_id": plant_type["attributes"]["drupal_internal__tid"],
+ "name": plant_type["attributes"]["name"],
+ "description": description or colander.null,
+ "changed": changed,
+ }
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # description
+ f.set_widget("description", "notes")
+
+ # changed
+ f.set_node("changed", WuttaDateTime())
+
+ def get_xref_buttons(self, plant_type):
+ model = self.app.model
+ session = self.Session()
+
+ buttons = [
+ self.make_button(
+ "View in farmOS",
+ primary=True,
+ url=self.app.get_farmos_url(
+ f"/taxonomy/term/{plant_type['drupal_id']}"
+ ),
+ target="_blank",
+ icon_left="external-link-alt",
+ )
+ ]
+
+ if wf_plant_type := (
+ session.query(model.PlantType)
+ .filter(model.PlantType.farmos_uuid == plant_type["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "plant_types.view", uuid=wf_plant_type.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+class PlantAssetView(FarmOSMasterView):
+ """
+ Master view for farmOS Plant Assets
+ """
+
+ model_name = "farmos_asset_plant"
+ model_title = "farmOS Plant Asset"
+ model_title_plural = "farmOS Plant Assets"
+
+ route_prefix = "farmos_asset_plant"
+ url_prefix = "/farmOS/assets/plant"
+
+ farmos_refurl_path = "/assets/plant"
+
+ grid_columns = [
+ "name",
+ "archived",
+ ]
+
+ sort_defaults = "name"
+
+ form_fields = [
+ "name",
+ "plant_types",
+ "archived",
+ "owners",
+ "location",
+ "notes",
+ "raw_image_url",
+ "large_image_url",
+ "thumbnail_image_url",
+ "image",
+ ]
+
+ def get_grid_data(self, columns=None, session=None):
+ result = self.farmos_client.asset.get("plant")
+ return [self.normalize_plant(a) for a in result["data"]]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
+ g.set_searchable("name")
+
+ # archived
+ g.set_renderer("archived", "boolean")
+
+ def get_instance(self):
+
+ plant = self.farmos_client.resource.get_id(
+ "asset", "plant", self.request.matchdict["uuid"]
+ )
+ self.raw_json = plant
+
+ # instance data
+ data = self.normalize_plant(plant["data"])
+
+ if relationships := plant["data"].get("relationships"):
+
+ # add plant types
+ if plant_type := relationships.get("plant_type"):
+ if plant_type["data"]:
+ data["plant_types"] = []
+ for plant_type in plant_type["data"]:
+ plant_type = self.farmos_client.resource.get_id(
+ "taxonomy_term", "plant_type", plant_type["id"]
+ )
+ data["plant_types"].append(
+ {
+ "uuid": plant_type["data"]["id"],
+ "name": plant_type["data"]["attributes"]["name"],
+ }
+ )
+
+ # add location
+ if location := relationships.get("location"):
+ if location["data"]:
+ location = self.farmos_client.resource.get_id(
+ "asset", "structure", location["data"][0]["id"]
+ )
+ data["location"] = {
+ "uuid": location["data"]["id"],
+ "name": location["data"]["attributes"]["name"],
+ }
+
+ # add owners
+ if owner := relationships.get("owner"):
+ data["owners"] = []
+ for owner_data in owner["data"]:
+ owner = self.farmos_client.resource.get_id(
+ "user", "user", owner_data["id"]
+ )
+ data["owners"].append(
+ {
+ "uuid": owner["data"]["id"],
+ "display_name": owner["data"]["attributes"]["display_name"],
+ }
+ )
+
+ # add image urls
+ if image := relationships.get("image"):
+ if image["data"]:
+ image = self.farmos_client.resource.get_id(
+ "file", "file", image["data"][0]["id"]
+ )
+ data["raw_image_url"] = self.app.get_farmos_url(
+ image["data"]["attributes"]["uri"]["url"]
+ )
+ # nb. other styles available: medium, wide
+ data["large_image_url"] = image["data"]["attributes"][
+ "image_style_uri"
+ ]["large"]
+ data["thumbnail_image_url"] = image["data"]["attributes"][
+ "image_style_uri"
+ ]["thumbnail"]
+
+ return data
+
+ def get_instance_title(self, plant):
+ 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"
+
+ return {
+ "uuid": plant["id"],
+ "drupal_id": plant["attributes"]["drupal_internal__id"],
+ "name": plant["attributes"]["name"],
+ "location": colander.null, # TODO
+ "archived": archived,
+ "notes": notes or colander.null,
+ }
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ plant = f.model_instance
+
+ # plant_types
+ f.set_node("plant_types", FarmOSPlantTypes(self.request))
+
+ # location
+ f.set_node("location", StructureType(self.request))
+
+ # owners
+ f.set_node("owners", UsersType(self.request))
+
+ # notes
+ f.set_widget("notes", "notes")
+
+ # archived
+ f.set_node("archived", colander.Boolean())
+
+ # image
+ if url := plant.get("large_image_url"):
+ f.set_widget("image", ImageWidget("plant image"))
+ f.set_default("image", url)
+
+ def get_xref_buttons(self, plant):
+ model = self.app.model
+ session = self.Session()
+
+ buttons = [
+ self.make_button(
+ "View in farmOS",
+ primary=True,
+ url=self.app.get_farmos_url(f"/asset/{plant['drupal_id']}"),
+ target="_blank",
+ icon_left="external-link-alt",
+ ),
+ ]
+
+ if wf_plant := (
+ session.query(model.Asset)
+ .filter(model.Asset.farmos_uuid == plant["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url("plant_assets.view", uuid=wf_plant.uuid),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"])
+ PlantTypeView.defaults(config)
+
+ PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"])
+ PlantAssetView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py
index fc05613..cf77967 100644
--- a/src/wuttafarm/web/views/logs.py
+++ b/src/wuttafarm/web/views/logs.py
@@ -25,12 +25,15 @@ Base views for Logs
from collections import OrderedDict
+import colander
+
from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.db import Session
from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views import WuttaFarmMasterView
-from wuttafarm.db.model import LogType
+from wuttafarm.db.model import LogType, Log
+from wuttafarm.web.forms.schema import LogAssetRefs
def get_log_type_enum(config):
@@ -96,6 +99,77 @@ class LogTypeView(WuttaFarmMasterView):
return buttons
+class LogView(WuttaFarmMasterView):
+ """
+ Master view for All Logs
+ """
+
+ model_class = Log
+ route_prefix = "log"
+ url_prefix = "/logs"
+
+ farmos_refurl_path = "/logs"
+
+ viewable = False
+ creatable = False
+ editable = False
+ deletable = False
+ model_is_versioned = False
+
+ labels = {
+ "message": "Log Name",
+ }
+
+ grid_columns = [
+ "status",
+ "drupal_id",
+ "timestamp",
+ "message",
+ "log_type",
+ "assets",
+ "location",
+ "quantity",
+ "groups",
+ "is_group_assignment",
+ ]
+
+ sort_defaults = ("timestamp", "desc")
+
+ filter_defaults = {
+ "message": {"active": True, "verb": "contains"},
+ }
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # drupal_id
+ g.set_label("drupal_id", "ID", column_only=True)
+
+ # timestamp
+ g.set_renderer("timestamp", "date")
+ g.set_link("timestamp")
+
+ # message
+ g.set_link("message")
+
+ # log_type
+ g.set_enum("log_type", get_log_type_enum(self.config))
+
+ # assets
+ g.set_renderer("assets", self.render_assets_for_grid)
+
+ # view action links to final log record
+ def log_url(log, i):
+ return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
+
+ g.add_action("view", icon="eye", url=log_url)
+
+ def render_assets_for_grid(self, log, field, value):
+ assets = [str(a.asset) for a in log._assets]
+ return ", ".join(assets)
+
+
class LogMasterView(WuttaFarmMasterView):
"""
Base class for Asset master views
@@ -165,13 +239,34 @@ class LogMasterView(WuttaFarmMasterView):
g.set_sorter("message", model.Log.message)
g.set_filter("message", model.Log.message)
+ # assets
+ g.set_renderer("assets", self.render_assets_for_grid)
+
+ def render_assets_for_grid(self, log, field, value):
+ return ", ".join([a.asset.asset_name for a in log.log._assets])
+
def configure_form(self, form):
f = form
super().configure_form(f)
+ enum = self.app.enum
+ log = f.model_instance
# timestamp
# TODO: the widget should be automatic (assn proxy field)
f.set_widget("timestamp", WuttaDateTimeWidget(self.request))
+ if self.creating:
+ 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", LogAssetRefs(self.request))
+ f.set_default("assets", [a.asset_uuid for a in log.log._assets])
+
+ # location
+ if self.creating or self.editing:
+ f.remove("location") # TODO: need to support this
# log_type
if self.creating:
@@ -183,31 +278,52 @@ class LogMasterView(WuttaFarmMasterView):
)
f.set_readonly("log_type")
+ # quantity
+ if self.creating or self.editing:
+ f.remove("quantity") # TODO: need to support this
+
# notes
f.set_widget("notes", "notes")
+ # owners
+ if self.creating or self.editing:
+ f.remove("owners") # TODO: need to support this
+
+ # status
+ f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS))
+
+ # is_group_assignment
+ f.set_node("is_group_assignment", colander.Boolean())
+
+ def objectify(self, form):
+ log = super().objectify(form)
+
+ if self.creating:
+ model_class = self.get_model_class()
+ log.log_type = self.get_farmos_log_type()
+
+ return log
+
def get_farmos_url(self, log):
return self.app.get_farmos_url(f"/log/{log.drupal_id}")
+ def get_farmos_log_type(self):
+ return self.model_class.__wutta_hint__["farmos_log_type"]
+
def get_xref_buttons(self, log):
buttons = super().get_xref_buttons(log)
if log.farmos_uuid:
-
- # TODO
- route = None
- if log.log_type == "activity":
- route = "farmos_logs_activity.view"
-
- if route:
- buttons.append(
- self.make_button(
- "View farmOS record",
- primary=True,
- url=self.request.route_url(route, uuid=log.farmos_uuid),
- icon_left="eye",
- )
+ log_type = self.get_farmos_log_type()
+ route = f"farmos_logs_{log_type}.view"
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(route, uuid=log.farmos_uuid),
+ icon_left="eye",
)
+ )
return buttons
@@ -218,6 +334,9 @@ def defaults(config, **kwargs):
LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"])
LogTypeView.defaults(config)
+ LogView = kwargs.get("LogView", base["LogView"])
+ LogView.defaults(config)
+
def includeme(config):
defaults(config)
diff --git a/src/wuttafarm/web/views/logs_activity.py b/src/wuttafarm/web/views/logs_activity.py
index d4333f5..dda3ca7 100644
--- a/src/wuttafarm/web/views/logs_activity.py
+++ b/src/wuttafarm/web/views/logs_activity.py
@@ -33,7 +33,7 @@ class ActivityLogView(LogMasterView):
"""
model_class = ActivityLog
- route_prefix = "activity_logs"
+ route_prefix = "logs_activity"
url_prefix = "/logs/activity"
farmos_refurl_path = "/logs/activity"
diff --git a/src/wuttafarm/web/views/logs_harvest.py b/src/wuttafarm/web/views/logs_harvest.py
new file mode 100644
index 0000000..825c864
--- /dev/null
+++ b/src/wuttafarm/web/views/logs_harvest.py
@@ -0,0 +1,50 @@
+# -*- 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 Harvest Logs
+"""
+
+from wuttafarm.web.views.logs import LogMasterView
+from wuttafarm.db.model import HarvestLog
+
+
+class HarvestLogView(LogMasterView):
+ """
+ Master view for Harvest Logs
+ """
+
+ model_class = HarvestLog
+ route_prefix = "logs_harvest"
+ url_prefix = "/logs/harvest"
+
+ farmos_refurl_path = "/logs/harvest"
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ HarvestLogView = kwargs.get("HarvestLogView", base["HarvestLogView"])
+ HarvestLogView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/logs_medical.py b/src/wuttafarm/web/views/logs_medical.py
new file mode 100644
index 0000000..d582db9
--- /dev/null
+++ b/src/wuttafarm/web/views/logs_medical.py
@@ -0,0 +1,50 @@
+# -*- 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 Medical Logs
+"""
+
+from wuttafarm.web.views.logs import LogMasterView
+from wuttafarm.db.model import MedicalLog
+
+
+class MedicalLogView(LogMasterView):
+ """
+ Master view for Medical Logs
+ """
+
+ model_class = MedicalLog
+ route_prefix = "logs_medical"
+ url_prefix = "/logs/medical"
+
+ farmos_refurl_path = "/logs/medical"
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ MedicalLogView = kwargs.get("MedicalLogView", base["MedicalLogView"])
+ MedicalLogView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/logs_observation.py b/src/wuttafarm/web/views/logs_observation.py
new file mode 100644
index 0000000..a4b9e8e
--- /dev/null
+++ b/src/wuttafarm/web/views/logs_observation.py
@@ -0,0 +1,50 @@
+# -*- 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 Observation Logs
+"""
+
+from wuttafarm.web.views.logs import LogMasterView
+from wuttafarm.db.model import ObservationLog
+
+
+class ObservationLogView(LogMasterView):
+ """
+ Master view for Observation Logs
+ """
+
+ model_class = ObservationLog
+ route_prefix = "logs_observation"
+ url_prefix = "/logs/observation"
+
+ farmos_refurl_path = "/logs/observation"
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ ObservationLogView = kwargs.get("ObservationLogView", base["ObservationLogView"])
+ ObservationLogView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py
new file mode 100644
index 0000000..d92949a
--- /dev/null
+++ b/src/wuttafarm/web/views/plants.py
@@ -0,0 +1,201 @@
+# -*- 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 wuttaweb.forms.schema import WuttaDictEnum
+
+from wuttafarm.db.model import PlantType, PlantAsset
+from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
+from wuttafarm.web.forms.schema import PlantTypeRefs
+from wuttafarm.web.forms.widgets import ImageWidget
+
+
+class PlantTypeView(AssetTypeMasterView):
+ """
+ Master view for Plant Types
+ """
+
+ model_class = PlantType
+ route_prefix = "plant_types"
+ url_prefix = "/plant-types"
+
+ farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview"
+
+ grid_columns = [
+ "name",
+ "description",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "description",
+ "farmos_uuid",
+ "drupal_id",
+ ]
+
+ 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, plant_type):
+ return self.app.get_farmos_url(f"/taxonomy/term/{plant_type.drupal_id}")
+
+ def get_xref_buttons(self, plant_type):
+ buttons = super().get_xref_buttons(plant_type)
+
+ if plant_type.farmos_uuid:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ "farmos_plant_types.view", uuid=plant_type.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+ def get_row_grid_data(self, plant_type):
+ model = self.app.model
+ session = self.Session()
+ return (
+ session.query(model.PlantAsset)
+ .join(model.Asset)
+ .outerjoin(model.PlantAssetPlantType)
+ .filter(model.PlantAssetPlantType.plant_type == plant_type)
+ )
+
+ 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)
+
+
+class PlantAssetView(AssetMasterView):
+ """
+ Master view for Plant Assets
+ """
+
+ model_class = PlantAsset
+ route_prefix = "plant_assets"
+ url_prefix = "/assets/plant"
+
+ farmos_refurl_path = "/assets/plant"
+
+ labels = {
+ "plant_types": "Crop/Variety",
+ }
+
+ grid_columns = [
+ "thumbnail",
+ "drupal_id",
+ "asset_name",
+ "plant_types",
+ "season",
+ "archived",
+ ]
+
+ form_fields = [
+ "asset_name",
+ "plant_types",
+ "season",
+ "notes",
+ "asset_type",
+ "archived",
+ "farmos_uuid",
+ "drupal_id",
+ "thumbnail_url",
+ "image_url",
+ "thumbnail",
+ "image",
+ ]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # plant_types
+ g.set_renderer("plant_types", self.render_grid_plant_types)
+
+ def render_grid_plant_types(self, plant, field, value):
+ return ", ".join([t.plant_type.name for t in plant._plant_types])
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ enum = self.app.enum
+ plant = f.model_instance
+
+ # plant_types
+ f.set_node("plant_types", PlantTypeRefs(self.request))
+ f.set_default("plant_types", [t.plant_type_uuid for t in plant._plant_types])
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"])
+ PlantTypeView.defaults(config)
+
+ PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"])
+ PlantAssetView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)