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..18ab1c0 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py @@ -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") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 0414168..feda137 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -33,5 +33,5 @@ from .users import WuttaFarmUser from .assets import AssetType from .land import LandType, LandAsset from .structures import StructureType, Structure -from .animals import AnimalType +from .animals import AnimalType, Animal from .logs import LogType diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py index a26b966..0eb6c0b 100644 --- a/src/wuttafarm/db/model/animals.py +++ b/src/wuttafarm/db/model/animals.py @@ -92,3 +92,103 @@ class AnimalType(model.Base): 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_internal_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/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 53182bb..871c560 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -100,6 +100,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["StructureType"] = StructureTypeImporter importers["Structure"] = StructureImporter importers["AnimalType"] = AnimalTypeImporter + importers["Animal"] = AnimalImporter importers["LogType"] = LogTypeImporter return importers @@ -134,6 +135,90 @@ class FromFarmOS(Importer): 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): """ farmOS API → WuttaFarm importer for Animal Types diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index e981dee..f646a96 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -30,6 +30,31 @@ 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): def __init__(self, request, *args, **kwargs): diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 135fa40..3dfe7ca 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -44,6 +44,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "title": "Assets", "type": "menu", "items": [ + { + "title": "Animals", + "route": "animals", + "perm": "animals.list", + }, { "title": "Structures", "route": "structures", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index f45169b..205ad98 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -47,6 +47,7 @@ def includeme(config): 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.log_types") # views for farmOS diff --git a/src/wuttafarm/web/views/animal_types.py b/src/wuttafarm/web/views/animal_types.py index ecd136c..2e10f30 100644 --- a/src/wuttafarm/web/views/animal_types.py +++ b/src/wuttafarm/web/views/animal_types.py @@ -23,7 +23,7 @@ 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 @@ -58,6 +58,20 @@ class AnimalTypeView(WuttaFarmMasterView): "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): g = grid super().configure_grid(g) @@ -87,6 +101,23 @@ class AnimalTypeView(WuttaFarmMasterView): 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() diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py new file mode 100644 index 0000000..2f5d894 --- /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_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) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 029ecfb..5a1ecd5 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 @@ -191,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()) @@ -209,7 +215,10 @@ 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, @@ -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): base = globals()