feat: add more assets (plant) and logs (harvest, medical, observation)

This commit is contained in:
Lance Edgar 2026-02-18 18:36:12 -06:00
parent b061959b18
commit 2e0ec73317
31 changed files with 2847 additions and 206 deletions

View file

@ -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")

View file

@ -35,5 +35,9 @@ from .asset_land import LandType, LandAsset
from .asset_structure import StructureType, StructureAsset from .asset_structure import StructureType, StructureAsset
from .asset_animal import AnimalType, AnimalAsset from .asset_animal import AnimalType, AnimalAsset
from .asset_group import GroupAsset from .asset_group import GroupAsset
from .log import LogType, Log from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType
from .log import LogType, Log, LogAsset
from .log_activity import ActivityLog from .log_activity import ActivityLog
from .log_harvest import HarvestLog
from .log_medical import MedicalLog
from .log_observation import ObservationLog

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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.
""",
)

View file

@ -92,7 +92,7 @@ class Log(model.Base):
__versioned__ = {} __versioned__ = {}
__wutta_hint__ = { __wutta_hint__ = {
"model_title": "Log", "model_title": "Log",
"model_title_plural": "Logs", "model_title_plural": "All Logs",
} }
uuid = model.uuid_column() uuid = model.uuid_column()
@ -153,6 +153,8 @@ class Log(model.Base):
""", """,
) )
_assets = orm.relationship("LogAsset", back_populates="log")
def __str__(self): def __str__(self):
return self.message or "" return self.message or ""
@ -177,3 +179,27 @@ def add_log_proxies(subclass):
Log.make_proxy(subclass, "log", "timestamp") Log.make_proxy(subclass, "log", "timestamp")
Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "status")
Log.make_proxy(subclass, "log", "notes") 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,
)

View file

@ -30,7 +30,7 @@ from wuttafarm.db.model.log import LogMixin, add_log_proxies
class ActivityLog(LogMixin, model.Base): class ActivityLog(LogMixin, model.Base):
""" """
Represents an activity log from farmOS Represents an Activity Log from farmOS
""" """
__tablename__ = "log_activity" __tablename__ = "log_activity"

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -34,3 +34,12 @@ ANIMAL_SEX = OrderedDict(
("F", "Female"), ("F", "Female"),
] ]
) )
LOG_STATUS = OrderedDict(
[
("pending", "Pending"),
("done", "Done"),
("abandoned", "Abandoned"),
]
)

View file

@ -363,3 +363,113 @@ class StructureAssetImporter(ToFarmOSAsset):
payload["attributes"].update(attrs) payload["attributes"].update(attrs)
return payload 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"

View file

@ -98,6 +98,10 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
importers["AnimalType"] = AnimalTypeImporter importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter importers["GroupAsset"] = GroupAssetImporter
importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter
importers["MedicalLog"] = MedicalLogImporter
importers["ObservationLog"] = ObservationLogImporter
return importers return importers
@ -261,3 +265,62 @@ class StructureAssetImporter(
"archived": structure.archived, "archived": structure.archived,
"_src_object": structure, "_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

View file

@ -104,8 +104,13 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["AnimalType"] = AnimalTypeImporter importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter importers["GroupAsset"] = GroupAssetImporter
importers["PlantType"] = PlantTypeImporter
importers["PlantAsset"] = PlantAssetImporter
importers["LogType"] = LogTypeImporter importers["LogType"] = LogTypeImporter
importers["ActivityLog"] = ActivityLogImporter importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter
importers["MedicalLog"] = MedicalLogImporter
importers["ObservationLog"] = ObservationLogImporter
return importers return importers
@ -144,6 +149,9 @@ class AssetImporterBase(FromFarmOS, ToWutta):
Base class for farmOS API WuttaFarm asset importers 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): def get_simple_fields(self):
""" """ """ """
fields = list(super().get_simple_fields()) fields = list(super().get_simple_fields())
@ -174,6 +182,12 @@ class AssetImporterBase(FromFarmOS, ToWutta):
) )
return fields 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): def normalize_source_data(self, **kwargs):
""" """ """ """
data = super().normalize_source_data(**kwargs) data = super().normalize_source_data(**kwargs)
@ -283,71 +297,6 @@ class AssetImporterBase(FromFarmOS, ToWutta):
return asset return asset
class LogImporterBase(FromFarmOS, ToWutta):
"""
Base class for farmOS API WuttaFarm log importers
"""
def get_farmos_log_type(self):
return self.model_class.__wutta_hint__["farmos_log_type"]
def get_simple_fields(self):
""" """
fields = list(super().get_simple_fields())
# nb. must explicitly declare proxy fields
fields.extend(
[
"farmos_uuid",
"drupal_id",
"log_type",
"message",
"timestamp",
"notes",
"status",
]
)
return fields
def get_source_objects(self):
""" """
log_type = self.get_farmos_log_type()
result = self.farmos_client.log.get(log_type)
return result["data"]
def normalize_source_object(self, log):
""" """
if notes := log["attributes"]["notes"]:
notes = notes["value"]
return {
"farmos_uuid": UUID(log["id"]),
"drupal_id": log["attributes"]["drupal_internal__id"],
"log_type": self.get_farmos_log_type(),
"message": log["attributes"]["name"],
"timestamp": self.normalize_datetime(log["attributes"]["timestamp"]),
"notes": notes,
"status": log["attributes"]["status"],
}
class ActivityLogImporter(LogImporterBase):
"""
farmOS API WuttaFarm importer for Activity Logs
"""
model_class = model.ActivityLog
supported_fields = [
"farmos_uuid",
"drupal_id",
"log_type",
"message",
"timestamp",
"notes",
"status",
]
class AnimalAssetImporter(AssetImporterBase): class AnimalAssetImporter(AssetImporterBase):
""" """
farmOS API WuttaFarm importer for Animals farmOS API WuttaFarm importer for Animals
@ -604,12 +553,12 @@ class LandTypeImporter(FromFarmOS, ToWutta):
} }
class LogTypeImporter(FromFarmOS, ToWutta): class PlantTypeImporter(FromFarmOS, ToWutta):
""" """
farmOS API WuttaFarm importer for Log Types farmOS API WuttaFarm importer for Plant Types
""" """
model_class = model.LogType model_class = model.PlantType
supported_fields = [ supported_fields = [
"farmos_uuid", "farmos_uuid",
@ -620,19 +569,112 @@ class LogTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
log_types = self.farmos_client.resource.get("log_type") result = self.farmos_client.resource.get("taxonomy_term", "plant_type")
return log_types["data"] return result["data"]
def normalize_source_object(self, log_type): def normalize_source_object(self, plant_type):
""" """ """ """
return { return {
"farmos_uuid": UUID(log_type["id"]), "farmos_uuid": UUID(plant_type["id"]),
"drupal_id": log_type["attributes"]["drupal_internal__id"], "drupal_id": plant_type["attributes"]["drupal_internal__tid"],
"name": log_type["attributes"]["label"], "name": plant_type["attributes"]["name"],
"description": log_type["attributes"]["description"], "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): class StructureAssetImporter(AssetImporterBase):
""" """
farmOS API WuttaFarm importer for Structure Assets farmOS API WuttaFarm importer for Structure Assets
@ -768,3 +810,229 @@ class UserImporter(FromFarmOS, ToWutta):
if not user.farmos_uuid: if not user.farmos_uuid:
return False return False
return True 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",
]

View file

@ -74,6 +74,25 @@ class AnimalTypeType(colander.SchemaType):
return AnimalTypeWidget(self.request, **kwargs) 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): class LandTypeRef(ObjectRef):
""" """
Custom schema type for a Custom schema type for a
@ -99,6 +118,23 @@ class LandTypeRef(ObjectRef):
return self.request.route_url("land_types.view", uuid=land_type.uuid) 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): class StructureType(colander.SchemaType):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
@ -177,3 +213,20 @@ class AssetParentRefs(WuttaSet):
from wuttafarm.web.forms.widgets import AssetParentRefsWidget from wuttafarm.web.forms.widgets import AssetParentRefsWidget
return AssetParentRefsWidget(self.request, **kwargs) 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)

View file

@ -81,6 +81,67 @@ class AnimalTypeWidget(Widget):
return super().serialize(field, cstruct, **kw) 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): class StructureWidget(Widget):
""" """
Widget to display a "structure" field. Widget to display a "structure" field.
@ -166,3 +227,34 @@ class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
return HTML.tag("ul", c=parents) return HTML.tag("ul", c=parents)
return super().serialize(field, cstruct, **kw) 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)

View file

@ -64,6 +64,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "land_assets", "route": "land_assets",
"perm": "land_assets.list", "perm": "land_assets.list",
}, },
{
"title": "Plant",
"route": "plant_assets",
"perm": "plant_assets.list",
},
{ {
"title": "Structure", "title": "Structure",
"route": "structure_assets", "route": "structure_assets",
@ -80,6 +85,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "land_types", "route": "land_types",
"perm": "land_types.list", "perm": "land_types.list",
}, },
{
"title": "Plant Types",
"route": "plant_types",
"perm": "plant_types.list",
},
{ {
"title": "Structure Types", "title": "Structure Types",
"route": "structure_types", "route": "structure_types",
@ -99,9 +109,29 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"type": "menu", "type": "menu",
"items": [ "items": [
{ {
"title": "Activity Logs", "title": "All Logs",
"route": "activity_logs", "route": "log",
"perm": "activity_logs.list", "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"}, {"type": "sep"},
{ {
@ -135,6 +165,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_groups", "route": "farmos_groups",
"perm": "farmos_groups.list", "perm": "farmos_groups.list",
}, },
{
"title": "Plants",
"route": "farmos_asset_plant",
"perm": "farmos_asset_plant.list",
},
{ {
"title": "Structures", "title": "Structures",
"route": "farmos_structures", "route": "farmos_structures",
@ -151,12 +186,32 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_logs_activity", "route": "farmos_logs_activity",
"perm": "farmos_logs_activity.list", "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"}, {"type": "sep"},
{ {
"title": "Animal Types", "title": "Animal Types",
"route": "farmos_animal_types", "route": "farmos_animal_types",
"perm": "farmos_animal_types.list", "perm": "farmos_animal_types.list",
}, },
{
"title": "Plant Types",
"route": "farmos_plant_types",
"perm": "farmos_plant_types.list",
},
{ {
"title": "Structure Types", "title": "Structure Types",
"route": "farmos_structure_types", "route": "farmos_structure_types",

View file

@ -47,8 +47,12 @@ def includeme(config):
config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.structures")
config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.animals")
config.include("wuttafarm.web.views.groups") config.include("wuttafarm.web.views.groups")
config.include("wuttafarm.web.views.plants")
config.include("wuttafarm.web.views.logs") config.include("wuttafarm.web.views.logs")
config.include("wuttafarm.web.views.logs_activity") 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 # views for farmOS
config.include("wuttafarm.web.views.farmos") config.include("wuttafarm.web.views.farmos")

View file

@ -29,7 +29,7 @@ from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttafarm.web.views import WuttaFarmMasterView 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.schema import AssetParentRefs
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
@ -140,6 +140,28 @@ class AssetMasterView(WuttaFarmMasterView):
"archived": {"active": True, "verb": "is_false"}, "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): def get_fallback_templates(self, template):
templates = super().get_fallback_templates(template) templates = super().get_fallback_templates(template)
@ -265,6 +287,8 @@ class AssetMasterView(WuttaFarmMasterView):
route = "farmos_groups.view" route = "farmos_groups.view"
elif asset.asset_type == "land": elif asset.asset_type == "land":
route = "farmos_land_assets.view" route = "farmos_land_assets.view"
elif asset.asset_type == "plant":
route = "farmos_asset_plant.view"
elif asset.asset_type == "structure": elif asset.asset_type == "structure":
route = "farmos_structures.view" route = "farmos_structures.view"
@ -280,6 +304,39 @@ class AssetMasterView(WuttaFarmMasterView):
return buttons 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -36,5 +36,9 @@ def includeme(config):
config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animal_types")
config.include("wuttafarm.web.views.farmos.animals") config.include("wuttafarm.web.views.farmos.animals")
config.include("wuttafarm.web.views.farmos.groups") 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.log_types")
config.include("wuttafarm.web.views.farmos.logs_activity") 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")

View file

@ -115,6 +115,9 @@ class GroupView(FarmOSMasterView):
else: else:
archived = group["attributes"]["status"] == "archived" archived = group["attributes"]["status"] == "archived"
if notes := group["attributes"]["notes"]:
notes = notes["value"]
return { return {
"uuid": group["id"], "uuid": group["id"],
"drupal_id": group["attributes"]["drupal_internal__id"], "drupal_id": group["attributes"]["drupal_internal__id"],
@ -124,7 +127,7 @@ class GroupView(FarmOSMasterView):
"is_fixed": group["attributes"]["is_fixed"], "is_fixed": group["attributes"]["is_fixed"],
"is_location": group["attributes"]["is_location"], "is_location": group["attributes"]["is_location"],
"archived": archived, "archived": archived,
"notes": group["attributes"]["notes"]["value"], "notes": notes or colander.null,
} }
def configure_form(self, form): def configure_form(self, form):

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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

View file

@ -20,20 +20,13 @@
# #
################################################################################ ################################################################################
""" """
View for farmOS activity logs View for farmOS Activity Logs
""" """
import datetime from wuttafarm.web.views.farmos.logs import LogMasterView
import colander
from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos import FarmOSMasterView
class ActivityLogView(FarmOSMasterView): class ActivityLogView(LogMasterView):
""" """
View for farmOS activity logs View for farmOS activity logs
""" """
@ -45,105 +38,9 @@ class ActivityLogView(FarmOSMasterView):
route_prefix = "farmos_logs_activity" route_prefix = "farmos_logs_activity"
url_prefix = "/farmOS/logs/activity" url_prefix = "/farmOS/logs/activity"
farmos_log_type = "activity"
farmos_refurl_path = "/logs/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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -25,12 +25,15 @@ Base views for Logs
from collections import OrderedDict from collections import OrderedDict
import colander
from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import LogType from wuttafarm.db.model import LogType, Log
from wuttafarm.web.forms.schema import LogAssetRefs
def get_log_type_enum(config): def get_log_type_enum(config):
@ -96,6 +99,77 @@ class LogTypeView(WuttaFarmMasterView):
return buttons 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): class LogMasterView(WuttaFarmMasterView):
""" """
Base class for Asset master views Base class for Asset master views
@ -165,13 +239,34 @@ class LogMasterView(WuttaFarmMasterView):
g.set_sorter("message", model.Log.message) g.set_sorter("message", model.Log.message)
g.set_filter("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): def configure_form(self, form):
f = form f = form
super().configure_form(f) super().configure_form(f)
enum = self.app.enum
log = f.model_instance
# timestamp # timestamp
# TODO: the widget should be automatic (assn proxy field) # TODO: the widget should be automatic (assn proxy field)
f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) 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 # log_type
if self.creating: if self.creating:
@ -183,23 +278,44 @@ class LogMasterView(WuttaFarmMasterView):
) )
f.set_readonly("log_type") f.set_readonly("log_type")
# quantity
if self.creating or self.editing:
f.remove("quantity") # TODO: need to support this
# notes # notes
f.set_widget("notes", "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): def get_farmos_url(self, log):
return self.app.get_farmos_url(f"/log/{log.drupal_id}") 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): def get_xref_buttons(self, log):
buttons = super().get_xref_buttons(log) buttons = super().get_xref_buttons(log)
if log.farmos_uuid: if log.farmos_uuid:
log_type = self.get_farmos_log_type()
# TODO route = f"farmos_logs_{log_type}.view"
route = None
if log.log_type == "activity":
route = "farmos_logs_activity.view"
if route:
buttons.append( buttons.append(
self.make_button( self.make_button(
"View farmOS record", "View farmOS record",
@ -218,6 +334,9 @@ def defaults(config, **kwargs):
LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"])
LogTypeView.defaults(config) LogTypeView.defaults(config)
LogView = kwargs.get("LogView", base["LogView"])
LogView.defaults(config)
def includeme(config): def includeme(config):
defaults(config) defaults(config)

View file

@ -33,7 +33,7 @@ class ActivityLogView(LogMasterView):
""" """
model_class = ActivityLog model_class = ActivityLog
route_prefix = "activity_logs" route_prefix = "logs_activity"
url_prefix = "/logs/activity" url_prefix = "/logs/activity"
farmos_refurl_path = "/logs/activity" farmos_refurl_path = "/logs/activity"

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)