diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2111345..1e7712a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,35 @@ All notable changes to WuttaFarm will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.4.0 (2026-02-17)
+
+### Feat
+
+- add basic support for WuttaFarm → farmOS export
+- convert group assets to use common base/mixin
+- convert structure assets to use common base/mixin
+- convert land assets to use common base/mixin
+- add "generic" assets, new animal assets based on that
+
+### Fix
+
+- misc. field tweaks for asset forms
+- show warning when viewing an archived asset
+- fix some perms for all assets view
+- fix initial admin perms per route renaming
+- add parent relationships support for land assets
+- cleanup Land views to better match farmOS
+- cleanup Structure views to better match farmOS
+- cleanup Group views to better match farmOS
+- add / display thumbnail image for animals
+- improve handling of 'archived' records for grid/form views
+- use Male/Female dict enum for animal sex field
+- prevent direct edit of `farmos_uuid` and `drupal_id` fields
+- use same datetime display format as farmOS
+- convert `active` flag to `archived`
+- suppress output when user farmos/drupal keys are empty
+- customize page footer to mention farmOS
+
## v0.3.1 (2026-02-14)
### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 073879b..12bce62 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaFarm"
-version = "0.3.1"
+version = "0.4.0"
description = "Web app to integrate with and extend farmOS"
readme = "README.md"
authors = [
@@ -34,7 +34,7 @@ dependencies = [
"pyramid_exclog",
"uvicorn[standard]",
"WuttaSync",
- "WuttaWeb[continuum]>=0.27.4",
+ "WuttaWeb[continuum]>=0.28.1",
]
@@ -58,6 +58,7 @@ wuttafarm = "wuttafarm.app:WuttaFarmAppProvider"
"wuttafarm" = "wuttafarm.web.menus:WuttaFarmMenuHandler"
[project.entry-points."wuttasync.importing"]
+"export.to_farmos.from_wuttafarm" = "wuttafarm.farmos.importing.wuttafarm:FromWuttaFarmToFarmOS"
"import.to_wuttafarm.from_farmos" = "wuttafarm.importing.farmos:FromFarmOSToWuttaFarm"
diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py
index 9cfe25d..087c48a 100644
--- a/src/wuttafarm/app.py
+++ b/src/wuttafarm/app.py
@@ -31,6 +31,8 @@ class WuttaFarmAppHandler(base.AppHandler):
Custom :term:`app handler` for WuttaFarm.
"""
+ display_format_datetime = "%a, %m/%d/%Y - %H:%M"
+
default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler"
default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler"
@@ -83,6 +85,38 @@ class WuttaFarmAppHandler(base.AppHandler):
handler = self.get_farmos_handler()
return handler.is_farmos_4x(*args, **kwargs)
+ def export_to_farmos(self, obj, require=True):
+ """
+ Export the given object to farmOS, using configured handler.
+
+ This should ensure the given object is also *updated* with the
+ farmOS UUID and Drupal ID, when new record is created in
+ farmOS.
+
+ :param obj: Any data object in WuttaFarm, e.g. AnimalAsset
+ instance.
+
+ :param require: If true, this will *require* the export
+ handler to support objects of the given type. If false,
+ then nothing will happen / export is silently skipped when
+ there is no such exporter.
+ """
+ handler = self.app.get_import_handler("export.to_farmos.from_wuttafarm")
+
+ model_name = type(obj).__name__
+ if model_name not in handler.importers:
+ if require:
+ raise ValueError(f"no exporter found for {model_name}")
+ return
+
+ # nb. begin txn to establish the API client
+ # TODO: should probably use current user oauth2 token instead
+ # of always making a new one here, which is what happens IIUC
+ handler.begin_target_transaction()
+ importer = handler.get_importer(model_name, caches_target=False)
+ normal = importer.normalize_source_object(obj)
+ importer.process_data(source_data=[normal])
+
class WuttaFarmAppProvider(base.AppProvider):
"""
diff --git a/src/wuttafarm/cli/__init__.py b/src/wuttafarm/cli/__init__.py
index 7f6c2bb..cd06344 100644
--- a/src/wuttafarm/cli/__init__.py
+++ b/src/wuttafarm/cli/__init__.py
@@ -26,5 +26,6 @@ WuttaFarm CLI
from .base import wuttafarm_typer
# nb. must bring in all modules for discovery to work
+from . import export_farmos
from . import import_farmos
from . import install
diff --git a/src/wuttafarm/cli/export_farmos.py b/src/wuttafarm/cli/export_farmos.py
new file mode 100644
index 0000000..18a21dd
--- /dev/null
+++ b/src/wuttafarm/cli/export_farmos.py
@@ -0,0 +1,41 @@
+# -*- 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 .
+#
+################################################################################
+"""
+See also: :ref:`wuttafarm-export-farmos`
+"""
+
+import typer
+
+from wuttasync.cli import import_command, ImportCommandHandler
+
+from wuttafarm.cli import wuttafarm_typer
+
+
+@wuttafarm_typer.command()
+@import_command
+def export_farmos(ctx: typer.Context, **kwargs):
+ """
+ Export data from WuttaFarm to farmOS API
+ """
+ config = ctx.parent.wutta_config
+ handler = ImportCommandHandler(config, key="export.to_farmos.from_wuttafarm")
+ handler.run(ctx)
diff --git a/src/wuttafarm/config.py b/src/wuttafarm/config.py
index fcc8aae..5828299 100644
--- a/src/wuttafarm/config.py
+++ b/src/wuttafarm/config.py
@@ -39,8 +39,9 @@ class WuttaFarmConfig(WuttaConfigExtension):
config.setdefault(f"{config.appname}.app_title", "WuttaFarm")
config.setdefault(f"{config.appname}.app_dist", "WuttaFarm")
- # app model
+ # app model/enum
config.setdefault(f"{config.appname}.model_spec", "wuttafarm.db.model")
+ config.setdefault(f"{config.appname}.enum_spec", "wuttafarm.enum")
# app handler
config.setdefault(
diff --git a/src/wuttafarm/db/alembic/versions/2a49127e974b_add_animal_thumbnail_url.py b/src/wuttafarm/db/alembic/versions/2a49127e974b_add_animal_thumbnail_url.py
new file mode 100644
index 0000000..7c32b29
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/2a49127e974b_add_animal_thumbnail_url.py
@@ -0,0 +1,41 @@
+"""add animal thumbnail url
+
+Revision ID: 2a49127e974b
+Revises: 8898184c5c75
+Create Date: 2026-02-14 19:41:22.039343
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "2a49127e974b"
+down_revision: Union[str, None] = "8898184c5c75"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # animal
+ op.add_column(
+ "animal", sa.Column("thumbnail_url", sa.String(length=255), nullable=True)
+ )
+ op.add_column(
+ "animal_version",
+ sa.Column(
+ "thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True
+ ),
+ )
+
+
+def downgrade() -> None:
+
+ # animal
+ op.drop_column("animal_version", "thumbnail_url")
+ op.drop_column("animal", "thumbnail_url")
diff --git a/src/wuttafarm/db/alembic/versions/34ec51d80f52_use_shared_base_for_structure_assets.py b/src/wuttafarm/db/alembic/versions/34ec51d80f52_use_shared_base_for_structure_assets.py
new file mode 100644
index 0000000..4be383f
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/34ec51d80f52_use_shared_base_for_structure_assets.py
@@ -0,0 +1,236 @@
+"""use shared base for Structure Assets
+
+Revision ID: 34ec51d80f52
+Revises: d882682c82f9
+Create Date: 2026-02-15 13:19:18.814523
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "34ec51d80f52"
+down_revision: Union[str, None] = "d882682c82f9"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # asset_structure
+ op.create_table(
+ "asset_structure",
+ sa.Column("structure_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["structure_type_uuid"],
+ ["structure_type.uuid"],
+ name=op.f("fk_asset_structure_structure_type_uuid_structure_type"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["asset.uuid"], name=op.f("fk_asset_structure_uuid_asset")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_structure")),
+ )
+ op.create_table(
+ "asset_structure_version",
+ sa.Column(
+ "structure_type_uuid",
+ wuttjamaican.db.util.UUID(),
+ 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_structure_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_structure_version_end_transaction_id"),
+ "asset_structure_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_structure_version_operation_type"),
+ "asset_structure_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_structure_version_pk_transaction_id",
+ "asset_structure_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_structure_version_pk_validity",
+ "asset_structure_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_structure_version_transaction_id"),
+ "asset_structure_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # structure
+ op.drop_index(
+ op.f("ix_structure_version_end_transaction_id"), table_name="structure_version"
+ )
+ op.drop_index(
+ op.f("ix_structure_version_operation_type"), table_name="structure_version"
+ )
+ op.drop_index(
+ op.f("ix_structure_version_pk_transaction_id"), table_name="structure_version"
+ )
+ op.drop_index(
+ op.f("ix_structure_version_pk_validity"), table_name="structure_version"
+ )
+ op.drop_index(
+ op.f("ix_structure_version_transaction_id"), table_name="structure_version"
+ )
+ op.drop_table("structure_version")
+ op.drop_table("structure")
+
+
+def downgrade() -> None:
+
+ # structure
+ op.create_table(
+ "structure",
+ sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
+ sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
+ sa.Column(
+ "structure_type_uuid", sa.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False),
+ sa.Column("is_fixed", 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(
+ ["structure_type_uuid"],
+ ["structure_type.uuid"],
+ name=op.f("fk_structure_structure_type_uuid_structure_type"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure")),
+ sa.UniqueConstraint(
+ "drupal_id",
+ name=op.f("uq_structure_drupal_id"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ sa.UniqueConstraint(
+ "farmos_uuid",
+ name=op.f("uq_structure_farmos_uuid"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ sa.UniqueConstraint(
+ "name",
+ name=op.f("uq_structure_name"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ )
+ op.create_table(
+ "structure_version",
+ sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
+ sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
+ sa.Column("structure_type_uuid", sa.UUID(), 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(
+ "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_structure_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_structure_version_transaction_id"),
+ "structure_version",
+ ["transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_structure_version_pk_validity"),
+ "structure_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_structure_version_pk_transaction_id"),
+ "structure_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_structure_version_operation_type"),
+ "structure_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_structure_version_end_transaction_id"),
+ "structure_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+
+ # asset_structure
+ op.drop_index(
+ op.f("ix_asset_structure_version_transaction_id"),
+ table_name="asset_structure_version",
+ )
+ op.drop_index(
+ "ix_asset_structure_version_pk_validity", table_name="asset_structure_version"
+ )
+ op.drop_index(
+ "ix_asset_structure_version_pk_transaction_id",
+ table_name="asset_structure_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_structure_version_operation_type"),
+ table_name="asset_structure_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_structure_version_end_transaction_id"),
+ table_name="asset_structure_version",
+ )
+ op.drop_table("asset_structure_version")
+ op.drop_table("asset_structure")
diff --git a/src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py b/src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py
new file mode 100644
index 0000000..e943f77
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py
@@ -0,0 +1,125 @@
+"""add LandAssetParent model
+
+Revision ID: 554e6168c339
+Revises: 8cc1565d38e7
+Create Date: 2026-02-14 20:41:24.859064
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "554e6168c339"
+down_revision: Union[str, None] = "8cc1565d38e7"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # land_asset_parent
+ op.create_table(
+ "land_asset_parent",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("land_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("parent_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["land_asset_uuid"],
+ ["land_asset.uuid"],
+ name=op.f("fk_land_asset_parent_land_asset_uuid_land_asset"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["parent_asset_uuid"],
+ ["land_asset.uuid"],
+ name=op.f("fk_land_asset_parent_parent_asset_uuid_land_asset"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset_parent")),
+ )
+ op.create_table(
+ "land_asset_parent_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "land_asset_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "parent_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_land_asset_parent_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_land_asset_parent_version_end_transaction_id"),
+ "land_asset_parent_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_parent_version_operation_type"),
+ "land_asset_parent_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_land_asset_parent_version_pk_transaction_id",
+ "land_asset_parent_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_land_asset_parent_version_pk_validity",
+ "land_asset_parent_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_parent_version_transaction_id"),
+ "land_asset_parent_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # land_asset_parent
+ op.drop_index(
+ op.f("ix_land_asset_parent_version_transaction_id"),
+ table_name="land_asset_parent_version",
+ )
+ op.drop_index(
+ "ix_land_asset_parent_version_pk_validity",
+ table_name="land_asset_parent_version",
+ )
+ op.drop_index(
+ "ix_land_asset_parent_version_pk_transaction_id",
+ table_name="land_asset_parent_version",
+ )
+ op.drop_index(
+ op.f("ix_land_asset_parent_version_operation_type"),
+ table_name="land_asset_parent_version",
+ )
+ op.drop_index(
+ op.f("ix_land_asset_parent_version_end_transaction_id"),
+ table_name="land_asset_parent_version",
+ )
+ op.drop_table("land_asset_parent_version")
+ op.drop_table("land_asset_parent")
diff --git a/src/wuttafarm/db/alembic/versions/8898184c5c75_convert_active_to_archived.py b/src/wuttafarm/db/alembic/versions/8898184c5c75_convert_active_to_archived.py
new file mode 100644
index 0000000..70bbe2c
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/8898184c5c75_convert_active_to_archived.py
@@ -0,0 +1,250 @@
+"""convert active to archived
+
+Revision ID: 8898184c5c75
+Revises: 3e2ef02bf264
+Create Date: 2026-02-14 18:41:23.042951
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "8898184c5c75"
+down_revision: Union[str, None] = "3e2ef02bf264"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # animal
+ op.alter_column("animal", "active", new_column_name="archived")
+ animal = sa.sql.table(
+ "animal",
+ sa.sql.column("uuid"),
+ sa.sql.column("archived"),
+ )
+ cursor = op.get_bind().execute(animal.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ animal.update()
+ .where(animal.c.uuid == row.uuid)
+ .values({"archived": not row.archived})
+ )
+ op.alter_column("animal_version", "active", new_column_name="archived")
+ animal_version = sa.sql.table(
+ "animal_version",
+ sa.sql.column("uuid"),
+ sa.sql.column("archived"),
+ )
+ cursor = op.get_bind().execute(animal_version.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ animal_version.update()
+ .where(animal_version.c.uuid == row.uuid)
+ .values({"archived": not row.archived})
+ )
+
+ # group
+ op.alter_column("group", "active", new_column_name="archived")
+ group = sa.sql.table(
+ "group",
+ sa.sql.column("uuid"),
+ sa.sql.column("archived"),
+ )
+ cursor = op.get_bind().execute(group.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ group.update()
+ .where(group.c.uuid == row.uuid)
+ .values({"archived": not row.archived})
+ )
+ op.alter_column("group_version", "active", new_column_name="archived")
+ group_version = sa.sql.table(
+ "group_version",
+ sa.sql.column("uuid"),
+ sa.sql.column("archived"),
+ )
+ cursor = op.get_bind().execute(group_version.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ group_version.update()
+ .where(group_version.c.uuid == row.uuid)
+ .values({"archived": not row.archived})
+ )
+
+ # land_asset
+ op.alter_column("land_asset", "active", new_column_name="archived")
+ land_asset = sa.sql.table(
+ "land_asset",
+ sa.sql.column("uuid"),
+ sa.sql.column("archived"),
+ )
+ cursor = op.get_bind().execute(land_asset.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ land_asset.update()
+ .where(land_asset.c.uuid == row.uuid)
+ .values({"archived": not row.archived})
+ )
+ op.alter_column("land_asset_version", "active", new_column_name="archived")
+ land_asset_version = sa.sql.table(
+ "land_asset_version",
+ sa.sql.column("uuid"),
+ sa.sql.column("archived"),
+ )
+ cursor = op.get_bind().execute(land_asset_version.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ land_asset_version.update()
+ .where(land_asset_version.c.uuid == row.uuid)
+ .values({"archived": not row.archived})
+ )
+
+ # structure
+ op.alter_column("structure", "active", new_column_name="archived")
+ structure = sa.sql.table(
+ "structure",
+ sa.sql.column("uuid"),
+ sa.sql.column("archived"),
+ )
+ cursor = op.get_bind().execute(structure.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ structure.update()
+ .where(structure.c.uuid == row.uuid)
+ .values({"archived": not row.archived})
+ )
+ op.alter_column("structure_version", "active", new_column_name="archived")
+ structure_version = sa.sql.table(
+ "structure_version",
+ sa.sql.column("uuid"),
+ sa.sql.column("archived"),
+ )
+ cursor = op.get_bind().execute(structure_version.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ structure_version.update()
+ .where(structure_version.c.uuid == row.uuid)
+ .values({"archived": not row.archived})
+ )
+
+
+def downgrade() -> None:
+
+ # structure
+ op.alter_column("structure", "archived", new_column_name="active")
+ structure = sa.sql.table(
+ "structure",
+ sa.sql.column("uuid"),
+ sa.sql.column("active"),
+ )
+ cursor = op.get_bind().execute(structure.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ structure.update()
+ .where(structure.c.uuid == row.uuid)
+ .values({"active": not row.active})
+ )
+ op.alter_column("structure_version", "archived", new_column_name="active")
+ structure_version = sa.sql.table(
+ "structure_version",
+ sa.sql.column("uuid"),
+ sa.sql.column("active"),
+ )
+ cursor = op.get_bind().execute(structure_version.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ structure_version.update()
+ .where(structure_version.c.uuid == row.uuid)
+ .values({"active": not row.active})
+ )
+
+ # land_asset
+ op.alter_column("land_asset", "archived", new_column_name="active")
+ land_asset = sa.sql.table(
+ "land_asset",
+ sa.sql.column("uuid"),
+ sa.sql.column("active"),
+ )
+ cursor = op.get_bind().execute(land_asset.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ land_asset.update()
+ .where(land_asset.c.uuid == row.uuid)
+ .values({"active": not row.active})
+ )
+ op.alter_column("land_asset_version", "archived", new_column_name="active")
+ land_asset_version = sa.sql.table(
+ "land_asset_version",
+ sa.sql.column("uuid"),
+ sa.sql.column("active"),
+ )
+ cursor = op.get_bind().execute(land_asset_version.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ land_asset_version.update()
+ .where(land_asset_version.c.uuid == row.uuid)
+ .values({"active": not row.active})
+ )
+
+ # group
+ op.alter_column("group", "archived", new_column_name="active")
+ group = sa.sql.table(
+ "group",
+ sa.sql.column("uuid"),
+ sa.sql.column("active"),
+ )
+ cursor = op.get_bind().execute(group.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ group.update()
+ .where(group.c.uuid == row.uuid)
+ .values({"active": not row.active})
+ )
+ op.alter_column("group_version", "archived", new_column_name="active")
+ group_version = sa.sql.table(
+ "group_version",
+ sa.sql.column("uuid"),
+ sa.sql.column("active"),
+ )
+ cursor = op.get_bind().execute(group_version.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ group_version.update()
+ .where(group_version.c.uuid == row.uuid)
+ .values({"active": not row.active})
+ )
+
+ # animal
+ op.alter_column("animal", "archived", new_column_name="active")
+ animal = sa.sql.table(
+ "animal",
+ sa.sql.column("uuid"),
+ sa.sql.column("active"),
+ )
+ cursor = op.get_bind().execute(animal.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ animal.update()
+ .where(animal.c.uuid == row.uuid)
+ .values({"active": not row.active})
+ )
+ op.alter_column("animal_version", "archived", new_column_name="active")
+ animal_version = sa.sql.table(
+ "animal_version",
+ sa.sql.column("uuid"),
+ sa.sql.column("active"),
+ )
+ cursor = op.get_bind().execute(animal_version.select())
+ for row in cursor.fetchall():
+ op.get_bind().execute(
+ animal_version.update()
+ .where(animal_version.c.uuid == row.uuid)
+ .values({"active": not row.active})
+ )
diff --git a/src/wuttafarm/db/alembic/versions/8cc1565d38e7_add_structure_thumbnail_url.py b/src/wuttafarm/db/alembic/versions/8cc1565d38e7_add_structure_thumbnail_url.py
new file mode 100644
index 0000000..6bcd51b
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/8cc1565d38e7_add_structure_thumbnail_url.py
@@ -0,0 +1,41 @@
+"""add structure thumbnail url
+
+Revision ID: 8cc1565d38e7
+Revises: 2a49127e974b
+Create Date: 2026-02-14 20:07:33.913573
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "8cc1565d38e7"
+down_revision: Union[str, None] = "2a49127e974b"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # structure
+ op.add_column(
+ "structure", sa.Column("thumbnail_url", sa.String(length=255), nullable=True)
+ )
+ op.add_column(
+ "structure_version",
+ sa.Column(
+ "thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True
+ ),
+ )
+
+
+def downgrade() -> None:
+
+ # structure
+ op.drop_column("structure_version", "thumbnail_url")
+ op.drop_column("structure", "thumbnail_url")
diff --git a/src/wuttafarm/db/alembic/versions/aecfd9175624_use_shared_base_for_group_assets.py b/src/wuttafarm/db/alembic/versions/aecfd9175624_use_shared_base_for_group_assets.py
new file mode 100644
index 0000000..1295d40
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/aecfd9175624_use_shared_base_for_group_assets.py
@@ -0,0 +1,194 @@
+"""use shared base for Group Assets
+
+Revision ID: aecfd9175624
+Revises: 34ec51d80f52
+Create Date: 2026-02-15 13:57:01.055304
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "aecfd9175624"
+down_revision: Union[str, None] = "34ec51d80f52"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # asset_group
+ op.create_table(
+ "asset_group",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["asset.uuid"], name=op.f("fk_asset_group_uuid_asset")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_group")),
+ )
+ op.create_table(
+ "asset_group_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_group_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_group_version_end_transaction_id"),
+ "asset_group_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_group_version_operation_type"),
+ "asset_group_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_group_version_pk_transaction_id",
+ "asset_group_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_group_version_pk_validity",
+ "asset_group_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_group_version_transaction_id"),
+ "asset_group_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # group
+ op.drop_index(
+ op.f("ix_group_version_end_transaction_id"), table_name="group_version"
+ )
+ op.drop_index(op.f("ix_group_version_operation_type"), table_name="group_version")
+ op.drop_index(
+ op.f("ix_group_version_pk_transaction_id"), table_name="group_version"
+ )
+ op.drop_index(op.f("ix_group_version_pk_validity"), table_name="group_version")
+ op.drop_index(op.f("ix_group_version_transaction_id"), table_name="group_version")
+ op.drop_table("group_version")
+ op.drop_table("group")
+
+
+def downgrade() -> None:
+
+ # group
+ op.create_table(
+ "group",
+ sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
+ sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False),
+ sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False),
+ sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
+ sa.Column("notes", sa.TEXT(), 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.PrimaryKeyConstraint("uuid", name=op.f("pk_group")),
+ sa.UniqueConstraint(
+ "drupal_id",
+ name=op.f("uq_group_drupal_id"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ sa.UniqueConstraint(
+ "farmos_uuid",
+ name=op.f("uq_group_farmos_uuid"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ sa.UniqueConstraint(
+ "name",
+ name=op.f("uq_group_name"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ )
+ op.create_table(
+ "group_version",
+ sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("name", sa.VARCHAR(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("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
+ sa.Column("notes", sa.TEXT(), 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.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_group_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_group_version_transaction_id"),
+ "group_version",
+ ["transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_group_version_pk_validity"),
+ "group_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_group_version_pk_transaction_id"),
+ "group_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_group_version_operation_type"),
+ "group_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_group_version_end_transaction_id"),
+ "group_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+
+ # asset_group
+ op.drop_index(
+ op.f("ix_asset_group_version_transaction_id"), table_name="asset_group_version"
+ )
+ op.drop_index(
+ "ix_asset_group_version_pk_validity", table_name="asset_group_version"
+ )
+ op.drop_index(
+ "ix_asset_group_version_pk_transaction_id", table_name="asset_group_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_group_version_operation_type"), table_name="asset_group_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_group_version_end_transaction_id"),
+ table_name="asset_group_version",
+ )
+ op.drop_table("asset_group_version")
+ op.drop_table("asset_group")
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/alembic/versions/d882682c82f9_use_shared_base_for_land_assets.py b/src/wuttafarm/db/alembic/versions/d882682c82f9_use_shared_base_for_land_assets.py
new file mode 100644
index 0000000..7c9b5f7
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/d882682c82f9_use_shared_base_for_land_assets.py
@@ -0,0 +1,411 @@
+"""use shared base for Land Assets
+
+Revision ID: d882682c82f9
+Revises: d6e8d16d6854
+Create Date: 2026-02-15 12:00:27.036011
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "d882682c82f9"
+down_revision: Union[str, None] = "d6e8d16d6854"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # asset_parent
+ op.create_table(
+ "asset_parent",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("parent_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["asset_uuid"],
+ ["asset.uuid"],
+ name=op.f("fk_asset_parent_asset_uuid_asset"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["parent_uuid"],
+ ["asset.uuid"],
+ name=op.f("fk_asset_parent_parent_uuid_asset"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_parent")),
+ )
+ op.create_table(
+ "asset_parent_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "asset_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "parent_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_parent_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_parent_version_end_transaction_id"),
+ "asset_parent_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_parent_version_operation_type"),
+ "asset_parent_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_parent_version_pk_transaction_id",
+ "asset_parent_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_parent_version_pk_validity",
+ "asset_parent_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_parent_version_transaction_id"),
+ "asset_parent_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # asset_land
+ op.create_table(
+ "asset_land",
+ sa.Column("land_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["land_type_uuid"],
+ ["land_type.uuid"],
+ name=op.f("fk_asset_land_land_type_uuid_land_type"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["uuid"], ["asset.uuid"], name=op.f("fk_asset_land_uuid_asset")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_land")),
+ sa.UniqueConstraint(
+ "land_type_uuid", name=op.f("uq_asset_land_land_type_uuid")
+ ),
+ )
+ op.create_table(
+ "asset_land_version",
+ sa.Column(
+ "land_type_uuid",
+ wuttjamaican.db.util.UUID(),
+ 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_land_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_land_version_end_transaction_id"),
+ "asset_land_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_land_version_operation_type"),
+ "asset_land_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_land_version_pk_transaction_id",
+ "asset_land_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_land_version_pk_validity",
+ "asset_land_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_land_version_transaction_id"),
+ "asset_land_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+ # land_asset_parent
+ op.drop_index(
+ op.f("ix_land_asset_parent_version_end_transaction_id"),
+ table_name="land_asset_parent_version",
+ )
+ op.drop_index(
+ op.f("ix_land_asset_parent_version_operation_type"),
+ table_name="land_asset_parent_version",
+ )
+ op.drop_index(
+ op.f("ix_land_asset_parent_version_pk_transaction_id"),
+ table_name="land_asset_parent_version",
+ )
+ op.drop_index(
+ op.f("ix_land_asset_parent_version_pk_validity"),
+ table_name="land_asset_parent_version",
+ )
+ op.drop_index(
+ op.f("ix_land_asset_parent_version_transaction_id"),
+ table_name="land_asset_parent_version",
+ )
+ op.drop_table("land_asset_parent_version")
+ op.drop_table("land_asset_parent")
+
+ # land_asset
+ op.drop_index(
+ op.f("ix_land_asset_version_end_transaction_id"),
+ table_name="land_asset_version",
+ )
+ op.drop_index(
+ op.f("ix_land_asset_version_operation_type"), table_name="land_asset_version"
+ )
+ op.drop_index(
+ op.f("ix_land_asset_version_pk_transaction_id"), table_name="land_asset_version"
+ )
+ op.drop_index(
+ op.f("ix_land_asset_version_pk_validity"), table_name="land_asset_version"
+ )
+ op.drop_index(
+ op.f("ix_land_asset_version_transaction_id"), table_name="land_asset_version"
+ )
+ op.drop_table("land_asset_version")
+ op.drop_table("land_asset")
+
+
+def downgrade() -> None:
+
+ # land_asset
+ op.create_table(
+ "land_asset",
+ sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
+ sa.Column("land_type_uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False),
+ sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False),
+ sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
+ sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
+ sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
+ sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
+ sa.ForeignKeyConstraint(
+ ["land_type_uuid"],
+ ["land_type.uuid"],
+ name=op.f("fk_land_asset_land_type_uuid_land_type"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset")),
+ sa.UniqueConstraint(
+ "drupal_id",
+ name=op.f("uq_land_asset_drupal_id"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ sa.UniqueConstraint(
+ "farmos_uuid",
+ name=op.f("uq_land_asset_farmos_uuid"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ sa.UniqueConstraint(
+ "land_type_uuid",
+ name=op.f("uq_land_asset_land_type_uuid"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ sa.UniqueConstraint(
+ "name",
+ name=op.f("uq_land_asset_name"),
+ postgresql_include=[],
+ postgresql_nulls_not_distinct=False,
+ ),
+ )
+ op.create_table(
+ "land_asset_version",
+ sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
+ sa.Column("land_type_uuid", sa.UUID(), 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("archived", sa.BOOLEAN(), 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.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_land_asset_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_land_asset_version_transaction_id"),
+ "land_asset_version",
+ ["transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_version_pk_validity"),
+ "land_asset_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_version_pk_transaction_id"),
+ "land_asset_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_version_operation_type"),
+ "land_asset_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_version_end_transaction_id"),
+ "land_asset_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+
+ # land_asset_parent
+ op.create_table(
+ "land_asset_parent",
+ sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("land_asset_uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("parent_asset_uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.ForeignKeyConstraint(
+ ["land_asset_uuid"],
+ ["land_asset.uuid"],
+ name=op.f("fk_land_asset_parent_land_asset_uuid_land_asset"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["parent_asset_uuid"],
+ ["land_asset.uuid"],
+ name=op.f("fk_land_asset_parent_parent_asset_uuid_land_asset"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset_parent")),
+ )
+ op.create_table(
+ "land_asset_parent_version",
+ sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("land_asset_uuid", sa.UUID(), autoincrement=False, nullable=True),
+ sa.Column("parent_asset_uuid", sa.UUID(), 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.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_land_asset_parent_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_land_asset_parent_version_transaction_id"),
+ "land_asset_parent_version",
+ ["transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_parent_version_pk_validity"),
+ "land_asset_parent_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_parent_version_pk_transaction_id"),
+ "land_asset_parent_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_parent_version_operation_type"),
+ "land_asset_parent_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_parent_version_end_transaction_id"),
+ "land_asset_parent_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+
+ # asset_land
+ op.drop_table("asset_land")
+ op.drop_index(
+ op.f("ix_asset_land_version_transaction_id"), table_name="asset_land_version"
+ )
+ op.drop_index("ix_asset_land_version_pk_validity", table_name="asset_land_version")
+ op.drop_index(
+ "ix_asset_land_version_pk_transaction_id", table_name="asset_land_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_land_version_operation_type"), table_name="asset_land_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_land_version_end_transaction_id"),
+ table_name="asset_land_version",
+ )
+ op.drop_table("asset_land_version")
+
+ # asset_parent
+ op.drop_index(
+ op.f("ix_asset_parent_version_transaction_id"),
+ table_name="asset_parent_version",
+ )
+ op.drop_index(
+ "ix_asset_parent_version_pk_validity", table_name="asset_parent_version"
+ )
+ op.drop_index(
+ "ix_asset_parent_version_pk_transaction_id", table_name="asset_parent_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_parent_version_operation_type"),
+ table_name="asset_parent_version",
+ )
+ op.drop_index(
+ op.f("ix_asset_parent_version_end_transaction_id"),
+ table_name="asset_parent_version",
+ )
+ op.drop_table("asset_parent_version")
+ op.drop_table("asset_parent")
diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py
index 27d0070..367bc1c 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, AssetParent
from .land import LandType, LandAsset
-from .structures import StructureType, Structure
-from .animals import AnimalType, Animal
-from .groups import Group
+from .structures import StructureType, StructureAsset
+from .animals import AnimalType, AnimalAsset
+from .groups import GroupAsset
from .logs import LogType, ActivityLog
diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py
index e23f0c5..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,47 +141,5 @@ class Animal(model.Base):
""",
)
- active = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the animal is currently active.
- """,
- )
- 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.
- """,
- )
-
- 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..531fd62 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,160 @@ 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.
+ """,
+ )
+
+ _parents = orm.relationship(
+ "AssetParent",
+ foreign_keys="AssetParent.asset_uuid",
+ back_populates="asset",
+ cascade="all, delete-orphan",
+ cascade_backrefs=False,
+ )
+
+ 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")
+
+
+class AssetParent(model.Base):
+ """
+ Represents an "asset's parent relationship" from farmOS.
+ """
+
+ __tablename__ = "asset_parent"
+ __versioned__ = {}
+
+ uuid = model.uuid_column()
+
+ asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
+
+ asset = orm.relationship(
+ Asset,
+ foreign_keys=asset_uuid,
+ )
+
+ parent_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
+
+ parent = orm.relationship(
+ Asset,
+ foreign_keys=parent_uuid,
+ )
diff --git a/src/wuttafarm/db/model/groups.py b/src/wuttafarm/db/model/groups.py
index eae034f..84453a7 100644
--- a/src/wuttafarm/db/model/groups.py
+++ b/src/wuttafarm/db/model/groups.py
@@ -23,84 +23,23 @@
Model definition for Groups
"""
-import sqlalchemy as sa
-from sqlalchemy import orm
-
from wuttjamaican.db import model
+from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies
-class Group(model.Base):
+
+class GroupAsset(AssetMixin, model.Base):
"""
- Represents a "group" from farmOS
+ Represents a group asset from farmOS
"""
- __tablename__ = "group"
+ __tablename__ = "asset_group"
__versioned__ = {}
__wutta_hint__ = {
- "model_title": "Group",
- "model_title_plural": "Groups",
+ "model_title": "Group Asset",
+ "model_title_plural": "Group Assets",
+ "farmos_asset_type": "group",
}
- uuid = model.uuid_column()
- name = sa.Column(
- sa.String(length=100),
- nullable=False,
- unique=True,
- doc="""
- Name for the group.
- """,
- )
-
- is_location = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the group is considered to be a location.
- """,
- )
-
- is_fixed = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the group location is fixed.
- """,
- )
-
- active = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the group is active.
- """,
- )
-
- notes = sa.Column(
- sa.Text(),
- nullable=True,
- doc="""
- Arbitrary notes for the group.
- """,
- )
-
- farmos_uuid = sa.Column(
- model.UUID(),
- nullable=True,
- unique=True,
- doc="""
- UUID for the group within farmOS.
- """,
- )
-
- drupal_id = sa.Column(
- sa.Integer(),
- nullable=True,
- unique=True,
- doc="""
- Drupal internal ID for the group.
- """,
- )
-
- def __str__(self):
- return self.name or ""
+add_asset_proxies(GroupAsset)
diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py
index 53c93cf..1221c63 100644
--- a/src/wuttafarm/db/model/land.py
+++ b/src/wuttafarm/db/model/land.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 LandType(model.Base):
"""
@@ -76,81 +78,21 @@ class LandType(model.Base):
return self.name or ""
-class LandAsset(model.Base):
+class LandAsset(AssetMixin, model.Base):
"""
Represents a "land asset" from farmOS
"""
- __tablename__ = "land_asset"
+ __tablename__ = "asset_land"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Land Asset",
"model_title_plural": "Land Assets",
+ "farmos_asset_type": "animal",
}
- uuid = model.uuid_column()
-
- name = sa.Column(
- sa.String(length=100),
- nullable=False,
- unique=True,
- doc="""
- Name of the land asset.
- """,
- )
-
land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False, unique=True)
land_type = orm.relationship(LandType, back_populates="land_assets")
- is_location = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the land asset should be considered a location.
- """,
- )
- is_fixed = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the land asset's location is fixed.
- """,
- )
-
- notes = sa.Column(
- sa.Text(),
- nullable=True,
- doc="""
- Notes for the land asset.
- """,
- )
-
- active = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the land asset is currently active.
- """,
- )
-
- farmos_uuid = sa.Column(
- model.UUID(),
- nullable=True,
- unique=True,
- doc="""
- UUID for the land asset within farmOS.
- """,
- )
-
- drupal_id = sa.Column(
- sa.Integer(),
- nullable=True,
- unique=True,
- doc="""
- Drupal internal ID for the land asset.
- """,
- )
-
- def __str__(self):
- return self.name or ""
+add_asset_proxies(LandAsset)
diff --git a/src/wuttafarm/db/model/structures.py b/src/wuttafarm/db/model/structures.py
index d9fccdb..8c5371c 100644
--- a/src/wuttafarm/db/model/structures.py
+++ b/src/wuttafarm/db/model/structures.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 StructureType(model.Base):
"""
@@ -74,37 +76,19 @@ class StructureType(model.Base):
return self.name or ""
-class Structure(model.Base):
+class StructureAsset(AssetMixin, model.Base):
"""
Represents a structure from farmOS
"""
- __tablename__ = "structure"
+ __tablename__ = "asset_structure"
__versioned__ = {}
__wutta_hint__ = {
- "model_title": "Structure",
- "model_title_plural": "Structures",
+ "model_title": "Structure Asset",
+ "model_title_plural": "Structure Assets",
+ "farmos_asset_type": "structure",
}
- uuid = model.uuid_column()
-
- name = sa.Column(
- sa.String(length=100),
- nullable=False,
- unique=True,
- doc="""
- Name for the structure.
- """,
- )
-
- active = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the structure is currently active.
- """,
- )
-
structure_type_uuid = model.uuid_fk_column("structure_type.uuid", nullable=False)
structure_type = orm.relationship(
"StructureType",
@@ -113,55 +97,5 @@ class Structure(model.Base):
""",
)
- is_location = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the structure is considered a location.
- """,
- )
- is_fixed = sa.Column(
- sa.Boolean(),
- nullable=False,
- doc="""
- Whether the structure location is fixed.
- """,
- )
-
- notes = sa.Column(
- sa.Text(),
- nullable=True,
- doc="""
- Arbitrary notes for the structure.
- """,
- )
-
- image_url = sa.Column(
- sa.String(length=255),
- nullable=True,
- doc="""
- Optional image URL for the structure.
- """,
- )
-
- farmos_uuid = sa.Column(
- model.UUID(),
- nullable=True,
- unique=True,
- doc="""
- UUID for the structure within farmOS.
- """,
- )
-
- drupal_id = sa.Column(
- sa.Integer(),
- nullable=True,
- unique=True,
- doc="""
- Drupal internal ID for the structure.
- """,
- )
-
- def __str__(self):
- return self.name or ""
+add_asset_proxies(StructureAsset)
diff --git a/src/wuttafarm/emails.py b/src/wuttafarm/emails.py
index 55b1612..05416ab 100644
--- a/src/wuttafarm/emails.py
+++ b/src/wuttafarm/emails.py
@@ -26,6 +26,12 @@ Email sending config for WuttaFarm
from wuttasync.emails import ImportExportWarning
+class export_to_farmos_from_wuttafarm_warning(ImportExportWarning):
+ """
+ Diff warning for WuttaFarm → farmOS export.
+ """
+
+
class import_to_wuttafarm_from_farmos_warning(ImportExportWarning):
"""
Diff warning for farmOS → WuttaFarm import.
diff --git a/src/wuttafarm/enum.py b/src/wuttafarm/enum.py
new file mode 100644
index 0000000..41bf597
--- /dev/null
+++ b/src/wuttafarm/enum.py
@@ -0,0 +1,36 @@
+# -*- 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 .
+#
+################################################################################
+"""
+WuttaFarm enum values
+"""
+
+from collections import OrderedDict
+
+from wuttjamaican.enum import *
+
+
+ANIMAL_SEX = OrderedDict(
+ [
+ ("M", "Male"),
+ ("F", "Female"),
+ ]
+)
diff --git a/src/wuttafarm/farmos/importing/__init__.py b/src/wuttafarm/farmos/importing/__init__.py
new file mode 100644
index 0000000..a4b17eb
--- /dev/null
+++ b/src/wuttafarm/farmos/importing/__init__.py
@@ -0,0 +1,26 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Importing data *into* farmOS
+"""
+
+from . import model
diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py
new file mode 100644
index 0000000..6c3f5a0
--- /dev/null
+++ b/src/wuttafarm/farmos/importing/model.py
@@ -0,0 +1,365 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Importer models targeting farmOS
+"""
+
+import datetime
+from uuid import UUID
+
+import requests
+
+from wuttasync.importing import Importer
+
+
+class ToFarmOS(Importer):
+ """
+ Base class for data importer targeting the farmOS API.
+ """
+
+ key = "uuid"
+ caches_target = True
+
+ def format_datetime(self, dt):
+ """
+ Convert a WuttaFarm datetime object to the format required for
+ pushing to the farmOS API.
+ """
+ if dt is None:
+ return None
+ dt = self.app.localtime(dt)
+ return dt.timestamp()
+
+ def normalize_datetime(self, dt):
+ """
+ Convert a farmOS datetime value to naive UTC used by
+ WuttaFarm.
+
+ :param dt: Date/time string value "as-is" from the farmOS API.
+
+ :returns: Equivalent naive UTC ``datetime``
+ """
+ if dt is None:
+ return None
+ dt = datetime.datetime.fromisoformat(dt)
+ return self.app.make_utc(dt)
+
+
+class ToFarmOSAsset(ToFarmOS):
+ """
+ Base class for asset data importer targeting the farmOS API.
+ """
+
+ farmos_asset_type = None
+
+ def get_target_objects(self, **kwargs):
+ assets = self.farmos_client.asset.get(self.farmos_asset_type)
+ return assets["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:
+ asset = self.farmos_client.asset.get_id(self.farmos_asset_type, str(uuid))
+ except requests.HTTPError as exc:
+ if exc.response.status_code == 404:
+ return None
+ raise
+ return asset["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_asset_payload(source_data)
+ result = self.farmos_client.asset.send(self.farmos_asset_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_asset_payload(source_data)
+ payload["id"] = str(source_data["uuid"])
+ result = self.farmos_client.asset.send(self.farmos_asset_type, payload)
+ return self.normalize_target_object(result["data"])
+
+ def normalize_target_object(self, asset):
+
+ if notes := asset["attributes"]["notes"]:
+ notes = notes["value"]
+
+ return {
+ "uuid": UUID(asset["id"]),
+ "asset_name": asset["attributes"]["name"],
+ "is_location": asset["attributes"]["is_location"],
+ "is_fixed": asset["attributes"]["is_fixed"],
+ "notes": notes,
+ "archived": asset["attributes"]["archived"],
+ }
+
+ def get_asset_payload(self, source_data):
+
+ attrs = {}
+ if "asset_name" in self.fields:
+ attrs["name"] = source_data["asset_name"]
+ if "is_location" in self.fields:
+ attrs["is_location"] = source_data["is_location"]
+ if "is_fixed" in self.fields:
+ attrs["is_fixed"] = source_data["is_fixed"]
+ if "notes" in self.fields:
+ attrs["notes"] = {"value": source_data["notes"]}
+ if "archived" in self.fields:
+ attrs["archived"] = source_data["archived"]
+
+ payload = {"attributes": attrs}
+
+ return payload
+
+
+class AnimalAssetImporter(ToFarmOSAsset):
+
+ model_title = "AnimalAsset"
+ farmos_asset_type = "animal"
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "animal_type_uuid",
+ "sex",
+ "is_sterile",
+ "birthdate",
+ "notes",
+ "archived",
+ ]
+
+ def normalize_target_object(self, animal):
+ data = super().normalize_target_object(animal)
+ data.update(
+ {
+ "animal_type_uuid": UUID(
+ animal["relationships"]["animal_type"]["data"]["id"]
+ ),
+ "sex": animal["attributes"]["sex"],
+ "is_sterile": animal["attributes"]["is_sterile"],
+ "birthdate": self.normalize_datetime(animal["attributes"]["birthdate"]),
+ }
+ )
+ return data
+
+ def get_asset_payload(self, source_data):
+ payload = super().get_asset_payload(source_data)
+
+ attrs = {}
+ if "sex" in self.fields:
+ attrs["sex"] = source_data["sex"]
+ if "is_sterile" in self.fields:
+ attrs["is_sterile"] = source_data["is_sterile"]
+ if "birthdate" in self.fields:
+ attrs["birthdate"] = self.format_datetime(source_data["birthdate"])
+
+ rels = {}
+ if "animal_type_uuid" in self.fields:
+ rels["animal_type"] = {
+ "data": {
+ "id": str(source_data["animal_type_uuid"]),
+ "type": "taxonomy_term--animal_type",
+ }
+ }
+
+ payload["attributes"].update(attrs)
+ if rels:
+ payload.setdefault("relationships", {}).update(rels)
+
+ return payload
+
+
+class AnimalTypeImporter(ToFarmOS):
+
+ model_title = "AnimalType"
+
+ supported_fields = [
+ "uuid",
+ "name",
+ ]
+
+ def get_target_objects(self, **kwargs):
+ result = self.farmos_client.resource.get("taxonomy_term", "animal_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:
+ result = self.farmos_client.resource.get_id(
+ "taxonomy_term", "animal_type", str(uuid)
+ )
+ except requests.HTTPError as exc:
+ if exc.response.status_code == 404:
+ return None
+ raise
+ return result["data"]
+
+ def normalize_target_object(self, obj):
+ return {
+ "uuid": UUID(obj["id"]),
+ "name": obj["attributes"]["name"],
+ }
+
+ def get_type_payload(self, source_data):
+ return {
+ "attributes": {
+ "name": source_data["name"],
+ }
+ }
+
+ 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_type_payload(source_data)
+ result = self.farmos_client.resource.send(
+ "taxonomy_term", "animal_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_type_payload(source_data)
+ payload["id"] = str(source_data["uuid"])
+ result = self.farmos_client.resource.send(
+ "taxonomy_term", "animal_type", payload
+ )
+ return self.normalize_target_object(result["data"])
+
+
+class GroupAssetImporter(ToFarmOSAsset):
+
+ model_title = "GroupAsset"
+ farmos_asset_type = "group"
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "notes",
+ "archived",
+ ]
+
+
+class LandAssetImporter(ToFarmOSAsset):
+
+ model_title = "LandAsset"
+ farmos_asset_type = "land"
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "land_type_id",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "archived",
+ ]
+
+ def normalize_target_object(self, land):
+ data = super().normalize_target_object(land)
+ data.update(
+ {
+ "land_type_id": land["attributes"]["land_type"],
+ }
+ )
+ return data
+
+ def get_asset_payload(self, source_data):
+ payload = super().get_asset_payload(source_data)
+
+ attrs = {}
+ if "land_type_id" in self.fields:
+ attrs["land_type"] = source_data["land_type_id"]
+
+ if attrs:
+ payload["attributes"].update(attrs)
+
+ return payload
+
+
+class StructureAssetImporter(ToFarmOSAsset):
+
+ model_title = "StructureAsset"
+ farmos_asset_type = "structure"
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "structure_type_id",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "archived",
+ ]
+
+ def normalize_target_object(self, structure):
+ data = super().normalize_target_object(structure)
+ data.update(
+ {
+ "structure_type_id": structure["attributes"]["structure_type"],
+ }
+ )
+ return data
+
+ def get_asset_payload(self, source_data):
+ payload = super().get_asset_payload(source_data)
+
+ attrs = {}
+ if "structure_type_id" in self.fields:
+ attrs["structure_type"] = source_data["structure_type_id"]
+
+ if attrs:
+ payload["attributes"].update(attrs)
+
+ return payload
diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py
new file mode 100644
index 0000000..8ef8a77
--- /dev/null
+++ b/src/wuttafarm/farmos/importing/wuttafarm.py
@@ -0,0 +1,263 @@
+# -*- 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 .
+#
+################################################################################
+"""
+WuttaFarm → farmOS data export
+"""
+
+from oauthlib.oauth2 import BackendApplicationClient
+from requests_oauthlib import OAuth2Session
+
+from wuttasync.importing import ImportHandler, FromWuttaHandler, FromWutta, Orientation
+
+from wuttafarm.db import model
+from wuttafarm.farmos import importing as farmos_importing
+
+
+class FromWuttaFarmHandler(FromWuttaHandler):
+ """
+ Base class for import handler targeting WuttaFarm
+ """
+
+ source_key = "wuttafarm"
+
+
+class ToFarmOSHandler(ImportHandler):
+ """
+ Base class for export handlers using CSV file(s) as data target.
+ """
+
+ target_key = "farmos"
+ generic_target_title = "farmOS"
+
+ # TODO: a lot of duplication to cleanup here; see FromFarmOSHandler
+
+ def begin_target_transaction(self):
+ """
+ Establish the farmOS API client.
+ """
+ token = self.get_farmos_oauth2_token()
+ self.farmos_client = self.app.get_farmos_client(token=token)
+ self.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
+
+ def get_farmos_oauth2_token(self):
+
+ client_id = self.config.get(
+ "farmos.oauth2.importing.client_id", default="wuttafarm"
+ )
+ client_secret = self.config.require("farmos.oauth2.importing.client_secret")
+ scope = self.config.get("farmos.oauth2.importing.scope", default="farm_manager")
+
+ client = BackendApplicationClient(client_id=client_id)
+ oauth = OAuth2Session(client=client)
+
+ return oauth.fetch_token(
+ token_url=self.app.get_farmos_url("/oauth/token"),
+ include_client_id=True,
+ client_secret=client_secret,
+ scope=scope,
+ )
+
+ def get_importer_kwargs(self, key, **kwargs):
+ kwargs = super().get_importer_kwargs(key, **kwargs)
+ kwargs["farmos_client"] = self.farmos_client
+ kwargs["farmos_4x"] = self.farmos_4x
+ return kwargs
+
+
+class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
+ """
+ Handler for WuttaFarm → farmOS API export.
+ """
+
+ orientation = Orientation.EXPORT
+
+ def define_importers(self):
+ """ """
+ importers = super().define_importers()
+ importers["LandAsset"] = LandAssetImporter
+ importers["StructureAsset"] = StructureAssetImporter
+ importers["AnimalType"] = AnimalTypeImporter
+ importers["AnimalAsset"] = AnimalAssetImporter
+ importers["GroupAsset"] = GroupAssetImporter
+ return importers
+
+
+class FromWuttaFarm(FromWutta):
+
+ drupal_internal_id_field = "drupal_internal__id"
+
+ def create_target_object(self, key, source_data):
+ obj = super().create_target_object(key, source_data)
+ if obj is None:
+ return None
+
+ if not self.dry_run:
+
+ # set farmOS, Drupal key fields in WuttaFarm
+ api_object = obj["_new_object"]
+ wf_object = source_data["_src_object"]
+ wf_object.farmos_uuid = obj["uuid"]
+ wf_object.drupal_id = api_object["attributes"][
+ self.drupal_internal_id_field
+ ]
+
+ return obj
+
+
+class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImporter):
+ """
+ WuttaFarm → farmOS API exporter for Animal Assets
+ """
+
+ source_model_class = model.AnimalAsset
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "animal_type_uuid",
+ "sex",
+ "is_sterile",
+ "birthdate",
+ "notes",
+ "archived",
+ ]
+
+ def normalize_source_object(self, animal):
+ return {
+ "uuid": animal.farmos_uuid or self.app.make_true_uuid(),
+ "asset_name": animal.asset_name,
+ "animal_type_uuid": animal.animal_type.farmos_uuid,
+ "sex": animal.sex,
+ "is_sterile": animal.is_sterile,
+ "birthdate": animal.birthdate,
+ "notes": animal.notes,
+ "archived": animal.archived,
+ "_src_object": animal,
+ }
+
+
+class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporter):
+ """
+ WuttaFarm → farmOS API exporter for Animal Types
+ """
+
+ source_model_class = model.AnimalType
+
+ supported_fields = [
+ "uuid",
+ "name",
+ ]
+
+ drupal_internal_id_field = "drupal_internal__tid"
+
+ def normalize_source_object(self, animal_type):
+ return {
+ "uuid": animal_type.farmos_uuid or self.app.make_true_uuid(),
+ "name": animal_type.name,
+ "_src_object": animal_type,
+ }
+
+
+class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter):
+ """
+ WuttaFarm → farmOS API exporter for Group Assets
+ """
+
+ source_model_class = model.GroupAsset
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "notes",
+ "archived",
+ ]
+
+ def normalize_source_object(self, group):
+ return {
+ "uuid": group.farmos_uuid or self.app.make_true_uuid(),
+ "asset_name": group.asset_name,
+ "notes": group.notes,
+ "archived": group.archived,
+ "_src_object": group,
+ }
+
+
+class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter):
+ """
+ WuttaFarm → farmOS API exporter for Land Assets
+ """
+
+ source_model_class = model.LandAsset
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "land_type_id",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "archived",
+ ]
+
+ def normalize_source_object(self, land):
+ return {
+ "uuid": land.farmos_uuid or self.app.make_true_uuid(),
+ "asset_name": land.asset_name,
+ "land_type_id": land.land_type.drupal_id,
+ "is_location": land.is_location,
+ "is_fixed": land.is_fixed,
+ "notes": land.notes,
+ "archived": land.archived,
+ "_src_object": land,
+ }
+
+
+class StructureAssetImporter(
+ FromWuttaFarm, farmos_importing.model.StructureAssetImporter
+):
+ """
+ WuttaFarm → farmOS API exporter for Structure Assets
+ """
+
+ source_model_class = model.StructureAsset
+
+ supported_fields = [
+ "uuid",
+ "asset_name",
+ "structure_type_id",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "archived",
+ ]
+
+ def normalize_source_object(self, structure):
+ return {
+ "uuid": structure.farmos_uuid or self.app.make_true_uuid(),
+ "asset_name": structure.asset_name,
+ "structure_type_id": structure.structure_type.drupal_id,
+ "is_location": structure.is_location,
+ "is_fixed": structure.is_fixed,
+ "notes": structure.notes,
+ "archived": structure.archived,
+ "_src_object": structure,
+ }
diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py
index 4acbe24..4f9db20 100644
--- a/src/wuttafarm/importing/farmos.py
+++ b/src/wuttafarm/importing/farmos.py
@@ -100,10 +100,10 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["LandType"] = LandTypeImporter
importers["LandAsset"] = LandAssetImporter
importers["StructureType"] = StructureTypeImporter
- importers["Structure"] = StructureImporter
+ importers["StructureAsset"] = StructureAssetImporter
importers["AnimalType"] = AnimalTypeImporter
- importers["Animal"] = AnimalImporter
- importers["Group"] = GroupImporter
+ importers["AnimalAsset"] = AnimalAssetImporter
+ importers["GroupAsset"] = GroupAssetImporter
importers["LogType"] = LogTypeImporter
importers["ActivityLog"] = ActivityLogImporter
return importers
@@ -176,24 +176,170 @@ 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",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "archived",
+ "image_url",
+ "thumbnail_url",
+ ]
+ )
+ return fields
+
+ def get_supported_fields(self):
+ """ """
+ fields = list(super().get_supported_fields())
+ fields.extend(
+ [
+ "parents",
+ ]
+ )
+ return fields
+
+ def normalize_source_data(self, **kwargs):
+ """ """
+ data = super().normalize_source_data(**kwargs)
+
+ if "parents" in self.fields:
+ # nb. make sure parent-less (root) assets come first, so they
+ # exist when child assets need to reference them
+ data.sort(key=lambda l: len(l["parents"]))
+
+ return data
+
+ 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"
+
+ parents = None
+ if "parents" in self.fields:
+ parents = []
+ for parent in asset["relationships"]["parent"]["data"]:
+ parents.append((self.get_asset_type(parent), UUID(parent["id"])))
+
+ return {
+ "farmos_uuid": UUID(asset["id"]),
+ "drupal_id": asset["attributes"]["drupal_internal__id"],
+ "asset_name": asset["attributes"]["name"],
+ "is_location": asset["attributes"]["is_location"],
+ "is_fixed": asset["attributes"]["is_fixed"],
+ "archived": archived,
+ "notes": notes,
+ "image_url": image_url,
+ "thumbnail_url": thumbnail_url,
+ "parents": parents,
+ }
+
+ def get_asset_type(self, asset):
+ return asset["type"].split("--")[1]
+
+ def normalize_target_object(self, asset):
+ data = super().normalize_target_object(asset)
+
+ if "parents" in self.fields:
+ data["parents"] = [
+ (p.parent.asset_type, p.parent.farmos_uuid)
+ for p in asset.asset._parents
+ ]
+
+ return data
+
+ def update_target_object(self, asset, source_data, target_data=None):
+ model = self.app.model
+ asset = super().update_target_object(asset, source_data, target_data)
+
+ if "parents" in self.fields:
+ if not target_data or target_data["parents"] != source_data["parents"]:
+
+ for key in source_data["parents"]:
+ asset_type, farmos_uuid = key
+ if not target_data or key not in target_data["parents"]:
+ self.target_session.flush()
+ parent = (
+ self.target_session.query(model.Asset)
+ .filter(model.Asset.asset_type == asset_type)
+ .filter(model.Asset.farmos_uuid == farmos_uuid)
+ .one()
+ )
+ asset.asset._parents.append(model.AssetParent(parent=parent))
+
+ if target_data:
+ for key in target_data["parents"]:
+ asset_type, farmos_uuid = key
+ if key not in source_data["parents"]:
+ parent = (
+ self.target_session.query(model.Asset)
+ .filter(model.Asset.asset_type == asset_type)
+ .filter(model.Asset.farmos_uuid == farmos_uuid)
+ .one()
+ )
+ parent = (
+ self.target_session.query(model.AssetParent)
+ .filter(model.AssetParent.asset == asset)
+ .filter(model.AssetParent.parent == parent)
+ .one()
+ )
+ self.target_session.delete(parent)
+
+ return asset
+
+
+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",
"birthdate",
"notes",
- "active",
+ "archived",
"image_url",
+ "thumbnail_url",
]
def setup(self):
@@ -213,25 +359,18 @@ class AnimalImporter(FromFarmOS, ToWutta):
def normalize_source_object(self, animal):
""" """
animal_type_uuid = None
- image_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"]
+ 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)
@@ -248,26 +387,17 @@ class AnimalImporter(FromFarmOS, ToWutta):
else:
sterile = animal["attributes"]["is_castrated"]
- if notes := animal["attributes"]["notes"]:
- notes = notes["value"]
-
- if self.farmos_4x:
- active = not animal["attributes"]["archived"]
- else:
- active = animal["attributes"]["status"] == "active"
-
- 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,
- "active": active,
- "notes": notes,
- "image_url": image_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):
@@ -330,21 +460,25 @@ class AssetTypeImporter(FromFarmOS, ToWutta):
}
-class GroupImporter(FromFarmOS, ToWutta):
+class GroupAssetImporter(AssetImporterBase):
"""
- farmOS API → WuttaFarm importer for Groups
+ farmOS API → WuttaFarm importer for Group Assets
"""
- model_class = model.Group
+ model_class = model.GroupAsset
supported_fields = [
"farmos_uuid",
"drupal_id",
- "name",
+ "asset_type",
+ "asset_name",
"is_location",
"is_fixed",
"notes",
- "active",
+ "archived",
+ "image_url",
+ "thumbnail_url",
+ "parents",
]
def get_source_objects(self):
@@ -354,26 +488,16 @@ class GroupImporter(FromFarmOS, ToWutta):
def normalize_source_object(self, group):
""" """
- if notes := group["attributes"]["notes"]:
- notes = notes["value"]
-
- if self.farmos_4x:
- active = not group["attributes"]["archived"]
- else:
- active = group["attributes"]["status"] == "active"
-
- return {
- "farmos_uuid": UUID(group["id"]),
- "drupal_id": group["attributes"]["drupal_internal__id"],
- "name": group["attributes"]["name"],
- "is_location": group["attributes"]["is_location"],
- "is_fixed": group["attributes"]["is_fixed"],
- "active": active,
- "notes": notes,
- }
+ data = self.normalize_asset(group)
+ data.update(
+ {
+ "asset_type": "group",
+ }
+ )
+ return data
-class LandAssetImporter(FromFarmOS, ToWutta):
+class LandAssetImporter(AssetImporterBase):
"""
farmOS API → WuttaFarm importer for Land Assets
"""
@@ -383,15 +507,18 @@ class LandAssetImporter(FromFarmOS, ToWutta):
supported_fields = [
"farmos_uuid",
"drupal_id",
- "name",
+ "asset_type",
+ "asset_name",
"land_type_uuid",
"is_location",
"is_fixed",
"notes",
- "active",
+ "archived",
+ "parents",
]
def setup(self):
+ """ """
super().setup()
model = self.app.model
@@ -414,24 +541,14 @@ class LandAssetImporter(FromFarmOS, ToWutta):
)
return None
- if notes := land["attributes"]["notes"]:
- notes = notes["value"]
-
- if self.farmos_4x:
- active = not land["attributes"]["archived"]
- else:
- active = land["attributes"]["status"] == "active"
-
- return {
- "farmos_uuid": UUID(land["id"]),
- "drupal_id": land["attributes"]["drupal_internal__id"],
- "name": land["attributes"]["name"],
- "land_type_uuid": land_type.uuid,
- "is_location": land["attributes"]["is_location"],
- "is_fixed": land["attributes"]["is_fixed"],
- "active": active,
- "notes": notes,
- }
+ data = self.normalize_asset(land)
+ data.update(
+ {
+ "asset_type": "land",
+ "land_type_uuid": land_type.uuid,
+ }
+ )
+ return data
class LandTypeImporter(FromFarmOS, ToWutta):
@@ -490,23 +607,26 @@ class LogTypeImporter(FromFarmOS, ToWutta):
}
-class StructureImporter(FromFarmOS, ToWutta):
+class StructureAssetImporter(AssetImporterBase):
"""
- farmOS API → WuttaFarm importer for Structures
+ farmOS API → WuttaFarm importer for Structure Assets
"""
- model_class = model.Structure
+ model_class = model.StructureAsset
supported_fields = [
"farmos_uuid",
"drupal_id",
- "name",
+ "asset_type",
+ "asset_name",
"structure_type_uuid",
"is_location",
"is_fixed",
"notes",
- "active",
+ "archived",
"image_url",
+ "thumbnail_url",
+ "parents",
]
def setup(self):
@@ -528,43 +648,20 @@ class StructureImporter(FromFarmOS, ToWutta):
structure_type = self.structure_types_by_id.get(structure_type_id)
if not structure_type:
log.warning(
- "invalid structure_type '%s' for farmOS Structure: %s",
+ "invalid structure_type '%s' for farmOS Structure Asset: %s",
structure_type_id,
structure,
)
return None
- if notes := structure["attributes"]["notes"]:
- notes = notes["value"]
-
- image_url = None
- if relationships := structure.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"]
-
- if self.farmos_4x:
- active = not structure["attributes"]["archived"]
- else:
- active = structure["attributes"]["status"] == "active"
-
- return {
- "farmos_uuid": UUID(structure["id"]),
- "drupal_id": structure["attributes"]["drupal_internal__id"],
- "name": structure["attributes"]["name"],
- "structure_type_uuid": structure_type.uuid,
- "is_location": structure["attributes"]["is_location"],
- "is_fixed": structure["attributes"]["is_fixed"],
- "active": active,
- "notes": notes,
- "image_url": image_url,
- }
+ data = self.normalize_asset(structure)
+ data.update(
+ {
+ "asset_type": "structure",
+ "structure_type_uuid": structure_type.uuid,
+ }
+ )
+ return data
class StructureTypeImporter(FromFarmOS, ToWutta):
diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py
index f646a96..95b3e9d 100644
--- a/src/wuttafarm/web/forms/schema.py
+++ b/src/wuttafarm/web/forms/schema.py
@@ -27,7 +27,7 @@ import json
import colander
-from wuttaweb.forms.schema import ObjectRef
+from wuttaweb.forms.schema import ObjectRef, WuttaSet
class AnimalTypeRef(ObjectRef):
@@ -160,3 +160,20 @@ class UsersType(colander.SchemaType):
from wuttafarm.web.forms.widgets import UsersWidget
return UsersWidget(self.request, **kwargs)
+
+
+class AssetParentRefs(WuttaSet):
+ """
+ Schema type for Parents field which references assets.
+ """
+
+ 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 AssetParentRefsWidget
+
+ return AssetParentRefsWidget(self.request, **kwargs)
diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py
index f6a99fc..f812ccf 100644
--- a/src/wuttafarm/web/forms/widgets.py
+++ b/src/wuttafarm/web/forms/widgets.py
@@ -29,6 +29,9 @@ import colander
from deform.widget import Widget
from webhelpers2.html import HTML, tags
+from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget
+from wuttaweb.db import Session
+
class ImageWidget(Widget):
"""
@@ -132,3 +135,34 @@ class UsersWidget(Widget):
return HTML.tag("ul", c=items)
return super().serialize(field, cstruct, **kw)
+
+
+class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
+ """
+ Widget for Parents field which references assets.
+ """
+
+ def serialize(self, field, cstruct, **kw):
+ """ """
+ model = self.app.model
+ session = Session()
+
+ readonly = kw.get("readonly", self.readonly)
+ if readonly:
+ parents = []
+ for uuid in json.loads(cstruct):
+ parent = session.get(model.Asset, uuid)
+ parents.append(
+ HTML.tag(
+ "li",
+ c=tags.link_to(
+ str(parent),
+ self.request.route_url(
+ f"{parent.asset_type}_assets.view", uuid=parent.uuid
+ ),
+ ),
+ )
+ )
+ return HTML.tag("ul", c=parents)
+
+ return super().serialize(field, cstruct, **kw)
diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py
index 3e5bb46..bdd2fbf 100644
--- a/src/wuttafarm/web/menus.py
+++ b/src/wuttafarm/web/menus.py
@@ -45,41 +45,46 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"type": "menu",
"items": [
{
- "title": "Animals",
- "route": "animals",
- "perm": "animals.list",
+ "title": "All Assets",
+ "route": "assets",
+ "perm": "assets.list",
},
{
- "title": "Groups",
- "route": "groups",
- "perm": "groups.list",
+ "title": "Animal",
+ "route": "animal_assets",
+ "perm": "animal_assets.list",
},
{
- "title": "Structures",
- "route": "structures",
- "perm": "structures.list",
+ "title": "Group",
+ "route": "group_assets",
+ "perm": "group_assets.list",
},
{
"title": "Land",
"route": "land_assets",
"perm": "land_assets.list",
},
+ {
+ "title": "Structure",
+ "route": "structure_assets",
+ "perm": "structure_assets.list",
+ },
{"type": "sep"},
{
"title": "Animal Types",
"route": "animal_types",
"perm": "animal_types.list",
},
- {
- "title": "Structure Types",
- "route": "structure_types",
- "perm": "structure_types.list",
- },
{
"title": "Land Types",
"route": "land_types",
"perm": "land_types.list",
},
+ {
+ "title": "Structure Types",
+ "route": "structure_types",
+ "perm": "structure_types.list",
+ },
{
"title": "Asset Types",
"route": "asset_types",
diff --git a/src/wuttafarm/web/templates/assets/master/view.mako b/src/wuttafarm/web/templates/assets/master/view.mako
new file mode 100644
index 0000000..dac5a1c
--- /dev/null
+++ b/src/wuttafarm/web/templates/assets/master/view.mako
@@ -0,0 +1,14 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="page_content()">
+
+ % if instance.archived:
+
+ This asset is archived.
+ Archived assets should only be edited if they need corrections.
+
+ % endif
+
+ ${parent.page_content()}
+%def>
diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py
index a4d12dd..e44c16e 100644
--- a/src/wuttafarm/web/views/__init__.py
+++ b/src/wuttafarm/web/views/__init__.py
@@ -42,10 +42,8 @@ def includeme(config):
# native table views
config.include("wuttafarm.web.views.asset_types")
- 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.assets")
+ config.include("wuttafarm.web.views.land")
config.include("wuttafarm.web.views.structures")
config.include("wuttafarm.web.views.animals")
config.include("wuttafarm.web.views.groups")
diff --git a/src/wuttafarm/web/views/animal_types.py b/src/wuttafarm/web/views/animal_types.py
deleted file mode 100644
index 09d1e25..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",
- "active",
- ]
-
- 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 e22095e..bae7dde 100644
--- a/src/wuttafarm/web/views/animals.py
+++ b/src/wuttafarm/web/views/animals.py
@@ -23,30 +23,29 @@
Master view for Animals
"""
-from wuttafarm.db.model.animals import Animal
-from wuttafarm.web.views import WuttaFarmMasterView
+from wuttaweb.forms.schema import WuttaDictEnum
+
+from wuttafarm.db.model import AnimalType, AnimalAsset
+from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.web.forms.schema import AnimalTypeRef
from wuttafarm.web.forms.widgets import ImageWidget
-class AnimalView(WuttaFarmMasterView):
+class AnimalTypeView(AssetTypeMasterView):
"""
- 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 = "/assets/animal"
+ farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview"
grid_columns = [
"name",
- "animal_type",
- "sex",
- "is_sterile",
- "birthdate",
- "active",
+ "description",
+ "changed",
]
sort_defaults = "name"
@@ -57,60 +56,46 @@ class AnimalView(WuttaFarmMasterView):
form_fields = [
"name",
- "animal_type",
- "birthdate",
- "sex",
- "is_sterile",
- "active",
- "notes",
+ "description",
+ "changed",
"farmos_uuid",
"drupal_id",
- "image_url",
- "image",
]
+ 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)
- model = self.app.model
# 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")
+ def get_farmos_url(self, animal_type):
+ return self.app.get_farmos_url(f"/taxonomy/term/{animal_type.drupal_id}")
- def configure_form(self, form):
- f = form
- super().configure_form(f)
- animal = form.model_instance
+ def get_xref_buttons(self, animal_type):
+ buttons = super().get_xref_buttons(animal_type)
- # animal_type
- f.set_node("animal_type", AnimalTypeRef(self.request))
-
- # notes
- f.set_widget("notes", "notes")
-
- # image
- if 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:
+ if animal_type.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
- "farmos_animals.view", uuid=animal.farmos_uuid
+ "farmos_animal_types.view", uuid=animal_type.farmos_uuid
),
icon_left="eye",
)
@@ -118,12 +103,124 @@ class AnimalView(WuttaFarmMasterView):
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 = {
+ "animal_type": "Species/Breed",
+ "is_sterile": "Sterile",
+ }
+
+ grid_columns = [
+ "thumbnail",
+ "drupal_id",
+ "asset_name",
+ "animal_type",
+ "birthdate",
+ "is_sterile",
+ "sex",
+ "archived",
+ ]
+
+ form_fields = [
+ "asset_name",
+ "animal_type",
+ "birthdate",
+ "sex",
+ "is_sterile",
+ "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)
+ model = self.app.model
+ enum = self.app.enum
+
+ # 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)
+
+ # birthdate
+ g.set_renderer("birthdate", "date")
+
+ # sex
+ g.set_enum("sex", enum.ANIMAL_SEX)
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ enum = self.app.enum
+ animal = f.model_instance
+
+ # animal_type
+ f.set_node("animal_type", AnimalTypeRef(self.request))
+
+ # sex
+ if self.viewing and animal.sex is None:
+ pass # TODO: dict enum widget does not handle null values well
+ else:
+ f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX))
+ f.set_required("sex", False)
+
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..dffaae7
--- /dev/null
+++ b/src/wuttafarm/web/views/assets.py
@@ -0,0 +1,292 @@
+# -*- 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 wuttaweb.forms.schema import WuttaDictEnum
+from wuttaweb.db import Session
+
+from wuttafarm.web.views import WuttaFarmMasterView
+from wuttafarm.db.model import Asset
+from wuttafarm.web.forms.schema import AssetParentRefs
+from wuttafarm.web.forms.widgets import ImageWidget
+
+
+def get_asset_type_enum(config):
+ app = config.get_app()
+ model = app.model
+ session = 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
+
+
+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
+ model_is_versioned = False
+
+ grid_columns = [
+ "thumbnail",
+ "drupal_id",
+ "asset_name",
+ "asset_type",
+ "parents",
+ "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", get_asset_type_enum(self.config))
+
+ # parents
+ g.set_renderer("parents", self.render_parents_for_grid)
+
+ # 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 render_parents_for_grid(self, asset, field, value):
+ parents = [str(p.parent) for p in asset._parents]
+ return ", ".join(parents)
+
+ def grid_row_class(self, asset, data, i):
+ """ """
+ if asset.archived:
+ return "has-background-warning"
+ return None
+
+
+class AssetTypeMasterView(WuttaFarmMasterView):
+ """
+ Base class for "Asset Type" master views.
+
+ A bit of a misnmer perhaps, this is *not* for the actual AssetType
+ model, but rather the "secondary" types, e.g. AnimalType,
+ LandType etc.
+ """
+
+
+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_fallback_templates(self, template):
+ templates = super().get_fallback_templates(template)
+
+ if self.viewing:
+ templates.insert(0, "/assets/master/view.mako")
+
+ return templates
+
+ 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)
+
+ # parents
+ g.set_renderer("parents", self.render_parents_for_grid)
+
+ # archived
+ g.set_renderer("archived", "boolean")
+ g.set_sorter("archived", model.Asset.archived)
+ g.set_filter("archived", model.Asset.archived)
+
+ def render_parents_for_grid(self, asset, field, value):
+ parents = [str(p.parent) for p in asset.asset._parents]
+ return ", ".join(parents)
+
+ 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_node(
+ "asset_type",
+ WuttaDictEnum(self.request, get_asset_type_enum(self.config)),
+ )
+ f.set_readonly("asset_type")
+
+ # parents
+ if self.creating or self.editing:
+ f.remove("parents") # TODO: add support for this
+ else:
+ f.set_node("parents", AssetParentRefs(self.request))
+ f.set_default("parents", [p.parent_uuid for p in asset.asset._parents])
+
+ # 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"
+ elif asset.asset_type == "group":
+ route = "farmos_groups.view"
+ elif asset.asset_type == "land":
+ route = "farmos_land_assets.view"
+ elif asset.asset_type == "structure":
+ route = "farmos_structures.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/common.py b/src/wuttafarm/web/views/common.py
index cd68b78..121e631 100644
--- a/src/wuttafarm/web/views/common.py
+++ b/src/wuttafarm/web/views/common.py
@@ -54,12 +54,17 @@ class CommonView(base.CommonView):
"activity_logs.list",
"activity_logs.view",
"activity_logs.versions",
+ "animal_types.create",
+ "animal_types.edit",
"animal_types.list",
"animal_types.view",
"animal_types.versions",
- "animals.list",
- "animals.view",
- "animals.versions",
+ "animal_assets.create",
+ "animal_assets.edit",
+ "animal_assets.list",
+ "animal_assets.view",
+ "animal_assets.versions",
+ "assets.list",
"asset_types.list",
"asset_types.view",
"asset_types.versions",
@@ -85,9 +90,13 @@ class CommonView(base.CommonView):
"farmos_structures.view",
"farmos_users.list",
"farmos_users.view",
- "groups.list",
- "groups.view",
- "groups.versions",
+ "group_asests.create",
+ "group_asests.edit",
+ "group_asests.list",
+ "group_asests.view",
+ "group_asests.versions",
+ "land_assets.create",
+ "land_assets.edit",
"land_assets.list",
"land_assets.view",
"land_assets.versions",
@@ -100,9 +109,11 @@ class CommonView(base.CommonView):
"structure_types.list",
"structure_types.view",
"structure_types.versions",
- "structures.list",
- "structures.view",
- "structures.versions",
+ "structure_assets.create",
+ "structure_assets.edit",
+ "structure_assets.list",
+ "structure_assets.view",
+ "structure_assets.versions",
]
for perm in site_admin_perms:
auth.grant_permission(site_admin, perm)
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",
)
)
diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py
index df54b04..c6748c4 100644
--- a/src/wuttafarm/web/views/farmos/groups.py
+++ b/src/wuttafarm/web/views/farmos/groups.py
@@ -166,15 +166,15 @@ class GroupView(FarmOSMasterView):
]
if wf_group := (
- session.query(model.Group)
- .filter(model.Group.farmos_uuid == group["uuid"])
+ session.query(model.GroupAsset)
+ .filter(model.GroupAsset.farmos_uuid == group["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
- url=self.request.route_url("groups.view", uuid=wf_group.uuid),
+ url=self.request.route_url("group_assets.view", uuid=wf_group.uuid),
icon_left="eye",
)
)
diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py
index 618c2fa..550f432 100644
--- a/src/wuttafarm/web/views/farmos/structures.py
+++ b/src/wuttafarm/web/views/farmos/structures.py
@@ -211,8 +211,8 @@ class StructureView(FarmOSMasterView):
]
if wf_structure := (
- session.query(model.Structure)
- .filter(model.Structure.farmos_uuid == structure["uuid"])
+ session.query(model.StructureAsset)
+ .filter(model.StructureAsset.farmos_uuid == structure["uuid"])
.first()
):
buttons.append(
@@ -220,7 +220,7 @@ class StructureView(FarmOSMasterView):
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url(
- "structures.view", uuid=wf_structure.uuid
+ "structure_assets.view", uuid=wf_structure.uuid
),
icon_left="eye",
)
diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py
index 5f2746b..21d7ed4 100644
--- a/src/wuttafarm/web/views/groups.py
+++ b/src/wuttafarm/web/views/groups.py
@@ -23,78 +23,37 @@
Master view for Groups
"""
-from wuttafarm.db.model.groups import Group
-from wuttafarm.web.views import WuttaFarmMasterView
+from wuttafarm.web.views.assets import AssetMasterView
+from wuttafarm.db.model.groups import GroupAsset
-class GroupView(WuttaFarmMasterView):
+class GroupView(AssetMasterView):
"""
Master view for Groups
"""
- model_class = Group
- route_prefix = "groups"
- url_prefix = "/groups"
+ model_class = GroupAsset
+ route_prefix = "group_assets"
+ url_prefix = "/assets/group"
farmos_refurl_path = "/assets/group"
grid_columns = [
- "name",
- "is_location",
- "is_fixed",
- "active",
+ "thumbnail",
+ "drupal_id",
+ "asset_name",
+ "archived",
]
- sort_defaults = "name"
-
- filter_defaults = {
- "name": {"active": True, "verb": "contains"},
- }
-
form_fields = [
- "name",
- "is_location",
- "is_fixed",
- "active",
+ "asset_name",
"notes",
+ "asset_type",
+ "archived",
"farmos_uuid",
"drupal_id",
]
- def configure_grid(self, grid):
- g = grid
- super().configure_grid(g)
-
- # name
- g.set_link("name")
-
- def configure_form(self, form):
- f = form
- super().configure_form(f)
-
- # notes
- f.set_widget("notes", "notes")
-
- def get_farmos_url(self, group):
- return self.app.get_farmos_url(f"/asset/{group.drupal_id}")
-
- def get_xref_buttons(self, group):
- buttons = super().get_xref_buttons(group)
-
- if group.farmos_uuid:
- buttons.append(
- self.make_button(
- "View farmOS record",
- primary=True,
- url=self.request.route_url(
- "farmos_groups.view", uuid=group.farmos_uuid
- ),
- icon_left="eye",
- )
- )
-
- return buttons
-
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/land_types.py b/src/wuttafarm/web/views/land.py
similarity index 53%
rename from src/wuttafarm/web/views/land_types.py
rename to src/wuttafarm/web/views/land.py
index 21bfabc..aad15e7 100644
--- a/src/wuttafarm/web/views/land_types.py
+++ b/src/wuttafarm/web/views/land.py
@@ -23,11 +23,14 @@
Master view for Land Types
"""
+from webhelpers2.html import HTML, tags
+
from wuttafarm.db.model.land import LandType, LandAsset
-from wuttafarm.web.views import WuttaFarmMasterView
+from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
+from wuttafarm.web.forms.schema import LandTypeRef
-class LandTypeView(WuttaFarmMasterView):
+class LandTypeView(AssetTypeMasterView):
"""
Master view for Land Types
"""
@@ -57,13 +60,13 @@ class LandTypeView(WuttaFarmMasterView):
rows_viewable = True
row_grid_columns = [
- "name",
+ "asset_name",
"is_location",
"is_fixed",
- "active",
+ "archived",
]
- rows_sort_defaults = "name"
+ rows_sort_defaults = "asset_name"
def configure_grid(self, grid):
g = grid
@@ -92,27 +95,102 @@ class LandTypeView(WuttaFarmMasterView):
def get_row_grid_data(self, land_type):
model = self.app.model
session = self.Session()
- return session.query(model.LandAsset).filter(
- model.LandAsset.land_type == land_type
+ return (
+ session.query(model.LandAsset)
+ .join(model.Asset)
+ .filter(model.LandAsset.land_type == land_type)
)
def configure_row_grid(self, grid):
g = grid
super().configure_row_grid(g)
+ model = self.app.model
- # name
- g.set_link("name")
+ # 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)
+
+ # is_location
+ g.set_renderer("is_location", "boolean")
+ g.set_sorter("is_location", model.Asset.is_location)
+ g.set_filter("is_location", model.Asset.is_location)
+
+ # is_fixed
+ g.set_renderer("is_fixed", "boolean")
+ g.set_sorter("is_fixed", model.Asset.is_fixed)
+ g.set_filter("is_fixed", model.Asset.is_fixed)
+
+ # 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, land_asset, i):
return self.request.route_url("land_assets.view", uuid=land_asset.uuid)
+class LandAssetView(AssetMasterView):
+ """
+ Master view for Land Assets
+ """
+
+ model_class = LandAsset
+ route_prefix = "land_assets"
+ url_prefix = "/assets/land"
+
+ farmos_refurl_path = "/assets/land"
+
+ grid_columns = [
+ "thumbnail",
+ "drupal_id",
+ "asset_name",
+ "land_type",
+ "parents",
+ "archived",
+ ]
+
+ form_fields = [
+ "asset_name",
+ "parents",
+ "notes",
+ "asset_type",
+ "land_type",
+ "is_location",
+ "is_fixed",
+ "archived",
+ "farmos_uuid",
+ "drupal_id",
+ ]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+ model = self.app.model
+
+ # land_type
+ g.set_joiner("land_type", lambda q: q.join(model.LandType))
+ g.set_sorter("land_type", model.LandType.name)
+ g.set_filter("land_type", model.LandType.name, label="Land Type Name")
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ land = f.model_instance
+
+ # land_type
+ f.set_node("land_type", LandTypeRef(self.request))
+
+
def defaults(config, **kwargs):
base = globals()
LandTypeView = kwargs.get("LandTypeView", base["LandTypeView"])
LandTypeView.defaults(config)
+ LandAssetView = kwargs.get("LandAssetView", base["LandAssetView"])
+ LandAssetView.defaults(config)
+
def includeme(config):
defaults(config)
diff --git a/src/wuttafarm/web/views/land_assets.py b/src/wuttafarm/web/views/land_assets.py
deleted file mode 100644
index 18f7a3d..0000000
--- a/src/wuttafarm/web/views/land_assets.py
+++ /dev/null
@@ -1,117 +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 Land Assets
-"""
-
-from wuttafarm.db.model.land import LandAsset
-from wuttafarm.web.views import WuttaFarmMasterView
-from wuttafarm.web.forms.schema import LandTypeRef
-
-
-class LandAssetView(WuttaFarmMasterView):
- """
- Master view for Land Assets
- """
-
- model_class = LandAsset
- route_prefix = "land_assets"
- url_prefix = "/land-assets"
-
- farmos_refurl_path = "/assets/land"
-
- grid_columns = [
- "name",
- "land_type",
- "is_location",
- "is_fixed",
- "notes",
- "active",
- ]
-
- sort_defaults = "name"
-
- filter_defaults = {
- "name": {"active": True, "verb": "contains"},
- }
-
- form_fields = [
- "name",
- "land_type",
- "is_location",
- "is_fixed",
- "notes",
- "active",
- "farmos_uuid",
- "drupal_id",
- ]
-
- def configure_grid(self, grid):
- g = grid
- super().configure_grid(g)
- model = self.app.model
-
- # name
- g.set_link("name")
-
- # land_type
- g.set_joiner("land_type", lambda q: q.join(model.LandType))
- g.set_sorter("land_type", model.LandType.name)
- g.set_filter("land_type", model.LandType.name, label="Land Type Name")
-
- def configure_form(self, form):
- f = form
- super().configure_form(f)
-
- # land_type
- f.set_node("land_type", LandTypeRef(self.request))
-
- def get_farmos_url(self, land):
- return self.app.get_farmos_url(f"/asset/{land.drupal_id}")
-
- def get_xref_buttons(self, land_asset):
- buttons = super().get_xref_buttons(land_asset)
-
- if land_asset.farmos_uuid:
- buttons.append(
- self.make_button(
- "View farmOS record",
- primary=True,
- url=self.request.route_url(
- "farmos_land_assets.view", uuid=land_asset.farmos_uuid
- ),
- icon_left="eye",
- )
- )
-
- return buttons
-
-
-def defaults(config, **kwargs):
- base = globals()
-
- LandAssetView = kwargs.get("LandAssetView", base["LandAssetView"])
- LandAssetView.defaults(config)
-
-
-def includeme(config):
- defaults(config)
diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py
index 7ff165b..0e25a30 100644
--- a/src/wuttafarm/web/views/master.py
+++ b/src/wuttafarm/web/views/master.py
@@ -23,6 +23,8 @@
Base class for WuttaFarm master views
"""
+from webhelpers2.html import tags
+
from wuttaweb.views import MasterView
@@ -37,12 +39,14 @@ class WuttaFarmMasterView(MasterView):
"farmos_uuid": "farmOS UUID",
"drupal_id": "Drupal ID",
"image_url": "Image URL",
+ "thumbnail_url": "Thumbnail URL",
}
row_labels = {
"farmos_uuid": "farmOS UUID",
"drupal_id": "Drupal ID",
"image_url": "Image URL",
+ "thumbnail_url": "Thumbnail URL",
}
def get_farmos_url(self, obj):
@@ -55,6 +59,13 @@ class WuttaFarmMasterView(MasterView):
return context
+ def render_grid_thumbnail(self, obj, field, value):
+ if obj.thumbnail_url:
+ return tags.image(
+ obj.thumbnail_url, f"thumbnail for {self.get_model_title()}"
+ )
+ return None
+
def get_xref_buttons(self, obj):
url = self.get_farmos_url(obj)
if url:
@@ -68,3 +79,24 @@ class WuttaFarmMasterView(MasterView):
)
]
return []
+
+ def configure_form(self, form):
+ """ """
+ f = form
+ super().configure_form(f)
+
+ # farmos_uuid
+ if self.creating:
+ f.remove("farmos_uuid")
+ else:
+ f.set_readonly("farmos_uuid")
+
+ # drupal_id
+ if self.creating:
+ f.remove("drupal_id")
+ else:
+ f.set_readonly("drupal_id")
+
+ def persist(self, obj, session=None):
+ super().persist(obj, session)
+ self.app.export_to_farmos(obj, require=False)
diff --git a/src/wuttafarm/web/views/structure_types.py b/src/wuttafarm/web/views/structure_types.py
deleted file mode 100644
index ca85fb9..0000000
--- a/src/wuttafarm/web/views/structure_types.py
+++ /dev/null
@@ -1,118 +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 Structure Types
-"""
-
-from wuttafarm.db.model.structures import StructureType, Structure
-from wuttafarm.web.views import WuttaFarmMasterView
-
-
-class StructureTypeView(WuttaFarmMasterView):
- """
- Master view for Structure Types
- """
-
- model_class = StructureType
- route_prefix = "structure_types"
- url_prefix = "/structure-types"
-
- grid_columns = [
- "name",
- ]
-
- sort_defaults = "name"
-
- filter_defaults = {
- "name": {"active": True, "verb": "contains"},
- }
-
- form_fields = [
- "name",
- "farmos_uuid",
- "drupal_id",
- ]
-
- has_rows = True
- row_model_class = Structure
- rows_viewable = True
-
- row_grid_columns = [
- "name",
- "is_location",
- "is_fixed",
- "active",
- ]
-
- rows_sort_defaults = "name"
-
- def configure_grid(self, grid):
- g = grid
- super().configure_grid(g)
-
- # name
- g.set_link("name")
-
- def get_xref_buttons(self, structure_type):
- buttons = super().get_xref_buttons(structure_type)
-
- if structure_type.farmos_uuid:
- buttons.append(
- self.make_button(
- "View farmOS record",
- primary=True,
- url=self.request.route_url(
- "farmos_structure_types.view", uuid=structure_type.farmos_uuid
- ),
- icon_left="eye",
- )
- )
-
- return buttons
-
- def get_row_grid_data(self, structure_type):
- model = self.app.model
- session = self.Session()
- return session.query(model.Structure).filter(
- model.Structure.structure_type == structure_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, structure, i):
- return self.request.route_url("structures.view", uuid=structure.uuid)
-
-
-def defaults(config, **kwargs):
- base = globals()
-
- StructureTypeView = kwargs.get("StructureTypeView", base["StructureTypeView"])
- StructureTypeView.defaults(config)
-
-
-def includeme(config):
- defaults(config)
diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py
index df58fda..aa9bf31 100644
--- a/src/wuttafarm/web/views/structures.py
+++ b/src/wuttafarm/web/views/structures.py
@@ -23,29 +23,23 @@
Master view for Structures
"""
-from wuttafarm.db.model.structures import Structure
-from wuttafarm.web.views import WuttaFarmMasterView
+from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
+from wuttafarm.db.model import StructureType, StructureAsset
from wuttafarm.web.forms.schema import StructureTypeRef
from wuttafarm.web.forms.widgets import ImageWidget
-class StructureView(WuttaFarmMasterView):
+class StructureTypeView(AssetTypeMasterView):
"""
- Master view for Structures
+ Master view for Structure Types
"""
- model_class = Structure
- route_prefix = "structures"
- url_prefix = "/structures"
-
- farmos_refurl_path = "/assets/structure"
+ model_class = StructureType
+ route_prefix = "structure_types"
+ url_prefix = "/structure-types"
grid_columns = [
"name",
- "structure_type",
- "is_location",
- "is_fixed",
- "active",
]
sort_defaults = "name"
@@ -56,14 +50,119 @@ class StructureView(WuttaFarmMasterView):
form_fields = [
"name",
+ "farmos_uuid",
+ "drupal_id",
+ ]
+
+ has_rows = True
+ row_model_class = StructureAsset
+ rows_viewable = True
+
+ row_grid_columns = [
+ "asset_name",
+ "is_location",
+ "is_fixed",
+ "archived",
+ ]
+
+ rows_sort_defaults = "asset_name"
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
+
+ def get_xref_buttons(self, structure_type):
+ buttons = super().get_xref_buttons(structure_type)
+
+ if structure_type.farmos_uuid:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ "farmos_structure_types.view", uuid=structure_type.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+ def get_row_grid_data(self, structure_type):
+ model = self.app.model
+ session = self.Session()
+ return (
+ session.query(model.StructureAsset)
+ .join(model.Asset)
+ .filter(model.StructureAsset.structure_type == structure_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)
+
+ # is_location
+ g.set_renderer("is_location", "boolean")
+ g.set_sorter("is_location", model.Asset.is_location)
+ g.set_filter("is_location", model.Asset.is_location)
+
+ # is_fixed
+ g.set_renderer("is_fixed", "boolean")
+ g.set_sorter("is_fixed", model.Asset.is_fixed)
+ g.set_filter("is_fixed", model.Asset.is_fixed)
+
+ # 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, structure, i):
+ return self.request.route_url("structure_assets.view", uuid=structure.uuid)
+
+
+class StructureAssetView(AssetMasterView):
+ """
+ Master view for Structures
+ """
+
+ model_class = StructureAsset
+ route_prefix = "structure_assets"
+ url_prefix = "/asset/structures"
+
+ farmos_refurl_path = "/assets/structure"
+
+ grid_columns = [
+ "thumbnail",
+ "drupal_id",
+ "asset_name",
+ "structure_type",
+ "parents",
+ "archived",
+ ]
+
+ form_fields = [
+ "asset_name",
+ "parents",
+ "notes",
+ "asset_type",
"structure_type",
"is_location",
"is_fixed",
- "notes",
- "active",
+ "archived",
"farmos_uuid",
"drupal_id",
+ "thumbnail_url",
"image_url",
+ "thumbnail",
"image",
]
@@ -72,9 +171,6 @@ class StructureView(WuttaFarmMasterView):
super().configure_grid(g)
model = self.app.model
- # name
- g.set_link("name")
-
# structure_type
g.set_joiner("structure_type", lambda q: q.join(model.StructureType))
g.set_sorter("structure_type", model.StructureType.name)
@@ -90,37 +186,15 @@ class StructureView(WuttaFarmMasterView):
# structure_type
f.set_node("structure_type", StructureTypeRef(self.request))
- # image
- if structure.image_url:
- f.set_widget("image", ImageWidget("structure image"))
- f.set_default("image", structure.image_url)
-
- def get_farmos_url(self, structure):
- return self.app.get_farmos_url(f"/asset/{structure.drupal_id}")
-
- def get_xref_buttons(self, structure):
- buttons = super().get_xref_buttons(structure)
-
- if structure.farmos_uuid:
- buttons.append(
- self.make_button(
- "View farmOS record",
- primary=True,
- url=self.request.route_url(
- "farmos_structures.view", uuid=structure.farmos_uuid
- ),
- icon_left="eye",
- )
- )
-
- return buttons
-
def defaults(config, **kwargs):
base = globals()
- StructureView = kwargs.get("StructureView", base["StructureView"])
- StructureView.defaults(config)
+ StructureTypeView = kwargs.get("StructureTypeView", base["StructureTypeView"])
+ StructureTypeView.defaults(config)
+
+ StructureAssetView = kwargs.get("StructureAssetView", base["StructureAssetView"])
+ StructureAssetView.defaults(config)
def includeme(config):