feat: add native table for Land Assets; import from farmOS API

This commit is contained in:
Lance Edgar 2026-02-10 20:15:44 -06:00
parent 6204db8ae3
commit 1d898cb580
11 changed files with 500 additions and 2 deletions

View file

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

View file

@ -31,7 +31,7 @@ from .users import WuttaFarmUser
# wuttafarm proper models # wuttafarm proper models
from .assets import AssetType from .assets import AssetType
from .land import LandType from .land import LandType, LandAsset
from .structures import StructureType from .structures import StructureType
from .animals import AnimalType from .animals import AnimalType
from .logs import LogType from .logs import LogType

View file

@ -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): def __str__(self):
return self.name or "" return self.name or ""

View file

@ -24,6 +24,7 @@ Data import for farmOS -> WuttaFarm
""" """
import datetime import datetime
import logging
from uuid import UUID from uuid import UUID
from oauthlib.oauth2 import BackendApplicationClient from oauthlib.oauth2 import BackendApplicationClient
@ -34,6 +35,9 @@ from wuttasync.importing import ImportHandler, ToWuttaHandler, Importer, ToWutta
from wuttafarm.db import model from wuttafarm.db import model
log = logging.getLogger(__name__)
class FromFarmOSHandler(ImportHandler): class FromFarmOSHandler(ImportHandler):
""" """
Base class for import handler using farmOS API as data source. Base class for import handler using farmOS API as data source.
@ -92,6 +96,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["User"] = UserImporter importers["User"] = UserImporter
importers["AssetType"] = AssetTypeImporter importers["AssetType"] = AssetTypeImporter
importers["LandType"] = LandTypeImporter importers["LandType"] = LandTypeImporter
importers["LandAsset"] = LandAssetImporter
importers["StructureType"] = StructureTypeImporter importers["StructureType"] = StructureTypeImporter
importers["AnimalType"] = AnimalTypeImporter importers["AnimalType"] = AnimalTypeImporter
importers["LogType"] = LogTypeImporter 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): class LandTypeImporter(FromFarmOS, ToWutta):
""" """
farmOS API WuttaFarm importer for Land Types farmOS API WuttaFarm importer for Land Types

View file

@ -27,6 +27,8 @@ import json
import colander import colander
from wuttaweb.forms.schema import ObjectRef
class AnimalTypeType(colander.SchemaType): class AnimalTypeType(colander.SchemaType):
@ -47,6 +49,31 @@ class AnimalTypeType(colander.SchemaType):
return AnimalTypeWidget(self.request, **kwargs) return AnimalTypeWidget(self.request, **kwargs)
class LandTypeRef(ObjectRef):
"""
Custom schema type for a
:class:`~wuttafarm.db.model.land.LandType` reference field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self): # pylint: disable=empty-docstring
""" """
model = self.app.model
return model.LandType
def sort_query(self, query): # pylint: disable=empty-docstring
""" """
return query.order_by(self.model_class.name)
def get_object_url(self, obj): # pylint: disable=empty-docstring
""" """
land_type = obj
return self.request.route_url("land_types.view", uuid=land_type.uuid)
class StructureType(colander.SchemaType): class StructureType(colander.SchemaType):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):

View file

@ -44,6 +44,12 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"title": "Assets", "title": "Assets",
"type": "menu", "type": "menu",
"items": [ "items": [
{
"title": "Land",
"route": "land_assets",
"perm": "land_assets.list",
},
{"type": "sep"},
{ {
"title": "Animal Types", "title": "Animal Types",
"route": "animal_types", "route": "animal_types",

View file

@ -45,6 +45,7 @@ def includeme(config):
config.include("wuttafarm.web.views.land_types") config.include("wuttafarm.web.views.land_types")
config.include("wuttafarm.web.views.structure_types") config.include("wuttafarm.web.views.structure_types")
config.include("wuttafarm.web.views.animal_types") config.include("wuttafarm.web.views.animal_types")
config.include("wuttafarm.web.views.land_assets")
config.include("wuttafarm.web.views.log_types") config.include("wuttafarm.web.views.log_types")
# views for farmOS # views for farmOS

View file

@ -49,6 +49,7 @@ class LandAssetView(FarmOSMasterView):
grid_columns = [ grid_columns = [
"name", "name",
"land_type",
"is_fixed", "is_fixed",
"is_location", "is_location",
"status", "status",
@ -59,6 +60,7 @@ class LandAssetView(FarmOSMasterView):
form_fields = [ form_fields = [
"name", "name",
"land_type",
"is_fixed", "is_fixed",
"is_location", "is_location",
"status", "status",
@ -118,6 +120,7 @@ class LandAssetView(FarmOSMasterView):
"uuid": land["id"], "uuid": land["id"],
"drupal_internal_id": land["attributes"]["drupal_internal__id"], "drupal_internal_id": land["attributes"]["drupal_internal__id"],
"name": land["attributes"]["name"], "name": land["attributes"]["name"],
"land_type": land["attributes"]["land_type"],
"created": created, "created": created,
"changed": changed, "changed": changed,
"is_fixed": land["attributes"]["is_fixed"], "is_fixed": land["attributes"]["is_fixed"],
@ -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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -0,0 +1,117 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Land Assets
"""
from wuttafarm.db.model.land import LandAsset
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.web.forms.schema import LandTypeRef
class LandAssetView(WuttaFarmMasterView):
"""
Master view for Land Assets
"""
model_class = LandAsset
route_prefix = "land_assets"
url_prefix = "/land-assets"
farmos_refurl_path = "/assets/land"
grid_columns = [
"name",
"land_type",
"is_location",
"is_fixed",
"notes",
"active",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"land_type",
"is_location",
"is_fixed",
"notes",
"active",
"farmos_uuid",
"drupal_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)

View file

@ -23,7 +23,7 @@
Master view for Land Types 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 from wuttafarm.web.views import WuttaFarmMasterView
@ -52,6 +52,19 @@ class LandTypeView(WuttaFarmMasterView):
"drupal_internal_id", "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): def configure_grid(self, grid):
g = grid g = grid
super().configure_grid(g) super().configure_grid(g)
@ -76,6 +89,23 @@ class LandTypeView(WuttaFarmMasterView):
return buttons 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -38,6 +38,11 @@ class WuttaFarmMasterView(MasterView):
"drupal_internal_id": "Drupal Internal ID", "drupal_internal_id": "Drupal Internal ID",
} }
row_labels = {
"farmos_uuid": "farmOS UUID",
"drupal_internal_id": "Drupal Internal ID",
}
def get_farmos_url(self, obj): def get_farmos_url(self, obj):
return None return None