feat: add native table for Structures; import from farmOS API

This commit is contained in:
Lance Edgar 2026-02-13 10:45:38 -06:00
parent 1d898cb580
commit c38d00a7cc
11 changed files with 521 additions and 5 deletions

View file

@ -0,0 +1,136 @@
"""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_internal_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_internal_id", name=op.f("uq_structure_drupal_internal_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_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_structure_version")
),
)
op.create_index(
op.f("ix_structure_version_end_transaction_id"),
"structure_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_operation_type"),
"structure_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_structure_version_pk_transaction_id",
"structure_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_structure_version_pk_validity",
"structure_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_transaction_id"),
"structure_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# structure
op.drop_index(
op.f("ix_structure_version_transaction_id"), table_name="structure_version"
)
op.drop_index("ix_structure_version_pk_validity", table_name="structure_version")
op.drop_index(
"ix_structure_version_pk_transaction_id", table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_operation_type"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_end_transaction_id"), table_name="structure_version"
)
op.drop_table("structure_version")
op.drop_table("structure")

View file

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

View file

@ -72,3 +72,96 @@ class StructureType(model.Base):
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_internal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the structure.
""",
)
def __str__(self):
return self.name or ""

View file

@ -98,6 +98,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["LandType"] = LandTypeImporter
importers["LandAsset"] = LandAssetImporter
importers["StructureType"] = StructureTypeImporter
importers["Structure"] = StructureImporter
importers["AnimalType"] = AnimalTypeImporter
importers["LogType"] = LogTypeImporter
return importers
@ -305,6 +306,80 @@ class LogTypeImporter(FromFarmOS, ToWutta):
}
class StructureImporter(FromFarmOS, ToWutta):
"""
farmOS API WuttaFarm importer for Structures
"""
model_class = model.Structure
supported_fields = [
"farmos_uuid",
"drupal_internal_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_internal_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_internal_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

View file

@ -93,6 +93,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):

View file

@ -44,6 +44,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"title": "Assets",
"type": "menu",
"items": [
{
"title": "Structures",
"route": "structures",
"perm": "structures.list",
},
{
"title": "Land",
"route": "land_assets",

View file

@ -46,6 +46,7 @@ def includeme(config):
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.log_types")
# views for farmOS

View file

@ -186,17 +186,39 @@ 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_internal_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()

View file

@ -36,11 +36,13 @@ class WuttaFarmMasterView(MasterView):
labels = {
"farmos_uuid": "farmOS UUID",
"drupal_internal_id": "Drupal Internal ID",
"image_url": "Image URL",
}
row_labels = {
"farmos_uuid": "farmOS UUID",
"drupal_internal_id": "Drupal Internal ID",
"image_url": "Image URL",
}
def get_farmos_url(self, obj):

View file

@ -23,7 +23,7 @@
Master view for Structure Types
"""
from wuttafarm.db.model.structures import StructureType
from wuttafarm.db.model.structures import StructureType, Structure
from wuttafarm.web.views import WuttaFarmMasterView
@ -52,6 +52,19 @@ class StructureTypeView(WuttaFarmMasterView):
"drupal_internal_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)
@ -76,6 +89,23 @@ class StructureTypeView(WuttaFarmMasterView):
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()

View file

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