feat: add "generic" assets, new animal assets based on that

This commit is contained in:
Lance Edgar 2026-02-15 10:42:50 -06:00
parent ac084c4e79
commit 140f3cbdba
11 changed files with 920 additions and 333 deletions

View file

@ -0,0 +1,333 @@
"""add generic, animal assets
Revision ID: d6e8d16d6854
Revises: 554e6168c339
Create Date: 2026-02-15 09:11:04.886362
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "d6e8d16d6854"
down_revision: Union[str, None] = "554e6168c339"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal
op.drop_table("animal")
op.drop_index(
op.f("ix_animal_version_end_transaction_id"), table_name="animal_version"
)
op.drop_index(op.f("ix_animal_version_operation_type"), table_name="animal_version")
op.drop_index(
op.f("ix_animal_version_pk_transaction_id"), table_name="animal_version"
)
op.drop_index(op.f("ix_animal_version_pk_validity"), table_name="animal_version")
op.drop_index(op.f("ix_animal_version_transaction_id"), table_name="animal_version")
op.drop_table("animal_version")
# asset
op.create_table(
"asset",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.Column("asset_type", sa.String(length=100), nullable=False),
sa.Column("asset_name", sa.String(length=100), nullable=False),
sa.Column("is_location", sa.Boolean(), nullable=False),
sa.Column("is_fixed", sa.Boolean(), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("thumbnail_url", sa.String(length=255), nullable=True),
sa.Column("image_url", sa.String(length=255), nullable=True),
sa.Column("archived", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["asset_type"],
["asset_type.drupal_id"],
name=op.f("fk_asset_asset_type_asset_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_asset_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_asset_farmos_uuid")),
)
op.create_table(
"asset_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"asset_type", sa.String(length=100), autoincrement=False, nullable=True
),
sa.Column(
"asset_name", sa.String(length=100), autoincrement=False, nullable=True
),
sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
sa.Column(
"thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"image_url", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column("archived", sa.Boolean(), 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_version")
),
)
op.create_index(
op.f("ix_asset_version_end_transaction_id"),
"asset_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_version_operation_type"),
"asset_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_version_pk_transaction_id",
"asset_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_version_pk_validity",
"asset_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_version_transaction_id"),
"asset_version",
["transaction_id"],
unique=False,
)
# asset_animal
op.create_table(
"asset_animal",
sa.Column("animal_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("birthdate", sa.DateTime(), nullable=True),
sa.Column("sex", sa.String(length=1), nullable=True),
sa.Column("is_sterile", sa.Boolean(), nullable=True),
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["animal_type_uuid"],
["animal_type.uuid"],
name=op.f("fk_asset_animal_animal_type_uuid_animal_type"),
),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_animal_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_animal")),
)
op.create_table(
"asset_animal_version",
sa.Column(
"animal_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column("sex", sa.String(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_animal_version")
),
)
op.create_index(
op.f("ix_asset_animal_version_end_transaction_id"),
"asset_animal_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_animal_version_operation_type"),
"asset_animal_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_animal_version_pk_transaction_id",
"asset_animal_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_animal_version_pk_validity",
"asset_animal_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_animal_version_transaction_id"),
"asset_animal_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# asset_animal
op.drop_index(
op.f("ix_asset_animal_version_transaction_id"),
table_name="asset_animal_version",
)
op.drop_index(
"ix_asset_animal_version_pk_validity", table_name="asset_animal_version"
)
op.drop_index(
"ix_asset_animal_version_pk_transaction_id", table_name="asset_animal_version"
)
op.drop_index(
op.f("ix_asset_animal_version_operation_type"),
table_name="asset_animal_version",
)
op.drop_index(
op.f("ix_asset_animal_version_end_transaction_id"),
table_name="asset_animal_version",
)
op.drop_table("asset_animal_version")
op.drop_table("asset_animal")
# asset
op.drop_index(op.f("ix_asset_version_transaction_id"), table_name="asset_version")
op.drop_index("ix_asset_version_pk_validity", table_name="asset_version")
op.drop_index("ix_asset_version_pk_transaction_id", table_name="asset_version")
op.drop_index(op.f("ix_asset_version_operation_type"), table_name="asset_version")
op.drop_index(
op.f("ix_asset_version_end_transaction_id"), table_name="asset_version"
)
op.drop_table("asset_version")
op.drop_table("asset")
# animal
op.create_table(
"animal",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column("animal_type_uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column("sex", sa.VARCHAR(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.ForeignKeyConstraint(
["animal_type_uuid"],
["animal_type.uuid"],
name=op.f("fk_animal_animal_type_uuid_animal_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal")),
sa.UniqueConstraint(
"drupal_id",
name=op.f("uq_animal_drupal_id"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"farmos_uuid",
name=op.f("uq_animal_farmos_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_table(
"animal_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column("animal_type_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column(
"birthdate", postgresql.TIMESTAMP(), autoincrement=False, nullable=True
),
sa.Column("sex", sa.VARCHAR(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_animal_version")
),
)
op.create_index(
op.f("ix_animal_version_transaction_id"),
"animal_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_pk_validity"),
"animal_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_pk_transaction_id"),
"animal_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_animal_version_operation_type"),
"animal_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_end_transaction_id"),
"animal_version",
["end_transaction_id"],
unique=False,
)

View file

@ -30,9 +30,9 @@ from wuttjamaican.db.model import *
from .users import WuttaFarmUser
# wuttafarm proper models
from .assets import AssetType
from .assets import AssetType, Asset
from .land import LandType, LandAsset, LandAssetParent
from .structures import StructureType, Structure
from .animals import AnimalType, Animal
from .animals import AnimalType, AnimalAsset
from .groups import Group
from .logs import LogType, ActivityLog

View file

@ -28,6 +28,8 @@ from sqlalchemy import orm
from wuttjamaican.db import model
from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies
class AnimalType(model.Base):
"""
@ -94,28 +96,19 @@ class AnimalType(model.Base):
return self.name or ""
class Animal(model.Base):
class AnimalAsset(AssetMixin, model.Base):
"""
Represents an animal from farmOS
Represents an animal asset from farmOS
"""
__tablename__ = "animal"
__tablename__ = "asset_animal"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Animal",
"model_title_plural": "Animals",
"model_title": "Animal Asset",
"model_title_plural": "Animal Assets",
"farmos_asset_type": "animal",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
doc="""
Name for the animal.
""",
)
animal_type_uuid = model.uuid_fk_column("animal_type.uuid", nullable=False)
animal_type = orm.relationship(
"AnimalType",
@ -148,56 +141,5 @@ class Animal(model.Base):
""",
)
archived = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the animal is archived.
""",
)
notes = sa.Column(
sa.Text(),
nullable=True,
doc="""
Arbitrary notes for the animal.
""",
)
image_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional image URL for the animal.
""",
)
thumbnail_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional thumbnail URL for the animal.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the animal within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the animal.
""",
)
def __str__(self):
return self.name or ""
add_asset_proxies(AnimalAsset)

View file

@ -25,6 +25,7 @@ Model definition for Asset Types
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr
from wuttjamaican.db import model
@ -80,3 +81,127 @@ class AssetType(model.Base):
def __str__(self):
return self.name or ""
class Asset(model.Base):
"""
Represents an asset (of any kind) from farmOS.
"""
__tablename__ = "asset"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Asset",
"model_title_plural": "All Assets",
}
uuid = model.uuid_column()
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the asset within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the asset.
""",
)
asset_type = sa.Column(
sa.String(length=100), sa.ForeignKey("asset_type.drupal_id"), nullable=False
)
asset_name = sa.Column(
sa.String(length=100),
nullable=False,
doc="""
Name of the asset.
""",
)
is_location = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the asset should be considered a location.
""",
)
is_fixed = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the asset's location is fixed.
""",
)
notes = sa.Column(
sa.Text(),
nullable=True,
doc="""
Notes for the asset.
""",
)
thumbnail_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional thumbnail URL for the asset.
""",
)
image_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional image URL for the asset.
""",
)
archived = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the asset is archived.
""",
)
def __str__(self):
return self.asset_name or ""
class AssetMixin:
uuid = model.uuid_fk_column("asset.uuid", nullable=False, primary_key=True)
@declared_attr
def asset(cls):
return orm.relationship(Asset)
def __str__(self):
return self.asset_name or ""
def add_asset_proxies(subclass):
Asset.make_proxy(subclass, "asset", "farmos_uuid")
Asset.make_proxy(subclass, "asset", "drupal_id")
Asset.make_proxy(subclass, "asset", "asset_type")
Asset.make_proxy(subclass, "asset", "asset_name")
Asset.make_proxy(subclass, "asset", "is_location")
Asset.make_proxy(subclass, "asset", "is_fixed")
Asset.make_proxy(subclass, "asset", "notes")
Asset.make_proxy(subclass, "asset", "thumbnail_url")
Asset.make_proxy(subclass, "asset", "image_url")
Asset.make_proxy(subclass, "asset", "archived")

View file

@ -102,7 +102,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["StructureType"] = StructureTypeImporter
importers["Structure"] = StructureImporter
importers["AnimalType"] = AnimalTypeImporter
importers["Animal"] = AnimalImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["Group"] = GroupImporter
importers["LogType"] = LogTypeImporter
importers["ActivityLog"] = ActivityLogImporter
@ -176,17 +176,77 @@ class ActivityLogImporter(FromFarmOS, ToWutta):
}
class AnimalImporter(FromFarmOS, ToWutta):
class AssetImporterBase(FromFarmOS, ToWutta):
"""
Base class for farmOS API WuttaFarm asset importers
"""
def get_simple_fields(self):
""" """
fields = list(super().get_simple_fields())
# nb. must explicitly declare proxy fields
fields.extend(
[
"farmos_uuid",
"drupal_id",
"asset_type",
"asset_name",
"notes",
"archived",
"image_url",
"thumbnail_url",
]
)
return fields
def normalize_asset(self, asset):
""" """
image_url = None
thumbnail_url = None
if relationships := asset.get("relationships"):
if image := relationships.get("image"):
if image["data"]:
image = self.farmos_client.resource.get_id(
"file", "file", image["data"][0]["id"]
)
if image_style := image["data"]["attributes"].get(
"image_style_uri"
):
image_url = image_style["large"]
thumbnail_url = image_style["thumbnail"]
if notes := asset["attributes"]["notes"]:
notes = notes["value"]
if self.farmos_4x:
archived = asset["attributes"]["archived"]
else:
archived = asset["attributes"]["status"] == "archived"
return {
"farmos_uuid": UUID(asset["id"]),
"drupal_id": asset["attributes"]["drupal_internal__id"],
"asset_name": asset["attributes"]["name"],
"archived": archived,
"notes": notes,
"image_url": image_url,
"thumbnail_url": thumbnail_url,
}
class AnimalAssetImporter(AssetImporterBase):
"""
farmOS API WuttaFarm importer for Animals
"""
model_class = model.Animal
model_class = model.AnimalAsset
supported_fields = [
"farmos_uuid",
"drupal_id",
"name",
"asset_type",
"asset_name",
"animal_type_uuid",
"sex",
"is_sterile",
@ -214,27 +274,18 @@ class AnimalImporter(FromFarmOS, ToWutta):
def normalize_source_object(self, animal):
""" """
animal_type_uuid = None
image_url = None
thumbnail_url = None
if relationships := animal.get("relationships"):
if animal_type := relationships.get("animal_type"):
if animal_type["data"]:
if animal_type := self.animal_types_by_farmos_uuid.get(
if wf_animal_type := self.animal_types_by_farmos_uuid.get(
UUID(animal_type["data"]["id"])
):
animal_type_uuid = animal_type.uuid
if image := relationships.get("image"):
if image["data"]:
image = self.farmos_client.resource.get_id(
"file", "file", image["data"][0]["id"]
)
if image_style := image["data"]["attributes"].get(
"image_style_uri"
):
image_url = image_style["large"]
thumbnail_url = image_style["thumbnail"]
animal_type_uuid = wf_animal_type.uuid
else:
log.warning(
"animal type not found: %s", animal_type["data"]["id"]
)
if not animal_type_uuid:
log.warning("missing/invalid animal_type for farmOS Animal: %s", animal)
@ -251,27 +302,17 @@ class AnimalImporter(FromFarmOS, ToWutta):
else:
sterile = animal["attributes"]["is_castrated"]
if notes := animal["attributes"]["notes"]:
notes = notes["value"]
if self.farmos_4x:
archived = animal["attributes"]["archived"]
else:
archived = animal["attributes"]["status"] == "archived"
return {
"farmos_uuid": UUID(animal["id"]),
"drupal_id": animal["attributes"]["drupal_internal__id"],
"name": animal["attributes"]["name"],
"animal_type_uuid": animal_type.uuid,
"sex": animal["attributes"]["sex"],
"is_sterile": sterile,
"birthdate": birthdate,
"archived": archived,
"notes": notes,
"image_url": image_url,
"thumbnail_url": thumbnail_url,
}
data = self.normalize_asset(animal)
data.update(
{
"asset_type": "animal",
"animal_type_uuid": animal_type_uuid,
"sex": animal["attributes"]["sex"],
"is_sterile": sterile,
"birthdate": birthdate,
}
)
return data
class AnimalTypeImporter(FromFarmOS, ToWutta):

View file

@ -45,10 +45,16 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"type": "menu",
"items": [
{
"title": "Animals",
"route": "animals",
"perm": "animals.list",
"title": "All Assets",
"route": "assets",
"perm": "assets.list",
},
{
"title": "Animal",
"route": "animal_assets",
"perm": "animal_assets.list",
},
{"type": "sep"},
{
"title": "Groups",
"route": "groups",

View file

@ -42,9 +42,9 @@ def includeme(config):
# native table views
config.include("wuttafarm.web.views.asset_types")
config.include("wuttafarm.web.views.assets")
config.include("wuttafarm.web.views.land_types")
config.include("wuttafarm.web.views.structure_types")
config.include("wuttafarm.web.views.animal_types")
config.include("wuttafarm.web.views.land_assets")
config.include("wuttafarm.web.views.structures")
config.include("wuttafarm.web.views.animals")

View file

@ -1,128 +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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Animal Types
"""
from wuttafarm.db.model.animals import AnimalType, Animal
from wuttafarm.web.views import WuttaFarmMasterView
class AnimalTypeView(WuttaFarmMasterView):
"""
Master view for Animal Types
"""
model_class = AnimalType
route_prefix = "animal_types"
url_prefix = "/animal-types"
farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview"
grid_columns = [
"name",
"description",
"changed",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"description",
"changed",
"farmos_uuid",
"drupal_id",
]
has_rows = True
row_model_class = Animal
rows_viewable = True
row_grid_columns = [
"name",
"sex",
"is_sterile",
"birthdate",
"archived",
]
rows_sort_defaults = "name"
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# name
g.set_link("name")
def get_farmos_url(self, animal_type):
return self.app.get_farmos_url(f"/taxonomy/term/{animal_type.drupal_id}")
def get_xref_buttons(self, animal_type):
buttons = super().get_xref_buttons(animal_type)
if animal_type.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_animal_types.view", uuid=animal_type.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def get_row_grid_data(self, animal_type):
model = self.app.model
session = self.Session()
return session.query(model.Animal).filter(
model.Animal.animal_type == animal_type
)
def configure_row_grid(self, grid):
g = grid
super().configure_row_grid(g)
# name
g.set_link("name")
def get_row_action_url_view(self, animal, i):
return self.request.route_url("animals.view", uuid=animal.uuid)
def defaults(config, **kwargs):
base = globals()
AnimalTypeView = kwargs.get("AnimalTypeView", base["AnimalTypeView"])
AnimalTypeView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -25,25 +25,129 @@ Master view for Animals
from wuttaweb.forms.schema import WuttaDictEnum
from wuttafarm.db.model.animals import Animal
from wuttafarm.db.model import AnimalType, AnimalAsset
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.web.views.assets import AssetMasterView
from wuttafarm.web.forms.schema import AnimalTypeRef
from wuttafarm.web.forms.widgets import ImageWidget
class AnimalView(WuttaFarmMasterView):
class AnimalTypeView(WuttaFarmMasterView):
"""
Master view for Animals
Master view for Animal Types
"""
model_class = Animal
route_prefix = "animals"
url_prefix = "/animals"
model_class = AnimalType
route_prefix = "animal_types"
url_prefix = "/animal-types"
farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview"
grid_columns = [
"name",
"description",
"changed",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"description",
"changed",
"farmos_uuid",
"drupal_id",
]
has_rows = True
row_model_class = AnimalAsset
rows_viewable = True
row_grid_columns = [
"asset_name",
"sex",
"is_sterile",
"birthdate",
"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, animal_type):
return self.app.get_farmos_url(f"/taxonomy/term/{animal_type.drupal_id}")
def get_xref_buttons(self, animal_type):
buttons = super().get_xref_buttons(animal_type)
if animal_type.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_animal_types.view", uuid=animal_type.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def get_row_grid_data(self, animal_type):
model = self.app.model
session = self.Session()
return (
session.query(model.AnimalAsset)
.join(model.Asset)
.filter(model.AnimalAsset.animal_type == animal_type)
)
def configure_row_grid(self, grid):
g = grid
super().configure_row_grid(g)
model = self.app.model
enum = self.app.enum
# 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)
# sex
g.set_enum("sex", enum.ANIMAL_SEX)
# 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, animal, i):
return self.request.route_url("animal_assets.view", uuid=animal.uuid)
class AnimalAssetView(AssetMasterView):
"""
Master view for Animal Assets
"""
model_class = AnimalAsset
route_prefix = "animal_assets"
url_prefix = "/assets/animal"
farmos_refurl_path = "/assets/animal"
labels = {
"name": "Asset Name",
"animal_type": "Species/Breed",
"is_sterile": "Sterile",
}
@ -51,7 +155,7 @@ class AnimalView(WuttaFarmMasterView):
grid_columns = [
"thumbnail",
"drupal_id",
"name",
"asset_name",
"animal_type",
"birthdate",
"is_sterile",
@ -59,15 +163,8 @@ class AnimalView(WuttaFarmMasterView):
"archived",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
"archived": {"active": True, "verb": "is_false"},
}
form_fields = [
"name",
"asset_name",
"animal_type",
"birthdate",
"sex",
@ -89,21 +186,10 @@ class AnimalView(WuttaFarmMasterView):
model = self.app.model
enum = self.app.enum
# thumbnail
g.set_renderer("thumbnail", self.render_grid_thumbnail)
g.set_label("thumbnail", "", column_only=True)
g.set_centered("thumbnail")
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
# name
g.set_link("name")
# animal_type
g.set_joiner("animal_type", lambda q: q.join(model.AnimalType))
g.set_sorter("animal_type", model.AnimalType.name)
g.set_filter("animal_type", model.AnimalType.name, label="Animal Type Name")
g.set_filter("animal_type", model.AnimalType.name)
# birthdate
g.set_renderer("birthdate", "date")
@ -111,17 +197,10 @@ class AnimalView(WuttaFarmMasterView):
# sex
g.set_enum("sex", enum.ANIMAL_SEX)
def grid_row_class(self, animal, data, i):
""" """
if animal.archived:
return "has-background-warning"
return None
def configure_form(self, form):
f = form
super().configure_form(f)
enum = self.app.enum
animal = form.model_instance
# animal_type
f.set_node("animal_type", AnimalTypeRef(self.request))
@ -129,64 +208,15 @@ class AnimalView(WuttaFarmMasterView):
# sex
f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX))
# notes
f.set_widget("notes", "notes")
# asset_type
if self.creating:
f.remove("asset_type")
else:
f.set_default("asset_type", "Animal")
f.set_readonly("asset_type")
# thumbnail_url
if self.creating or self.editing:
f.remove("thumbnail_url")
# image_url
if self.creating or self.editing:
f.remove("image_url")
# thumbnail
if self.creating or self.editing:
f.remove("thumbnail")
elif animal.thumbnail_url:
f.set_widget("thumbnail", ImageWidget("animal thumbnail"))
f.set_default("thumbnail", animal.thumbnail_url)
# image
if self.creating or self.editing:
f.remove("image")
elif animal.image_url:
f.set_widget("image", ImageWidget("animal image"))
f.set_default("image", animal.image_url)
def get_farmos_url(self, animal):
return self.app.get_farmos_url(f"/asset/{animal.drupal_id}")
def get_xref_buttons(self, animal):
buttons = super().get_xref_buttons(animal)
if animal.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_animals.view", uuid=animal.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()
AnimalView = kwargs.get("AnimalView", base["AnimalView"])
AnimalView.defaults(config)
AnimalTypeView = kwargs.get("AnimalTypeView", base["AnimalTypeView"])
AnimalTypeView.defaults(config)
AnimalAssetView = kwargs.get("AnimalAssetView", base["AnimalAssetView"])
AnimalAssetView.defaults(config)
def includeme(config):

View file

@ -0,0 +1,236 @@
# -*- 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 Assets
"""
from collections import OrderedDict
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import Asset
from wuttafarm.web.forms.widgets import ImageWidget
class AssetView(WuttaFarmMasterView):
"""
Master view for Assets
"""
model_class = Asset
route_prefix = "assets"
url_prefix = "/assets"
farmos_refurl_path = "/assets"
viewable = False
creatable = False
editable = False
deletable = False
grid_columns = [
"thumbnail",
"drupal_id",
"asset_name",
"asset_type",
"archived",
]
sort_defaults = "asset_name"
filter_defaults = {
"asset_name": {"active": True, "verb": "contains"},
"archived": {"active": True, "verb": "is_false"},
}
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# thumbnail
g.set_renderer("thumbnail", self.render_grid_thumbnail)
g.set_label("thumbnail", "", column_only=True)
g.set_centered("thumbnail")
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
# asset_name
g.set_link("asset_name")
# asset_type
g.set_enum("asset_type", self.get_asset_type_enum())
# view action links to final asset record
def asset_url(asset, i):
return self.request.route_url(
f"{asset.asset_type}_assets.view", uuid=asset.uuid
)
g.add_action("view", icon="eye", url=asset_url)
def get_asset_type_enum(self):
model = self.app.model
session = self.Session()
asset_types = OrderedDict()
query = session.query(model.AssetType).order_by(model.AssetType.name)
for asset_type in query:
asset_types[asset_type.drupal_id] = asset_type.name
return asset_types
def grid_row_class(self, asset, data, i):
""" """
if asset.archived:
return "has-background-warning"
return None
class AssetMasterView(WuttaFarmMasterView):
"""
Base class for Asset master views
"""
sort_defaults = "asset_name"
filter_defaults = {
"asset_name": {"active": True, "verb": "contains"},
"archived": {"active": True, "verb": "is_false"},
}
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.Asset)
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
model = self.app.model
# thumbnail
g.set_renderer("thumbnail", self.render_grid_thumbnail)
g.set_label("thumbnail", "", column_only=True)
g.set_centered("thumbnail")
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
g.set_sorter("drupal_id", model.Asset.drupal_id)
g.set_filter("drupal_id", model.Asset.drupal_id)
# 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 grid_row_class(self, asset, data, i):
""" """
if asset.archived:
return "has-background-warning"
return None
def configure_form(self, form):
f = form
super().configure_form(f)
asset = form.model_instance
# asset_type
if self.creating:
f.remove("asset_type")
else:
f.set_readonly("asset_type")
# notes
f.set_widget("notes", "notes")
# thumbnail_url
if self.creating or self.editing:
f.remove("thumbnail_url")
# image_url
if self.creating or self.editing:
f.remove("image_url")
# thumbnail
if self.creating or self.editing:
f.remove("thumbnail")
elif asset.thumbnail_url:
f.set_widget("thumbnail", ImageWidget("animal thumbnail"))
f.set_default("thumbnail", asset.thumbnail_url)
# image
if self.creating or self.editing:
f.remove("image")
elif asset.image_url:
f.set_widget("image", ImageWidget("animal image"))
f.set_default("image", asset.image_url)
def objectify(self, form):
asset = super().objectify(form)
if self.creating:
model_class = self.get_model_class()
asset.asset_type = model_class.__wutta_hint__["farmos_asset_type"]
return asset
def get_farmos_url(self, asset):
return self.app.get_farmos_url(f"/asset/{asset.drupal_id}")
def get_xref_buttons(self, asset):
buttons = super().get_xref_buttons(asset)
if asset.farmos_uuid:
# TODO
route = None
if asset.asset_type == "animal":
route = "farmos_animals.view"
if route:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(route, uuid=asset.farmos_uuid),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()
AssetView = kwargs.get("AssetView", base["AssetView"])
AssetView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -251,15 +251,17 @@ class AnimalView(FarmOSMasterView):
]
if wf_animal := (
session.query(model.Animal)
.filter(model.Animal.farmos_uuid == animal["uuid"])
session.query(model.Asset)
.filter(model.Asset.farmos_uuid == animal["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url("animals.view", uuid=wf_animal.uuid),
url=self.request.route_url(
"animal_assets.view", uuid=wf_animal.uuid
),
icon_left="eye",
)
)