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..ede9b22 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py @@ -0,0 +1,136 @@ +"""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_internal_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_internal_id", name=op.f("uq_land_asset_drupal_internal_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_internal_id", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_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 4b5f558..3b6b479 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -31,7 +31,7 @@ from .users import WuttaFarmUser # wuttafarm proper models from .assets import AssetType -from .land import LandType +from .land import LandType, LandAsset from .structures import StructureType from .animals import AnimalType from .logs import LogType diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py index dc4f0f3..ca18d70 100644 --- a/src/wuttafarm/db/model/land.py +++ b/src/wuttafarm/db/model/land.py @@ -70,5 +70,87 @@ class LandType(model.Base): """, ) + 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_internal_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/importing/farmos.py b/src/wuttafarm/importing/farmos.py index b2f6997..232aab2 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -24,6 +24,7 @@ Data import for farmOS -> WuttaFarm """ import datetime +import logging from uuid import UUID from oauthlib.oauth2 import BackendApplicationClient @@ -34,6 +35,9 @@ 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. @@ -92,6 +96,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["User"] = UserImporter importers["AssetType"] = AssetTypeImporter importers["LandType"] = LandTypeImporter + importers["LandAsset"] = LandAssetImporter importers["StructureType"] = StructureTypeImporter importers["AnimalType"] = AnimalTypeImporter importers["LogType"] = LogTypeImporter @@ -188,6 +193,62 @@ class AssetTypeImporter(FromFarmOS, ToWutta): } +class LandAssetImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Land Assets + """ + + model_class = model.LandAsset + + supported_fields = [ + "farmos_uuid", + "drupal_internal_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_internal_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_internal_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 diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index a38588a..9085935 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -27,6 +27,8 @@ import json import colander +from wuttaweb.forms.schema import ObjectRef + class AnimalTypeType(colander.SchemaType): @@ -47,6 +49,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): diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 5bfee69..fa5c803 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -44,6 +44,12 @@ class WuttaFarmMenuHandler(base.MenuHandler): "title": "Assets", "type": "menu", "items": [ + { + "title": "Land", + "route": "land_assets", + "perm": "land_assets.list", + }, + {"type": "sep"}, { "title": "Animal Types", "route": "animal_types", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 412619c..78a917d 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -45,6 +45,7 @@ def includeme(config): 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.log_types") # views for farmOS diff --git a/src/wuttafarm/web/views/farmos/land_assets.py b/src/wuttafarm/web/views/farmos/land_assets.py index 5a8cc24..185c9d0 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", @@ -118,6 +120,7 @@ class LandAssetView(FarmOSMasterView): "uuid": land["id"], "drupal_internal_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"], @@ -158,6 +161,36 @@ class LandAssetView(FarmOSMasterView): ), ] + 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_internal_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/land_assets.py b/src/wuttafarm/web/views/land_assets.py new file mode 100644 index 0000000..d1025eb --- /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_internal_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_internal_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 index c9711a4..05a0644 100644 --- a/src/wuttafarm/web/views/land_types.py +++ b/src/wuttafarm/web/views/land_types.py @@ -23,7 +23,7 @@ Master view for Land Types """ -from wuttafarm.db.model.land import LandType +from wuttafarm.db.model.land import LandType, LandAsset from wuttafarm.web.views import WuttaFarmMasterView @@ -52,6 +52,19 @@ class LandTypeView(WuttaFarmMasterView): "drupal_internal_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) @@ -76,6 +89,23 @@ class LandTypeView(WuttaFarmMasterView): 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() diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 69a7f89..4242cd1 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -38,6 +38,11 @@ class WuttaFarmMasterView(MasterView): "drupal_internal_id": "Drupal Internal ID", } + row_labels = { + "farmos_uuid": "farmOS UUID", + "drupal_internal_id": "Drupal Internal ID", + } + def get_farmos_url(self, obj): return None