diff --git a/CHANGELOG.md b/CHANGELOG.md index d096239..8cf9487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.5.0 (2026-02-18) + +### Feat + +- add `produces_eggs` flag for animal, group assets +- add more assets (plant) and logs (harvest, medical, observation) +- refactor log models, views to use generic/common base + +### Fix + +- rename db model modules, for better convention +- add override for requests cert validation + ## v0.4.1 (2026-02-17) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 44bea43..a474302 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.4.1" +version = "0.5.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ 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/alembic/versions/82a03f4ef1a4_add_produces_eggs_via_eggmixin.py b/src/wuttafarm/db/alembic/versions/82a03f4ef1a4_add_produces_eggs_via_eggmixin.py new file mode 100644 index 0000000..9bed92c --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/82a03f4ef1a4_add_produces_eggs_via_eggmixin.py @@ -0,0 +1,52 @@ +"""add produces_eggs via EggMixin + +Revision ID: 82a03f4ef1a4 +Revises: 11e0e46f48a6 +Create Date: 2026-02-18 18:45:36.015144 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "82a03f4ef1a4" +down_revision: Union[str, None] = "11e0e46f48a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_animal + op.add_column( + "asset_animal", sa.Column("produces_eggs", sa.Boolean(), nullable=True) + ) + op.add_column( + "asset_animal_version", + sa.Column("produces_eggs", sa.Boolean(), autoincrement=False, nullable=True), + ) + + # asset_group + op.add_column( + "asset_group", sa.Column("produces_eggs", sa.Boolean(), nullable=True) + ) + op.add_column( + "asset_group_version", + sa.Column("produces_eggs", sa.Boolean(), autoincrement=False, nullable=True), + ) + + +def downgrade() -> None: + + # asset_group + op.drop_column("asset_group_version", "produces_eggs") + op.drop_column("asset_group", "produces_eggs") + + # asset_animal + op.drop_column("asset_animal_version", "produces_eggs") + op.drop_column("asset_animal", "produces_eggs") diff --git a/src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py b/src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py new file mode 100644 index 0000000..0b82da9 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py @@ -0,0 +1,206 @@ +"""add generic log base + +Revision ID: dd6351e69233 +Revises: b8cd4a8f981f +Create Date: 2026-02-18 12:09:05.200134 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "dd6351e69233" +down_revision: Union[str, None] = "b8cd4a8f981f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log + op.create_table( + "log", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_type", sa.String(length=100), nullable=False), + sa.Column("message", sa.String(length=255), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["log_type"], ["log_type.drupal_id"], name=op.f("fk_log_log_type_log_type") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_log_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_farmos_uuid")), + ) + op.create_table( + "log_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_type", sa.String(length=100), autoincrement=False, nullable=True + ), + sa.Column("message", sa.String(length=255), autoincrement=False, nullable=True), + sa.Column("timestamp", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("status", sa.String(length=20), autoincrement=False, nullable=True), + sa.Column("notes", sa.Text(), 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_log_version")), + ) + op.create_index( + op.f("ix_log_version_end_transaction_id"), + "log_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_version_operation_type"), + "log_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_version_pk_transaction_id", + "log_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_version_pk_validity", + "log_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_version_transaction_id"), + "log_version", + ["transaction_id"], + unique=False, + ) + + # log_activity + op.drop_column("log_activity_version", "status") + op.drop_column("log_activity_version", "farmos_uuid") + op.drop_column("log_activity_version", "timestamp") + op.drop_column("log_activity_version", "message") + op.drop_column("log_activity_version", "drupal_id") + op.drop_column("log_activity_version", "notes") + op.drop_constraint( + op.f("uq_log_activity_drupal_id"), "log_activity", type_="unique" + ) + op.drop_constraint( + op.f("uq_log_activity_farmos_uuid"), "log_activity", type_="unique" + ) + op.create_foreign_key( + op.f("fk_log_activity_uuid_log"), "log_activity", "log", ["uuid"], ["uuid"] + ) + op.drop_column("log_activity", "status") + op.drop_column("log_activity", "farmos_uuid") + op.drop_column("log_activity", "timestamp") + op.drop_column("log_activity", "message") + op.drop_column("log_activity", "drupal_id") + op.drop_column("log_activity", "notes") + + +def downgrade() -> None: + + # log_activity + op.add_column( + "log_activity", + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity", + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity", + sa.Column( + "message", sa.VARCHAR(length=255), autoincrement=False, nullable=False + ), + ) + op.add_column( + "log_activity", + sa.Column( + "timestamp", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + ) + op.add_column( + "log_activity", + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity", + sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=False), + ) + op.drop_constraint( + op.f("fk_log_activity_uuid_log"), "log_activity", type_="foreignkey" + ) + op.create_unique_constraint( + op.f("uq_log_activity_farmos_uuid"), + "log_activity", + ["farmos_uuid"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("uq_log_activity_drupal_id"), + "log_activity", + ["drupal_id"], + postgresql_nulls_not_distinct=False, + ) + op.add_column( + "log_activity_version", + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity_version", + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity_version", + sa.Column( + "message", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + ) + op.add_column( + "log_activity_version", + sa.Column( + "timestamp", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "log_activity_version", + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity_version", + sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=True), + ) + + # log + op.drop_index(op.f("ix_log_version_transaction_id"), table_name="log_version") + op.drop_index("ix_log_version_pk_validity", table_name="log_version") + op.drop_index("ix_log_version_pk_transaction_id", table_name="log_version") + op.drop_index(op.f("ix_log_version_operation_type"), table_name="log_version") + op.drop_index(op.f("ix_log_version_end_transaction_id"), table_name="log_version") + op.drop_table("log_version") + op.drop_table("log") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 367bc1c..f9eb790 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,9 +30,14 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models -from .assets import AssetType, Asset, AssetParent -from .land import LandType, LandAsset -from .structures import StructureType, StructureAsset -from .animals import AnimalType, AnimalAsset -from .groups import GroupAsset -from .logs import LogType, ActivityLog +from .asset import AssetType, Asset, AssetParent +from .asset_land import LandType, LandAsset +from .asset_structure import StructureType, StructureAsset +from .asset_animal import AnimalType, AnimalAsset +from .asset_group import GroupAsset +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/assets.py b/src/wuttafarm/db/model/asset.py similarity index 95% rename from src/wuttafarm/db/model/assets.py rename to src/wuttafarm/db/model/asset.py index 531fd62..90372e2 100644 --- a/src/wuttafarm/db/model/assets.py +++ b/src/wuttafarm/db/model/asset.py @@ -215,6 +215,18 @@ def add_asset_proxies(subclass): Asset.make_proxy(subclass, "asset", "archived") +class EggMixin: + + produces_eggs = sa.Column( + sa.Boolean(), + nullable=True, + doc=""" + Whether the group asset produces eggs (i.e. it should be + available in the egg harvest form). + """, + ) + + class AssetParent(model.Base): """ Represents an "asset's parent relationship" from farmOS. diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/asset_animal.py similarity index 96% rename from src/wuttafarm/db/model/animals.py rename to src/wuttafarm/db/model/asset_animal.py index 8c0df35..768b0f9 100644 --- a/src/wuttafarm/db/model/animals.py +++ b/src/wuttafarm/db/model/asset_animal.py @@ -28,7 +28,7 @@ from sqlalchemy import orm from wuttjamaican.db import model -from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies, EggMixin class AnimalType(model.Base): @@ -84,7 +84,7 @@ class AnimalType(model.Base): return self.name or "" -class AnimalAsset(AssetMixin, model.Base): +class AnimalAsset(AssetMixin, EggMixin, model.Base): """ Represents an animal asset from farmOS """ diff --git a/src/wuttafarm/db/model/groups.py b/src/wuttafarm/db/model/asset_group.py similarity index 91% rename from src/wuttafarm/db/model/groups.py rename to src/wuttafarm/db/model/asset_group.py index 84453a7..ad4d184 100644 --- a/src/wuttafarm/db/model/groups.py +++ b/src/wuttafarm/db/model/asset_group.py @@ -25,10 +25,10 @@ Model definition for Groups from wuttjamaican.db import model -from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies, EggMixin -class GroupAsset(AssetMixin, model.Base): +class GroupAsset(AssetMixin, EggMixin, model.Base): """ Represents a group asset from farmOS """ diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/asset_land.py similarity index 97% rename from src/wuttafarm/db/model/land.py rename to src/wuttafarm/db/model/asset_land.py index 1221c63..bbd7bf0 100644 --- a/src/wuttafarm/db/model/land.py +++ b/src/wuttafarm/db/model/asset_land.py @@ -28,7 +28,7 @@ from sqlalchemy import orm from wuttjamaican.db import model -from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies class LandType(model.Base): 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/structures.py b/src/wuttafarm/db/model/asset_structure.py similarity index 97% rename from src/wuttafarm/db/model/structures.py rename to src/wuttafarm/db/model/asset_structure.py index 8c5371c..7f4fc23 100644 --- a/src/wuttafarm/db/model/structures.py +++ b/src/wuttafarm/db/model/asset_structure.py @@ -28,7 +28,7 @@ from sqlalchemy import orm from wuttjamaican.db import model -from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies class StructureType(model.Base): diff --git a/src/wuttafarm/db/model/logs.py b/src/wuttafarm/db/model/log.py similarity index 67% rename from src/wuttafarm/db/model/logs.py rename to src/wuttafarm/db/model/log.py index 76f7715..a86c447 100644 --- a/src/wuttafarm/db/model/logs.py +++ b/src/wuttafarm/db/model/log.py @@ -20,11 +20,12 @@ # ################################################################################ """ -Model definition for Log Types +Model definition for Logs """ import sqlalchemy as sa from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr from wuttjamaican.db import model @@ -82,20 +83,26 @@ class LogType(model.Base): return self.name or "" -class ActivityLog(model.Base): +class Log(model.Base): """ - Represents an activity log from farmOS + Represents a base log record from farmOS """ - __tablename__ = "log_activity" + __tablename__ = "log" __versioned__ = {} __wutta_hint__ = { - "model_title": "Activity Log", - "model_title_plural": "Activity Logs", + "model_title": "Log", + "model_title_plural": "All Logs", } uuid = model.uuid_column() + log_type = sa.Column( + sa.String(length=100), + sa.ForeignKey("log_type.drupal_id"), + nullable=False, + ) + message = sa.Column( sa.String(length=255), nullable=False, @@ -146,5 +153,53 @@ class ActivityLog(model.Base): """, ) + _assets = orm.relationship("LogAsset", back_populates="log") + def __str__(self): return self.message or "" + + +class LogMixin: + + uuid = model.uuid_fk_column("log.uuid", nullable=False, primary_key=True) + + @declared_attr + def log(cls): + return orm.relationship(Log) + + def __str__(self): + return self.message or "" + + +def add_log_proxies(subclass): + Log.make_proxy(subclass, "log", "farmos_uuid") + Log.make_proxy(subclass, "log", "drupal_id") + Log.make_proxy(subclass, "log", "log_type") + Log.make_proxy(subclass, "log", "message") + 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 new file mode 100644 index 0000000..2f5f6e5 --- /dev/null +++ b/src/wuttafarm/db/model/log_activity.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 Activity Logs +""" + +from wuttjamaican.db import model + +from wuttafarm.db.model.log import LogMixin, add_log_proxies + + +class ActivityLog(LogMixin, model.Base): + """ + Represents an Activity Log from farmOS + """ + + __tablename__ = "log_activity" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Activity Log", + "model_title_plural": "Activity Logs", + "farmos_log_type": "activity", + } + + +add_log_proxies(ActivityLog) 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..d20c068 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -125,6 +125,7 @@ class ToFarmOSAsset(ToFarmOS): "asset_name": asset["attributes"]["name"], "is_location": asset["attributes"]["is_location"], "is_fixed": asset["attributes"]["is_fixed"], + "produces_eggs": asset["attributes"].get("produces_eggs"), "notes": notes, "archived": asset["attributes"]["archived"], } @@ -138,6 +139,8 @@ class ToFarmOSAsset(ToFarmOS): attrs["is_location"] = source_data["is_location"] if "is_fixed" in self.fields: attrs["is_fixed"] = source_data["is_fixed"] + if "produces_eggs" in self.fields: + attrs["produces_eggs"] = source_data["produces_eggs"] if "notes" in self.fields: attrs["notes"] = {"value": source_data["notes"]} if "archived" in self.fields: @@ -159,6 +162,7 @@ class AnimalAssetImporter(ToFarmOSAsset): "animal_type_uuid", "sex", "is_sterile", + "produces_eggs", "birthdate", "notes", "archived", @@ -286,6 +290,7 @@ class GroupAssetImporter(ToFarmOSAsset): supported_fields = [ "uuid", "asset_name", + "produces_eggs", "notes", "archived", ] @@ -363,3 +368,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..ffd78b7 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 @@ -136,6 +140,7 @@ class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImpor "animal_type_uuid", "sex", "is_sterile", + "produces_eggs", "birthdate", "notes", "archived", @@ -148,6 +153,7 @@ class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImpor "animal_type_uuid": animal.animal_type.farmos_uuid, "sex": animal.sex, "is_sterile": animal.is_sterile, + "produces_eggs": animal.produces_eggs, "birthdate": animal.birthdate, "notes": animal.notes, "archived": animal.archived, @@ -187,6 +193,7 @@ class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporte supported_fields = [ "uuid", "asset_name", + "produces_eggs", "notes", "archived", ] @@ -195,6 +202,7 @@ class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporte return { "uuid": group.farmos_uuid or self.app.make_true_uuid(), "asset_name": group.asset_name, + "produces_eggs": group.produces_eggs, "notes": group.notes, "archived": group.archived, "_src_object": group, @@ -261,3 +269,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 b07d06d..d1cac19 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 @@ -139,48 +144,14 @@ class FromFarmOS(Importer): return self.app.make_utc(dt) -class ActivityLogImporter(FromFarmOS, ToWutta): - """ - farmOS API → WuttaFarm importer for Activity Logs - """ - - model_class = model.ActivityLog - - supported_fields = [ - "farmos_uuid", - "drupal_id", - "message", - "timestamp", - "notes", - "status", - ] - - def get_source_objects(self): - """ """ - logs = self.farmos_client.log.get("activity") - return logs["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"], - "message": log["attributes"]["name"], - "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), - "notes": notes, - "status": log["attributes"]["status"], - } - - 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()) @@ -211,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) @@ -335,6 +312,7 @@ class AnimalAssetImporter(AssetImporterBase): "animal_type_uuid", "sex", "is_sterile", + "produces_eggs", "birthdate", "notes", "archived", @@ -394,6 +372,7 @@ class AnimalAssetImporter(AssetImporterBase): "animal_type_uuid": animal_type_uuid, "sex": animal["attributes"]["sex"], "is_sterile": sterile, + "produces_eggs": animal["attributes"]["produces_eggs"], "birthdate": birthdate, } ) @@ -472,6 +451,7 @@ class GroupAssetImporter(AssetImporterBase): "asset_name", "is_location", "is_fixed", + "produces_eggs", "notes", "archived", "image_url", @@ -490,6 +470,7 @@ class GroupAssetImporter(AssetImporterBase): data.update( { "asset_type": "group", + "produces_eggs": group["attributes"]["produces_eggs"], } ) return data @@ -576,12 +557,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", @@ -592,19 +573,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 @@ -740,3 +814,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 e44c16e..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.log_types") + 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/animals.py b/src/wuttafarm/web/views/animals.py index 7016e36..72a05ee 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -157,6 +157,7 @@ class AnimalAssetView(AssetMasterView): "birthdate", "is_sterile", "sex", + "produces_eggs", "archived", ] @@ -166,6 +167,7 @@ class AnimalAssetView(AssetMasterView): "birthdate", "sex", "is_sterile", + "produces_eggs", "notes", "asset_type", "archived", diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py index 775fa3a..b9f560a 100644 --- a/src/wuttafarm/web/views/asset_types.py +++ b/src/wuttafarm/web/views/asset_types.py @@ -23,7 +23,7 @@ Master view for Asset Types """ -from wuttafarm.db.model.assets import AssetType +from wuttafarm.db.model import AssetType from wuttafarm.web.views import WuttaFarmMasterView 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/groups.py b/src/wuttafarm/web/views/groups.py index 21d7ed4..4b26463 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -24,7 +24,7 @@ Master view for Groups """ from wuttafarm.web.views.assets import AssetMasterView -from wuttafarm.db.model.groups import GroupAsset +from wuttafarm.db.model import GroupAsset class GroupView(AssetMasterView): @@ -42,6 +42,7 @@ class GroupView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", + "produces_eggs", "archived", ] @@ -49,6 +50,7 @@ class GroupView(AssetMasterView): "asset_name", "notes", "asset_type", + "produces_eggs", "archived", "farmos_uuid", "drupal_id", diff --git a/src/wuttafarm/web/views/land.py b/src/wuttafarm/web/views/land.py index aad15e7..22827a0 100644 --- a/src/wuttafarm/web/views/land.py +++ b/src/wuttafarm/web/views/land.py @@ -25,7 +25,7 @@ Master view for Land Types from webhelpers2.html import HTML, tags -from wuttafarm.db.model.land import LandType, LandAsset +from wuttafarm.db.model import LandType, LandAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.forms.schema import LandTypeRef diff --git a/src/wuttafarm/web/views/log_types.py b/src/wuttafarm/web/views/log_types.py deleted file mode 100644 index 13ea35f..0000000 --- a/src/wuttafarm/web/views/log_types.py +++ /dev/null @@ -1,90 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaFarm --Web app to integrate with and extend farmOS -# Copyright © 2026 Lance Edgar -# -# This file is part of WuttaFarm. -# -# WuttaFarm is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# WuttaFarm. If not, see . -# -################################################################################ -""" -Master view for Log Types -""" - -from wuttafarm.db.model.logs import LogType -from wuttafarm.web.views import WuttaFarmMasterView - - -class LogTypeView(WuttaFarmMasterView): - """ - Master view for Log Types - """ - - model_class = LogType - route_prefix = "log_types" - url_prefix = "/log-types" - - grid_columns = [ - "name", - "description", - ] - - sort_defaults = "name" - - filter_defaults = { - "name": {"active": True, "verb": "contains"}, - } - - form_fields = [ - "name", - "description", - "farmos_uuid", - "drupal_id", - ] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - - def get_xref_buttons(self, log_type): - buttons = super().get_xref_buttons(log_type) - - if log_type.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_log_types.view", uuid=log_type.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - - -def defaults(config, **kwargs): - base = globals() - - LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) - LogTypeView.defaults(config) - - -def includeme(config): - defaults(config) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py new file mode 100644 index 0000000..cf77967 --- /dev/null +++ b/src/wuttafarm/web/views/logs.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaFarm --Web app to integrate with and extend farmOS +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaFarm. +# +# WuttaFarm is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaFarm. If not, see . +# +################################################################################ +""" +Base 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, Log +from wuttafarm.web.forms.schema import LogAssetRefs + + +def get_log_type_enum(config): + app = config.get_app() + model = app.model + session = Session() + log_types = OrderedDict() + query = session.query(model.LogType).order_by(model.LogType.name) + for log_type in query: + log_types[log_type.drupal_id] = log_type.name + return log_types + + +class LogTypeView(WuttaFarmMasterView): + """ + Master view for Log Types + """ + + model_class = LogType + route_prefix = "log_types" + url_prefix = "/log-types" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, log_type): + buttons = super().get_xref_buttons(log_type) + + if log_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_log_types.view", uuid=log_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + 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 + """ + + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "assets", + "location", + "quantity", + "is_group_assignment", + ] + + sort_defaults = ("timestamp", "desc") + + filter_defaults = { + "message": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "message", + "timestamp", + "assets", + "location", + "quantity", + "notes", + "status", + "log_type", + "owners", + "is_group_assignment", + "farmos_uuid", + "drupal_id", + ] + + def get_query(self, session=None): + """ """ + model = self.app.model + model_class = self.get_model_class() + session = session or self.Session() + return session.query(model_class).join(model.Log) + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + + # status + g.set_sorter("status", model.Log.status) + g.set_filter("status", model.Log.status) + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", model.Log.drupal_id) + g.set_filter("drupal_id", model.Log.drupal_id) + + # timestamp + g.set_renderer("timestamp", "date") + g.set_link("timestamp") + g.set_sorter("timestamp", model.Log.timestamp) + g.set_filter("timestamp", model.Log.timestamp) + + # message + g.set_link("message") + 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: + f.remove("log_type") + else: + f.set_node( + "log_type", + WuttaDictEnum(self.request, get_log_type_enum(self.config)), + ) + 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: + 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 + + +def defaults(config, **kwargs): + base = globals() + + 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 a2b2154..dda3ca7 100644 --- a/src/wuttafarm/web/views/logs_activity.py +++ b/src/wuttafarm/web/views/logs_activity.py @@ -23,76 +23,21 @@ Master view for Activity Logs """ -from wuttafarm.db.model.logs import ActivityLog -from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.views.logs import LogMasterView +from wuttafarm.db.model import ActivityLog -class ActivityLogView(WuttaFarmMasterView): +class ActivityLogView(LogMasterView): """ Master view for Activity Logs """ model_class = ActivityLog - route_prefix = "activity_logs" + route_prefix = "logs_activity" url_prefix = "/logs/activity" farmos_refurl_path = "/logs/activity" - grid_columns = [ - "message", - "timestamp", - "status", - ] - - sort_defaults = ("timestamp", "desc") - - filter_defaults = { - "message": {"active": True, "verb": "contains"}, - } - - form_fields = [ - "message", - "timestamp", - "status", - "notes", - "farmos_uuid", - "drupal_id", - ] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # message - g.set_link("message") - - def configure_form(self, form): - f = form - super().configure_form(f) - - # notes - f.set_widget("notes", "notes") - - def get_farmos_url(self, log): - return self.app.get_farmos_url(f"/log/{log.drupal_id}") - - def get_xref_buttons(self, log): - buttons = super().get_xref_buttons(log) - - if log.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_logs_activity.view", uuid=log.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - def defaults(config, **kwargs): base = globals() 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)