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

This commit is contained in:
Lance Edgar 2026-02-13 12:28:41 -06:00
parent c38d00a7cc
commit 3e5ca3483e
10 changed files with 537 additions and 4 deletions

View file

@ -0,0 +1,131 @@
"""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_internal_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_internal_id", name=op.f("uq_animal_drupal_internal_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_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_animal_version")
),
)
op.create_index(
op.f("ix_animal_version_end_transaction_id"),
"animal_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_operation_type"),
"animal_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_animal_version_pk_transaction_id",
"animal_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_animal_version_pk_validity",
"animal_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_transaction_id"),
"animal_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# animal
op.drop_index(op.f("ix_animal_version_transaction_id"), table_name="animal_version")
op.drop_index("ix_animal_version_pk_validity", table_name="animal_version")
op.drop_index("ix_animal_version_pk_transaction_id", table_name="animal_version")
op.drop_index(op.f("ix_animal_version_operation_type"), table_name="animal_version")
op.drop_index(
op.f("ix_animal_version_end_transaction_id"), table_name="animal_version"
)
op.drop_table("animal_version")
op.drop_table("animal")

View file

@ -33,5 +33,5 @@ from .users import WuttaFarmUser
from .assets import AssetType from .assets import AssetType
from .land import LandType, LandAsset from .land import LandType, LandAsset
from .structures import StructureType, Structure from .structures import StructureType, Structure
from .animals import AnimalType from .animals import AnimalType, Animal
from .logs import LogType from .logs import LogType

View file

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

View file

@ -100,6 +100,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["StructureType"] = StructureTypeImporter importers["StructureType"] = StructureTypeImporter
importers["Structure"] = StructureImporter importers["Structure"] = StructureImporter
importers["AnimalType"] = AnimalTypeImporter importers["AnimalType"] = AnimalTypeImporter
importers["Animal"] = AnimalImporter
importers["LogType"] = LogTypeImporter importers["LogType"] = LogTypeImporter
return importers return importers
@ -134,6 +135,90 @@ class FromFarmOS(Importer):
return self.app.make_utc(dt) return self.app.make_utc(dt)
class AnimalImporter(FromFarmOS, ToWutta):
"""
farmOS API WuttaFarm importer for Animals
"""
model_class = model.Animal
supported_fields = [
"farmos_uuid",
"drupal_internal_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_internal_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): class AnimalTypeImporter(FromFarmOS, ToWutta):
""" """
farmOS API WuttaFarm importer for Animal Types farmOS API WuttaFarm importer for Animal Types

View file

@ -30,6 +30,31 @@ import colander
from wuttaweb.forms.schema import ObjectRef from wuttaweb.forms.schema import ObjectRef
class AnimalTypeRef(ObjectRef):
"""
Custom schema type for a
:class:`~wuttafarm.db.model.animals.AnimalType` reference field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self): # pylint: disable=empty-docstring
""" """
model = self.app.model
return model.AnimalType
def sort_query(self, query): # pylint: disable=empty-docstring
""" """
return query.order_by(self.model_class.name)
def get_object_url(self, obj): # pylint: disable=empty-docstring
""" """
animal_type = obj
return self.request.route_url("animal_types.view", uuid=animal_type.uuid)
class AnimalTypeType(colander.SchemaType): class AnimalTypeType(colander.SchemaType):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):

View file

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

View file

@ -47,6 +47,7 @@ def includeme(config):
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.land_assets")
config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.structures")
config.include("wuttafarm.web.views.animals")
config.include("wuttafarm.web.views.log_types") config.include("wuttafarm.web.views.log_types")
# views for farmOS # views for farmOS

View file

@ -23,7 +23,7 @@
Master view for Animal Types Master view for Animal Types
""" """
from wuttafarm.db.model.animals import AnimalType from wuttafarm.db.model.animals import AnimalType, Animal
from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.web.views import WuttaFarmMasterView
@ -58,6 +58,20 @@ class AnimalTypeView(WuttaFarmMasterView):
"drupal_internal_id", "drupal_internal_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): def configure_grid(self, grid):
g = grid g = grid
super().configure_grid(g) super().configure_grid(g)
@ -87,6 +101,23 @@ class AnimalTypeView(WuttaFarmMasterView):
return buttons 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -0,0 +1,130 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Animals
"""
from wuttafarm.db.model.animals import Animal
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.web.forms.schema import AnimalTypeRef
from wuttafarm.web.forms.widgets import ImageWidget
class AnimalView(WuttaFarmMasterView):
"""
Master view for Animals
"""
model_class = Animal
route_prefix = "animals"
url_prefix = "/animals"
farmos_refurl_path = "/assets/animal"
grid_columns = [
"name",
"animal_type",
"sex",
"is_sterile",
"birthdate",
"active",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"animal_type",
"birthdate",
"sex",
"is_sterile",
"active",
"notes",
"farmos_uuid",
"drupal_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")
# 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_internal_id}")
def get_xref_buttons(self, animal):
buttons = super().get_xref_buttons(animal)
if animal.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_animals.view", uuid=animal.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()
AnimalView = kwargs.get("AnimalView", base["AnimalView"])
AnimalView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -27,8 +27,10 @@ import datetime
import colander import colander
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.schema import UsersType, AnimalTypeType, StructureType from wuttafarm.web.forms.schema import UsersType, AnimalTypeType, StructureType
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
@ -191,6 +193,10 @@ class AnimalView(FarmOSMasterView):
# animal_type # animal_type
f.set_node("animal_type", AnimalTypeType(self.request)) f.set_node("animal_type", AnimalTypeType(self.request))
# birthdate
f.set_node("birthdate", WuttaDateTime())
f.set_widget("birthdate", WuttaDateTimeWidget(self.request))
# is_castrated # is_castrated
f.set_node("is_castrated", colander.Boolean()) f.set_node("is_castrated", colander.Boolean())
@ -209,7 +215,10 @@ class AnimalView(FarmOSMasterView):
f.set_default("image", url) f.set_default("image", url)
def get_xref_buttons(self, animal): def get_xref_buttons(self, animal):
return [ model = self.app.model
session = self.Session()
buttons = [
self.make_button( self.make_button(
"View in farmOS", "View in farmOS",
primary=True, primary=True,
@ -219,6 +228,22 @@ class AnimalView(FarmOSMasterView):
), ),
] ]
if wf_animal := (
session.query(model.Animal)
.filter(model.Animal.farmos_uuid == animal["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url("animals.view", uuid=wf_animal.uuid),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()