diff --git a/src/wuttafarm/db/alembic/versions/1c89f3fbb521_add_materialtype.py b/src/wuttafarm/db/alembic/versions/1c89f3fbb521_add_materialtype.py new file mode 100644 index 0000000..0d4c0f5 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/1c89f3fbb521_add_materialtype.py @@ -0,0 +1,116 @@ +"""add MaterialType + +Revision ID: 1c89f3fbb521 +Revises: 82a497e30a97 +Create Date: 2026-03-08 14:38:04.538621 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "1c89f3fbb521" +down_revision: Union[str, None] = "82a497e30a97" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # material_type + op.create_table( + "material_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_material_type")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_material_type_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_material_type_farmos_uuid")), + ) + op.create_table( + "material_type_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( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_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_material_type_version") + ), + ) + op.create_index( + op.f("ix_material_type_version_end_transaction_id"), + "material_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_material_type_version_operation_type"), + "material_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_material_type_version_pk_transaction_id", + "material_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_material_type_version_pk_validity", + "material_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_material_type_version_transaction_id"), + "material_type_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # material_type + op.drop_index( + op.f("ix_material_type_version_transaction_id"), + table_name="material_type_version", + ) + op.drop_index( + "ix_material_type_version_pk_validity", table_name="material_type_version" + ) + op.drop_index( + "ix_material_type_version_pk_transaction_id", table_name="material_type_version" + ) + op.drop_index( + op.f("ix_material_type_version_operation_type"), + table_name="material_type_version", + ) + op.drop_index( + op.f("ix_material_type_version_end_transaction_id"), + table_name="material_type_version", + ) + op.drop_table("material_type_version") + op.drop_table("material_type") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 929e64b..ba80389 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -31,6 +31,7 @@ from .users import WuttaFarmUser # wuttafarm proper models from .unit import Unit, Measure +from .material_type import MaterialType from .quantities import QuantityType, Quantity, StandardQuantity from .asset import AssetType, Asset, AssetParent from .asset_land import LandType, LandAsset diff --git a/src/wuttafarm/db/model/material_type.py b/src/wuttafarm/db/model/material_type.py new file mode 100644 index 0000000..e21f38c --- /dev/null +++ b/src/wuttafarm/db/model/material_type.py @@ -0,0 +1,80 @@ +# -*- 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 Material Types +""" + +import sqlalchemy as sa + +from wuttjamaican.db import model + + +class MaterialType(model.Base): + """ + Represents a "material type" (taxonomy term) from farmOS + """ + + __tablename__ = "material_type" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Material Type", + "model_title_plural": "Material Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + doc=""" + Name of the material type. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional description for the material type. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the material type within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the material type. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 9994f55..980c329 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -71,6 +71,7 @@ class ToFarmOSTaxonomy(ToFarmOS): supported_fields = [ "uuid", "name", + "description", ] def get_target_objects(self, **kwargs): @@ -102,17 +103,24 @@ class ToFarmOSTaxonomy(ToFarmOS): return result["data"] def normalize_target_object(self, obj): + if description := obj["attributes"]["description"]: + description = description["value"] return { "uuid": UUID(obj["id"]), "name": obj["attributes"]["name"], + "description": description, } def get_term_payload(self, source_data): - return { - "attributes": { - "name": source_data["name"], - } - } + + attrs = {} + if "name" in self.fields: + attrs["name"] = source_data["name"] + if "description" in self.fields: + attrs["description"] = {"value": source_data["description"]} + + payload = {"attributes": attrs} + return payload def create_target_object(self, key, source_data): if source_data.get("__ignoreme__"): @@ -311,6 +319,12 @@ class AnimalTypeImporter(ToFarmOSTaxonomy): farmos_taxonomy_type = "animal_type" +class MaterialTypeImporter(ToFarmOSTaxonomy): + + model_title = "MaterialType" + farmos_taxonomy_type = "material_type" + + class GroupAssetImporter(ToFarmOSAsset): model_title = "GroupAsset" diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 8b682a3..549d3b0 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -105,6 +105,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["Season"] = SeasonImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter + importers["MaterialType"] = MaterialTypeImporter importers["StandardQuantity"] = StandardQuantityImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter @@ -199,26 +200,46 @@ class AnimalAssetImporter( return data -class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporter): +class FromWuttaFarmTaxonomy(FromWuttaFarm): + """ + Base class for taxonomy term exporters + """ + + supported_fields = [ + "uuid", + "name", + "description", + ] + + drupal_internal_id_field = "drupal_internal__tid" + + def normalize_source_object(self, term): + return { + "uuid": term.farmos_uuid or self.app.make_true_uuid(), + "name": term.name, + "description": term.description, + "_src_object": term, + } + + +class AnimalTypeImporter( + FromWuttaFarmTaxonomy, farmos_importing.model.AnimalTypeImporter +): """ WuttaFarm → farmOS API exporter for Animal Types """ source_model_class = model.AnimalType - supported_fields = [ - "uuid", - "name", - ] - drupal_internal_id_field = "drupal_internal__tid" +class MaterialTypeImporter( + FromWuttaFarmTaxonomy, farmos_importing.model.MaterialTypeImporter +): + """ + WuttaFarm → farmOS API exporter for Material Types + """ - def normalize_source_object(self, animal_type): - return { - "uuid": animal_type.farmos_uuid or self.app.make_true_uuid(), - "name": animal_type.name, - "_src_object": animal_type, - } + source_model_class = model.MaterialType class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter): diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 1e07e77..58eff68 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -114,6 +114,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["PlantAsset"] = PlantAssetImporter importers["Measure"] = MeasureImporter importers["Unit"] = UnitImporter + importers["MaterialType"] = MaterialTypeImporter importers["QuantityType"] = QuantityTypeImporter importers["StandardQuantity"] = StandardQuantityImporter importers["LogType"] = LogTypeImporter @@ -397,12 +398,12 @@ class AnimalAssetImporter(AssetImporterBase): return data -class AnimalTypeImporter(FromFarmOS, ToWutta): +class TaxonomyImporterBase(FromFarmOS, ToWutta): """ - farmOS API → WuttaFarm importer for Animal Types + farmOS API → WuttaFarm importer for taxonomy terms """ - model_class = model.AnimalType + taxonomy_type = None supported_fields = [ "farmos_uuid", @@ -413,18 +414,41 @@ class AnimalTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - return list(self.farmos_client.resource.iterate("taxonomy_term", "animal_type")) + return list( + self.farmos_client.resource.iterate("taxonomy_term", self.taxonomy_type) + ) - def normalize_source_object(self, animal_type): + def normalize_source_object(self, term): """ """ + if description := term["attributes"]["description"]: + description = description["value"] + return { - "farmos_uuid": UUID(animal_type["id"]), - "drupal_id": animal_type["attributes"]["drupal_internal__tid"], - "name": animal_type["attributes"]["name"], - "description": animal_type["attributes"]["description"], + "farmos_uuid": UUID(term["id"]), + "drupal_id": term["attributes"]["drupal_internal__tid"], + "name": term["attributes"]["name"], + "description": description, } +class AnimalTypeImporter(TaxonomyImporterBase): + """ + farmOS API → WuttaFarm importer for Animal Types + """ + + model_class = model.AnimalType + taxonomy_type = "animal_type" + + +class MaterialTypeImporter(TaxonomyImporterBase): + """ + farmOS API → WuttaFarm importer for Material Types + """ + + model_class = model.MaterialType + taxonomy_type = "material_type" + + class AssetTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Asset Types diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 3b75b4e..9252e5b 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -193,6 +193,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "log_types", "perm": "log_types.list", }, + { + "title": "Material Types", + "route": "material_types", + "perm": "material_types.list", + }, { "title": "Measures", "route": "measures", @@ -307,6 +312,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_log_types", "perm": "farmos_log_types.list", }, + { + "title": "Material Types", + "route": "farmos_material_types", + "perm": "farmos_material_types.list", + }, { "title": "Quantity Types", "route": "farmos_quantity_types", @@ -431,6 +441,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_log_types", "perm": "farmos_log_types.list", }, + { + "title": "Material Types", + "route": "farmos_material_types", + "perm": "farmos_material_types.list", + }, { "title": "Quantity Types", "route": "farmos_quantity_types", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 0d58a72..d189e42 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -25,7 +25,7 @@ WuttaFarm Views from wuttaweb.views import essential -from .master import WuttaFarmMasterView +from .master import WuttaFarmMasterView, TaxonomyMasterView def includeme(config): @@ -48,6 +48,7 @@ def includeme(config): # native table views if mode != enum.FARMOS_INTEGRATION_MODE_WRAPPER: config.include("wuttafarm.web.views.units") + config.include("wuttafarm.web.views.material_types") config.include("wuttafarm.web.views.quantities") config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.assets") diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index e59ac1f..9cb57d5 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,6 +28,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") + config.include("wuttafarm.web.views.farmos.materials") config.include("wuttafarm.web.views.farmos.quantities") config.include("wuttafarm.web.views.farmos.asset_types") config.include("wuttafarm.web.views.farmos.units") diff --git a/src/wuttafarm/web/views/farmos/materials.py b/src/wuttafarm/web/views/farmos/materials.py new file mode 100644 index 0000000..e56557d --- /dev/null +++ b/src/wuttafarm/web/views/farmos/materials.py @@ -0,0 +1,76 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS materials +""" + +from wuttafarm.web.views.farmos.master import TaxonomyMasterView + + +class MaterialTypeView(TaxonomyMasterView): + """ + Master view for Material Types in farmOS. + """ + + model_name = "farmos_material_type" + model_title = "farmOS Material Type" + model_title_plural = "farmOS Material Types" + + route_prefix = "farmos_material_types" + url_prefix = "/farmOS/material-types" + + farmos_taxonomy_type = "material_type" + farmos_refurl_path = "/admin/structure/taxonomy/manage/material_type/overview" + + def get_xref_buttons(self, material_type): + buttons = super().get_xref_buttons(material_type) + model = self.app.model + session = self.Session() + + if wf_material_type := ( + session.query(model.MaterialType) + .filter(model.MaterialType.farmos_uuid == material_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "material_types.view", uuid=wf_material_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + MaterialTypeView = kwargs.get("MaterialTypeView", base["MaterialTypeView"]) + MaterialTypeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 5d37d55..53bbac2 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -148,3 +148,112 @@ class WuttaFarmMasterView(MasterView): bundle = self.get_farmos_bundle() client = get_farmos_client_for_user(self.request) client.resource.delete(entity_type, bundle, farmos_uuid) + + +class TaxonomyMasterView(WuttaFarmMasterView): + """ + Base class for master views serving taxonomy terms. + """ + + farmos_entity_type = "taxonomy_term" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "drupal_id", + "farmos_uuid", + ] + + 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) + + # description + f.set_widget("description", "notes") + + def get_farmos_url(self, obj): + return self.app.get_farmos_url(f"/taxonomy/term/{obj.drupal_id}") + + def get_xref_buttons(self, term): + buttons = super().get_xref_buttons(term) + + if term.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + f"{self.farmos_route_prefix}.view", uuid=term.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + def ajax_create(self): + """ + AJAX view to create a new taxonomy term. + """ + model = self.app.model + session = self.Session() + data = get_form_data(self.request) + + name = data.get("name") + if not name: + return {"error": "Name is required"} + + term = self.model_class(name=name) + session.add(term) + session.flush() + + if self.app.is_farmos_mirror(): + client = get_farmos_client_for_user(self.request) + self.app.auto_sync_to_farmos(term, client=client) + + return { + "uuid": term.uuid.hex, + "name": term.name, + "farmos_uuid": term.farmos_uuid.hex, + "drupal_id": term.drupal_id, + } + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._taxonomy_defaults(config) + + @classmethod + def _taxonomy_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + + # ajax_create + config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new") + config.add_view( + cls, + attr="ajax_create", + route_name=f"{route_prefix}.ajax_create", + permission=f"{permission_prefix}.create", + renderer="json", + ) diff --git a/src/wuttafarm/web/views/material_types.py b/src/wuttafarm/web/views/material_types.py new file mode 100644 index 0000000..d2118a7 --- /dev/null +++ b/src/wuttafarm/web/views/material_types.py @@ -0,0 +1,52 @@ +# -*- 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 Material Types +""" + +from wuttafarm.web.views import TaxonomyMasterView +from wuttafarm.db.model import MaterialType + + +class MaterialTypeView(TaxonomyMasterView): + """ + Master view for Material Types + """ + + model_class = MaterialType + route_prefix = "material_types" + url_prefix = "/material-types" + + farmos_route_prefix = "farmos_material_types" + farmos_bundle = "material_type" + farmos_refurl_path = "/admin/structure/taxonomy/manage/material_type/overview" + + +def defaults(config, **kwargs): + base = globals() + + MaterialTypeView = kwargs.get("MaterialTypeView", base["MaterialTypeView"]) + MaterialTypeView.defaults(config) + + +def includeme(config): + defaults(config)