Compare commits

..

19 commits

Author SHA1 Message Date
34cb6b210d bump: version 0.2.3 → 0.3.0 2026-02-13 15:51:52 -06:00
061dac39f9 docs: add basic docs for oauth2 setup, import data from farmOS 2026-02-13 15:50:32 -06:00
be64b4959a docs: add basic docs for CLI commands 2026-02-13 15:18:53 -06:00
311a2c328b fix: always make 'farmos' system user in app setup
mainly for sake of attributing data changes coming from farmOS
2026-02-13 15:11:10 -06:00
935c64464a fix: avoid error for Create User form 2026-02-13 15:07:48 -06:00
1dbf14f3bb fix: add more perms to Site Admin role in app setup 2026-02-13 15:07:48 -06:00
ed768a83d0 feat: add native table for Activity Logs; import from farmOS API 2026-02-13 14:53:02 -06:00
f4e4c3efb3 fix: rename drupal_internal_id => drupal_id 2026-02-13 14:53:02 -06:00
81daa5d913 feat: add native table for Groups; import from farmOS API 2026-02-13 14:53:02 -06:00
3e5ca3483e feat: add native table for Animals; import from farmOS API 2026-02-13 14:52:58 -06:00
c38d00a7cc feat: add native table for Structures; import from farmOS API 2026-02-13 12:28:54 -06:00
1d898cb580 feat: add native table for Land Assets; import from farmOS API 2026-02-13 10:43:34 -06:00
6204db8ae3 feat: add native table for Log Types; import from farmOS API 2026-02-10 19:51:08 -06:00
5189c12f43 feat: add native table for Structure Types; import from farmOS API 2026-02-10 19:43:20 -06:00
b573ae459e feat: add native table for Land Types; import from farmOS API 2026-02-10 19:43:18 -06:00
10666de488 feat: add native table for Asset Types; import from farmOS API 2026-02-10 19:21:01 -06:00
fd2f09fcf3 feat: add extension table for Users; import from farmOS API 2026-02-10 19:21:01 -06:00
4a517bf7bf feat: add native table for Animal Types; import from farmOS API 2026-02-10 19:20:59 -06:00
09042747a0 feat: add "See raw JSON data" button for farmOS API views 2026-02-10 18:30:35 -06:00
69 changed files with 5095 additions and 46 deletions

View file

@ -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/) 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). 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) ## v0.2.3 (2026-02-08)
### Fix ### Fix

View file

@ -0,0 +1,6 @@
``wuttafarm.cli.base``
======================
.. automodule:: wuttafarm.cli.base
:members:

View file

@ -0,0 +1,6 @@
``wuttafarm.cli.import_farmos``
===============================
.. automodule:: wuttafarm.cli.import_farmos
:members:

View file

@ -0,0 +1,6 @@
``wuttafarm.cli.install``
=========================
.. automodule:: wuttafarm.cli.install
:members:

View file

@ -0,0 +1,6 @@
``wuttafarm.importing.farmos``
==============================
.. automodule:: wuttafarm.importing.farmos
:members:

View file

@ -0,0 +1,6 @@
``wuttafarm.importing``
=======================
.. automodule:: wuttafarm.importing
:members:

View file

@ -21,6 +21,7 @@ extensions = [
"sphinx.ext.intersphinx", "sphinx.ext.intersphinx",
"sphinx.ext.viewcode", "sphinx.ext.viewcode",
"sphinx.ext.todo", "sphinx.ext.todo",
"sphinxcontrib.programoutput",
] ]
templates_path = ["_templates"] templates_path = ["_templates"]

View file

@ -8,9 +8,6 @@ and extend `farmOS`_.
.. _WuttaWeb: https://wuttaproject.org .. _WuttaWeb: https://wuttaproject.org
.. _farmOS: https://farmos.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 It is just an experiment so far; the ideas I hope to play with
include: include:
@ -19,6 +16,9 @@ include:
- possibly add more schema / extra features - possibly add more schema / extra features
- possibly sync data back to farmOS - possibly sync data back to farmOS
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
:target: https://github.com/psf/black
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
@ -27,6 +27,7 @@ include:
narr/install narr/install
narr/auth narr/auth
narr/features narr/features
narr/cli
.. toctree:: .. toctree::
@ -37,11 +38,16 @@ include:
api/wuttafarm.app api/wuttafarm.app
api/wuttafarm.auth api/wuttafarm.auth
api/wuttafarm.cli api/wuttafarm.cli
api/wuttafarm.cli.base
api/wuttafarm.cli.import_farmos
api/wuttafarm.cli.install
api/wuttafarm.config api/wuttafarm.config
api/wuttafarm.db api/wuttafarm.db
api/wuttafarm.db.model api/wuttafarm.db.model
api/wuttafarm.farmos api/wuttafarm.farmos
api/wuttafarm.farmos.handler api/wuttafarm.farmos.handler
api/wuttafarm.importing
api/wuttafarm.importing.farmos
api/wuttafarm.install api/wuttafarm.install
api/wuttafarm.web api/wuttafarm.web
api/wuttafarm.web.app api/wuttafarm.web.app

View file

@ -36,7 +36,13 @@ browse farmOS data within the WuttaFarm views.
If you login to WuttaFarm directly with username/password, then If you login to WuttaFarm directly with username/password, then
your user session will not have a farmOS access token and so the 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 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 will initiate the OAuth2 workflow, at which point you may be asked to

39
docs/narr/cli.rst Normal file
View file

@ -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

View file

@ -14,6 +14,10 @@ Here is the list of features currently supported:
* performance isn't bad, but data is not very "complete" * performance isn't bad, but data is not very "complete"
* more data could be fetched, but not sure this is the best way..? * 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 Screenshots
----------- -----------

View file

@ -60,3 +60,93 @@ are encouraged to enable it anyway.
When the installer completes it will output a command you can then use 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 to run the web app. Do that and you can then view the app in a
browser at http://localhost:9080 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

View file

@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaFarm" name = "WuttaFarm"
version = "0.2.3" version = "0.3.0"
description = "Web app to integrate with and extend farmOS" description = "Web app to integrate with and extend farmOS"
readme = "README.md" readme = "README.md"
authors = [ authors = [
@ -33,12 +33,13 @@ dependencies = [
"psycopg2", "psycopg2",
"pyramid_exclog", "pyramid_exclog",
"uvicorn[standard]", "uvicorn[standard]",
"WuttaSync",
"WuttaWeb[continuum]>=0.27.4", "WuttaWeb[continuum]>=0.27.4",
] ]
[project.optional-dependencies] [project.optional-dependencies]
docs = ["Sphinx", "furo"] docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"]
[project.scripts] [project.scripts]
@ -47,12 +48,18 @@ docs = ["Sphinx", "furo"]
[project.entry-points."paste.app_factory"] [project.entry-points."paste.app_factory"]
"main" = "wuttafarm.web.app:main" "main" = "wuttafarm.web.app:main"
[project.entry-points."wutta.app.providers"]
wuttafarm = "wuttafarm.app:WuttaFarmAppProvider"
[project.entry-points."wutta.config.extensions"] [project.entry-points."wutta.config.extensions"]
"wuttafarm" = "wuttafarm.config:WuttaFarmConfig" "wuttafarm" = "wuttafarm.config:WuttaFarmConfig"
[project.entry-points."wutta.web.menus"] [project.entry-points."wutta.web.menus"]
"wuttafarm" = "wuttafarm.web.menus:WuttaFarmMenuHandler" "wuttafarm" = "wuttafarm.web.menus:WuttaFarmMenuHandler"
[project.entry-points."wuttasync.importing"]
"import.to_wuttafarm.from_farmos" = "wuttafarm.importing.farmos:FromFarmOSToWuttaFarm"
[project.urls] [project.urls]
Homepage = "https://forgejo.wuttaproject.org/wutta/wuttafarm" Homepage = "https://forgejo.wuttaproject.org/wutta/wuttafarm"

View file

@ -64,3 +64,11 @@ class WuttaFarmAppHandler(base.AppHandler):
""" """
handler = self.get_farmos_handler() handler = self.get_farmos_handler()
return handler.get_farmos_client(*args, **kwargs) return handler.get_farmos_client(*args, **kwargs)
class WuttaFarmAppProvider(base.AppProvider):
"""
The :term:`app provider` for WuttaFarm.
"""
email_modules = ["wuttafarm.emails"]

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttaFarm CLI
"""
from .base import wuttafarm_typer
# nb. must bring in all modules for discovery to work
from . import import_farmos
from . import install

31
src/wuttafarm/cli/base.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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"
)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -25,12 +25,7 @@ WuttaFarm CLI
import typer import typer
from wuttjamaican.cli import make_typer from wuttafarm.cli import wuttafarm_typer
wuttafarm_typer = make_typer(
name="wuttafarm", help="WuttaFarm -- Web app to integrate with and extend farmOS"
)
@wuttafarm_typer.command() @wuttafarm_typer.command()

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -26,4 +26,13 @@ WuttaFarm data models
# bring in all of wutta # bring in all of wutta
from wuttjamaican.db.model import * 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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 ""

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 ""

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 ""

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 ""

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 ""

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 ""

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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")

32
src/wuttafarm/emails.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Email sending config for WuttaFarm
"""
from wuttasync.emails import ImportExportWarning
class import_to_wuttafarm_from_farmos_warning(ImportExportWarning):
"""
Diff warning for farmOS WuttaFarm import.
"""

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Importing data to WuttaFarm
"""

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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

View file

@ -27,6 +27,33 @@ import json
import colander 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): class AnimalTypeType(colander.SchemaType):
@ -47,6 +74,31 @@ class AnimalTypeType(colander.SchemaType):
return AnimalTypeWidget(self.request, **kwargs) 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): class StructureType(colander.SchemaType):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
@ -66,6 +118,31 @@ class StructureType(colander.SchemaType):
return StructureWidget(self.request, **kwargs) 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): class UsersType(colander.SchemaType):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):

View file

@ -33,10 +33,80 @@ class WuttaFarmMenuHandler(base.MenuHandler):
def make_menus(self, request, **kwargs): def make_menus(self, request, **kwargs):
return [ return [
self.make_asset_menu(request),
self.make_log_menu(request),
self.make_farmos_menu(request), self.make_farmos_menu(request),
self.make_admin_menu(request, include_people=True), 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): def make_farmos_menu(self, request):
config = request.wutta_config config = request.wutta_config
app = config.get_app() app = config.get_app()

View file

@ -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:
<wutta-tool-panel heading="Tools">
<b-button type="is-primary"
icon-pack="fas"
icon-left="code"
@click="viewJsonShowDialog = true">
See raw JSON data
</b-button>
</wutta-tool-panel>
<${b}-modal :width="1200"
% if request.use_oruga:
v-model:active="viewJsonShowDialog"
% else:
:active.sync="viewJsonShowDialog"
% endif
>
<div class="card">
<div class="card-content">
${rendered_json|n}
</div>
</div>
</${b}-modal>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if raw_json:
<script>
ThisPageData.viewJsonShowDialog = false
</script>
% endif
</%def>

View file

@ -25,6 +25,8 @@ WuttaFarm Views
from wuttaweb.views import essential from wuttaweb.views import essential
from .master import WuttaFarmMasterView
def includeme(config): def includeme(config):
@ -34,8 +36,21 @@ def includeme(config):
**{ **{
"wuttaweb.views.auth": "wuttafarm.web.views.auth", "wuttaweb.views.auth": "wuttafarm.web.views.auth",
"wuttaweb.views.common": "wuttafarm.web.views.common", "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 # views for farmOS
config.include("wuttafarm.web.views.farmos") config.include("wuttafarm.web.views.farmos")

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Animal Types
"""
from wuttafarm.db.model.animals import AnimalType, Animal
from wuttafarm.web.views import WuttaFarmMasterView
class AnimalTypeView(WuttaFarmMasterView):
"""
Master view for Animal Types
"""
model_class = AnimalType
route_prefix = "animal_types"
url_prefix = "/animal-types"
farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview"
grid_columns = [
"name",
"description",
"changed",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"description",
"changed",
"farmos_uuid",
"drupal_id",
]
has_rows = True
row_model_class = Animal
rows_viewable = True
row_grid_columns = [
"name",
"sex",
"is_sterile",
"birthdate",
"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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -45,9 +45,24 @@ class CommonView(base.CommonView):
farm_viewer = auth.get_role_farm_viewer(session) farm_viewer = auth.get_role_farm_viewer(session)
farm_viewer.notes = "this is meant to mirror the corresponding role in farmOS" 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() site_admin = session.query(model.Role).filter_by(name="Site Admin").first()
if site_admin: if site_admin:
site_admin_perms = [ 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.list",
"farmos_animal_types.view", "farmos_animal_types.view",
"farmos_animals.list", "farmos_animals.list",
@ -70,6 +85,24 @@ class CommonView(base.CommonView):
"farmos_structures.view", "farmos_structures.view",
"farmos_users.list", "farmos_users.list",
"farmos_users.view", "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: for perm in site_admin_perms:
auth.grant_permission(site_admin, perm) auth.grant_permission(site_admin, perm)

View file

@ -79,6 +79,7 @@ class AnimalTypeView(FarmOSMasterView):
animal_type = self.farmos_client.resource.get_id( animal_type = self.farmos_client.resource.get_id(
"taxonomy_term", "animal_type", self.request.matchdict["uuid"] "taxonomy_term", "animal_type", self.request.matchdict["uuid"]
) )
self.raw_json = animal_type
return self.normalize_animal_type(animal_type["data"]) return self.normalize_animal_type(animal_type["data"])
def get_instance_title(self, animal_type): def get_instance_title(self, animal_type):
@ -95,7 +96,7 @@ class AnimalTypeView(FarmOSMasterView):
return { return {
"uuid": animal_type["id"], "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"], "name": animal_type["attributes"]["name"],
"description": description or colander.null, "description": description or colander.null,
"changed": changed, "changed": changed,
@ -112,18 +113,39 @@ class AnimalTypeView(FarmOSMasterView):
f.set_node("changed", WuttaDateTime()) f.set_node("changed", WuttaDateTime())
def get_xref_buttons(self, animal_type): def get_xref_buttons(self, animal_type):
return [ model = self.app.model
session = self.Session()
buttons = [
self.make_button( self.make_button(
"View in farmOS", "View in farmOS",
primary=True, primary=True,
url=self.app.get_farmos_url( url=self.app.get_farmos_url(
f"/taxonomy/term/{animal_type['drupal_internal_id']}" f"/taxonomy/term/{animal_type['drupal_id']}"
), ),
target="_blank", target="_blank",
icon_left="external-link-alt", 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -27,8 +27,10 @@ import datetime
import colander 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.schema import UsersType, AnimalTypeType, StructureType
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
@ -99,6 +101,7 @@ class AnimalView(FarmOSMasterView):
animal = self.farmos_client.resource.get_id( animal = self.farmos_client.resource.get_id(
"asset", "animal", self.request.matchdict["uuid"] "asset", "animal", self.request.matchdict["uuid"]
) )
self.raw_json = animal
# instance data # instance data
data = self.normalize_animal(animal["data"]) data = self.normalize_animal(animal["data"])
@ -172,7 +175,7 @@ class AnimalView(FarmOSMasterView):
return { return {
"uuid": animal["id"], "uuid": animal["id"],
"drupal_internal_id": animal["attributes"]["drupal_internal__id"], "drupal_id": animal["attributes"]["drupal_internal__id"],
"name": animal["attributes"]["name"], "name": animal["attributes"]["name"],
"birthdate": birthdate, "birthdate": birthdate,
"sex": animal["attributes"]["sex"], "sex": animal["attributes"]["sex"],
@ -190,6 +193,10 @@ class AnimalView(FarmOSMasterView):
# animal_type # animal_type
f.set_node("animal_type", AnimalTypeType(self.request)) f.set_node("animal_type", AnimalTypeType(self.request))
# birthdate
f.set_node("birthdate", WuttaDateTime())
f.set_widget("birthdate", WuttaDateTimeWidget(self.request))
# is_castrated # is_castrated
f.set_node("is_castrated", colander.Boolean()) f.set_node("is_castrated", colander.Boolean())
@ -208,16 +215,35 @@ class AnimalView(FarmOSMasterView):
f.set_default("image", url) f.set_default("image", url)
def get_xref_buttons(self, animal): def get_xref_buttons(self, animal):
return [ model = self.app.model
session = self.Session()
buttons = [
self.make_button( self.make_button(
"View in farmOS", "View in farmOS",
primary=True, 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", target="_blank",
icon_left="external-link-alt", 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -69,6 +69,7 @@ class AssetTypeView(FarmOSMasterView):
asset_type = self.farmos_client.resource.get_id( asset_type = self.farmos_client.resource.get_id(
"asset_type", "asset_type", self.request.matchdict["uuid"] "asset_type", "asset_type", self.request.matchdict["uuid"]
) )
self.raw_json = asset_type
return self.normalize_asset_type(asset_type["data"]) return self.normalize_asset_type(asset_type["data"])
def get_instance_title(self, asset_type): def get_instance_title(self, asset_type):
@ -77,7 +78,7 @@ class AssetTypeView(FarmOSMasterView):
def normalize_asset_type(self, asset_type): def normalize_asset_type(self, asset_type):
return { return {
"uuid": asset_type["id"], "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"], "label": asset_type["attributes"]["label"],
"description": asset_type["attributes"]["description"], "description": asset_type["attributes"]["description"],
} }
@ -89,6 +90,29 @@ class AssetTypeView(FarmOSMasterView):
# description # description
f.set_widget("description", "notes") 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -88,11 +88,10 @@ class GroupView(FarmOSMasterView):
g.set_renderer("changed", "datetime") g.set_renderer("changed", "datetime")
def get_instance(self): def get_instance(self):
group = self.farmos_client.resource.get_id( group = self.farmos_client.resource.get_id(
"asset", "group", self.request.matchdict["uuid"] "asset", "group", self.request.matchdict["uuid"]
) )
self.raw_json = group
return self.normalize_group(group["data"]) return self.normalize_group(group["data"])
def get_instance_title(self, group): def get_instance_title(self, group):
@ -110,7 +109,7 @@ class GroupView(FarmOSMasterView):
return { return {
"uuid": group["id"], "uuid": group["id"],
"drupal_internal_id": group["attributes"]["drupal_internal__id"], "drupal_id": group["attributes"]["drupal_internal__id"],
"name": group["attributes"]["name"], "name": group["attributes"]["name"],
"created": created, "created": created,
"changed": changed, "changed": changed,
@ -142,16 +141,35 @@ class GroupView(FarmOSMasterView):
f.set_widget("changed", WuttaDateTimeWidget(self.request)) f.set_widget("changed", WuttaDateTimeWidget(self.request))
def get_xref_buttons(self, group): def get_xref_buttons(self, group):
return [ model = self.app.model
session = self.Session()
buttons = [
self.make_button( self.make_button(
"View in farmOS", "View in farmOS",
primary=True, 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", target="_blank",
icon_left="external-link-alt", 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -49,6 +49,7 @@ class LandAssetView(FarmOSMasterView):
grid_columns = [ grid_columns = [
"name", "name",
"land_type",
"is_fixed", "is_fixed",
"is_location", "is_location",
"status", "status",
@ -59,6 +60,7 @@ class LandAssetView(FarmOSMasterView):
form_fields = [ form_fields = [
"name", "name",
"land_type",
"is_fixed", "is_fixed",
"is_location", "is_location",
"status", "status",
@ -95,6 +97,7 @@ class LandAssetView(FarmOSMasterView):
land_asset = self.farmos_client.resource.get_id( land_asset = self.farmos_client.resource.get_id(
"asset", "land", self.request.matchdict["uuid"] "asset", "land", self.request.matchdict["uuid"]
) )
self.raw_json = land_asset
return self.normalize_land_asset(land_asset["data"]) return self.normalize_land_asset(land_asset["data"])
def get_instance_title(self, land_asset): def get_instance_title(self, land_asset):
@ -115,8 +118,9 @@ class LandAssetView(FarmOSMasterView):
return { return {
"uuid": land["id"], "uuid": land["id"],
"drupal_internal_id": land["attributes"]["drupal_internal__id"], "drupal_id": land["attributes"]["drupal_internal__id"],
"name": land["attributes"]["name"], "name": land["attributes"]["name"],
"land_type": land["attributes"]["land_type"],
"created": created, "created": created,
"changed": changed, "changed": changed,
"is_fixed": land["attributes"]["is_fixed"], "is_fixed": land["attributes"]["is_fixed"],
@ -151,12 +155,42 @@ class LandAssetView(FarmOSMasterView):
self.make_button( self.make_button(
"View in farmOS", "View in farmOS",
primary=True, 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", target="_blank",
icon_left="external-link-alt", 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -64,6 +64,7 @@ class LandTypeView(FarmOSMasterView):
land_type = self.farmos_client.resource.get_id( land_type = self.farmos_client.resource.get_id(
"land_type", "land_type", self.request.matchdict["uuid"] "land_type", "land_type", self.request.matchdict["uuid"]
) )
self.raw_json = land_type
return self.normalize_land_type(land_type["data"]) return self.normalize_land_type(land_type["data"])
def get_instance_title(self, land_type): def get_instance_title(self, land_type):
@ -72,10 +73,33 @@ class LandTypeView(FarmOSMasterView):
def normalize_land_type(self, land_type): def normalize_land_type(self, land_type):
return { return {
"uuid": land_type["id"], "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"], "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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -66,6 +66,7 @@ class LogTypeView(FarmOSMasterView):
log_type = self.farmos_client.resource.get_id( log_type = self.farmos_client.resource.get_id(
"log_type", "log_type", self.request.matchdict["uuid"] "log_type", "log_type", self.request.matchdict["uuid"]
) )
self.raw_json = log_type
return self.normalize_log_type(log_type["data"]) return self.normalize_log_type(log_type["data"])
def get_instance_title(self, log_type): def get_instance_title(self, log_type):
@ -74,7 +75,7 @@ class LogTypeView(FarmOSMasterView):
def normalize_log_type(self, log_type): def normalize_log_type(self, log_type):
return { return {
"uuid": log_type["id"], "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"], "label": log_type["attributes"]["label"],
"description": log_type["attributes"]["description"], "description": log_type["attributes"]["description"],
} }
@ -86,6 +87,27 @@ class LogTypeView(FarmOSMasterView):
# description # description
f.set_widget("description", "notes") 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -79,6 +79,7 @@ class ActivityLogView(FarmOSMasterView):
def get_instance(self): def get_instance(self):
log = self.farmos_client.log.get_id("activity", self.request.matchdict["uuid"]) log = self.farmos_client.log.get_id("activity", self.request.matchdict["uuid"])
self.raw_json = log
return self.normalize_log(log["data"]) return self.normalize_log(log["data"])
def get_instance_title(self, log): def get_instance_title(self, log):
@ -95,7 +96,7 @@ class ActivityLogView(FarmOSMasterView):
return { return {
"uuid": log["id"], "uuid": log["id"],
"drupal_internal_id": log["attributes"]["drupal_internal__id"], "drupal_id": log["attributes"]["drupal_internal__id"],
"name": log["attributes"]["name"], "name": log["attributes"]["name"],
"timestamp": timestamp, "timestamp": timestamp,
"status": log["attributes"]["status"], "status": log["attributes"]["status"],
@ -114,16 +115,35 @@ class ActivityLogView(FarmOSMasterView):
f.set_widget("notes", "notes") f.set_widget("notes", "notes")
def get_xref_buttons(self, log): def get_xref_buttons(self, log):
return [ model = self.app.model
session = self.Session()
buttons = [
self.make_button( self.make_button(
"View in farmOS", "View in farmOS",
primary=True, 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", target="_blank",
icon_left="external-link-alt", 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -23,6 +23,10 @@
Base class for farmOS master views Base class for farmOS master views
""" """
import json
import markdown
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttafarm.web.util import save_farmos_oauth2_token from wuttafarm.web.util import save_farmos_oauth2_token
@ -54,6 +58,7 @@ class FarmOSMasterView(MasterView):
def __init__(self, request, context=None): def __init__(self, request, context=None):
super().__init__(request, context=context) super().__init__(request, context=context)
self.farmos_client = self.get_farmos_client() self.farmos_client = self.get_farmos_client()
self.raw_json = None
def get_farmos_client(self): def get_farmos_client(self):
token = self.request.session.get("farmos.oauth2.token") 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) 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): def get_template_context(self, context):
if self.listing and self.farmos_refurl_path: if self.listing and self.farmos_refurl_path:
context["farmos_refurl"] = self.app.get_farmos_url(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 return context

View file

@ -66,6 +66,7 @@ class StructureTypeView(FarmOSMasterView):
structure_type = self.farmos_client.resource.get_id( structure_type = self.farmos_client.resource.get_id(
"structure_type", "structure_type", self.request.matchdict["uuid"] "structure_type", "structure_type", self.request.matchdict["uuid"]
) )
self.raw_json = structure_type
return self.normalize_structure_type(structure_type["data"]) return self.normalize_structure_type(structure_type["data"])
def get_instance_title(self, structure_type): def get_instance_title(self, structure_type):
@ -74,10 +75,33 @@ class StructureTypeView(FarmOSMasterView):
def normalize_structure_type(self, structure_type): def normalize_structure_type(self, structure_type):
return { return {
"uuid": structure_type["id"], "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"], "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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -94,7 +94,7 @@ class StructureView(FarmOSMasterView):
structure = self.farmos_client.resource.get_id( structure = self.farmos_client.resource.get_id(
"asset", "structure", self.request.matchdict["uuid"] "asset", "structure", self.request.matchdict["uuid"]
) )
self.raw_json = structure
data = self.normalize_structure(structure["data"]) data = self.normalize_structure(structure["data"])
if relationships := structure["data"].get("relationships"): if relationships := structure["data"].get("relationships"):
@ -147,7 +147,7 @@ class StructureView(FarmOSMasterView):
return { return {
"uuid": structure["id"], "uuid": structure["id"],
"drupal_internal_id": structure["attributes"]["drupal_internal__id"], "drupal_id": structure["attributes"]["drupal_internal__id"],
"name": structure["attributes"]["name"], "name": structure["attributes"]["name"],
"structure_type": structure["attributes"]["structure_type"], "structure_type": structure["attributes"]["structure_type"],
"is_fixed": structure["attributes"]["is_fixed"], "is_fixed": structure["attributes"]["is_fixed"],
@ -186,17 +186,37 @@ class StructureView(FarmOSMasterView):
f.set_default("image", url) f.set_default("image", url)
def get_xref_buttons(self, structure): def get_xref_buttons(self, structure):
drupal_id = structure["drupal_internal_id"] model = self.app.model
return [ session = self.Session()
buttons = [
self.make_button( self.make_button(
"View in farmOS", "View in farmOS",
primary=True, 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", target="_blank",
icon_left="external-link-alt", 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -77,6 +77,7 @@ class UserView(FarmOSMasterView):
user = self.farmos_client.resource.get_id( user = self.farmos_client.resource.get_id(
"user", "user", self.request.matchdict["uuid"] "user", "user", self.request.matchdict["uuid"]
) )
self.raw_json = user
return self.normalize_user(user["data"]) return self.normalize_user(user["data"])
def get_instance_title(self, user): def get_instance_title(self, user):
@ -94,7 +95,7 @@ class UserView(FarmOSMasterView):
return { return {
"uuid": user["id"], "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"], "display_name": user["attributes"]["display_name"],
"name": user["attributes"].get("name") or colander.null, "name": user["attributes"].get("name") or colander.null,
"mail": user["attributes"].get("mail") or colander.null, "mail": user["attributes"].get("mail") or colander.null,
@ -115,17 +116,36 @@ class UserView(FarmOSMasterView):
f.set_node("changed", WuttaDateTime()) f.set_node("changed", WuttaDateTime())
def get_xref_buttons(self, user): def get_xref_buttons(self, user):
if drupal_id := user["drupal_internal_id"]: model = self.app.model
return [ session = self.Session()
buttons = []
if drupal_id := user["drupal_id"]:
buttons.append(
self.make_button( self.make_button(
"View in farmOS", "View in farmOS",
primary=True, primary=True,
url=self.app.get_farmos_url(f"/user/{drupal_id}"), url=self.app.get_farmos_url(f"/user/{drupal_id}"),
target="_blank", target="_blank",
icon_left="external-link-alt", 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): def defaults(config, **kwargs):

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 []

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)