diff --git a/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py b/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py new file mode 100644 index 0000000..66a79ec --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py @@ -0,0 +1,114 @@ +"""add Groups + +Revision ID: 92b813360b99 +Revises: 1b2d3224e5dc +Create Date: 2026-02-13 13:09:48.718064 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "92b813360b99" +down_revision: Union[str, None] = "1b2d3224e5dc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # group + op.create_table( + "group", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("is_location", sa.Boolean(), nullable=False), + sa.Column("is_fixed", sa.Boolean(), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_group")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_group_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_group_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_group_name")), + ) + op.create_table( + "group_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("is_location", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("is_fixed", 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( + "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_group_version") + ), + ) + op.create_index( + op.f("ix_group_version_end_transaction_id"), + "group_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_group_version_operation_type"), + "group_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_group_version_pk_transaction_id", + "group_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_group_version_pk_validity", + "group_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_group_version_transaction_id"), + "group_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # group + op.drop_index(op.f("ix_group_version_transaction_id"), table_name="group_version") + op.drop_index("ix_group_version_pk_validity", table_name="group_version") + op.drop_index("ix_group_version_pk_transaction_id", table_name="group_version") + op.drop_index(op.f("ix_group_version_operation_type"), table_name="group_version") + op.drop_index( + op.f("ix_group_version_end_transaction_id"), table_name="group_version" + ) + op.drop_table("group_version") + op.drop_table("group") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index feda137..1a3e677 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -34,4 +34,5 @@ from .assets import AssetType from .land import LandType, LandAsset from .structures import StructureType, Structure from .animals import AnimalType, Animal +from .groups import Group from .logs import LogType diff --git a/src/wuttafarm/db/model/groups.py b/src/wuttafarm/db/model/groups.py new file mode 100644 index 0000000..3bcac19 --- /dev/null +++ b/src/wuttafarm/db/model/groups.py @@ -0,0 +1,106 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Groups +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + + +class Group(model.Base): + """ + Represents a "group" from farmOS + """ + + __tablename__ = "group" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Group", + "model_title_plural": "Groups", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name for the group. + """, + ) + + is_location = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the group is considered to be a location. + """, + ) + + is_fixed = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the group location is fixed. + """, + ) + + active = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the group is active. + """, + ) + + notes = sa.Column( + sa.Text(), + nullable=True, + doc=""" + Arbitrary notes for the group. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the group within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the group. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 871c560..2623eab 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -101,6 +101,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["Structure"] = StructureImporter importers["AnimalType"] = AnimalTypeImporter importers["Animal"] = AnimalImporter + importers["Group"] = GroupImporter importers["LogType"] = LogTypeImporter return importers @@ -279,6 +280,44 @@ class AssetTypeImporter(FromFarmOS, ToWutta): } +class GroupImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Groups + """ + + model_class = model.Group + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + "is_location", + "is_fixed", + "notes", + "active", + ] + + def get_source_objects(self): + """ """ + groups = self.farmos_client.asset.get("group") + return groups["data"] + + def normalize_source_object(self, group): + """ """ + if notes := group["attributes"]["notes"]: + notes = notes["value"] + + return { + "farmos_uuid": UUID(group["id"]), + "drupal_internal_id": group["attributes"]["drupal_internal__id"], + "name": group["attributes"]["name"], + "is_location": group["attributes"]["is_location"], + "is_fixed": group["attributes"]["is_fixed"], + "active": group["attributes"]["status"] == "active", + "notes": notes, + } + + class LandAssetImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Land Assets diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 3dfe7ca..9606c86 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -49,6 +49,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "animals", "perm": "animals.list", }, + { + "title": "Groups", + "route": "groups", + "perm": "groups.list", + }, { "title": "Structures", "route": "structures", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 205ad98..8606025 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -48,6 +48,7 @@ def includeme(config): config.include("wuttafarm.web.views.land_assets") config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.animals") + config.include("wuttafarm.web.views.groups") config.include("wuttafarm.web.views.log_types") # views for farmOS diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index 127dd43..b41a987 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -141,7 +141,10 @@ class GroupView(FarmOSMasterView): f.set_widget("changed", WuttaDateTimeWidget(self.request)) def get_xref_buttons(self, group): - return [ + model = self.app.model + session = self.Session() + + buttons = [ self.make_button( "View in farmOS", primary=True, @@ -151,6 +154,22 @@ class GroupView(FarmOSMasterView): ), ] + if wf_group := ( + session.query(model.Group) + .filter(model.Group.farmos_uuid == group["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("groups.view", uuid=wf_group.uuid), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py new file mode 100644 index 0000000..5c7fee9 --- /dev/null +++ b/src/wuttafarm/web/views/groups.py @@ -0,0 +1,107 @@ +# -*- 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 Groups +""" + +from wuttafarm.db.model.groups import Group +from wuttafarm.web.views import WuttaFarmMasterView + + +class GroupView(WuttaFarmMasterView): + """ + Master view for Groups + """ + + model_class = Group + route_prefix = "groups" + url_prefix = "/groups" + + farmos_refurl_path = "/assets/group" + + grid_columns = [ + "name", + "is_location", + "is_fixed", + "active", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "is_location", + "is_fixed", + "active", + "notes", + "farmos_uuid", + "drupal_internal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def configure_form(self, form): + f = form + super().configure_form(f) + + # notes + f.set_widget("notes", "notes") + + def get_farmos_url(self, group): + return self.app.get_farmos_url(f"/asset/{group.drupal_internal_id}") + + def get_xref_buttons(self, group): + buttons = super().get_xref_buttons(group) + + if group.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_groups.view", uuid=group.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + GroupView = kwargs.get("GroupView", base["GroupView"]) + GroupView.defaults(config) + + +def includeme(config): + defaults(config)