diff --git a/docs/api/wuttafarm.cli.base.rst b/docs/api/wuttafarm.cli.base.rst
new file mode 100644
index 0000000..19afd5c
--- /dev/null
+++ b/docs/api/wuttafarm.cli.base.rst
@@ -0,0 +1,6 @@
+
+``wuttafarm.cli.base``
+======================
+
+.. automodule:: wuttafarm.cli.base
+ :members:
diff --git a/docs/api/wuttafarm.cli.import_farmos.rst b/docs/api/wuttafarm.cli.import_farmos.rst
new file mode 100644
index 0000000..12a6d03
--- /dev/null
+++ b/docs/api/wuttafarm.cli.import_farmos.rst
@@ -0,0 +1,6 @@
+
+``wuttafarm.cli.import_farmos``
+===============================
+
+.. automodule:: wuttafarm.cli.import_farmos
+ :members:
diff --git a/docs/api/wuttafarm.cli.install.rst b/docs/api/wuttafarm.cli.install.rst
new file mode 100644
index 0000000..e825989
--- /dev/null
+++ b/docs/api/wuttafarm.cli.install.rst
@@ -0,0 +1,6 @@
+
+``wuttafarm.cli.install``
+=========================
+
+.. automodule:: wuttafarm.cli.install
+ :members:
diff --git a/docs/api/wuttafarm.importing.farmos.rst b/docs/api/wuttafarm.importing.farmos.rst
new file mode 100644
index 0000000..b6e00b4
--- /dev/null
+++ b/docs/api/wuttafarm.importing.farmos.rst
@@ -0,0 +1,6 @@
+
+``wuttafarm.importing.farmos``
+==============================
+
+.. automodule:: wuttafarm.importing.farmos
+ :members:
diff --git a/docs/api/wuttafarm.importing.rst b/docs/api/wuttafarm.importing.rst
new file mode 100644
index 0000000..5c331b9
--- /dev/null
+++ b/docs/api/wuttafarm.importing.rst
@@ -0,0 +1,6 @@
+
+``wuttafarm.importing``
+=======================
+
+.. automodule:: wuttafarm.importing
+ :members:
diff --git a/docs/index.rst b/docs/index.rst
index 4c7887b..a68b748 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -8,9 +8,6 @@ and extend `farmOS`_.
.. _WuttaWeb: https://wuttaproject.org
.. _farmOS: https://farmos.org
-.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
- :target: https://github.com/psf/black
-
It is just an experiment so far; the ideas I hope to play with
include:
@@ -19,6 +16,9 @@ include:
- possibly add more schema / extra features
- possibly sync data back to farmOS
+.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
+ :target: https://github.com/psf/black
+
.. toctree::
:maxdepth: 2
@@ -37,11 +37,16 @@ include:
api/wuttafarm.app
api/wuttafarm.auth
api/wuttafarm.cli
+ api/wuttafarm.cli.base
+ api/wuttafarm.cli.import_farmos
+ api/wuttafarm.cli.install
api/wuttafarm.config
api/wuttafarm.db
api/wuttafarm.db.model
api/wuttafarm.farmos
api/wuttafarm.farmos.handler
+ api/wuttafarm.importing
+ api/wuttafarm.importing.farmos
api/wuttafarm.install
api/wuttafarm.web
api/wuttafarm.web.app
diff --git a/pyproject.toml b/pyproject.toml
index fbc8df2..4c3d4d2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ dependencies = [
"psycopg2",
"pyramid_exclog",
"uvicorn[standard]",
+ "WuttaSync",
"WuttaWeb[continuum]>=0.27.4",
]
@@ -47,12 +48,18 @@ docs = ["Sphinx", "furo"]
[project.entry-points."paste.app_factory"]
"main" = "wuttafarm.web.app:main"
+[project.entry-points."wutta.app.providers"]
+wuttafarm = "wuttafarm.app:WuttaFarmAppProvider"
+
[project.entry-points."wutta.config.extensions"]
"wuttafarm" = "wuttafarm.config:WuttaFarmConfig"
[project.entry-points."wutta.web.menus"]
"wuttafarm" = "wuttafarm.web.menus:WuttaFarmMenuHandler"
+[project.entry-points."wuttasync.importing"]
+"import.to_wuttafarm.from_farmos" = "wuttafarm.importing.farmos:FromFarmOSToWuttaFarm"
+
[project.urls]
Homepage = "https://forgejo.wuttaproject.org/wutta/wuttafarm"
diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py
index 26c6ef8..72dd675 100644
--- a/src/wuttafarm/app.py
+++ b/src/wuttafarm/app.py
@@ -64,3 +64,11 @@ class WuttaFarmAppHandler(base.AppHandler):
"""
handler = self.get_farmos_handler()
return handler.get_farmos_client(*args, **kwargs)
+
+
+class WuttaFarmAppProvider(base.AppProvider):
+ """
+ The :term:`app provider` for WuttaFarm.
+ """
+
+ email_modules = ["wuttafarm.emails"]
diff --git a/src/wuttafarm/cli/__init__.py b/src/wuttafarm/cli/__init__.py
new file mode 100644
index 0000000..7f6c2bb
--- /dev/null
+++ b/src/wuttafarm/cli/__init__.py
@@ -0,0 +1,30 @@
+# -*- 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 CLI
+"""
+
+from .base import wuttafarm_typer
+
+# nb. must bring in all modules for discovery to work
+from . import import_farmos
+from . import install
diff --git a/src/wuttafarm/cli/base.py b/src/wuttafarm/cli/base.py
new file mode 100644
index 0000000..de16ead
--- /dev/null
+++ b/src/wuttafarm/cli/base.py
@@ -0,0 +1,31 @@
+# -*- 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 CLI - base Typer instance
+"""
+
+from wuttjamaican.cli import make_typer
+
+
+wuttafarm_typer = make_typer(
+ name="wuttafarm", help="WuttaFarm -- Web app to integrate with and extend farmOS"
+)
diff --git a/src/wuttafarm/cli/import_farmos.py b/src/wuttafarm/cli/import_farmos.py
new file mode 100644
index 0000000..4343d43
--- /dev/null
+++ b/src/wuttafarm/cli/import_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-import-farmos`
+"""
+
+import typer
+
+from wuttasync.cli import import_command, ImportCommandHandler
+
+from wuttafarm.cli import wuttafarm_typer
+
+
+@wuttafarm_typer.command()
+@import_command
+def import_farmos(ctx: typer.Context, **kwargs):
+ """
+ Import data from farmOS API to WuttaFarm
+ """
+ config = ctx.parent.wutta_config
+ handler = ImportCommandHandler(config, key="import.to_wuttafarm.from_farmos")
+ handler.run(ctx)
diff --git a/src/wuttafarm/cli.py b/src/wuttafarm/cli/install.py
similarity index 89%
rename from src/wuttafarm/cli.py
rename to src/wuttafarm/cli/install.py
index 2f377a3..c82dab2 100644
--- a/src/wuttafarm/cli.py
+++ b/src/wuttafarm/cli/install.py
@@ -25,12 +25,7 @@ WuttaFarm CLI
import typer
-from wuttjamaican.cli import make_typer
-
-
-wuttafarm_typer = make_typer(
- name="wuttafarm", help="WuttaFarm -- Web app to integrate with and extend farmOS"
-)
+from wuttafarm.cli import wuttafarm_typer
@wuttafarm_typer.command()
diff --git a/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py b/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py
new file mode 100644
index 0000000..7ddc814
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py
@@ -0,0 +1,120 @@
+"""add Animal Types
+
+Revision ID: 2b6385d0fa17
+Revises:
+Create Date: 2026-02-08 14:55:42.236918
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "2b6385d0fa17"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = ("wuttafarm",)
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # animal_type
+ op.create_table(
+ "animal_type",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("description", sa.String(length=255), nullable=True),
+ sa.Column("changed", sa.DateTime(), nullable=True),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_internal_id", sa.Integer(), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal_type")),
+ sa.UniqueConstraint(
+ "drupal_internal_id", name=op.f("uq_animal_type_drupal_internal_id")
+ ),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_animal_type_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_animal_type_name")),
+ )
+ op.create_table(
+ "animal_type_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
+ sa.Column(
+ "description", sa.String(length=255), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_animal_type_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_animal_type_version_end_transaction_id"),
+ "animal_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_animal_type_version_operation_type"),
+ "animal_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_animal_type_version_pk_transaction_id",
+ "animal_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_animal_type_version_pk_validity",
+ "animal_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_animal_type_version_transaction_id"),
+ "animal_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # animal_type
+ op.drop_index(
+ op.f("ix_animal_type_version_transaction_id"), table_name="animal_type_version"
+ )
+ op.drop_index(
+ "ix_animal_type_version_pk_validity", table_name="animal_type_version"
+ )
+ op.drop_index(
+ "ix_animal_type_version_pk_transaction_id", table_name="animal_type_version"
+ )
+ op.drop_index(
+ op.f("ix_animal_type_version_operation_type"), table_name="animal_type_version"
+ )
+ op.drop_index(
+ op.f("ix_animal_type_version_end_transaction_id"),
+ table_name="animal_type_version",
+ )
+ op.drop_table("animal_type_version")
+ op.drop_table("animal_type")
diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py
index b52d7c8..d0693cb 100644
--- a/src/wuttafarm/db/model/__init__.py
+++ b/src/wuttafarm/db/model/__init__.py
@@ -26,4 +26,5 @@ WuttaFarm data models
# bring in all of wutta
from wuttjamaican.db.model import *
-# TODO: import other/custom models here...
+# wuttafarm models
+from .animals import AnimalType
diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py
new file mode 100644
index 0000000..a26b966
--- /dev/null
+++ b/src/wuttafarm/db/model/animals.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaFarm --Web app to integrate with and extend farmOS
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaFarm.
+#
+# WuttaFarm is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# WuttaFarm. If not, see .
+#
+################################################################################
+"""
+Model definition for Animal Types
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class AnimalType(model.Base):
+ """
+ Represents an "animal type" (taxonomy term) from farmOS
+ """
+
+ __tablename__ = "animal_type"
+ __versioned__ = {
+ "exclude": [
+ "changed",
+ ],
+ }
+ __wutta_hint__ = {
+ "model_title": "Animal Type",
+ "model_title_plural": "Animal Types",
+ }
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ unique=True,
+ doc="""
+ Name of the animal type.
+ """,
+ )
+
+ description = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Optional description for the animal type.
+ """,
+ )
+
+ changed = sa.Column(
+ sa.DateTime(),
+ nullable=True,
+ doc="""
+ When the animal type was last changed, according to farmOS.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the animal type within farmOS.
+ """,
+ )
+
+ drupal_internal_id = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the animal type.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
diff --git a/src/wuttafarm/emails.py b/src/wuttafarm/emails.py
new file mode 100644
index 0000000..55b1612
--- /dev/null
+++ b/src/wuttafarm/emails.py
@@ -0,0 +1,32 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Email sending config for WuttaFarm
+"""
+
+from wuttasync.emails import ImportExportWarning
+
+
+class import_to_wuttafarm_from_farmos_warning(ImportExportWarning):
+ """
+ Diff warning for farmOS → WuttaFarm import.
+ """
diff --git a/src/wuttafarm/importing/__init__.py b/src/wuttafarm/importing/__init__.py
new file mode 100644
index 0000000..6711d56
--- /dev/null
+++ b/src/wuttafarm/importing/__init__.py
@@ -0,0 +1,24 @@
+# -*- 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 to WuttaFarm
+"""
diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py
new file mode 100644
index 0000000..e9e4735
--- /dev/null
+++ b/src/wuttafarm/importing/farmos.py
@@ -0,0 +1,154 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Data import for farmOS -> WuttaFarm
+"""
+
+import datetime
+from uuid import UUID
+
+from oauthlib.oauth2 import BackendApplicationClient
+from requests_oauthlib import OAuth2Session
+
+from wuttasync.importing import ImportHandler, ToWuttaHandler, Importer, ToWutta
+
+from wuttafarm.db import model
+
+
+class FromFarmOSHandler(ImportHandler):
+ """
+ Base class for import handler using farmOS API as data source.
+ """
+
+ source_key = "farmos"
+ generic_source_title = "farmOS"
+
+ def begin_source_transaction(self):
+ """
+ Establish the farmOS API client.
+ """
+ token = self.get_farmos_oauth2_token()
+ self.farmos_client = self.app.get_farmos_client(token=token)
+
+ 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
+ return kwargs
+
+
+class ToWuttaFarmHandler(ToWuttaHandler):
+ """
+ Base class for import handler targeting WuttaFarm
+ """
+
+ target_key = "wuttafarm"
+
+
+class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
+ """
+ Handler for farmOS → WuttaFarm import.
+ """
+
+ def define_importers(self):
+ """ """
+ importers = super().define_importers()
+ importers["AnimalType"] = AnimalTypeImporter
+ return importers
+
+
+class FromFarmOS(Importer):
+ """
+ Base class for importers using farmOS API as data source.
+ """
+
+ key = "farmos_uuid"
+
+ def get_supported_fields(self):
+ """
+ Auto-remove the ``uuid`` field, since we use ``farmos_uuid``
+ instead for the importer key.
+ """
+ fields = list(super().get_supported_fields())
+ if "uuid" in fields:
+ fields.remove("uuid")
+ return fields
+
+ 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``
+ """
+ dt = datetime.datetime.fromisoformat(dt)
+ return self.app.make_utc(dt)
+
+
+class AnimalTypeImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Animal Types
+ """
+
+ model_class = model.AnimalType
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_internal_id",
+ "name",
+ "description",
+ "changed",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type")
+ return animal_types["data"]
+
+ def normalize_source_object(self, animal_type):
+ """ """
+ return {
+ "farmos_uuid": UUID(animal_type["id"]),
+ "drupal_internal_id": animal_type["attributes"]["drupal_internal__tid"],
+ "name": animal_type["attributes"]["name"],
+ "description": animal_type["attributes"]["description"],
+ "changed": self.normalize_datetime(animal_type["attributes"]["changed"]),
+ }
diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py
index ab6f440..7571b3c 100644
--- a/src/wuttafarm/web/menus.py
+++ b/src/wuttafarm/web/menus.py
@@ -33,10 +33,24 @@ class WuttaFarmMenuHandler(base.MenuHandler):
def make_menus(self, request, **kwargs):
return [
+ self.make_asset_menu(request),
self.make_farmos_menu(request),
self.make_admin_menu(request, include_people=True),
]
+ def make_asset_menu(self, request):
+ return {
+ "title": "Assets",
+ "type": "menu",
+ "items": [
+ {
+ "title": "Animal Types",
+ "route": "animal_types",
+ "perm": "animal_types.list",
+ },
+ ],
+ }
+
def make_farmos_menu(self, request):
config = request.wutta_config
app = config.get_app()
diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py
index 63ce536..86dcd81 100644
--- a/src/wuttafarm/web/views/__init__.py
+++ b/src/wuttafarm/web/views/__init__.py
@@ -25,6 +25,8 @@ WuttaFarm Views
from wuttaweb.views import essential
+from .master import WuttaFarmMasterView
+
def includeme(config):
@@ -37,5 +39,8 @@ def includeme(config):
}
)
+ # native table views
+ config.include("wuttafarm.web.views.animal_types")
+
# views for farmOS
config.include("wuttafarm.web.views.farmos")
diff --git a/src/wuttafarm/web/views/animal_types.py b/src/wuttafarm/web/views/animal_types.py
new file mode 100644
index 0000000..ecd136c
--- /dev/null
+++ b/src/wuttafarm/web/views/animal_types.py
@@ -0,0 +1,99 @@
+# -*- 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
+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_internal_id",
+ ]
+
+ 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_internal_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 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/farmos/animal_types.py b/src/wuttafarm/web/views/farmos/animal_types.py
index 0e8e4df..ae0b0d4 100644
--- a/src/wuttafarm/web/views/farmos/animal_types.py
+++ b/src/wuttafarm/web/views/farmos/animal_types.py
@@ -113,7 +113,10 @@ class AnimalTypeView(FarmOSMasterView):
f.set_node("changed", WuttaDateTime())
def get_xref_buttons(self, animal_type):
- return [
+ model = self.app.model
+ session = self.Session()
+
+ buttons = [
self.make_button(
"View in farmOS",
primary=True,
@@ -122,9 +125,27 @@ class AnimalTypeView(FarmOSMasterView):
),
target="_blank",
icon_left="external-link-alt",
- ),
+ )
]
+ if wf_animal_type := (
+ session.query(model.AnimalType)
+ .filter(model.AnimalType.farmos_uuid == animal_type["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "animal_types.view", uuid=wf_animal_type.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py
new file mode 100644
index 0000000..69a7f89
--- /dev/null
+++ b/src/wuttafarm/web/views/master.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaFarm --Web app to integrate with and extend farmOS
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaFarm.
+#
+# WuttaFarm is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# WuttaFarm. If not, see .
+#
+################################################################################
+"""
+Base class for WuttaFarm master views
+"""
+
+from wuttaweb.views import MasterView
+
+
+class WuttaFarmMasterView(MasterView):
+ """
+ Base class for WuttaFarm master views
+ """
+
+ farmos_refurl_path = None
+
+ labels = {
+ "farmos_uuid": "farmOS UUID",
+ "drupal_internal_id": "Drupal Internal ID",
+ }
+
+ def get_farmos_url(self, obj):
+ return None
+
+ def get_template_context(self, context):
+
+ if self.listing and self.farmos_refurl_path:
+ context["farmos_refurl"] = self.app.get_farmos_url(self.farmos_refurl_path)
+
+ return context
+
+ def get_xref_buttons(self, obj):
+ url = self.get_farmos_url(obj)
+ if url:
+ return [
+ self.make_button(
+ "View in farmOS",
+ primary=True,
+ url=url,
+ target="_blank",
+ icon_left="external-link-alt",
+ )
+ ]
+ return []