diff --git a/src/wuttafarm/db/alembic/versions/d6e8d16d6854_add_generic_animal_assets.py b/src/wuttafarm/db/alembic/versions/d6e8d16d6854_add_generic_animal_assets.py
new file mode 100644
index 0000000..cd0a34a
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/d6e8d16d6854_add_generic_animal_assets.py
@@ -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,
+ )
diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py
index e8bce3f..a549879 100644
--- a/src/wuttafarm/db/model/__init__.py
+++ b/src/wuttafarm/db/model/__init__.py
@@ -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
diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py
index 1aec805..548be86 100644
--- a/src/wuttafarm/db/model/animals.py
+++ b/src/wuttafarm/db/model/animals.py
@@ -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)
diff --git a/src/wuttafarm/db/model/assets.py b/src/wuttafarm/db/model/assets.py
index 581be62..85c7eb4 100644
--- a/src/wuttafarm/db/model/assets.py
+++ b/src/wuttafarm/db/model/assets.py
@@ -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")
diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py
index adc63d0..6d8c573 100644
--- a/src/wuttafarm/importing/farmos.py
+++ b/src/wuttafarm/importing/farmos.py
@@ -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):
diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py
index 3e5bb46..071e5b6 100644
--- a/src/wuttafarm/web/menus.py
+++ b/src/wuttafarm/web/menus.py
@@ -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",
diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py
index a4d12dd..d27e6d9 100644
--- a/src/wuttafarm/web/views/__init__.py
+++ b/src/wuttafarm/web/views/__init__.py
@@ -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")
diff --git a/src/wuttafarm/web/views/animal_types.py b/src/wuttafarm/web/views/animal_types.py
deleted file mode 100644
index acb1bd9..0000000
--- a/src/wuttafarm/web/views/animal_types.py
+++ /dev/null
@@ -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 .
-#
-################################################################################
-"""
-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)
diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py
index 5758d68..b415cd6 100644
--- a/src/wuttafarm/web/views/animals.py
+++ b/src/wuttafarm/web/views/animals.py
@@ -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):
diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py
new file mode 100644
index 0000000..b8b1dfc
--- /dev/null
+++ b/src/wuttafarm/web/views/assets.py
@@ -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 .
+#
+################################################################################
+"""
+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)
diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py
index d181a02..c9c2887 100644
--- a/src/wuttafarm/web/views/farmos/animals.py
+++ b/src/wuttafarm/web/views/farmos/animals.py
@@ -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",
)
)