diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5241ed..f6b8703 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ 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.3.0 (2026-02-13)
+
+### Feat
+
+- add native table for Activity Logs; import from farmOS API
+- add native table for Groups; import from farmOS API
+- add native table for Animals; import from farmOS API
+- add native table for Structures; import from farmOS API
+- add native table for Land Assets; import from farmOS API
+- add native table for Log Types; import from farmOS API
+- add native table for Structure Types; import from farmOS API
+- add native table for Land Types; import from farmOS API
+- add native table for Asset Types; import from farmOS API
+- add extension table for Users; import from farmOS API
+- add native table for Animal Types; import from farmOS API
+- add "See raw JSON data" button for farmOS API views
+
+### Fix
+
+- always make 'farmos' system user in app setup
+- avoid error for Create User form
+- add more perms to Site Admin role in app setup
+- rename `drupal_internal_id` => `drupal_id`
+
## v0.2.3 (2026-02-08)
### Fix
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/conf.py b/docs/conf.py
index 3caa6e3..f3fd9e2 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -21,6 +21,7 @@ extensions = [
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
"sphinx.ext.todo",
+ "sphinxcontrib.programoutput",
]
templates_path = ["_templates"]
diff --git a/docs/index.rst b/docs/index.rst
index 4c7887b..be04bee 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
@@ -27,6 +27,7 @@ include:
narr/install
narr/auth
narr/features
+ narr/cli
.. toctree::
@@ -37,11 +38,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/docs/narr/auth.rst b/docs/narr/auth.rst
index 67a63fa..536f3d0 100644
--- a/docs/narr/auth.rst
+++ b/docs/narr/auth.rst
@@ -36,7 +36,13 @@ browse farmOS data within the WuttaFarm views.
If you login to WuttaFarm directly with username/password, then
your user session will not have a farmOS access token and so the
- farmOS data views in WuttaFarm will not work.
+ farmOS data views in WuttaFarm will not work (i.e. anything under
+ the **farmOS** menu).
+
+ (However this does not affect the "native" data views for
+ WuttaFarm. Users can see data which was already imported from
+ farmOS without an access token - if they have appropriate
+ permissions in WuttaFarm.)
On the login page, click the "Login via farmOS / OAuth2" button. This
will initiate the OAuth2 workflow, at which point you may be asked to
diff --git a/docs/narr/cli.rst b/docs/narr/cli.rst
new file mode 100644
index 0000000..70b7c1e
--- /dev/null
+++ b/docs/narr/cli.rst
@@ -0,0 +1,39 @@
+
+========================
+ Command Line Interface
+========================
+
+WuttaFarm ships with the following commands.
+
+For more general info about CLI see
+:doc:`wuttjamaican:narr/cli/index`.
+
+
+.. _wuttafarm-install:
+
+``wuttafarm install``
+---------------------
+
+Run the WuttaFarm app installer.
+
+This will create the :term:`app dir` and initial config files, and
+create the schema within the :term:`app database`.
+
+Defined in: :mod:`wuttafarm.cli.install`
+
+.. program-output:: wuttafarm install --help
+
+
+.. _wuttafarm-import-farmos:
+
+``wuttafarm import-farmos``
+---------------------------
+
+Import data from the farmOS API into the WuttaFarm :term:`app
+database`.
+
+Defined in: :mod:`wuttafarm.cli.import_farmos`
+
+.. program-output:: wuttafarm import-farmos --help
+
+
diff --git a/docs/narr/features.rst b/docs/narr/features.rst
index 00e435b..60a9120 100644
--- a/docs/narr/features.rst
+++ b/docs/narr/features.rst
@@ -14,6 +14,10 @@ Here is the list of features currently supported:
* performance isn't bad, but data is not very "complete"
* more data could be fetched, but not sure this is the best way..?
+* import some data from farmOS
+ * limited data is imported from farmOS API into native app tables
+ * this data is exposed in views, similar to direct farmOS views (above)
+
Screenshots
-----------
diff --git a/docs/narr/install.rst b/docs/narr/install.rst
index fdb9958..1147a6d 100644
--- a/docs/narr/install.rst
+++ b/docs/narr/install.rst
@@ -60,3 +60,93 @@ are encouraged to enable it anyway.
When the installer completes it will output a command you can then use
to run the web app. Do that and you can then view the app in a
browser at http://localhost:9080
+
+
+OAuth2 Setup
+------------
+
+At this point the web app should be ready for OAuth2 login; however
+the OAuth2 provider in farmOS needs some more config before it will
+work.
+
+WuttaFarm uses the default ``farm`` consumer, so the only thing you
+should have to do here is edit that to add your redirect URL. This
+will vary based on your WuttaFarm site name, e.g.
+
+.. code-block:: none
+
+ https://wuttafarm.example.com/farmos/oauth/callback
+
+With that in place you should be able to login via OAuth2; see also
+:doc:`/narr/auth`.
+
+However while you're there, you should also do some setup for the sake
+of the farmOS → WuttaFarm data import. This import will also use the
+farmOS API and therefore also needs an oauth2 access token; however it
+uses the Client Credentials workflow instead of the Authorization Code
+workflow. Therefore you must create a new *user* and a new OAuth2
+*consumer* for it.
+
+First add a new user in farmOS, named ``wuttafarm``. It should
+probably be given the Manager role, since WuttaFarm will eventually
+also support "exporting" data back to farmOS.
+
+Then add a new OAuth2 consumer (aka. client) with these attributes:
+
+* **Label:** WuttaFarm
+* **Client ID:** wuttafarm
+* **New Secret:** (put something in here, to be used as client secret)
+* **Grant Types:** Client Credentials, Refresh Token (maybe more?)
+* **User:** wuttafarm
+* **3rd Party?** yes
+* **Confidential?** yes
+* **Access Token Expiration Time:** maybe set to 3600? or maybe 300
+ default is okay?
+* **Allowed Origins:** put your oauth callback URL here (same as for
+ default ``farm`` consumer)
+
+WuttaFarm also needs to know the client secret for sake of running the
+import; so add this to your ``app/wutta.conf`` file. Of course
+replace the value with whatever client secret you gave the new
+consumer:
+
+.. code-block:: ini
+
+ [farmos.oauth2]
+ importing.client_secret = you_cant_guess_me
+
+
+Import Data from farmOS
+-----------------------
+
+You must have done all the OAuth2 setup (previous section) before the
+import will work.
+
+But now that you did all that, importing should be quick and easy.
+
+The very first import will be limited and "special" to account for any
+users which were already created in WuttaFarm. This command will
+ensure WuttaFarm gets *all* user accounts and each is appropriately
+mapped to the farmOS account:
+
+.. code-block:: sh
+
+ ./venv/bin/wuttafarm --runas farmos import-farmos User --key username
+
+Note also the ``--runas farmos`` arg which helps the WuttaFarm data
+versioning know "who" is responsible for the changes. We use a
+dedicated ``farmos`` user account in WuttaFarm, to represent the
+farmOS system as a whole.
+
+From now on you can run the "full" import normally:
+
+.. code-block:: sh
+
+ ./venv/bin/wuttafarm --runas farmos import-farmos
+
+And it can sometimes be helpful to "double-check" in order to make
+sure all data is fully synced:
+
+.. code-block:: sh
+
+ ./venv/bin/wuttafarm --runas farmos import-farmos --delete --dry-run -W
diff --git a/pyproject.toml b/pyproject.toml
index fbc8df2..f8fc499 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaFarm"
-version = "0.2.3"
+version = "0.3.0"
description = "Web app to integrate with and extend farmOS"
readme = "README.md"
authors = [
@@ -33,12 +33,13 @@ dependencies = [
"psycopg2",
"pyramid_exclog",
"uvicorn[standard]",
+ "WuttaSync",
"WuttaWeb[continuum]>=0.27.4",
]
[project.optional-dependencies]
-docs = ["Sphinx", "furo"]
+docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"]
[project.scripts]
@@ -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/1b2d3224e5dc_add_animals.py b/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py
new file mode 100644
index 0000000..78400ac
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py
@@ -0,0 +1,127 @@
+"""add Animals
+
+Revision ID: 1b2d3224e5dc
+Revises: 4dbba8aeb1e5
+Create Date: 2026-02-13 11:55:19.564221
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "1b2d3224e5dc"
+down_revision: Union[str, None] = "4dbba8aeb1e5"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # animal
+ op.create_table(
+ "animal",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ 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("active", sa.Boolean(), nullable=False),
+ sa.Column("notes", sa.Text(), nullable=True),
+ sa.Column("image_url", sa.String(length=255), nullable=True),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.Integer(), nullable=True),
+ sa.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")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_animal_farmos_uuid")),
+ )
+ op.create_table(
+ "animal_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(
+ "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("active", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
+ sa.Column(
+ "image_url", sa.String(length=255), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_animal_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_animal_version_end_transaction_id"),
+ "animal_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_animal_version_operation_type"),
+ "animal_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_animal_version_pk_transaction_id",
+ "animal_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_animal_version_pk_validity",
+ "animal_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_animal_version_transaction_id"),
+ "animal_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # animal
+ op.drop_index(op.f("ix_animal_version_transaction_id"), table_name="animal_version")
+ op.drop_index("ix_animal_version_pk_validity", table_name="animal_version")
+ op.drop_index("ix_animal_version_pk_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_end_transaction_id"), table_name="animal_version"
+ )
+ op.drop_table("animal_version")
+ op.drop_table("animal")
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..4e1481f
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py
@@ -0,0 +1,116 @@
+"""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_id", sa.Integer(), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal_type")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_animal_type_drupal_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_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/alembic/versions/3e2ef02bf264_add_activity_logs.py b/src/wuttafarm/db/alembic/versions/3e2ef02bf264_add_activity_logs.py
new file mode 100644
index 0000000..5fca4be
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/3e2ef02bf264_add_activity_logs.py
@@ -0,0 +1,118 @@
+"""add Activity Logs
+
+Revision ID: 3e2ef02bf264
+Revises: 92b813360b99
+Create Date: 2026-02-13 14:36:47.191922
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "3e2ef02bf264"
+down_revision: Union[str, None] = "92b813360b99"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # log_activity
+ op.create_table(
+ "log_activity",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("message", sa.String(length=255), nullable=False),
+ sa.Column("timestamp", sa.DateTime(), nullable=False),
+ sa.Column("status", sa.String(length=20), nullable=False),
+ sa.Column("notes", sa.Text(), nullable=True),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.Integer(), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_activity")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_log_activity_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_activity_farmos_uuid")),
+ )
+ op.create_table(
+ "log_activity_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("message", sa.String(length=255), autoincrement=False, nullable=True),
+ sa.Column("timestamp", sa.DateTime(), autoincrement=False, nullable=True),
+ sa.Column("status", sa.String(length=20), autoincrement=False, nullable=True),
+ sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_log_activity_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_log_activity_version_end_transaction_id"),
+ "log_activity_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_activity_version_operation_type"),
+ "log_activity_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_activity_version_pk_transaction_id",
+ "log_activity_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_activity_version_pk_validity",
+ "log_activity_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_activity_version_transaction_id"),
+ "log_activity_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # log_activity
+ op.drop_index(
+ op.f("ix_log_activity_version_transaction_id"),
+ table_name="log_activity_version",
+ )
+ op.drop_index(
+ "ix_log_activity_version_pk_validity", table_name="log_activity_version"
+ )
+ op.drop_index(
+ "ix_log_activity_version_pk_transaction_id", table_name="log_activity_version"
+ )
+ op.drop_index(
+ op.f("ix_log_activity_version_operation_type"),
+ table_name="log_activity_version",
+ )
+ op.drop_index(
+ op.f("ix_log_activity_version_end_transaction_id"),
+ table_name="log_activity_version",
+ )
+ op.drop_table("log_activity_version")
+ op.drop_table("log_activity")
diff --git a/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py b/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py
new file mode 100644
index 0000000..94e8186
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py
@@ -0,0 +1,132 @@
+"""add Structures
+
+Revision ID: 4dbba8aeb1e5
+Revises: e416b96467fc
+Create Date: 2026-02-13 10:17:15.179202
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "4dbba8aeb1e5"
+down_revision: Union[str, None] = "e416b96467fc"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # structure
+ op.create_table(
+ "structure",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("active", sa.Boolean(), nullable=False),
+ sa.Column("structure_type_uuid", wuttjamaican.db.util.UUID(), 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("image_url", sa.String(length=255), nullable=True),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.Integer(), nullable=True),
+ sa.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")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_structure_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_structure_name")),
+ )
+ op.create_table(
+ "structure_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("active", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column(
+ "structure_type_uuid",
+ wuttjamaican.db.util.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.String(length=255), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_structure_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_structure_version_end_transaction_id"),
+ "structure_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_structure_version_operation_type"),
+ "structure_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_structure_version_pk_transaction_id",
+ "structure_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_structure_version_pk_validity",
+ "structure_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_structure_version_transaction_id"),
+ "structure_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # structure
+ op.drop_index(
+ op.f("ix_structure_version_transaction_id"), table_name="structure_version"
+ )
+ op.drop_index("ix_structure_version_pk_validity", table_name="structure_version")
+ op.drop_index(
+ "ix_structure_version_pk_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_end_transaction_id"), table_name="structure_version"
+ )
+ op.drop_table("structure_version")
+ op.drop_table("structure")
diff --git a/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py b/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py
new file mode 100644
index 0000000..0dc2d29
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py
@@ -0,0 +1,112 @@
+"""add WuttaFarmUser
+
+Revision ID: 6c56bcd1c028
+Revises: 2b6385d0fa17
+Create Date: 2026-02-09 20:46:20.995903
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "6c56bcd1c028"
+down_revision: Union[str, None] = "2b6385d0fa17"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # wuttafarm_user
+ op.create_table(
+ "wuttafarm_user",
+ 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.ForeignKeyConstraint(
+ ["uuid"], ["user.uuid"], name=op.f("fk_wuttafarm_user_uuid_user")
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_wuttafarm_user")),
+ )
+ op.create_table(
+ "wuttafarm_user_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(
+ "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_wuttafarm_user_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_wuttafarm_user_version_end_transaction_id"),
+ "wuttafarm_user_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_wuttafarm_user_version_operation_type"),
+ "wuttafarm_user_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_wuttafarm_user_version_pk_transaction_id",
+ "wuttafarm_user_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_wuttafarm_user_version_pk_validity",
+ "wuttafarm_user_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_wuttafarm_user_version_transaction_id"),
+ "wuttafarm_user_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # wuttafarm_user
+ op.drop_index(
+ op.f("ix_wuttafarm_user_version_transaction_id"),
+ table_name="wuttafarm_user_version",
+ )
+ op.drop_index(
+ "ix_wuttafarm_user_version_pk_validity", table_name="wuttafarm_user_version"
+ )
+ op.drop_index(
+ "ix_wuttafarm_user_version_pk_transaction_id",
+ table_name="wuttafarm_user_version",
+ )
+ op.drop_index(
+ op.f("ix_wuttafarm_user_version_operation_type"),
+ table_name="wuttafarm_user_version",
+ )
+ op.drop_index(
+ op.f("ix_wuttafarm_user_version_end_transaction_id"),
+ table_name="wuttafarm_user_version",
+ )
+ op.drop_table("wuttafarm_user_version")
+ op.drop_table("wuttafarm_user")
diff --git a/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py b/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py
new file mode 100644
index 0000000..7223844
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py
@@ -0,0 +1,110 @@
+"""add Groups
+
+Revision ID: 92b813360b99
+Revises: 1b2d3224e5dc
+Create Date: 2026-02-13 13:09:48.718064
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "92b813360b99"
+down_revision: Union[str, None] = "1b2d3224e5dc"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # group
+ op.create_table(
+ "group",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("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("active", sa.Boolean(), nullable=False),
+ sa.Column("notes", sa.Text(), nullable=True),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.Integer(), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_group")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_group_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_group_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_group_name")),
+ )
+ op.create_table(
+ "group_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("is_location", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_group_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_group_version_end_transaction_id"),
+ "group_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_group_version_operation_type"),
+ "group_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_group_version_pk_transaction_id",
+ "group_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_group_version_pk_validity",
+ "group_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_group_version_transaction_id"),
+ "group_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # group
+ op.drop_index(op.f("ix_group_version_transaction_id"), table_name="group_version")
+ op.drop_index("ix_group_version_pk_validity", table_name="group_version")
+ op.drop_index("ix_group_version_pk_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_end_transaction_id"), table_name="group_version"
+ )
+ op.drop_table("group_version")
+ op.drop_table("group")
diff --git a/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py b/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py
new file mode 100644
index 0000000..15d89fa
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py
@@ -0,0 +1,110 @@
+"""add Land Types
+
+Revision ID: 9f2243df9566
+Revises: cf3f8f46d8bc
+Create Date: 2026-02-10 19:10:02.851756
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "9f2243df9566"
+down_revision: Union[str, None] = "cf3f8f46d8bc"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # land_type
+ op.create_table(
+ "land_type",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.String(length=50), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_type")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_land_type_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_land_type_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_land_type_name")),
+ )
+ op.create_table(
+ "land_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(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "drupal_id", sa.String(length=50), 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_type_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_land_type_version_end_transaction_id"),
+ "land_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_type_version_operation_type"),
+ "land_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_land_type_version_pk_transaction_id",
+ "land_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_land_type_version_pk_validity",
+ "land_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_type_version_transaction_id"),
+ "land_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # land_type
+ op.drop_index(
+ op.f("ix_land_type_version_transaction_id"), table_name="land_type_version"
+ )
+ op.drop_index("ix_land_type_version_pk_validity", table_name="land_type_version")
+ op.drop_index(
+ "ix_land_type_version_pk_transaction_id", table_name="land_type_version"
+ )
+ op.drop_index(
+ op.f("ix_land_type_version_operation_type"), table_name="land_type_version"
+ )
+ op.drop_index(
+ op.f("ix_land_type_version_end_transaction_id"), table_name="land_type_version"
+ )
+ op.drop_table("land_type_version")
+ op.drop_table("land_type")
diff --git a/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py b/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py
new file mode 100644
index 0000000..ed4c344
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py
@@ -0,0 +1,115 @@
+"""add Asset Types
+
+Revision ID: cf3f8f46d8bc
+Revises: 6c56bcd1c028
+Create Date: 2026-02-10 18:42:24.560312
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "cf3f8f46d8bc"
+down_revision: Union[str, None] = "6c56bcd1c028"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # asset_type
+ op.create_table(
+ "asset_type",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("description", sa.String(length=255), nullable=True),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.String(length=50), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_type")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_asset_type_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_asset_type_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_asset_type_name")),
+ )
+ op.create_table(
+ "asset_type_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
+ sa.Column(
+ "description", sa.String(length=255), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "drupal_id", sa.String(length=50), 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_type_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_asset_type_version_end_transaction_id"),
+ "asset_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_type_version_operation_type"),
+ "asset_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_type_version_pk_transaction_id",
+ "asset_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_asset_type_version_pk_validity",
+ "asset_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_asset_type_version_transaction_id"),
+ "asset_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # asset_type
+ op.drop_index(
+ op.f("ix_asset_type_version_transaction_id"), table_name="asset_type_version"
+ )
+ op.drop_index("ix_asset_type_version_pk_validity", table_name="asset_type_version")
+ op.drop_index(
+ "ix_asset_type_version_pk_transaction_id", table_name="asset_type_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_type_version_operation_type"), table_name="asset_type_version"
+ )
+ op.drop_index(
+ op.f("ix_asset_type_version_end_transaction_id"),
+ table_name="asset_type_version",
+ )
+ op.drop_table("asset_type_version")
+ op.drop_table("asset_type")
diff --git a/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py b/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py
new file mode 100644
index 0000000..b71c4a6
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py
@@ -0,0 +1,116 @@
+"""add Structure Types
+
+Revision ID: d7479d7161a8
+Revises: 9f2243df9566
+Create Date: 2026-02-10 19:24:20.249826
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "d7479d7161a8"
+down_revision: Union[str, None] = "9f2243df9566"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # structure_type
+ op.create_table(
+ "structure_type",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.String(length=50), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure_type")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_structure_type_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_structure_type_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_structure_type_name")),
+ )
+ op.create_table(
+ "structure_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(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "drupal_id", sa.String(length=50), 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_structure_type_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_structure_type_version_end_transaction_id"),
+ "structure_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_structure_type_version_operation_type"),
+ "structure_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_structure_type_version_pk_transaction_id",
+ "structure_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_structure_type_version_pk_validity",
+ "structure_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_structure_type_version_transaction_id"),
+ "structure_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # structure_type
+ op.drop_index(
+ op.f("ix_structure_type_version_transaction_id"),
+ table_name="structure_type_version",
+ )
+ op.drop_index(
+ "ix_structure_type_version_pk_validity", table_name="structure_type_version"
+ )
+ op.drop_index(
+ "ix_structure_type_version_pk_transaction_id",
+ table_name="structure_type_version",
+ )
+ op.drop_index(
+ op.f("ix_structure_type_version_operation_type"),
+ table_name="structure_type_version",
+ )
+ op.drop_index(
+ op.f("ix_structure_type_version_end_transaction_id"),
+ table_name="structure_type_version",
+ )
+ op.drop_table("structure_type_version")
+ op.drop_table("structure_type")
diff --git a/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py b/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py
new file mode 100644
index 0000000..862d3be
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py
@@ -0,0 +1,114 @@
+"""add Log Types
+
+Revision ID: e0d9f72575d6
+Revises: d7479d7161a8
+Create Date: 2026-02-10 19:35:06.631814
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "e0d9f72575d6"
+down_revision: Union[str, None] = "d7479d7161a8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # log_type
+ op.create_table(
+ "log_type",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("description", sa.String(length=255), nullable=True),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.String(length=50), nullable=True),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_type")),
+ sa.UniqueConstraint("drupal_id", name=op.f("uq_log_type_drupal_id")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_type_farmos_uuid")),
+ sa.UniqueConstraint("name", name=op.f("uq_log_type_name")),
+ )
+ op.create_table(
+ "log_type_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
+ sa.Column(
+ "description", sa.String(length=255), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column(
+ "drupal_id", sa.String(length=50), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_log_type_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_log_type_version_end_transaction_id"),
+ "log_type_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_type_version_operation_type"),
+ "log_type_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_type_version_pk_transaction_id",
+ "log_type_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_log_type_version_pk_validity",
+ "log_type_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_log_type_version_transaction_id"),
+ "log_type_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # log_type
+ op.drop_index(
+ op.f("ix_log_type_version_transaction_id"), table_name="log_type_version"
+ )
+ op.drop_index("ix_log_type_version_pk_validity", table_name="log_type_version")
+ op.drop_index(
+ "ix_log_type_version_pk_transaction_id", table_name="log_type_version"
+ )
+ op.drop_index(
+ op.f("ix_log_type_version_operation_type"), table_name="log_type_version"
+ )
+ op.drop_index(
+ op.f("ix_log_type_version_end_transaction_id"), table_name="log_type_version"
+ )
+ op.drop_table("log_type_version")
+ op.drop_table("log_type")
diff --git a/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py b/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py
new file mode 100644
index 0000000..5f7dd87
--- /dev/null
+++ b/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py
@@ -0,0 +1,132 @@
+"""add Land Assets
+
+Revision ID: e416b96467fc
+Revises: e0d9f72575d6
+Create Date: 2026-02-13 09:39:31.327442
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "e416b96467fc"
+down_revision: Union[str, None] = "e0d9f72575d6"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # land_asset
+ op.create_table(
+ "land_asset",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("land_type_uuid", wuttjamaican.db.util.UUID(), 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("active", sa.Boolean(), nullable=False),
+ sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("drupal_id", sa.Integer(), 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")),
+ sa.UniqueConstraint("farmos_uuid", name=op.f("uq_land_asset_farmos_uuid")),
+ sa.UniqueConstraint(
+ "land_type_uuid", name=op.f("uq_land_asset_land_type_uuid")
+ ),
+ sa.UniqueConstraint("name", name=op.f("uq_land_asset_name")),
+ )
+ op.create_table(
+ "land_asset_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(
+ "land_type_uuid",
+ wuttjamaican.db.util.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("active", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column(
+ "farmos_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_land_asset_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_land_asset_version_end_transaction_id"),
+ "land_asset_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_land_asset_version_operation_type"),
+ "land_asset_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_land_asset_version_pk_transaction_id",
+ "land_asset_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "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_transaction_id"),
+ "land_asset_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # land_asset
+ op.drop_index(
+ op.f("ix_land_asset_version_transaction_id"), table_name="land_asset_version"
+ )
+ op.drop_index("ix_land_asset_version_pk_validity", table_name="land_asset_version")
+ op.drop_index(
+ "ix_land_asset_version_pk_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_end_transaction_id"),
+ table_name="land_asset_version",
+ )
+ op.drop_table("land_asset_version")
+ op.drop_table("land_asset")
diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py
index b52d7c8..27d0070 100644
--- a/src/wuttafarm/db/model/__init__.py
+++ b/src/wuttafarm/db/model/__init__.py
@@ -26,4 +26,13 @@ WuttaFarm data models
# bring in all of wutta
from wuttjamaican.db.model import *
-# TODO: import other/custom models here...
+# wutta model extensions
+from .users import WuttaFarmUser
+
+# wuttafarm proper models
+from .assets import AssetType
+from .land import LandType, LandAsset
+from .structures import StructureType, Structure
+from .animals import AnimalType, Animal
+from .groups import Group
+from .logs import LogType, ActivityLog
diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py
new file mode 100644
index 0000000..e23f0c5
--- /dev/null
+++ b/src/wuttafarm/db/model/animals.py
@@ -0,0 +1,194 @@
+# -*- 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_id = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the animal type.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
+
+
+class Animal(model.Base):
+ """
+ Represents an animal from farmOS
+ """
+
+ __tablename__ = "animal"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Animal",
+ "model_title_plural": "Animals",
+ }
+
+ 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",
+ doc="""
+ Reference to the animal type.
+ """,
+ )
+
+ birthdate = sa.Column(
+ sa.DateTime(),
+ nullable=True,
+ doc="""
+ Birth date (and time) for the animal, if known.
+ """,
+ )
+
+ sex = sa.Column(
+ sa.String(length=1),
+ nullable=True,
+ doc="""
+ Sex of the animal.
+ """,
+ )
+
+ is_sterile = sa.Column(
+ sa.Boolean(),
+ nullable=True,
+ doc="""
+ Whether the animal is sterile (e.g. castrated).
+ """,
+ )
+
+ 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 ""
diff --git a/src/wuttafarm/db/model/assets.py b/src/wuttafarm/db/model/assets.py
new file mode 100644
index 0000000..581be62
--- /dev/null
+++ b/src/wuttafarm/db/model/assets.py
@@ -0,0 +1,82 @@
+# -*- 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 Asset Types
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class AssetType(model.Base):
+ """
+ Represents an "asset type" from farmOS
+ """
+
+ __tablename__ = "asset_type"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Asset Type",
+ "model_title_plural": "Asset Types",
+ }
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ unique=True,
+ doc="""
+ Name of the asset type.
+ """,
+ )
+
+ description = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Description for the asset type.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the asset type within farmOS.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.String(length=50),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the asset type.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
diff --git a/src/wuttafarm/db/model/groups.py b/src/wuttafarm/db/model/groups.py
new file mode 100644
index 0000000..eae034f
--- /dev/null
+++ b/src/wuttafarm/db/model/groups.py
@@ -0,0 +1,106 @@
+# -*- 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 Groups
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class Group(model.Base):
+ """
+ Represents a "group" from farmOS
+ """
+
+ __tablename__ = "group"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Group",
+ "model_title_plural": "Groups",
+ }
+
+ 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 ""
diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py
new file mode 100644
index 0000000..53c93cf
--- /dev/null
+++ b/src/wuttafarm/db/model/land.py
@@ -0,0 +1,156 @@
+# -*- 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 Land Types
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class LandType(model.Base):
+ """
+ Represents a "land type" from farmOS
+ """
+
+ __tablename__ = "land_type"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Land Type",
+ "model_title_plural": "Land Types",
+ }
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ unique=True,
+ doc="""
+ Name of the land type.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the land type within farmOS.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.String(length=50),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the land type.
+ """,
+ )
+
+ land_assets = orm.relationship("LandAsset", back_populates="land_type")
+
+ def __str__(self):
+ return self.name or ""
+
+
+class LandAsset(model.Base):
+ """
+ Represents a "land asset" from farmOS
+ """
+
+ __tablename__ = "land_asset"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Land Asset",
+ "model_title_plural": "Land Assets",
+ }
+
+ 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 ""
diff --git a/src/wuttafarm/db/model/logs.py b/src/wuttafarm/db/model/logs.py
new file mode 100644
index 0000000..76f7715
--- /dev/null
+++ b/src/wuttafarm/db/model/logs.py
@@ -0,0 +1,150 @@
+# -*- 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 Log Types
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class LogType(model.Base):
+ """
+ Represents a "log type" from farmOS
+ """
+
+ __tablename__ = "log_type"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Log Type",
+ "model_title_plural": "Log Types",
+ }
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ unique=True,
+ doc="""
+ Name of the log type.
+ """,
+ )
+
+ description = sa.Column(
+ sa.String(length=255),
+ nullable=True,
+ doc="""
+ Optional description for the log type.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the log type within farmOS.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.String(length=50),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the log type.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
+
+
+class ActivityLog(model.Base):
+ """
+ Represents an activity log from farmOS
+ """
+
+ __tablename__ = "log_activity"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Activity Log",
+ "model_title_plural": "Activity Logs",
+ }
+
+ uuid = model.uuid_column()
+
+ message = sa.Column(
+ sa.String(length=255),
+ nullable=False,
+ doc="""
+ Message text for the log.
+ """,
+ )
+
+ timestamp = sa.Column(
+ sa.DateTime(),
+ nullable=False,
+ doc="""
+ Date and time when the log event occurred / will occur.
+ """,
+ )
+
+ status = sa.Column(
+ sa.String(length=20),
+ nullable=False,
+ doc="""
+ Current status of the log event.
+ """,
+ )
+
+ notes = sa.Column(
+ sa.Text(),
+ nullable=True,
+ doc="""
+ Arbitrary notes for the log event.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the log within farmOS.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the log.
+ """,
+ )
+
+ def __str__(self):
+ return self.message or ""
diff --git a/src/wuttafarm/db/model/structures.py b/src/wuttafarm/db/model/structures.py
new file mode 100644
index 0000000..d9fccdb
--- /dev/null
+++ b/src/wuttafarm/db/model/structures.py
@@ -0,0 +1,167 @@
+# -*- 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 Structure Types
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class StructureType(model.Base):
+ """
+ Represents a "structure type" from farmOS
+ """
+
+ __tablename__ = "structure_type"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Structure Type",
+ "model_title_plural": "Structure Types",
+ }
+
+ uuid = model.uuid_column()
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ unique=True,
+ doc="""
+ Name of the structure type.
+ """,
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ unique=True,
+ doc="""
+ UUID for the structure type within farmOS.
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.String(length=50),
+ nullable=True,
+ unique=True,
+ doc="""
+ Drupal internal ID for the structure type.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
+
+
+class Structure(model.Base):
+ """
+ Represents a structure from farmOS
+ """
+
+ __tablename__ = "structure"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Structure",
+ "model_title_plural": "Structures",
+ }
+
+ 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",
+ doc="""
+ Reference to the type of structure.
+ """,
+ )
+
+ 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 ""
diff --git a/src/wuttafarm/db/model/users.py b/src/wuttafarm/db/model/users.py
new file mode 100644
index 0000000..d194175
--- /dev/null
+++ b/src/wuttafarm/db/model/users.py
@@ -0,0 +1,80 @@
+# -*- 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 Users (extension)
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class WuttaFarmUser(model.Base):
+ """
+ WuttaFarm extension for the User model.
+ """
+
+ __tablename__ = "wuttafarm_user"
+ __versioned__ = {}
+
+ uuid = model.uuid_column(sa.ForeignKey("user.uuid"), default=None)
+
+ user = orm.relationship(
+ model.User,
+ doc="""
+ Reference to the User which this record extends.
+ """,
+ backref=orm.backref(
+ "_wuttafarm",
+ uselist=False,
+ cascade="all, delete-orphan",
+ cascade_backrefs=False,
+ doc="""
+ Reference to the WuttaFarm-specific extension record for
+ the user.
+ """,
+ ),
+ )
+
+ farmos_uuid = sa.Column(
+ model.UUID(),
+ nullable=True,
+ doc="""
+ UUID for the user within farmOS
+ """,
+ )
+
+ drupal_id = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ doc="""
+ Drupal internal ID for the user.
+ """,
+ )
+
+ def __str__(self):
+ return str(self.user or "")
+
+
+WuttaFarmUser.make_proxy(model.User, "_wuttafarm", "farmos_uuid")
+WuttaFarmUser.make_proxy(model.User, "_wuttafarm", "drupal_id")
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..4717c78
--- /dev/null
+++ b/src/wuttafarm/importing/farmos.py
@@ -0,0 +1,620 @@
+# -*- 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
+import logging
+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
+
+
+log = logging.getLogger(__name__)
+
+
+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["User"] = UserImporter
+ importers["AssetType"] = AssetTypeImporter
+ importers["LandType"] = LandTypeImporter
+ importers["LandAsset"] = LandAssetImporter
+ importers["StructureType"] = StructureTypeImporter
+ importers["Structure"] = StructureImporter
+ importers["AnimalType"] = AnimalTypeImporter
+ importers["Animal"] = AnimalImporter
+ importers["Group"] = GroupImporter
+ importers["LogType"] = LogTypeImporter
+ importers["ActivityLog"] = ActivityLogImporter
+ 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 ActivityLogImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Activity Logs
+ """
+
+ model_class = model.ActivityLog
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "message",
+ "timestamp",
+ "notes",
+ "status",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ logs = self.farmos_client.log.get("activity")
+ return logs["data"]
+
+ def normalize_source_object(self, log):
+ """ """
+
+ if notes := log["attributes"]["notes"]:
+ notes = notes["value"]
+
+ return {
+ "farmos_uuid": UUID(log["id"]),
+ "drupal_id": log["attributes"]["drupal_internal__id"],
+ "message": log["attributes"]["name"],
+ "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]),
+ "notes": notes,
+ "status": log["attributes"]["status"],
+ }
+
+
+class AnimalImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Animals
+ """
+
+ model_class = model.Animal
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ "animal_type_uuid",
+ "sex",
+ "is_sterile",
+ "birthdate",
+ "notes",
+ "active",
+ "image_url",
+ ]
+
+ def setup(self):
+ super().setup()
+ model = self.app.model
+
+ self.animal_types_by_farmos_uuid = {}
+ for animal_type in self.target_session.query(model.AnimalType):
+ if animal_type.farmos_uuid:
+ self.animal_types_by_farmos_uuid[animal_type.farmos_uuid] = animal_type
+
+ def get_source_objects(self):
+ """ """
+ animals = self.farmos_client.asset.get("animal")
+ return animals["data"]
+
+ 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(
+ 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"]
+
+ if not animal_type_uuid:
+ log.warning("missing/invalid animal_type for farmOS Animal: %s", animal)
+ return None
+
+ birthdate = animal["attributes"]["birthdate"]
+ if birthdate:
+ birthdate = datetime.datetime.fromisoformat(birthdate)
+ birthdate = self.app.localtime(birthdate)
+ birthdate = self.app.make_utc(birthdate)
+
+ if notes := animal["attributes"]["notes"]:
+ notes = notes["value"]
+
+ 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": animal["attributes"]["is_castrated"],
+ "birthdate": birthdate,
+ "active": animal["attributes"]["status"] == "active",
+ "notes": notes,
+ "image_url": image_url,
+ }
+
+
+class AnimalTypeImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Animal Types
+ """
+
+ model_class = model.AnimalType
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_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_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"]),
+ }
+
+
+class AssetTypeImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Asset Types
+ """
+
+ model_class = model.AssetType
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ "description",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ asset_types = self.farmos_client.resource.get("asset_type")
+ return asset_types["data"]
+
+ def normalize_source_object(self, asset_type):
+ """ """
+ return {
+ "farmos_uuid": UUID(asset_type["id"]),
+ "drupal_id": asset_type["attributes"]["drupal_internal__id"],
+ "name": asset_type["attributes"]["label"],
+ "description": asset_type["attributes"]["description"],
+ }
+
+
+class GroupImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Groups
+ """
+
+ model_class = model.Group
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "active",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ groups = self.farmos_client.asset.get("group")
+ return groups["data"]
+
+ def normalize_source_object(self, group):
+ """ """
+ if notes := group["attributes"]["notes"]:
+ notes = notes["value"]
+
+ 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": group["attributes"]["status"] == "active",
+ "notes": notes,
+ }
+
+
+class LandAssetImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Land Assets
+ """
+
+ model_class = model.LandAsset
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ "land_type_uuid",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "active",
+ ]
+
+ def setup(self):
+ super().setup()
+ model = self.app.model
+
+ self.land_types_by_id = {}
+ for land_type in self.target_session.query(model.LandType):
+ self.land_types_by_id[land_type.drupal_id] = land_type
+
+ def get_source_objects(self):
+ """ """
+ land_assets = self.farmos_client.asset.get("land")
+ return land_assets["data"]
+
+ def normalize_source_object(self, land):
+ """ """
+ land_type_id = land["attributes"]["land_type"]
+ land_type = self.land_types_by_id.get(land_type_id)
+ if not land_type:
+ log.warning(
+ "invalid land_type '%s' for farmOS Land Asset: %s", land_type_id, land
+ )
+ return None
+
+ if notes := land["attributes"]["notes"]:
+ notes = notes["value"]
+
+ 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": land["attributes"]["status"] == "active",
+ "notes": notes,
+ }
+
+
+class LandTypeImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Land Types
+ """
+
+ model_class = model.LandType
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ land_types = self.farmos_client.resource.get("land_type")
+ return land_types["data"]
+
+ def normalize_source_object(self, land_type):
+ """ """
+ return {
+ "farmos_uuid": UUID(land_type["id"]),
+ "drupal_id": land_type["attributes"]["drupal_internal__id"],
+ "name": land_type["attributes"]["label"],
+ }
+
+
+class LogTypeImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Log Types
+ """
+
+ model_class = model.LogType
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ "description",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ log_types = self.farmos_client.resource.get("log_type")
+ return log_types["data"]
+
+ def normalize_source_object(self, log_type):
+ """ """
+ return {
+ "farmos_uuid": UUID(log_type["id"]),
+ "drupal_id": log_type["attributes"]["drupal_internal__id"],
+ "name": log_type["attributes"]["label"],
+ "description": log_type["attributes"]["description"],
+ }
+
+
+class StructureImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Structures
+ """
+
+ model_class = model.Structure
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ "structure_type_uuid",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "active",
+ "image_url",
+ ]
+
+ def setup(self):
+ super().setup()
+ model = self.app.model
+
+ self.structure_types_by_id = {}
+ for structure_type in self.target_session.query(model.StructureType):
+ self.structure_types_by_id[structure_type.drupal_id] = structure_type
+
+ def get_source_objects(self):
+ """ """
+ structures = self.farmos_client.asset.get("structure")
+ return structures["data"]
+
+ def normalize_source_object(self, structure):
+ """ """
+ structure_type_id = structure["attributes"]["structure_type"]
+ 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",
+ 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"]
+
+ 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": structure["attributes"]["status"] == "active",
+ "notes": notes,
+ "image_url": image_url,
+ }
+
+
+class StructureTypeImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Structure Types
+ """
+
+ model_class = model.StructureType
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "name",
+ ]
+
+ def get_source_objects(self):
+ """ """
+ structure_types = self.farmos_client.resource.get("structure_type")
+ return structure_types["data"]
+
+ def normalize_source_object(self, structure_type):
+ """ """
+ return {
+ "farmos_uuid": UUID(structure_type["id"]),
+ "drupal_id": structure_type["attributes"]["drupal_internal__id"],
+ "name": structure_type["attributes"]["label"],
+ }
+
+
+class UserImporter(FromFarmOS, ToWutta):
+ """
+ farmOS API → WuttaFarm importer for Users
+ """
+
+ model_class = model.User
+
+ supported_fields = [
+ "farmos_uuid",
+ "drupal_id",
+ "username",
+ ]
+
+ def get_simple_fields(self):
+ """ """
+ fields = list(super().get_simple_fields())
+ # nb. must explicitly declare extension fields
+ fields.extend(
+ [
+ "farmos_uuid",
+ "drupal_id",
+ ]
+ )
+ return fields
+
+ def get_source_objects(self):
+ """ """
+ users = self.farmos_client.resource.get("user")
+ return users["data"]
+
+ def normalize_source_object(self, user):
+ """ """
+
+ # nb. skip Anonymous user which does not have drupal id
+ drupal_id = user["attributes"].get("drupal_internal__uid")
+ if not drupal_id:
+ return None
+
+ return {
+ "farmos_uuid": UUID(user["id"]),
+ "drupal_id": drupal_id,
+ "username": user["attributes"]["name"],
+ }
+
+ def can_delete_object(self, user, data=None):
+ """
+ Prevent delete for users which do not exist in farmOS.
+ """
+ if not user.farmos_uuid:
+ return False
+ return True
diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py
index a38588a..f646a96 100644
--- a/src/wuttafarm/web/forms/schema.py
+++ b/src/wuttafarm/web/forms/schema.py
@@ -27,6 +27,33 @@ import json
import colander
+from wuttaweb.forms.schema import ObjectRef
+
+
+class AnimalTypeRef(ObjectRef):
+ """
+ Custom schema type for a
+ :class:`~wuttafarm.db.model.animals.AnimalType` reference field.
+
+ This is a subclass of
+ :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
+ """
+
+ @property
+ def model_class(self): # pylint: disable=empty-docstring
+ """ """
+ model = self.app.model
+ return model.AnimalType
+
+ def sort_query(self, query): # pylint: disable=empty-docstring
+ """ """
+ return query.order_by(self.model_class.name)
+
+ def get_object_url(self, obj): # pylint: disable=empty-docstring
+ """ """
+ animal_type = obj
+ return self.request.route_url("animal_types.view", uuid=animal_type.uuid)
+
class AnimalTypeType(colander.SchemaType):
@@ -47,6 +74,31 @@ class AnimalTypeType(colander.SchemaType):
return AnimalTypeWidget(self.request, **kwargs)
+class LandTypeRef(ObjectRef):
+ """
+ Custom schema type for a
+ :class:`~wuttafarm.db.model.land.LandType` reference field.
+
+ This is a subclass of
+ :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
+ """
+
+ @property
+ def model_class(self): # pylint: disable=empty-docstring
+ """ """
+ model = self.app.model
+ return model.LandType
+
+ def sort_query(self, query): # pylint: disable=empty-docstring
+ """ """
+ return query.order_by(self.model_class.name)
+
+ def get_object_url(self, obj): # pylint: disable=empty-docstring
+ """ """
+ land_type = obj
+ return self.request.route_url("land_types.view", uuid=land_type.uuid)
+
+
class StructureType(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
@@ -66,6 +118,31 @@ class StructureType(colander.SchemaType):
return StructureWidget(self.request, **kwargs)
+class StructureTypeRef(ObjectRef):
+ """
+ Custom schema type for a
+ :class:`~wuttafarm.db.model.structures.Structure` reference field.
+
+ This is a subclass of
+ :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
+ """
+
+ @property
+ def model_class(self): # pylint: disable=empty-docstring
+ """ """
+ model = self.app.model
+ return model.StructureType
+
+ def sort_query(self, query): # pylint: disable=empty-docstring
+ """ """
+ return query.order_by(self.model_class.name)
+
+ def get_object_url(self, obj): # pylint: disable=empty-docstring
+ """ """
+ structure_type = obj
+ return self.request.route_url("structure_types.view", uuid=structure_type.uuid)
+
+
class UsersType(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py
index ab6f440..3e5bb46 100644
--- a/src/wuttafarm/web/menus.py
+++ b/src/wuttafarm/web/menus.py
@@ -33,10 +33,80 @@ class WuttaFarmMenuHandler(base.MenuHandler):
def make_menus(self, request, **kwargs):
return [
+ self.make_asset_menu(request),
+ self.make_log_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": "Animals",
+ "route": "animals",
+ "perm": "animals.list",
+ },
+ {
+ "title": "Groups",
+ "route": "groups",
+ "perm": "groups.list",
+ },
+ {
+ "title": "Structures",
+ "route": "structures",
+ "perm": "structures.list",
+ },
+ {
+ "title": "Land",
+ "route": "land_assets",
+ "perm": "land_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": "Asset Types",
+ "route": "asset_types",
+ "perm": "asset_types.list",
+ },
+ ],
+ }
+
+ def make_log_menu(self, request):
+ return {
+ "title": "Logs",
+ "type": "menu",
+ "items": [
+ {
+ "title": "Activity Logs",
+ "route": "activity_logs",
+ "perm": "activity_logs.list",
+ },
+ {"type": "sep"},
+ {
+ "title": "Log Types",
+ "route": "log_types",
+ "perm": "log_types.list",
+ },
+ ],
+ }
+
def make_farmos_menu(self, request):
config = request.wutta_config
app = config.get_app()
diff --git a/src/wuttafarm/web/templates/farmos/master/view.mako b/src/wuttafarm/web/templates/farmos/master/view.mako
new file mode 100644
index 0000000..5e7bcd0
--- /dev/null
+++ b/src/wuttafarm/web/templates/farmos/master/view.mako
@@ -0,0 +1,45 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="tool_panels()">
+ ${parent.tool_panels()}
+ ${self.tool_panel_tools()}
+%def>
+
+<%def name="tool_panel_tools()">
+ % if raw_json:
+
+
+
+ See raw JSON data
+
+
+
+ <${b}-modal :width="1200"
+ % if request.use_oruga:
+ v-model:active="viewJsonShowDialog"
+ % else:
+ :active.sync="viewJsonShowDialog"
+ % endif
+ >
+
+
+ ${rendered_json|n}
+
+
+ ${b}-modal>
+
+ % endif
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+ % if raw_json:
+
+ % endif
+%def>
diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py
index 63ce536..a4d12dd 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):
@@ -34,8 +36,21 @@ def includeme(config):
**{
"wuttaweb.views.auth": "wuttafarm.web.views.auth",
"wuttaweb.views.common": "wuttafarm.web.views.common",
+ "wuttaweb.views.users": "wuttafarm.web.views.users",
}
)
+ # 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.structures")
+ config.include("wuttafarm.web.views.animals")
+ config.include("wuttafarm.web.views.groups")
+ config.include("wuttafarm.web.views.log_types")
+ config.include("wuttafarm.web.views.logs_activity")
+
# 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..09d1e25
--- /dev/null
+++ b/src/wuttafarm/web/views/animal_types.py
@@ -0,0 +1,128 @@
+# -*- 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
new file mode 100644
index 0000000..e22095e
--- /dev/null
+++ b/src/wuttafarm/web/views/animals.py
@@ -0,0 +1,130 @@
+# -*- 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 Animals
+"""
+
+from wuttafarm.db.model.animals import Animal
+from wuttafarm.web.views import WuttaFarmMasterView
+from wuttafarm.web.forms.schema import AnimalTypeRef
+from wuttafarm.web.forms.widgets import ImageWidget
+
+
+class AnimalView(WuttaFarmMasterView):
+ """
+ Master view for Animals
+ """
+
+ model_class = Animal
+ route_prefix = "animals"
+ url_prefix = "/animals"
+
+ farmos_refurl_path = "/assets/animal"
+
+ grid_columns = [
+ "name",
+ "animal_type",
+ "sex",
+ "is_sterile",
+ "birthdate",
+ "active",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "animal_type",
+ "birthdate",
+ "sex",
+ "is_sterile",
+ "active",
+ "notes",
+ "farmos_uuid",
+ "drupal_id",
+ "image_url",
+ "image",
+ ]
+
+ 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 configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ animal = form.model_instance
+
+ # 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:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ "farmos_animals.view", uuid=animal.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ AnimalView = kwargs.get("AnimalView", base["AnimalView"])
+ AnimalView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py
new file mode 100644
index 0000000..775fa3a
--- /dev/null
+++ b/src/wuttafarm/web/views/asset_types.py
@@ -0,0 +1,90 @@
+# -*- 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 Asset Types
+"""
+
+from wuttafarm.db.model.assets import AssetType
+from wuttafarm.web.views import WuttaFarmMasterView
+
+
+class AssetTypeView(WuttaFarmMasterView):
+ """
+ Master view for Asset Types
+ """
+
+ model_class = AssetType
+ route_prefix = "asset_types"
+ url_prefix = "/asset-types"
+
+ grid_columns = [
+ "name",
+ "description",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "description",
+ "farmos_uuid",
+ "drupal_id",
+ ]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
+
+ def get_xref_buttons(self, asset_type):
+ buttons = super().get_xref_buttons(asset_type)
+
+ if asset_type.farmos_uuid:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ "farmos_asset_types.view", uuid=asset_type.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ AssetTypeView = kwargs.get("AssetTypeView", base["AssetTypeView"])
+ AssetTypeView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py
index f46c018..cd68b78 100644
--- a/src/wuttafarm/web/views/common.py
+++ b/src/wuttafarm/web/views/common.py
@@ -45,9 +45,24 @@ class CommonView(base.CommonView):
farm_viewer = auth.get_role_farm_viewer(session)
farm_viewer.notes = "this is meant to mirror the corresponding role in farmOS"
+ # create system user to represent farmOS
+ auth.make_user(session, username="farmos", prevent_edit=True)
+
site_admin = session.query(model.Role).filter_by(name="Site Admin").first()
if site_admin:
site_admin_perms = [
+ "activity_logs.list",
+ "activity_logs.view",
+ "activity_logs.versions",
+ "animal_types.list",
+ "animal_types.view",
+ "animal_types.versions",
+ "animals.list",
+ "animals.view",
+ "animals.versions",
+ "asset_types.list",
+ "asset_types.view",
+ "asset_types.versions",
"farmos_animal_types.list",
"farmos_animal_types.view",
"farmos_animals.list",
@@ -70,6 +85,24 @@ class CommonView(base.CommonView):
"farmos_structures.view",
"farmos_users.list",
"farmos_users.view",
+ "groups.list",
+ "groups.view",
+ "groups.versions",
+ "land_assets.list",
+ "land_assets.view",
+ "land_assets.versions",
+ "land_types.list",
+ "land_types.view",
+ "land_types.versions",
+ "log_types.list",
+ "log_types.view",
+ "log_types.versions",
+ "structure_types.list",
+ "structure_types.view",
+ "structure_types.versions",
+ "structures.list",
+ "structures.view",
+ "structures.versions",
]
for perm in site_admin_perms:
auth.grant_permission(site_admin, perm)
diff --git a/src/wuttafarm/web/views/farmos/animal_types.py b/src/wuttafarm/web/views/farmos/animal_types.py
index a974242..94d02d8 100644
--- a/src/wuttafarm/web/views/farmos/animal_types.py
+++ b/src/wuttafarm/web/views/farmos/animal_types.py
@@ -79,6 +79,7 @@ class AnimalTypeView(FarmOSMasterView):
animal_type = self.farmos_client.resource.get_id(
"taxonomy_term", "animal_type", self.request.matchdict["uuid"]
)
+ self.raw_json = animal_type
return self.normalize_animal_type(animal_type["data"])
def get_instance_title(self, animal_type):
@@ -95,7 +96,7 @@ class AnimalTypeView(FarmOSMasterView):
return {
"uuid": animal_type["id"],
- "drupal_internal_id": animal_type["attributes"]["drupal_internal__tid"],
+ "drupal_id": animal_type["attributes"]["drupal_internal__tid"],
"name": animal_type["attributes"]["name"],
"description": description or colander.null,
"changed": changed,
@@ -112,18 +113,39 @@ 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,
url=self.app.get_farmos_url(
- f"/taxonomy/term/{animal_type['drupal_internal_id']}"
+ f"/taxonomy/term/{animal_type['drupal_id']}"
),
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/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py
index 8eca5af..760ad34 100644
--- a/src/wuttafarm/web/views/farmos/animals.py
+++ b/src/wuttafarm/web/views/farmos/animals.py
@@ -27,8 +27,10 @@ import datetime
import colander
-from wuttafarm.web.views.farmos import FarmOSMasterView
+from wuttaweb.forms.schema import WuttaDateTime
+from wuttaweb.forms.widgets import WuttaDateTimeWidget
+from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.schema import UsersType, AnimalTypeType, StructureType
from wuttafarm.web.forms.widgets import ImageWidget
@@ -99,6 +101,7 @@ class AnimalView(FarmOSMasterView):
animal = self.farmos_client.resource.get_id(
"asset", "animal", self.request.matchdict["uuid"]
)
+ self.raw_json = animal
# instance data
data = self.normalize_animal(animal["data"])
@@ -172,7 +175,7 @@ class AnimalView(FarmOSMasterView):
return {
"uuid": animal["id"],
- "drupal_internal_id": animal["attributes"]["drupal_internal__id"],
+ "drupal_id": animal["attributes"]["drupal_internal__id"],
"name": animal["attributes"]["name"],
"birthdate": birthdate,
"sex": animal["attributes"]["sex"],
@@ -190,6 +193,10 @@ class AnimalView(FarmOSMasterView):
# animal_type
f.set_node("animal_type", AnimalTypeType(self.request))
+ # birthdate
+ f.set_node("birthdate", WuttaDateTime())
+ f.set_widget("birthdate", WuttaDateTimeWidget(self.request))
+
# is_castrated
f.set_node("is_castrated", colander.Boolean())
@@ -208,16 +215,35 @@ class AnimalView(FarmOSMasterView):
f.set_default("image", url)
def get_xref_buttons(self, animal):
- return [
+ model = self.app.model
+ session = self.Session()
+
+ buttons = [
self.make_button(
"View in farmOS",
primary=True,
- url=self.app.get_farmos_url(f"/asset/{animal['drupal_internal_id']}"),
+ url=self.app.get_farmos_url(f"/asset/{animal['drupal_id']}"),
target="_blank",
icon_left="external-link-alt",
),
]
+ if wf_animal := (
+ session.query(model.Animal)
+ .filter(model.Animal.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),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/asset_types.py b/src/wuttafarm/web/views/farmos/asset_types.py
index 75eebbe..a2fac2f 100644
--- a/src/wuttafarm/web/views/farmos/asset_types.py
+++ b/src/wuttafarm/web/views/farmos/asset_types.py
@@ -69,6 +69,7 @@ class AssetTypeView(FarmOSMasterView):
asset_type = self.farmos_client.resource.get_id(
"asset_type", "asset_type", self.request.matchdict["uuid"]
)
+ self.raw_json = asset_type
return self.normalize_asset_type(asset_type["data"])
def get_instance_title(self, asset_type):
@@ -77,7 +78,7 @@ class AssetTypeView(FarmOSMasterView):
def normalize_asset_type(self, asset_type):
return {
"uuid": asset_type["id"],
- "drupal_internal_id": asset_type["attributes"]["drupal_internal__id"],
+ "drupal_id": asset_type["attributes"]["drupal_internal__id"],
"label": asset_type["attributes"]["label"],
"description": asset_type["attributes"]["description"],
}
@@ -89,6 +90,29 @@ class AssetTypeView(FarmOSMasterView):
# description
f.set_widget("description", "notes")
+ def get_xref_buttons(self, asset_type):
+ model = self.app.model
+ session = self.Session()
+ buttons = []
+
+ if wf_asset_type := (
+ session.query(model.AssetType)
+ .filter(model.AssetType.farmos_uuid == asset_type["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "asset_types.view", uuid=wf_asset_type.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py
index 4664a6b..66224fe 100644
--- a/src/wuttafarm/web/views/farmos/groups.py
+++ b/src/wuttafarm/web/views/farmos/groups.py
@@ -88,11 +88,10 @@ class GroupView(FarmOSMasterView):
g.set_renderer("changed", "datetime")
def get_instance(self):
-
group = self.farmos_client.resource.get_id(
"asset", "group", self.request.matchdict["uuid"]
)
-
+ self.raw_json = group
return self.normalize_group(group["data"])
def get_instance_title(self, group):
@@ -110,7 +109,7 @@ class GroupView(FarmOSMasterView):
return {
"uuid": group["id"],
- "drupal_internal_id": group["attributes"]["drupal_internal__id"],
+ "drupal_id": group["attributes"]["drupal_internal__id"],
"name": group["attributes"]["name"],
"created": created,
"changed": changed,
@@ -142,16 +141,35 @@ class GroupView(FarmOSMasterView):
f.set_widget("changed", WuttaDateTimeWidget(self.request))
def get_xref_buttons(self, group):
- return [
+ model = self.app.model
+ session = self.Session()
+
+ buttons = [
self.make_button(
"View in farmOS",
primary=True,
- url=self.app.get_farmos_url(f"/asset/{group['drupal_internal_id']}"),
+ url=self.app.get_farmos_url(f"/asset/{group['drupal_id']}"),
target="_blank",
icon_left="external-link-alt",
),
]
+ if wf_group := (
+ session.query(model.Group)
+ .filter(model.Group.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),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/land_assets.py b/src/wuttafarm/web/views/farmos/land_assets.py
index a496cc5..64f43cc 100644
--- a/src/wuttafarm/web/views/farmos/land_assets.py
+++ b/src/wuttafarm/web/views/farmos/land_assets.py
@@ -49,6 +49,7 @@ class LandAssetView(FarmOSMasterView):
grid_columns = [
"name",
+ "land_type",
"is_fixed",
"is_location",
"status",
@@ -59,6 +60,7 @@ class LandAssetView(FarmOSMasterView):
form_fields = [
"name",
+ "land_type",
"is_fixed",
"is_location",
"status",
@@ -95,6 +97,7 @@ class LandAssetView(FarmOSMasterView):
land_asset = self.farmos_client.resource.get_id(
"asset", "land", self.request.matchdict["uuid"]
)
+ self.raw_json = land_asset
return self.normalize_land_asset(land_asset["data"])
def get_instance_title(self, land_asset):
@@ -115,8 +118,9 @@ class LandAssetView(FarmOSMasterView):
return {
"uuid": land["id"],
- "drupal_internal_id": land["attributes"]["drupal_internal__id"],
+ "drupal_id": land["attributes"]["drupal_internal__id"],
"name": land["attributes"]["name"],
+ "land_type": land["attributes"]["land_type"],
"created": created,
"changed": changed,
"is_fixed": land["attributes"]["is_fixed"],
@@ -151,12 +155,42 @@ class LandAssetView(FarmOSMasterView):
self.make_button(
"View in farmOS",
primary=True,
- url=self.app.get_farmos_url(f"/asset/{land['drupal_internal_id']}"),
+ url=self.app.get_farmos_url(f"/asset/{land['drupal_id']}"),
target="_blank",
icon_left="external-link-alt",
),
]
+ def get_xref_buttons(self, land):
+ model = self.app.model
+ session = self.Session()
+
+ buttons = [
+ self.make_button(
+ "View in farmOS",
+ primary=True,
+ url=self.app.get_farmos_url(f"/asset/{land['drupal_id']}"),
+ target="_blank",
+ icon_left="external-link-alt",
+ ),
+ ]
+
+ if wf_land := (
+ session.query(model.LandAsset)
+ .filter(model.LandAsset.farmos_uuid == land["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url("land_assets.view", uuid=wf_land.uuid),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/land_types.py b/src/wuttafarm/web/views/farmos/land_types.py
index aadece8..e9eccea 100644
--- a/src/wuttafarm/web/views/farmos/land_types.py
+++ b/src/wuttafarm/web/views/farmos/land_types.py
@@ -64,6 +64,7 @@ class LandTypeView(FarmOSMasterView):
land_type = self.farmos_client.resource.get_id(
"land_type", "land_type", self.request.matchdict["uuid"]
)
+ self.raw_json = land_type
return self.normalize_land_type(land_type["data"])
def get_instance_title(self, land_type):
@@ -72,10 +73,33 @@ class LandTypeView(FarmOSMasterView):
def normalize_land_type(self, land_type):
return {
"uuid": land_type["id"],
- "drupal_internal_id": land_type["attributes"]["drupal_internal__id"],
+ "drupal_id": land_type["attributes"]["drupal_internal__id"],
"label": land_type["attributes"]["label"],
}
+ def get_xref_buttons(self, land_type):
+ model = self.app.model
+ session = self.Session()
+ buttons = []
+
+ if wf_land_type := (
+ session.query(model.LandType)
+ .filter(model.LandType.farmos_uuid == land_type["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "land_types.view", uuid=wf_land_type.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/log_types.py b/src/wuttafarm/web/views/farmos/log_types.py
index 6e72f8f..1f6404a 100644
--- a/src/wuttafarm/web/views/farmos/log_types.py
+++ b/src/wuttafarm/web/views/farmos/log_types.py
@@ -66,6 +66,7 @@ class LogTypeView(FarmOSMasterView):
log_type = self.farmos_client.resource.get_id(
"log_type", "log_type", self.request.matchdict["uuid"]
)
+ self.raw_json = log_type
return self.normalize_log_type(log_type["data"])
def get_instance_title(self, log_type):
@@ -74,7 +75,7 @@ class LogTypeView(FarmOSMasterView):
def normalize_log_type(self, log_type):
return {
"uuid": log_type["id"],
- "drupal_internal_id": log_type["attributes"]["drupal_internal__id"],
+ "drupal_id": log_type["attributes"]["drupal_internal__id"],
"label": log_type["attributes"]["label"],
"description": log_type["attributes"]["description"],
}
@@ -86,6 +87,27 @@ class LogTypeView(FarmOSMasterView):
# description
f.set_widget("description", "notes")
+ def get_xref_buttons(self, log_type):
+ model = self.app.model
+ session = self.Session()
+ buttons = []
+
+ if wf_log_type := (
+ session.query(model.LogType)
+ .filter(model.LogType.farmos_uuid == log_type["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url("log_types.view", uuid=wf_log_type.uuid),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/logs_activity.py b/src/wuttafarm/web/views/farmos/logs_activity.py
index 61b4e85..e966810 100644
--- a/src/wuttafarm/web/views/farmos/logs_activity.py
+++ b/src/wuttafarm/web/views/farmos/logs_activity.py
@@ -79,6 +79,7 @@ class ActivityLogView(FarmOSMasterView):
def get_instance(self):
log = self.farmos_client.log.get_id("activity", self.request.matchdict["uuid"])
+ self.raw_json = log
return self.normalize_log(log["data"])
def get_instance_title(self, log):
@@ -95,7 +96,7 @@ class ActivityLogView(FarmOSMasterView):
return {
"uuid": log["id"],
- "drupal_internal_id": log["attributes"]["drupal_internal__id"],
+ "drupal_id": log["attributes"]["drupal_internal__id"],
"name": log["attributes"]["name"],
"timestamp": timestamp,
"status": log["attributes"]["status"],
@@ -114,16 +115,35 @@ class ActivityLogView(FarmOSMasterView):
f.set_widget("notes", "notes")
def get_xref_buttons(self, log):
- return [
+ model = self.app.model
+ session = self.Session()
+
+ buttons = [
self.make_button(
"View in farmOS",
primary=True,
- url=self.app.get_farmos_url(f"/log/{log['drupal_internal_id']}"),
+ url=self.app.get_farmos_url(f"/log/{log['drupal_id']}"),
target="_blank",
icon_left="external-link-alt",
),
]
+ if wf_log := (
+ session.query(model.ActivityLog)
+ .filter(model.ActivityLog.farmos_uuid == log["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url("activity_logs.view", uuid=wf_log.uuid),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py
index 59003d0..955120b 100644
--- a/src/wuttafarm/web/views/farmos/master.py
+++ b/src/wuttafarm/web/views/farmos/master.py
@@ -23,6 +23,10 @@
Base class for farmOS master views
"""
+import json
+
+import markdown
+
from wuttaweb.views import MasterView
from wuttafarm.web.util import save_farmos_oauth2_token
@@ -54,6 +58,7 @@ class FarmOSMasterView(MasterView):
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.farmos_client = self.get_farmos_client()
+ self.raw_json = None
def get_farmos_client(self):
token = self.request.session.get("farmos.oauth2.token")
@@ -71,9 +76,26 @@ class FarmOSMasterView(MasterView):
return self.app.get_farmos_client(token=token, token_updater=token_updater)
+ def get_fallback_templates(self, template):
+ """ """
+ templates = super().get_fallback_templates(template)
+
+ if template == "view":
+ templates.insert(0, "/farmos/master/view.mako")
+
+ return templates
+
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)
+ if self.viewing and self.raw_json:
+ context["raw_json"] = self.raw_json
+ code = "```json\n" + json.dumps(self.raw_json, indent=2) + "\n```"
+ # TODO: this does not seem to be adding syntax highlight
+ context["rendered_json"] = markdown.markdown(
+ code, extensions=["fenced_code", "codehilite"]
+ )
+
return context
diff --git a/src/wuttafarm/web/views/farmos/structure_types.py b/src/wuttafarm/web/views/farmos/structure_types.py
index 3fe4741..b7e58d8 100644
--- a/src/wuttafarm/web/views/farmos/structure_types.py
+++ b/src/wuttafarm/web/views/farmos/structure_types.py
@@ -66,6 +66,7 @@ class StructureTypeView(FarmOSMasterView):
structure_type = self.farmos_client.resource.get_id(
"structure_type", "structure_type", self.request.matchdict["uuid"]
)
+ self.raw_json = structure_type
return self.normalize_structure_type(structure_type["data"])
def get_instance_title(self, structure_type):
@@ -74,10 +75,33 @@ class StructureTypeView(FarmOSMasterView):
def normalize_structure_type(self, structure_type):
return {
"uuid": structure_type["id"],
- "drupal_internal_id": structure_type["attributes"]["drupal_internal__id"],
+ "drupal_id": structure_type["attributes"]["drupal_internal__id"],
"label": structure_type["attributes"]["label"],
}
+ def get_xref_buttons(self, structure_type):
+ model = self.app.model
+ session = self.Session()
+ buttons = []
+
+ if wf_structure_type := (
+ session.query(model.StructureType)
+ .filter(model.StructureType.farmos_uuid == structure_type["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "structure_types.view", uuid=wf_structure_type.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py
index bbc4f1f..3626fb1 100644
--- a/src/wuttafarm/web/views/farmos/structures.py
+++ b/src/wuttafarm/web/views/farmos/structures.py
@@ -94,7 +94,7 @@ class StructureView(FarmOSMasterView):
structure = self.farmos_client.resource.get_id(
"asset", "structure", self.request.matchdict["uuid"]
)
-
+ self.raw_json = structure
data = self.normalize_structure(structure["data"])
if relationships := structure["data"].get("relationships"):
@@ -147,7 +147,7 @@ class StructureView(FarmOSMasterView):
return {
"uuid": structure["id"],
- "drupal_internal_id": structure["attributes"]["drupal_internal__id"],
+ "drupal_id": structure["attributes"]["drupal_internal__id"],
"name": structure["attributes"]["name"],
"structure_type": structure["attributes"]["structure_type"],
"is_fixed": structure["attributes"]["is_fixed"],
@@ -186,17 +186,37 @@ class StructureView(FarmOSMasterView):
f.set_default("image", url)
def get_xref_buttons(self, structure):
- drupal_id = structure["drupal_internal_id"]
- return [
+ model = self.app.model
+ session = self.Session()
+
+ buttons = [
self.make_button(
"View in farmOS",
primary=True,
- url=self.app.get_farmos_url(f"/asset/{drupal_id}"),
+ url=self.app.get_farmos_url(f"/asset/{structure['drupal_id']}"),
target="_blank",
icon_left="external-link-alt",
),
]
+ if wf_structure := (
+ session.query(model.Structure)
+ .filter(model.Structure.farmos_uuid == structure["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url(
+ "structures.view", uuid=wf_structure.uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttafarm/web/views/farmos/users.py b/src/wuttafarm/web/views/farmos/users.py
index 317bfe3..bb004ee 100644
--- a/src/wuttafarm/web/views/farmos/users.py
+++ b/src/wuttafarm/web/views/farmos/users.py
@@ -77,6 +77,7 @@ class UserView(FarmOSMasterView):
user = self.farmos_client.resource.get_id(
"user", "user", self.request.matchdict["uuid"]
)
+ self.raw_json = user
return self.normalize_user(user["data"])
def get_instance_title(self, user):
@@ -94,7 +95,7 @@ class UserView(FarmOSMasterView):
return {
"uuid": user["id"],
- "drupal_internal_id": user["attributes"].get("drupal_internal__uid"),
+ "drupal_id": user["attributes"].get("drupal_internal__uid"),
"display_name": user["attributes"]["display_name"],
"name": user["attributes"].get("name") or colander.null,
"mail": user["attributes"].get("mail") or colander.null,
@@ -115,17 +116,36 @@ class UserView(FarmOSMasterView):
f.set_node("changed", WuttaDateTime())
def get_xref_buttons(self, user):
- if drupal_id := user["drupal_internal_id"]:
- return [
+ model = self.app.model
+ session = self.Session()
+ buttons = []
+
+ if drupal_id := user["drupal_id"]:
+ buttons.append(
self.make_button(
"View in farmOS",
primary=True,
url=self.app.get_farmos_url(f"/user/{drupal_id}"),
target="_blank",
icon_left="external-link-alt",
- ),
- ]
- return None
+ )
+ )
+
+ if wf_user := (
+ session.query(model.WuttaFarmUser)
+ .filter(model.WuttaFarmUser.farmos_uuid == user["uuid"])
+ .first()
+ ):
+ buttons.append(
+ self.make_button(
+ f"View {self.app.get_title()} record",
+ primary=True,
+ url=self.request.route_url("users.view", uuid=wf_user.uuid),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
def defaults(config, **kwargs):
diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py
new file mode 100644
index 0000000..5f2746b
--- /dev/null
+++ b/src/wuttafarm/web/views/groups.py
@@ -0,0 +1,107 @@
+# -*- 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 Groups
+"""
+
+from wuttafarm.db.model.groups import Group
+from wuttafarm.web.views import WuttaFarmMasterView
+
+
+class GroupView(WuttaFarmMasterView):
+ """
+ Master view for Groups
+ """
+
+ model_class = Group
+ route_prefix = "groups"
+ url_prefix = "/groups"
+
+ farmos_refurl_path = "/assets/group"
+
+ grid_columns = [
+ "name",
+ "is_location",
+ "is_fixed",
+ "active",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "is_location",
+ "is_fixed",
+ "active",
+ "notes",
+ "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()
+
+ GroupView = kwargs.get("GroupView", base["GroupView"])
+ GroupView.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
new file mode 100644
index 0000000..18f7a3d
--- /dev/null
+++ b/src/wuttafarm/web/views/land_assets.py
@@ -0,0 +1,117 @@
+# -*- 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/land_types.py b/src/wuttafarm/web/views/land_types.py
new file mode 100644
index 0000000..21bfabc
--- /dev/null
+++ b/src/wuttafarm/web/views/land_types.py
@@ -0,0 +1,118 @@
+# -*- 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 Types
+"""
+
+from wuttafarm.db.model.land import LandType, LandAsset
+from wuttafarm.web.views import WuttaFarmMasterView
+
+
+class LandTypeView(WuttaFarmMasterView):
+ """
+ Master view for Land Types
+ """
+
+ model_class = LandType
+ route_prefix = "land_types"
+ url_prefix = "/land-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 = LandAsset
+ 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, land_type):
+ buttons = super().get_xref_buttons(land_type)
+
+ if land_type.farmos_uuid:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ "farmos_land_types.view", uuid=land_type.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+ 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
+ )
+
+ 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, land_asset, i):
+ return self.request.route_url("land_assets.view", uuid=land_asset.uuid)
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ LandTypeView = kwargs.get("LandTypeView", base["LandTypeView"])
+ LandTypeView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/log_types.py b/src/wuttafarm/web/views/log_types.py
new file mode 100644
index 0000000..13ea35f
--- /dev/null
+++ b/src/wuttafarm/web/views/log_types.py
@@ -0,0 +1,90 @@
+# -*- 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 Log Types
+"""
+
+from wuttafarm.db.model.logs import LogType
+from wuttafarm.web.views import WuttaFarmMasterView
+
+
+class LogTypeView(WuttaFarmMasterView):
+ """
+ Master view for Log Types
+ """
+
+ model_class = LogType
+ route_prefix = "log_types"
+ url_prefix = "/log-types"
+
+ grid_columns = [
+ "name",
+ "description",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "description",
+ "farmos_uuid",
+ "drupal_id",
+ ]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
+
+ def get_xref_buttons(self, log_type):
+ buttons = super().get_xref_buttons(log_type)
+
+ if log_type.farmos_uuid:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ "farmos_log_types.view", uuid=log_type.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"])
+ LogTypeView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/logs_activity.py b/src/wuttafarm/web/views/logs_activity.py
new file mode 100644
index 0000000..a2b2154
--- /dev/null
+++ b/src/wuttafarm/web/views/logs_activity.py
@@ -0,0 +1,105 @@
+# -*- 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 Activity Logs
+"""
+
+from wuttafarm.db.model.logs import ActivityLog
+from wuttafarm.web.views import WuttaFarmMasterView
+
+
+class ActivityLogView(WuttaFarmMasterView):
+ """
+ Master view for Activity Logs
+ """
+
+ model_class = ActivityLog
+ route_prefix = "activity_logs"
+ url_prefix = "/logs/activity"
+
+ farmos_refurl_path = "/logs/activity"
+
+ grid_columns = [
+ "message",
+ "timestamp",
+ "status",
+ ]
+
+ sort_defaults = ("timestamp", "desc")
+
+ filter_defaults = {
+ "message": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "message",
+ "timestamp",
+ "status",
+ "notes",
+ "farmos_uuid",
+ "drupal_id",
+ ]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # message
+ g.set_link("message")
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # notes
+ f.set_widget("notes", "notes")
+
+ def get_farmos_url(self, log):
+ return self.app.get_farmos_url(f"/log/{log.drupal_id}")
+
+ def get_xref_buttons(self, log):
+ buttons = super().get_xref_buttons(log)
+
+ if log.farmos_uuid:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ "farmos_logs_activity.view", uuid=log.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ ActivityLogView = kwargs.get("ActivityLogView", base["ActivityLogView"])
+ ActivityLogView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py
new file mode 100644
index 0000000..7ff165b
--- /dev/null
+++ b/src/wuttafarm/web/views/master.py
@@ -0,0 +1,70 @@
+# -*- 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_id": "Drupal ID",
+ "image_url": "Image URL",
+ }
+
+ row_labels = {
+ "farmos_uuid": "farmOS UUID",
+ "drupal_id": "Drupal ID",
+ "image_url": "Image URL",
+ }
+
+ 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 []
diff --git a/src/wuttafarm/web/views/structure_types.py b/src/wuttafarm/web/views/structure_types.py
new file mode 100644
index 0000000..ca85fb9
--- /dev/null
+++ b/src/wuttafarm/web/views/structure_types.py
@@ -0,0 +1,118 @@
+# -*- 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
new file mode 100644
index 0000000..df58fda
--- /dev/null
+++ b/src/wuttafarm/web/views/structures.py
@@ -0,0 +1,127 @@
+# -*- 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 Structures
+"""
+
+from wuttafarm.db.model.structures import Structure
+from wuttafarm.web.views import WuttaFarmMasterView
+from wuttafarm.web.forms.schema import StructureTypeRef
+from wuttafarm.web.forms.widgets import ImageWidget
+
+
+class StructureView(WuttaFarmMasterView):
+ """
+ Master view for Structures
+ """
+
+ model_class = Structure
+ route_prefix = "structures"
+ url_prefix = "/structures"
+
+ farmos_refurl_path = "/assets/structure"
+
+ grid_columns = [
+ "name",
+ "structure_type",
+ "is_location",
+ "is_fixed",
+ "active",
+ ]
+
+ sort_defaults = "name"
+
+ filter_defaults = {
+ "name": {"active": True, "verb": "contains"},
+ }
+
+ form_fields = [
+ "name",
+ "structure_type",
+ "is_location",
+ "is_fixed",
+ "notes",
+ "active",
+ "farmos_uuid",
+ "drupal_id",
+ "image_url",
+ "image",
+ ]
+
+ def configure_grid(self, grid):
+ g = grid
+ 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)
+ g.set_filter(
+ "structure_type", model.StructureType.name, label="Structure Type Name"
+ )
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ structure = form.model_instance
+
+ # 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)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/users.py b/src/wuttafarm/web/views/users.py
new file mode 100644
index 0000000..f35aef7
--- /dev/null
+++ b/src/wuttafarm/web/views/users.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 .
+#
+################################################################################
+"""
+Views for Users
+"""
+
+from wuttaweb.views import users as base
+
+
+class UserView(base.UserView):
+ """
+ Custom master view for Users.
+ """
+
+ labels = {
+ "farmos_uuid": "farmOS UUID",
+ "drupal_id": "Drupal ID",
+ }
+
+ def get_template_context(self, context):
+ context = super().get_template_context(context)
+
+ if self.listing:
+ context["farmos_refurl"] = self.app.get_farmos_url("/admin/people")
+
+ return context
+
+ def configure_form(self, form):
+ """ """
+ f = form
+ super().configure_form(f)
+ user = f.model_instance
+
+ # farmos_uuid
+ if not self.creating:
+ f.fields.append("farmos_uuid")
+ f.set_default("farmos_uuid", user.farmos_uuid)
+
+ # drupal_id
+ if not self.creating:
+ f.fields.append("drupal_id")
+ f.set_default("drupal_id", user.drupal_id)
+
+ def get_xref_buttons(self, user):
+ buttons = []
+
+ if user.drupal_id:
+ buttons.append(
+ self.make_button(
+ "View in farmOS",
+ primary=True,
+ url=self.app.get_farmos_url(f"/user/{user.drupal_id}"),
+ target="_blank",
+ icon_left="external-link-alt",
+ )
+ )
+
+ if user.farmos_uuid:
+ buttons.append(
+ self.make_button(
+ "View farmOS record",
+ primary=True,
+ url=self.request.route_url(
+ "farmos_users.view", uuid=user.farmos_uuid
+ ),
+ icon_left="eye",
+ )
+ )
+
+ return buttons
+
+
+def defaults(config, **kwargs):
+ local = globals()
+ UserView = kwargs.get("UserView", local["UserView"])
+ base.defaults(config, **{"UserView": UserView})
+
+
+def includeme(config):
+ defaults(config)