From 2e0ec733178d9e4a72a4298bc15f39da52a592a8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 18:36:12 -0600 Subject: [PATCH] feat: add more assets (plant) and logs (harvest, medical, observation) --- ...e46f48a6_add_plant_assets_and_more_logs.py | 596 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 6 +- src/wuttafarm/db/model/asset_plant.py | 132 ++++ src/wuttafarm/db/model/log.py | 28 +- src/wuttafarm/db/model/log_activity.py | 2 +- src/wuttafarm/db/model/log_harvest.py | 45 ++ src/wuttafarm/db/model/log_medical.py | 45 ++ src/wuttafarm/db/model/log_observation.py | 45 ++ src/wuttafarm/enum.py | 9 + src/wuttafarm/farmos/importing/model.py | 110 ++++ src/wuttafarm/farmos/importing/wuttafarm.py | 63 ++ src/wuttafarm/importing/farmos.py | 418 +++++++++--- src/wuttafarm/web/forms/schema.py | 53 ++ src/wuttafarm/web/forms/widgets.py | 92 +++ src/wuttafarm/web/menus.py | 61 +- src/wuttafarm/web/views/__init__.py | 4 + src/wuttafarm/web/views/assets.py | 59 +- src/wuttafarm/web/views/farmos/__init__.py | 4 + src/wuttafarm/web/views/farmos/groups.py | 5 +- src/wuttafarm/web/views/farmos/logs.py | 142 +++++ .../web/views/farmos/logs_activity.py | 111 +--- .../web/views/farmos/logs_harvest.py | 53 ++ .../web/views/farmos/logs_medical.py | 53 ++ .../web/views/farmos/logs_observation.py | 53 ++ src/wuttafarm/web/views/farmos/plants.py | 362 +++++++++++ src/wuttafarm/web/views/logs.py | 149 ++++- src/wuttafarm/web/views/logs_activity.py | 2 +- src/wuttafarm/web/views/logs_harvest.py | 50 ++ src/wuttafarm/web/views/logs_medical.py | 50 ++ src/wuttafarm/web/views/logs_observation.py | 50 ++ src/wuttafarm/web/views/plants.py | 201 ++++++ 31 files changed, 2847 insertions(+), 206 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/11e0e46f48a6_add_plant_assets_and_more_logs.py create mode 100644 src/wuttafarm/db/model/asset_plant.py create mode 100644 src/wuttafarm/db/model/log_harvest.py create mode 100644 src/wuttafarm/db/model/log_medical.py create mode 100644 src/wuttafarm/db/model/log_observation.py create mode 100644 src/wuttafarm/web/views/farmos/logs.py create mode 100644 src/wuttafarm/web/views/farmos/logs_harvest.py create mode 100644 src/wuttafarm/web/views/farmos/logs_medical.py create mode 100644 src/wuttafarm/web/views/farmos/logs_observation.py create mode 100644 src/wuttafarm/web/views/farmos/plants.py create mode 100644 src/wuttafarm/web/views/logs_harvest.py create mode 100644 src/wuttafarm/web/views/logs_medical.py create mode 100644 src/wuttafarm/web/views/logs_observation.py create mode 100644 src/wuttafarm/web/views/plants.py 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)