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