diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 087c48a..d0ca392 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -51,6 +51,44 @@ class WuttaFarmAppHandler(base.AppHandler): self.handlers["farmos"] = factory(self.config) return self.handlers["farmos"] + def get_farmos_integration_mode(self): + """ + Returns the integration mode for farmOS, i.e. to control the + app's behavior regarding that. + """ + enum = self.enum + return self.config.get( + f"{self.appname}.farmos_integration_mode", + default=enum.FARMOS_INTEGRATION_MODE_WRAPPER, + ) + + def is_farmos_mirror(self): + """ + Returns ``True`` if the app is configured in "mirror" + integration mode with regard to farmOS. + """ + enum = self.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR + + def is_farmos_wrapper(self): + """ + Returns ``True`` if the app is configured in "wrapper" + integration mode with regard to farmOS. + """ + enum = self.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER + + def is_standalone(self): + """ + Returns ``True`` if the app is configured in "standalone" mode + with regard to farmOS. + """ + enum = self.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_NONE + def get_farmos_url(self, *args, **kwargs): """ Get a farmOS URL. This is a convenience wrapper around @@ -85,7 +123,20 @@ class WuttaFarmAppHandler(base.AppHandler): handler = self.get_farmos_handler() return handler.is_farmos_4x(*args, **kwargs) - def export_to_farmos(self, obj, require=True): + def get_normalizer(self, farmos_client=None): + """ + Get the configured farmOS integration handler. + + :rtype: :class:`~wuttafarm.farmos.FarmOSHandler` + """ + spec = self.config.get( + f"{self.appname}.normalizer_spec", + default="wuttafarm.normal:Normalizer", + ) + factory = self.load_object(spec) + return factory(self.config, farmos_client) + + def auto_sync_to_farmos(self, obj, model_name=None, require=True): """ Export the given object to farmOS, using configured handler. @@ -103,7 +154,8 @@ class WuttaFarmAppHandler(base.AppHandler): """ handler = self.app.get_import_handler("export.to_farmos.from_wuttafarm") - model_name = type(obj).__name__ + if not model_name: + model_name = type(obj).__name__ if model_name not in handler.importers: if require: raise ValueError(f"no exporter found for {model_name}") @@ -117,6 +169,37 @@ class WuttaFarmAppHandler(base.AppHandler): normal = importer.normalize_source_object(obj) importer.process_data(source_data=[normal]) + def auto_sync_from_farmos(self, obj, model_name, require=True): + """ + Import the given object from farmOS, using configured handler. + + :param obj: Any data record from farmOS. + + :param model_name': Model name for the importer to use, + e.g. ``"AnimalAsset"``. + + :param require: If true, this will *require* the import + handler to support objects of the given type. If false, + then nothing will happen / import is silently skipped when + there is no such importer. + """ + handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos") + + if model_name not in handler.importers: + if require: + raise ValueError(f"no importer found for {model_name}") + return + + # nb. begin txn to establish the API client + # TODO: should probably use current user oauth2 token instead + # of always making a new one here, which is what happens IIUC + handler.begin_source_transaction() + with self.short_session(commit=True) as session: + handler.target_session = session + importer = handler.get_importer(model_name, caches_target=False) + normal = importer.normalize_source_object(obj) + importer.process_data(source_data=[normal]) + class WuttaFarmAppProvider(base.AppProvider): """ diff --git a/src/wuttafarm/config.py b/src/wuttafarm/config.py index 831698f..16a7578 100644 --- a/src/wuttafarm/config.py +++ b/src/wuttafarm/config.py @@ -52,7 +52,7 @@ class WuttaFarmConfig(WuttaConfigExtension): # web app menu config.setdefault( - f"{config.appname}.web.menus.handler.spec", + f"{config.appname}.web.menus.handler.default_spec", "wuttafarm.web.menus:WuttaFarmMenuHandler", ) diff --git a/src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py b/src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py new file mode 100644 index 0000000..816f05c --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py @@ -0,0 +1,119 @@ +"""add Quantity Types + +Revision ID: 1f98d27cabeb +Revises: ea88e72a5fa5 +Create Date: 2026-02-18 21:03:52.245619 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "1f98d27cabeb" +down_revision: Union[str, None] = "ea88e72a5fa5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # quantity_type + op.create_table( + "quantity_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.String(length=50), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_type")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_quantity_type_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_quantity_type_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_quantity_type_name")), + ) + op.create_table( + "quantity_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.String(length=50), 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_quantity_type_version") + ), + ) + op.create_index( + op.f("ix_quantity_type_version_end_transaction_id"), + "quantity_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_type_version_operation_type"), + "quantity_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_quantity_type_version_pk_transaction_id", + "quantity_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_quantity_type_version_pk_validity", + "quantity_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_type_version_transaction_id"), + "quantity_type_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # quantity_type + op.drop_index( + op.f("ix_quantity_type_version_transaction_id"), + table_name="quantity_type_version", + ) + op.drop_index( + "ix_quantity_type_version_pk_validity", table_name="quantity_type_version" + ) + op.drop_index( + "ix_quantity_type_version_pk_transaction_id", table_name="quantity_type_version" + ) + op.drop_index( + op.f("ix_quantity_type_version_operation_type"), + table_name="quantity_type_version", + ) + op.drop_index( + op.f("ix_quantity_type_version_end_transaction_id"), + table_name="quantity_type_version", + ) + op.drop_table("quantity_type_version") + op.drop_table("quantity_type") diff --git a/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py b/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py new file mode 100644 index 0000000..a6aab9d --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py @@ -0,0 +1,293 @@ +"""add Standard Quantities + +Revision ID: 5b6c87d8cddf +Revises: 1f98d27cabeb +Create Date: 2026-02-19 15:42:19.691148 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "5b6c87d8cddf" +down_revision: Union[str, None] = "1f98d27cabeb" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # measure + op.create_table( + "measure", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("drupal_id", sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_measure")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_measure_drupal_id")), + sa.UniqueConstraint("name", name=op.f("uq_measure_name")), + ) + op.create_table( + "measure_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( + "drupal_id", sa.String(length=20), 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_measure_version") + ), + ) + op.create_index( + op.f("ix_measure_version_end_transaction_id"), + "measure_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_measure_version_operation_type"), + "measure_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_measure_version_pk_transaction_id", + "measure_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_measure_version_pk_validity", + "measure_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_measure_version_transaction_id"), + "measure_version", + ["transaction_id"], + unique=False, + ) + + # quantity + op.create_table( + "quantity", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("quantity_type_id", sa.String(length=50), nullable=False), + sa.Column("measure_id", sa.String(length=20), nullable=False), + sa.Column("value_numerator", sa.Integer(), nullable=False), + sa.Column("value_denominator", sa.Integer(), nullable=False), + sa.Column("units_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("label", 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.ForeignKeyConstraint( + ["measure_id"], + ["measure.drupal_id"], + name=op.f("fk_quantity_measure_id_measure"), + ), + sa.ForeignKeyConstraint( + ["quantity_type_id"], + ["quantity_type.drupal_id"], + name=op.f("fk_quantity_quantity_type_id_quantity_type"), + ), + sa.ForeignKeyConstraint( + ["units_uuid"], ["unit.uuid"], name=op.f("fk_quantity_units_uuid_unit") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_quantity_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_quantity_farmos_uuid")), + ) + op.create_table( + "quantity_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "quantity_type_id", sa.String(length=50), autoincrement=False, nullable=True + ), + sa.Column( + "measure_id", sa.String(length=20), autoincrement=False, nullable=True + ), + sa.Column("value_numerator", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "value_denominator", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "units_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("label", 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_quantity_version") + ), + ) + op.create_index( + op.f("ix_quantity_version_end_transaction_id"), + "quantity_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_version_operation_type"), + "quantity_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_quantity_version_pk_transaction_id", + "quantity_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_quantity_version_pk_validity", + "quantity_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_version_transaction_id"), + "quantity_version", + ["transaction_id"], + unique=False, + ) + + # quantity_standard + op.create_table( + "quantity_standard", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["uuid"], ["quantity.uuid"], name=op.f("fk_quantity_standard_uuid_quantity") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_standard")), + ) + op.create_table( + "quantity_standard_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + 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_quantity_standard_version") + ), + ) + op.create_index( + op.f("ix_quantity_standard_version_end_transaction_id"), + "quantity_standard_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_standard_version_operation_type"), + "quantity_standard_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_quantity_standard_version_pk_transaction_id", + "quantity_standard_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_quantity_standard_version_pk_validity", + "quantity_standard_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_standard_version_transaction_id"), + "quantity_standard_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # quantity_standard + op.drop_index( + op.f("ix_quantity_standard_version_transaction_id"), + table_name="quantity_standard_version", + ) + op.drop_index( + "ix_quantity_standard_version_pk_validity", + table_name="quantity_standard_version", + ) + op.drop_index( + "ix_quantity_standard_version_pk_transaction_id", + table_name="quantity_standard_version", + ) + op.drop_index( + op.f("ix_quantity_standard_version_operation_type"), + table_name="quantity_standard_version", + ) + op.drop_index( + op.f("ix_quantity_standard_version_end_transaction_id"), + table_name="quantity_standard_version", + ) + op.drop_table("quantity_standard_version") + op.drop_table("quantity_standard") + + # quantity + op.drop_index( + op.f("ix_quantity_version_transaction_id"), table_name="quantity_version" + ) + op.drop_index("ix_quantity_version_pk_validity", table_name="quantity_version") + op.drop_index( + "ix_quantity_version_pk_transaction_id", table_name="quantity_version" + ) + op.drop_index( + op.f("ix_quantity_version_operation_type"), table_name="quantity_version" + ) + op.drop_index( + op.f("ix_quantity_version_end_transaction_id"), table_name="quantity_version" + ) + op.drop_table("quantity_version") + op.drop_table("quantity") + + # measure + op.drop_index( + op.f("ix_measure_version_transaction_id"), table_name="measure_version" + ) + op.drop_index("ix_measure_version_pk_validity", table_name="measure_version") + op.drop_index("ix_measure_version_pk_transaction_id", table_name="measure_version") + op.drop_index( + op.f("ix_measure_version_operation_type"), table_name="measure_version" + ) + op.drop_index( + op.f("ix_measure_version_end_transaction_id"), table_name="measure_version" + ) + op.drop_table("measure_version") + op.drop_table("measure") diff --git a/src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py b/src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py new file mode 100644 index 0000000..e85afed --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py @@ -0,0 +1,102 @@ +"""add Units + +Revision ID: ea88e72a5fa5 +Revises: 82a03f4ef1a4 +Create Date: 2026-02-18 20:01:40.720138 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "ea88e72a5fa5" +down_revision: Union[str, None] = "82a03f4ef1a4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # unit + op.create_table( + "unit", + 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_unit")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_unit_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_unit_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_unit_name")), + ) + op.create_table( + "unit_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_unit_version")), + ) + op.create_index( + op.f("ix_unit_version_end_transaction_id"), + "unit_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_unit_version_operation_type"), + "unit_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_unit_version_pk_transaction_id", + "unit_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_unit_version_pk_validity", + "unit_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_unit_version_transaction_id"), + "unit_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # unit + op.drop_index(op.f("ix_unit_version_transaction_id"), table_name="unit_version") + op.drop_index("ix_unit_version_pk_validity", table_name="unit_version") + op.drop_index("ix_unit_version_pk_transaction_id", table_name="unit_version") + op.drop_index(op.f("ix_unit_version_operation_type"), table_name="unit_version") + op.drop_index(op.f("ix_unit_version_end_transaction_id"), table_name="unit_version") + op.drop_table("unit_version") + op.drop_table("unit") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index f9eb790..68695e5 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,6 +30,8 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models +from .unit import Unit, Measure +from .quantities import QuantityType, Quantity, StandardQuantity from .asset import AssetType, Asset, AssetParent from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py new file mode 100644 index 0000000..4f537b9 --- /dev/null +++ b/src/wuttafarm/db/model/quantities.py @@ -0,0 +1,221 @@ +# -*- 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 Quantities +""" + +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr + +from wuttjamaican.db import model + + +class QuantityType(model.Base): + """ + Represents an "quantity type" from farmOS + """ + + __tablename__ = "quantity_type" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Quantity Type", + "model_title_plural": "Quantity Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the quantity type. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Description for the quantity type. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the quantity type within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.String(length=50), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the quantity type. + """, + ) + + def __str__(self): + return self.name or "" + + +class Quantity(model.Base): + """ + Represents a base quantity record from farmOS + """ + + __tablename__ = "quantity" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Quantity", + "model_title_plural": "All Quantities", + } + + uuid = model.uuid_column() + + quantity_type_id = sa.Column( + sa.String(length=50), + sa.ForeignKey("quantity_type.drupal_id"), + nullable=False, + ) + + quantity_type = orm.relationship(QuantityType) + + measure_id = sa.Column( + sa.String(length=20), + sa.ForeignKey("measure.drupal_id"), + nullable=False, + doc=""" + Measure for the quantity. + """, + ) + + measure = orm.relationship("Measure") + + value_numerator = sa.Column( + sa.Integer(), + nullable=False, + doc=""" + Numerator for the quantity value. + """, + ) + + value_denominator = sa.Column( + sa.Integer(), + nullable=False, + doc=""" + Denominator for the quantity value. + """, + ) + + units_uuid = model.uuid_fk_column("unit.uuid", nullable=False) + units = orm.relationship("Unit") + + label = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional label for the quantity. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the quantity within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the quantity. + """, + ) + + def render_as_text(self, config=None): + measure = str(self.measure or self.measure_id or "") + value = self.value_numerator / self.value_denominator + if config: + app = config.get_app() + value = app.render_quantity(value) + units = str(self.units or "") + return f"( {measure} ) {value} {units}" + + def __str__(self): + return self.render_as_text() + + +class QuantityMixin: + + uuid = model.uuid_fk_column("quantity.uuid", nullable=False, primary_key=True) + + @declared_attr + def quantity(cls): + return orm.relationship(Quantity) + + def render_as_text(self, config=None): + return self.quantity.render_as_text(config) + + def __str__(self): + return self.render_as_text() + + +def add_quantity_proxies(subclass): + Quantity.make_proxy(subclass, "quantity", "farmos_uuid") + Quantity.make_proxy(subclass, "quantity", "drupal_id") + Quantity.make_proxy(subclass, "quantity", "quantity_type") + Quantity.make_proxy(subclass, "quantity", "quantity_type_id") + Quantity.make_proxy(subclass, "quantity", "measure") + Quantity.make_proxy(subclass, "quantity", "measure_id") + Quantity.make_proxy(subclass, "quantity", "value_numerator") + Quantity.make_proxy(subclass, "quantity", "value_denominator") + Quantity.make_proxy(subclass, "quantity", "value_decimal") + Quantity.make_proxy(subclass, "quantity", "units_uuid") + Quantity.make_proxy(subclass, "quantity", "units") + Quantity.make_proxy(subclass, "quantity", "label") + + +class StandardQuantity(QuantityMixin, model.Base): + """ + Represents a Standard Quantity from farmOS + """ + + __tablename__ = "quantity_standard" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Standard Quantity", + "model_title_plural": "Standard Quantities", + "farmos_quantity_type": "standard", + } + + +add_quantity_proxies(StandardQuantity) diff --git a/src/wuttafarm/db/model/unit.py b/src/wuttafarm/db/model/unit.py new file mode 100644 index 0000000..e9c6e70 --- /dev/null +++ b/src/wuttafarm/db/model/unit.py @@ -0,0 +1,117 @@ +# -*- 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 Units +""" + +import sqlalchemy as sa + +from wuttjamaican.db import model + + +class Measure(model.Base): + """ + Represents a "measure" option (for quantities) from farmOS + """ + + __tablename__ = "measure" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Measure", + "model_title_plural": "Measures", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the measure. + """, + ) + + drupal_id = sa.Column( + sa.String(length=20), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the measure. + """, + ) + + def __str__(self): + return self.name or "" + + +class Unit(model.Base): + """ + Represents an "unit" (taxonomy term) from farmOS + """ + + __tablename__ = "unit" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Unit", + "model_title_plural": "Units", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the unit. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional description for the unit. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the unit within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the unit. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/enum.py b/src/wuttafarm/enum.py index 03181b9..870e4cd 100644 --- a/src/wuttafarm/enum.py +++ b/src/wuttafarm/enum.py @@ -28,6 +28,19 @@ from collections import OrderedDict from wuttjamaican.enum import * +FARMOS_INTEGRATION_MODE_WRAPPER = "wrapper" +FARMOS_INTEGRATION_MODE_MIRROR = "mirror" +FARMOS_INTEGRATION_MODE_NONE = "none" + +FARMOS_INTEGRATION_MODE = OrderedDict( + [ + (FARMOS_INTEGRATION_MODE_WRAPPER, "wrapper (API only)"), + (FARMOS_INTEGRATION_MODE_MIRROR, "mirror (2-way sync)"), + (FARMOS_INTEGRATION_MODE_NONE, "none (standalone)"), + ] +) + + ANIMAL_SEX = OrderedDict( [ ("M", "Male"), diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index d20c068..337649c 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -64,6 +64,81 @@ class ToFarmOS(Importer): return self.app.make_utc(dt) +class ToFarmOSTaxonomy(ToFarmOS): + + farmos_taxonomy_type = None + + supported_fields = [ + "uuid", + "name", + ] + + def get_target_objects(self, **kwargs): + result = self.farmos_client.resource.get( + "taxonomy_term", self.farmos_taxonomy_type + ) + return result["data"] + + def get_target_object(self, key): + + # fetch from cache, if applicable + if self.caches_target: + return super().get_target_object(key) + + # okay now must fetch via API + if self.get_keys() != ["uuid"]: + raise ValueError("must use uuid key for this to work") + uuid = key[0] + + try: + result = self.farmos_client.resource.get_id( + "taxonomy_term", self.farmos_taxonomy_type, str(uuid) + ) + except requests.HTTPError as exc: + if exc.response.status_code == 404: + return None + raise + return result["data"] + + def normalize_target_object(self, obj): + return { + "uuid": UUID(obj["id"]), + "name": obj["attributes"]["name"], + } + + def get_term_payload(self, source_data): + return { + "attributes": { + "name": source_data["name"], + } + } + + def create_target_object(self, key, source_data): + if source_data.get("__ignoreme__"): + return None + if self.dry_run: + return source_data + + payload = self.get_term_payload(source_data) + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + normal = self.normalize_target_object(result["data"]) + normal["_new_object"] = result["data"] + return normal + + def update_target_object(self, asset, source_data, target_data=None): + if self.dry_run: + return asset + + payload = self.get_term_payload(source_data) + payload["id"] = str(source_data["uuid"]) + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + return self.normalize_target_object(result["data"]) + + class ToFarmOSAsset(ToFarmOS): """ Base class for asset data importer targeting the farmOS API. @@ -151,6 +226,12 @@ class ToFarmOSAsset(ToFarmOS): return payload +class UnitImporter(ToFarmOSTaxonomy): + + model_title = "Unit" + farmos_taxonomy_type = "unit" + + class AnimalAssetImporter(ToFarmOSAsset): model_title = "AnimalAsset" @@ -209,77 +290,10 @@ class AnimalAssetImporter(ToFarmOSAsset): return payload -class AnimalTypeImporter(ToFarmOS): +class AnimalTypeImporter(ToFarmOSTaxonomy): model_title = "AnimalType" - - supported_fields = [ - "uuid", - "name", - ] - - def get_target_objects(self, **kwargs): - result = self.farmos_client.resource.get("taxonomy_term", "animal_type") - return result["data"] - - def get_target_object(self, key): - - # fetch from cache, if applicable - if self.caches_target: - return super().get_target_object(key) - - # okay now must fetch via API - if self.get_keys() != ["uuid"]: - raise ValueError("must use uuid key for this to work") - uuid = key[0] - - try: - result = self.farmos_client.resource.get_id( - "taxonomy_term", "animal_type", str(uuid) - ) - except requests.HTTPError as exc: - if exc.response.status_code == 404: - return None - raise - return result["data"] - - def normalize_target_object(self, obj): - return { - "uuid": UUID(obj["id"]), - "name": obj["attributes"]["name"], - } - - def get_type_payload(self, source_data): - return { - "attributes": { - "name": source_data["name"], - } - } - - def create_target_object(self, key, source_data): - if source_data.get("__ignoreme__"): - return None - if self.dry_run: - return source_data - - payload = self.get_type_payload(source_data) - result = self.farmos_client.resource.send( - "taxonomy_term", "animal_type", payload - ) - normal = self.normalize_target_object(result["data"]) - normal["_new_object"] = result["data"] - return normal - - def update_target_object(self, asset, source_data, target_data=None): - if self.dry_run: - return asset - - payload = self.get_type_payload(source_data) - payload["id"] = str(source_data["uuid"]) - result = self.farmos_client.resource.send( - "taxonomy_term", "animal_type", payload - ) - return self.normalize_target_object(result["data"]) + farmos_taxonomy_type = "animal_type" class GroupAssetImporter(ToFarmOSAsset): @@ -333,6 +347,59 @@ class LandAssetImporter(ToFarmOSAsset): return payload +class PlantAssetImporter(ToFarmOSAsset): + + model_title = "PlantAsset" + farmos_asset_type = "plant" + + supported_fields = [ + "uuid", + "asset_name", + "plant_type_uuids", + "notes", + "archived", + ] + + def normalize_target_object(self, plant): + data = super().normalize_target_object(plant) + data.update( + { + "plant_type_uuids": [ + UUID(p["id"]) for p in plant["relationships"]["plant_type"]["data"] + ], + } + ) + return data + + def get_asset_payload(self, source_data): + payload = super().get_asset_payload(source_data) + + attrs = {} + if "sex" in self.fields: + attrs["sex"] = source_data["sex"] + if "is_sterile" in self.fields: + attrs["is_sterile"] = source_data["is_sterile"] + if "birthdate" in self.fields: + attrs["birthdate"] = self.format_datetime(source_data["birthdate"]) + + rels = {} + if "plant_type_uuids" in self.fields: + rels["plant_type"] = {"data": []} + for uuid in source_data["plant_type_uuids"]: + rels["plant_type"]["data"].append( + { + "id": str(uuid), + "type": "taxonomy_term--plant_type", + } + ) + + payload["attributes"].update(attrs) + if rels: + payload.setdefault("relationships", {}).update(rels) + + return payload + + class StructureAssetImporter(ToFarmOSAsset): model_title = "StructureAsset" diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index ffd78b7..e11663f 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -98,6 +98,8 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter + importers["PlantAsset"] = PlantAssetImporter + importers["Unit"] = UnitImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter @@ -183,6 +185,28 @@ class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporte } +class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter): + """ + WuttaFarm → farmOS API exporter for Units + """ + + source_model_class = model.Unit + + supported_fields = [ + "uuid", + "name", + ] + + drupal_internal_id_field = "drupal_internal__tid" + + def normalize_source_object(self, unit): + return { + "uuid": unit.farmos_uuid or self.app.make_true_uuid(), + "name": unit.name, + "_src_object": unit, + } + + class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter): """ WuttaFarm → farmOS API exporter for Group Assets @@ -239,6 +263,32 @@ class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter) } +class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter): + """ + WuttaFarm → farmOS API exporter for Plant Assets + """ + + source_model_class = model.PlantAsset + + supported_fields = [ + "uuid", + "asset_name", + "plant_type_uuids", + "notes", + "archived", + ] + + def normalize_source_object(self, plant): + return { + "uuid": plant.farmos_uuid or self.app.make_true_uuid(), + "asset_name": plant.asset_name, + "plant_type_uuids": [t.plant_type.farmos_uuid for t in plant._plant_types], + "notes": plant.notes, + "archived": plant.archived, + "_src_object": plant, + } + + class StructureAssetImporter( FromWuttaFarm, farmos_importing.model.StructureAssetImporter ): diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index d1cac19..e17825b 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -53,6 +53,7 @@ class FromFarmOSHandler(ImportHandler): token = self.get_farmos_oauth2_token() self.farmos_client = self.app.get_farmos_client(token=token) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + self.normal = self.app.get_normalizer(self.farmos_client) def get_farmos_oauth2_token(self): @@ -76,6 +77,7 @@ class FromFarmOSHandler(ImportHandler): kwargs = super().get_importer_kwargs(key, **kwargs) kwargs["farmos_client"] = self.farmos_client kwargs["farmos_4x"] = self.farmos_4x + kwargs["normal"] = self.normal return kwargs @@ -106,6 +108,10 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["GroupAsset"] = GroupAssetImporter importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter + importers["Measure"] = MeasureImporter + importers["Unit"] = UnitImporter + importers["QuantityType"] = QuantityTypeImporter + importers["StandardQuantity"] = StandardQuantityImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter @@ -821,6 +827,95 @@ class UserImporter(FromFarmOS, ToWutta): ############################## +class MeasureImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Measures + """ + + model_class = model.Measure + + key = "drupal_id" + + supported_fields = [ + "drupal_id", + "name", + ] + + def get_source_objects(self): + """ """ + response = self.farmos_client.session.get( + self.app.get_farmos_url("/api/quantity/standard/resource/schema") + ) + response.raise_for_status() + data = response.json() + return data["definitions"]["attributes"]["properties"]["measure"]["oneOf"] + + def normalize_source_object(self, measure): + """ """ + return { + "drupal_id": measure["const"], + "name": measure["title"], + } + + +class UnitImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Units + """ + + model_class = model.Unit + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + result = self.farmos_client.resource.get("taxonomy_term", "unit") + return result["data"] + + def normalize_source_object(self, unit): + """ """ + return { + "farmos_uuid": UUID(unit["id"]), + "drupal_id": unit["attributes"]["drupal_internal__tid"], + "name": unit["attributes"]["name"], + "description": unit["attributes"]["description"], + } + + +class QuantityTypeImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Quantity Types + """ + + model_class = model.QuantityType + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + result = self.farmos_client.resource.get("quantity_type") + return result["data"] + + def normalize_source_object(self, quantity_type): + """ """ + return { + "farmos_uuid": UUID(quantity_type["id"]), + "drupal_id": quantity_type["attributes"]["drupal_internal__id"], + "name": quantity_type["attributes"]["label"], + "description": quantity_type["attributes"]["description"], + } + + class LogTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Log Types @@ -888,33 +983,25 @@ class LogImporterBase(FromFarmOS, ToWutta): def get_source_objects(self): """ """ log_type = self.get_farmos_log_type() - result = self.farmos_client.log.get(log_type) - return result["data"] - - def get_asset_type(self, asset): - return asset["type"].split("--")[1] + return list(self.farmos_client.log.iterate(log_type)) def normalize_source_object(self, log): """ """ - if notes := log["attributes"]["notes"]: - notes = notes["value"] + data = self.normal.normalize_farmos_log(log) + + data["farmos_uuid"] = UUID(data.pop("uuid")) + data["message"] = data.pop("name") + data["timestamp"] = self.app.make_utc(data["timestamp"]) + + # TODO + data["log_type"] = self.get_farmos_log_type() - assets = None if "assets" in self.fields: - assets = [] - for asset in log["relationships"]["asset"]["data"]: - assets.append((self.get_asset_type(asset), UUID(asset["id"]))) + data["assets"] = [ + (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] + ] - return { - "farmos_uuid": UUID(log["id"]), - "drupal_id": log["attributes"]["drupal_internal__id"], - "log_type": self.get_farmos_log_type(), - "message": log["attributes"]["name"], - "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), - "notes": notes, - "status": log["attributes"]["status"], - "assets": assets, - } + return data def normalize_target_object(self, log): data = super().normalize_target_object(log) @@ -1040,3 +1127,134 @@ class ObservationLogImporter(LogImporterBase): "status", "assets", ] + + +class QuantityImporterBase(FromFarmOS, ToWutta): + """ + Base class for farmOS API → WuttaFarm quantity importers + """ + + def get_farmos_quantity_type(self): + return self.model_class.__wutta_hint__["farmos_quantity_type"] + + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare proxy fields + fields.extend( + [ + "farmos_uuid", + "drupal_id", + "quantity_type_id", + "measure_id", + "value_numerator", + "value_denominator", + "units_uuid", + "label", + ] + ) + return fields + + def setup(self): + super().setup() + model = self.app.model + + self.quantity_types_by_farmos_uuid = {} + for quantity_type in self.target_session.query(model.QuantityType): + if quantity_type.farmos_uuid: + self.quantity_types_by_farmos_uuid[quantity_type.farmos_uuid] = ( + quantity_type + ) + + self.units_by_farmos_uuid = {} + for unit in self.target_session.query(model.Unit): + if unit.farmos_uuid: + self.units_by_farmos_uuid[unit.farmos_uuid] = unit + + def get_source_objects(self): + """ """ + quantity_type = self.get_farmos_quantity_type() + result = self.farmos_client.resource.get("quantity", quantity_type) + return result["data"] + + def get_quantity_type_by_farmos_uuid(self, uuid): + if hasattr(self, "quantity_types_by_farmos_uuid"): + return self.quantity_types_by_farmos_uuid.get(UUID(uuid)) + + model = self.app.model + return ( + self.target_session.query(model.QuantityType) + .filter(model.QuantityType.farmos_uuid == uuid) + .one() + ) + + def get_unit_by_farmos_uuid(self, uuid): + if hasattr(self, "units_by_farmos_uuid"): + return self.units_by_farmos_uuid.get(UUID(uuid)) + + model = self.app.model + return ( + self.target_session.query(model.Unit) + .filter(model.Unit.farmos_uuid == uuid) + .one() + ) + + def normalize_source_object(self, quantity): + """ """ + quantity_type_id = None + units_uuid = None + if relationships := quantity.get("relationships"): + + if quantity_type := relationships.get("quantity_type"): + if quantity_type["data"]: + if wf_quantity_type := self.get_quantity_type_by_farmos_uuid( + quantity_type["data"]["id"] + ): + quantity_type_id = wf_quantity_type.drupal_id + + if units := relationships.get("units"): + if units["data"]: + if wf_unit := self.get_unit_by_farmos_uuid(units["data"]["id"]): + units_uuid = wf_unit.uuid + + if not quantity_type_id: + log.warning( + "missing/invalid quantity_type for farmOS Quantity: %s", quantity + ) + return None + + if not units_uuid: + log.warning("missing/invalid units for farmOS Quantity: %s", quantity) + return None + + value = quantity["attributes"]["value"] + + return { + "farmos_uuid": UUID(quantity["id"]), + "drupal_id": quantity["attributes"]["drupal_internal__id"], + "quantity_type_id": quantity_type_id, + "measure_id": quantity["attributes"]["measure"], + "value_numerator": value["numerator"], + "value_denominator": value["denominator"], + "units_uuid": units_uuid, + "label": quantity["attributes"]["label"], + } + + +class StandardQuantityImporter(QuantityImporterBase): + """ + farmOS API → WuttaFarm importer for Standard Quantities + """ + + model_class = model.StandardQuantity + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "quantity_type_id", + "measure_id", + "value_numerator", + "value_denominator", + "units_uuid", + "label", + ] diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py new file mode 100644 index 0000000..ca7be39 --- /dev/null +++ b/src/wuttafarm/normal.py @@ -0,0 +1,199 @@ +# -*- 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 . +# +################################################################################ +""" +Data normalizer for WuttaFarm / farmOS +""" + +import datetime + +from wuttjamaican.app import GenericHandler + + +class Normalizer(GenericHandler): + """ + Base class and default implementation for the global data + normalizer. This should be used for normalizing records from + WuttaFarm and/or farmOS. + + The point here is to have a single place to put the normalization + logic, and let it be another thing which can be customized via + subclass. + """ + + _farmos_units = None + _farmos_measures = None + + def __init__(self, config, farmos_client=None): + super().__init__(config) + self.farmos_client = farmos_client + + def get_farmos_measures(self): + if self._farmos_measures: + return self._farmos_measures + + measures = {} + response = self.farmos_client.session.get( + self.app.get_farmos_url("/api/quantity/standard/resource/schema") + ) + response.raise_for_status() + data = response.json() + for measure in data["definitions"]["attributes"]["properties"]["measure"][ + "oneOf" + ]: + measures[measure["const"]] = measure["title"] + + self._farmos_measures = measures + return self._farmos_measures + + def get_farmos_measure_name(self, measure_id): + measures = self.get_farmos_measures() + return measures[measure_id] + + def get_farmos_unit(self, uuid): + units = self.get_farmos_units() + return units[uuid] + + def get_farmos_units(self): + if self._farmos_units: + return self._farmos_units + + units = {} + result = self.farmos_client.resource.get("taxonomy_term", "unit") + for unit in result["data"]: + units[unit["id"]] = unit + + self._farmos_units = units + return self._farmos_units + + def normalize_farmos_log(self, log, included={}): + + if timestamp := log["attributes"]["timestamp"]: + timestamp = datetime.datetime.fromisoformat(timestamp) + timestamp = self.app.localtime(timestamp) + + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + log_type_object = {} + log_type_uuid = None + asset_objects = [] + quantity_objects = [] + quantity_uuids = [] + owner_objects = [] + owner_uuids = [] + if relationships := log.get("relationships"): + + if log_type := relationships.get("log_type"): + log_type_uuid = log_type["data"]["id"] + if log_type := included.get(log_type_uuid): + log_type_object = { + "uuid": log_type["id"], + "name": log_type["attributes"]["label"], + } + + if assets := relationships.get("asset"): + for asset in assets["data"]: + asset_object = { + "uuid": asset["id"], + "type": asset["type"], + "asset_type": asset["type"].split("--")[1], + } + if asset := included.get(asset["id"]): + attrs = asset["attributes"] + rels = asset["relationships"] + asset_object.update( + { + "drupal_id": attrs["drupal_internal__id"], + "name": attrs["name"], + "is_location": attrs["is_location"], + "is_fixed": attrs["is_fixed"], + "archived": attrs["archived"], + "notes": attrs["notes"], + } + ) + asset_objects.append(asset_object) + + if quantities := relationships.get("quantity"): + for quantity in quantities["data"]: + quantity_uuid = quantity["id"] + quantity_uuids.append(quantity_uuid) + if quantity := included.get(quantity_uuid): + attrs = quantity["attributes"] + rels = quantity["relationships"] + value = attrs["value"] + + unit_uuid = rels["units"]["data"]["id"] + unit = self.get_farmos_unit(unit_uuid) + + measure_id = attrs["measure"] + + quantity_objects.append( + { + "uuid": quantity["id"], + "drupal_id": attrs["drupal_internal__id"], + "quantity_type_uuid": rels["quantity_type"]["data"][ + "id" + ], + "quantity_type_id": rels["quantity_type"]["data"][ + "meta" + ]["drupal_internal__target_id"], + "measure_id": measure_id, + "measure_name": self.get_farmos_measure_name( + measure_id + ), + "value_numerator": value["numerator"], + "value_decimal": value["decimal"], + "value_denominator": value["denominator"], + "unit_uuid": unit_uuid, + "unit_name": unit["attributes"]["name"], + } + ) + + if owners := relationships.get("owner"): + for user in owners["data"]: + user_uuid = user["id"] + owner_uuids.append(user_uuid) + if user := included.get(user_uuid): + owner_objects.append( + { + "uuid": user["id"], + "name": user["attributes"]["name"], + } + ) + + return { + "uuid": log["id"], + "drupal_id": log["attributes"]["drupal_internal__id"], + "log_type_uuid": log_type_uuid, + "log_type": log_type_object, + "name": log["attributes"]["name"], + "timestamp": timestamp, + "assets": asset_objects, + "quantities": quantity_objects, + "quantity_uuids": quantity_uuids, + "is_group_assignment": log["attributes"]["is_group_assignment"], + "quick": log["attributes"]["quick"], + "status": log["attributes"]["status"], + "notes": notes, + "owners": owner_objects, + "owner_uuids": owner_uuids, + } diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 123f662..a5c396b 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -55,6 +55,135 @@ class AnimalTypeRef(ObjectRef): return self.request.route_url("animal_types.view", uuid=animal_type.uuid) +class LogQuick(WuttaSet): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import LogQuickWidget + + return LogQuickWidget(**kwargs) + + +class FarmOSUnitRef(colander.SchemaType): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSUnitRefWidget + + return FarmOSUnitRefWidget(**kwargs) + + +class FarmOSRef(colander.SchemaType): + + def __init__(self, request, route_prefix, *args, **kwargs): + self.values = kwargs.pop("values", None) + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + def get_values(self): + if callable(self.values): + self.values = self.values() + return self.values + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + # nb. keep a ref to this for later use + node.model_instance = appstruct + + # serialize to PK as string + return appstruct["uuid"] + + def deserialize(self, node, cstruct): + if not cstruct: + return colander.null + + # nb. deserialize to PK string, not dict + return cstruct + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSRefWidget + + if not kwargs.get("readonly"): + if "values" not in kwargs: + if values := self.get_values(): + kwargs["values"] = values + + return FarmOSRefWidget(self.request, self.route_prefix, **kwargs) + + +class FarmOSRefs(WuttaSet): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(request, *args, **kwargs) + self.route_prefix = route_prefix + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSRefsWidget + + return FarmOSRefsWidget(self.request, self.route_prefix, **kwargs) + + +class FarmOSAssetRefs(WuttaSet): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSAssetRefsWidget + + return FarmOSAssetRefsWidget(self.request, **kwargs) + + +class FarmOSLocationRefs(WuttaSet): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSLocationRefsWidget + + return FarmOSLocationRefsWidget(self.request, **kwargs) + + +class FarmOSQuantityRefs(WuttaSet): + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSQuantityRefsWidget + + return FarmOSQuantityRefsWidget(**kwargs) + + class AnimalTypeType(colander.SchemaType): def __init__(self, request, *args, **kwargs): @@ -179,6 +308,27 @@ class StructureTypeRef(ObjectRef): return self.request.route_url("structure_types.view", uuid=structure_type.uuid) +class UnitRef(ObjectRef): + """ + Custom schema type for a :class:`~wuttafarm.db.model.units.Unit` + reference field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): + model = self.app.model + return model.Unit + + def sort_query(self, query): + return query.order_by(self.model_class.name) + + def get_object_url(self, unit): + return self.request.route_url("units.view", uuid=unit.uuid) + + class UsersType(colander.SchemaType): def __init__(self, request, *args, **kwargs): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index d5bf5c2..5fc9d55 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -26,12 +26,14 @@ Custom form widgets for WuttaFarm import json import colander -from deform.widget import Widget +from deform.widget import Widget, SelectWidget from webhelpers2.html import HTML, tags from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget from wuttaweb.db import Session +from wuttafarm.web.util import render_quantity_objects + class ImageWidget(Widget): """ @@ -54,6 +56,172 @@ class ImageWidget(Widget): return super().serialize(field, cstruct, **kw) +class LogQuickWidget(Widget): + """ + Widget to display an image URL for a record. + """ + + def serialize(self, field, cstruct, **kw): + """ """ + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + items = [] + for quick in json.loads(cstruct): + items.append(HTML.tag("li", c=quick)) + return HTML.tag("ul", c=items) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSRefWidget(SelectWidget): + """ + Generic widget to display "any reference field" - as a link to + view the farmOS record it references. Only used by the farmOS + direct API views. + """ + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + try: + obj = json.loads(cstruct) + except json.JSONDecodeError: + name = dict(self.values)[cstruct] + obj = {"uuid": cstruct, "name": name} + + return tags.link_to( + obj["name"], + self.request.route_url(f"{self.route_prefix}.view", uuid=obj["uuid"]), + ) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSRefsWidget(Widget): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + links = [] + for obj in json.loads(cstruct): + url = self.request.route_url( + f"{self.route_prefix}.view", uuid=obj["uuid"] + ) + links.append(HTML.tag("li", c=tags.link_to(obj["name"], url))) + + return HTML.tag("ul", c=links) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSAssetRefsWidget(Widget): + """ + Widget to display a "Assets" field for an asset. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + assets = [] + for asset in json.loads(cstruct): + url = self.request.route_url( + f"farmos_{asset['asset_type']}_assets.view", uuid=asset["uuid"] + ) + assets.append(HTML.tag("li", c=tags.link_to(asset["name"], url))) + + return HTML.tag("ul", c=assets) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSLocationRefsWidget(Widget): + """ + Widget to display a "Locations" field for an asset. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + locations = [] + for location in json.loads(cstruct): + asset_type = location["type"].split("--")[1] + url = self.request.route_url( + f"farmos_{asset_type}_assets.view", uuid=location["uuid"] + ) + locations.append(HTML.tag("li", c=tags.link_to(location["name"], url))) + + return HTML.tag("ul", c=locations) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSQuantityRefsWidget(Widget): + """ + Widget to display a "Quantities" field for a log. + """ + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + quantities = json.loads(cstruct) + return render_quantity_objects(quantities) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSUnitRefWidget(Widget): + """ + Widget to display a "Units" field for a quantity. + """ + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + unit = json.loads(cstruct) + return unit["name"] + + return super().serialize(field, cstruct, **kw) + + class AnimalTypeWidget(Widget): """ Widget to display an "animal type" field. @@ -162,7 +330,7 @@ class StructureWidget(Widget): return tags.link_to( structure["name"], self.request.route_url( - "farmos_structures.view", uuid=structure["uuid"] + "farmos_structure_assets.view", uuid=structure["uuid"] ), ) diff --git a/src/wuttafarm/web/grids.py b/src/wuttafarm/web/grids.py new file mode 100644 index 0000000..8f4cde5 --- /dev/null +++ b/src/wuttafarm/web/grids.py @@ -0,0 +1,300 @@ +# -*- 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 . +# +################################################################################ +""" +Custom grid stuff for use with farmOS / JSONAPI +""" + +import datetime + +from wuttaweb.grids.filters import GridFilter + + +class SimpleFilter(GridFilter): + + default_verbs = ["equal", "not_equal"] + + def __init__(self, request, key, path=None, **kwargs): + super().__init__(request, key, **kwargs) + self.path = path or key + + def filter_equal(self, data, value): + if value := self.coerce_value(value): + data.add_filter(self.path, "=", value) + return data + + def filter_not_equal(self, data, value): + if value := self.coerce_value(value): + data.add_filter(self.path, "<>", value) + return data + + def filter_is_null(self, data, value): + data.add_filter(self.path, "IS NULL", None) + return data + + def filter_is_not_null(self, data, value): + data.add_filter(self.path, "IS NOT NULL", None) + return data + + +class StringFilter(SimpleFilter): + + default_verbs = ["contains", "equal", "not_equal"] + + def filter_contains(self, data, value): + if value := self.coerce_value(value): + data.add_filter(self.path, "CONTAINS", value) + return data + + +class NullableStringFilter(StringFilter): + + default_verbs = ["contains", "equal", "not_equal", "is_null", "is_not_null"] + + +class IntegerFilter(SimpleFilter): + + default_verbs = [ + "equal", + "not_equal", + "less_than", + "less_equal", + "greater_than", + "greater_equal", + ] + + def filter_less_than(self, data, value): + if value := self.coerce_value(value): + data.add_filter(self.path, "<", value) + return data + + def filter_less_equal(self, data, value): + if value := self.coerce_value(value): + data.add_filter(self.path, "<=", value) + return data + + def filter_greater_than(self, data, value): + if value := self.coerce_value(value): + data.add_filter(self.path, ">", value) + return data + + def filter_greater_equal(self, data, value): + if value := self.coerce_value(value): + data.add_filter(self.path, ">=", value) + return data + + +class NullableIntegerFilter(IntegerFilter): + + default_verbs = ["equal", "not_equal", "is_null", "is_not_null"] + + +class BooleanFilter(SimpleFilter): + + default_verbs = ["is_true", "is_false"] + + def filter_is_true(self, data, value): + data.add_filter(self.path, "=", 1) + return data + + def filter_is_false(self, data, value): + data.add_filter(self.path, "=", 0) + return data + + +class NullableBooleanFilter(BooleanFilter): + + default_verbs = ["is_true", "is_false", "is_null", "is_not_null"] + + +# TODO: this may not work, it's not used anywhere yet +class DateFilter(SimpleFilter): + + data_type = "date" + + default_verbs = [ + "equal", + "not_equal", + "greater_than", + "greater_equal", + "less_than", + "less_equal", + # 'between', + ] + + default_verb_labels = { + "equal": "on", + "not_equal": "not on", + "greater_than": "after", + "greater_equal": "on or after", + "less_than": "before", + "less_equal": "on or before", + # "between": "between", + "is_null": "is null", + "is_not_null": "is not null", + "is_any": "is any", + } + + def coerce_value(self, value): + if value: + if isinstance(value, datetime.date): + return value + + try: + dt = datetime.datetime.strptime(value, "%Y-%m-%d") + except ValueError: + log.warning("invalid date value: %s", value) + else: + return dt.date() + + return None + + +# TODO: this is not very complete yet, so far used only for animal birthdate +class DateTimeFilter(DateFilter): + + default_verbs = ["equal", "is_null", "is_not_null"] + + def coerce_value(self, value): + """ + Convert user input to a proper ``datetime.date`` object. + """ + if value: + if isinstance(value, datetime.date): + return value + + try: + dt = datetime.datetime.strptime(value, "%Y-%m-%d") + except ValueError: + log.warning("invalid date value: %s", value) + else: + return dt.date() + + return None + + def filter_equal(self, data, value): + if value := self.coerce_value(value): + + start = datetime.datetime.combine(value, datetime.time(0)) + start = self.app.localtime(start, from_utc=False) + + stop = datetime.datetime.combine( + value + datetime.timedelta(days=1), datetime.time(0) + ) + stop = self.app.localtime(stop, from_utc=False) + + data.add_filter(self.path, ">=", int(start.timestamp())) + data.add_filter(self.path, "<", int(stop.timestamp())) + + return data + + +class SimpleSorter: + + def __init__(self, key): + self.key = key + + def __call__(self, data, sortdir): + data.add_sorter(self.key, sortdir) + return data + + +class ResourceData: + + def __init__( + self, + config, + farmos_client, + content_type, + include=None, + normalizer=None, + ): + self.config = config + self.farmos_client = farmos_client + self.entity, self.bundle = content_type.split("--") + self.filters = [] + self.sorters = [] + self.include = include + self.normalizer = normalizer + self._data = None + + def __bool__(self): + return True + + def __getitem__(self, subscript): + return self.get_data()[subscript] + + def __len__(self): + return len(self._data) + + def add_filter(self, path, operator, value): + self.filters.append((path, operator, value)) + + def add_sorter(self, path, sortdir): + self.sorters.append((path, sortdir)) + + def get_data(self): + if self._data is None: + params = {} + + i = 0 + for path, operator, value in self.filters: + i += 1 + key = f"{i:03d}" + params[f"filter[{key}][condition][path]"] = path + params[f"filter[{key}][condition][operator]"] = operator + params[f"filter[{key}][condition][value]"] = value + + sorters = [] + for path, sortdir in self.sorters: + prefix = "-" if sortdir == "desc" else "" + sorters.append(f"{prefix}{path}") + if sorters: + params["sort"] = ",".join(sorters) + + # nb. while the API allows for pagination, it does not + # tell me how many total records there are (IIUC). also + # if i ask for e.g. items 21-40 (page 2 @ 20/page) i am + # not guaranteed to get 20 items even if there are plenty + # in the DB, since Drupal may filter some out based on + # permissions. (granted that may not be an issue in + # practice, but can't rule it out.) so the punchline is, + # we fetch "all" (sic) data and send it to the frontend, + # and pagination happens there. + + # TODO: if we ever try again, this sort of works... + # params["page[offset]"] = start + # params["page[limit]"] = stop - start + + if self.include: + params["include"] = self.include + + result = self.farmos_client.resource.get( + self.entity, self.bundle, params=params + ) + data = result["data"] + included = {obj["id"]: obj for obj in result.get("included", [])} + + if self.normalizer: + data = [self.normalizer(d, included) for d in data] + + self._data = data + return self._data diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index d52a6ca..6ce4a8d 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -32,12 +32,50 @@ class WuttaFarmMenuHandler(base.MenuHandler): """ def make_menus(self, request, **kwargs): - return [ - self.make_asset_menu(request), - self.make_log_menu(request), - self.make_farmos_menu(request), - self.make_admin_menu(request, include_people=True), - ] + enum = self.app.enum + mode = self.app.get_farmos_integration_mode() + + quick_menu = self.make_quick_menu(request) + admin_menu = self.make_admin_menu(request, include_people=True) + + if mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER: + return [ + quick_menu, + self.make_farmos_asset_menu(request), + self.make_farmos_log_menu(request), + self.make_farmos_other_menu(request), + admin_menu, + ] + + elif mode == enum.FARMOS_INTEGRATION_MODE_MIRROR: + return [ + quick_menu, + self.make_asset_menu(request), + self.make_log_menu(request), + self.make_farmos_full_menu(request), + admin_menu, + ] + + else: # FARMOS_INTEGRATION_MODE_NONE + return [ + quick_menu, + self.make_asset_menu(request), + self.make_log_menu(request), + admin_menu, + ] + + def make_quick_menu(self, request): + return { + "title": "Quick", + "type": "menu", + "items": [ + { + "title": "Eggs", + "route": "quick.eggs", + # "perm": "assets.list", + }, + ], + } def make_asset_menu(self, request): return { @@ -134,15 +172,41 @@ class WuttaFarmMenuHandler(base.MenuHandler): "perm": "logs_observation.list", }, {"type": "sep"}, + { + "title": "All Quantities", + "route": "quantities", + "perm": "quantities.list", + }, + { + "title": "Standard Quantities", + "route": "quantities_standard", + "perm": "quantities_standard.list", + }, + {"type": "sep"}, { "title": "Log Types", "route": "log_types", "perm": "log_types.list", }, + { + "title": "Measures", + "route": "measures", + "perm": "measures.list", + }, + { + "title": "Quantity Types", + "route": "quantity_types", + "perm": "quantity_types.list", + }, + { + "title": "Units", + "route": "units", + "perm": "units.list", + }, ], } - def make_farmos_menu(self, request): + def make_farmos_full_menu(self, request): config = request.wutta_config app = config.get_app() return { @@ -156,30 +220,30 @@ class WuttaFarmMenuHandler(base.MenuHandler): }, {"type": "sep"}, { - "title": "Animals", - "route": "farmos_animals", - "perm": "farmos_animals.list", + "title": "Animal Assets", + "route": "farmos_animal_assets", + "perm": "farmos_animal_assets.list", }, { - "title": "Groups", - "route": "farmos_groups", - "perm": "farmos_groups.list", + "title": "Group Assets", + "route": "farmos_group_assets", + "perm": "farmos_group_assets.list", }, { - "title": "Plants", - "route": "farmos_asset_plant", - "perm": "farmos_asset_plant.list", - }, - { - "title": "Structures", - "route": "farmos_structures", - "perm": "farmos_structures.list", - }, - { - "title": "Land", + "title": "Land Assets", "route": "farmos_land_assets", "perm": "farmos_land_assets.list", }, + { + "title": "Plant Assets", + "route": "farmos_plant_assets", + "perm": "farmos_plant_assets.list", + }, + { + "title": "Structure Assets", + "route": "farmos_structure_assets", + "perm": "farmos_structure_assets.list", + }, {"type": "sep"}, { "title": "Activity Logs", @@ -207,6 +271,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Land Types", + "route": "farmos_land_types", + "perm": "farmos_land_types.list", + }, { "title": "Plant Types", "route": "farmos_plant_types", @@ -217,11 +286,7 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_structure_types", "perm": "farmos_structure_types.list", }, - { - "title": "Land Types", - "route": "farmos_land_types", - "perm": "farmos_land_types.list", - }, + {"type": "sep"}, { "title": "Asset Types", "route": "farmos_asset_types", @@ -232,6 +297,155 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_log_types", "perm": "farmos_log_types.list", }, + { + "title": "Quantity Types", + "route": "farmos_quantity_types", + "perm": "farmos_quantity_types.list", + }, + { + "title": "Standard Quantities", + "route": "farmos_quantities_standard", + "perm": "farmos_quantities_standard.list", + }, + { + "title": "Units", + "route": "farmos_units", + "perm": "farmos_units.list", + }, + {"type": "sep"}, + { + "title": "Users", + "route": "farmos_users", + "perm": "farmos_users.list", + }, + ], + } + + def make_farmos_asset_menu(self, request): + config = request.wutta_config + app = config.get_app() + return { + "title": "Assets", + "type": "menu", + "items": [ + { + "title": "Animal", + "route": "farmos_animal_assets", + "perm": "farmos_animal_assets.list", + }, + { + "title": "Group", + "route": "farmos_group_assets", + "perm": "farmos_group_assets.list", + }, + { + "title": "Land", + "route": "farmos_land_assets", + "perm": "farmos_land_assets.list", + }, + { + "title": "Plant", + "route": "farmos_plant_assets", + "perm": "farmos_plant_assets.list", + }, + { + "title": "Structure", + "route": "farmos_structure_assets", + "perm": "farmos_structure_assets.list", + }, + {"type": "sep"}, + { + "title": "Animal Types", + "route": "farmos_animal_types", + "perm": "farmos_animal_types.list", + }, + { + "title": "Land Types", + "route": "farmos_land_types", + "perm": "farmos_land_types.list", + }, + { + "title": "Plant Types", + "route": "farmos_plant_types", + "perm": "farmos_plant_types.list", + }, + { + "title": "Structure Types", + "route": "farmos_structure_types", + "perm": "farmos_structure_types.list", + }, + {"type": "sep"}, + { + "title": "Asset Types", + "route": "farmos_asset_types", + "perm": "farmos_asset_types.list", + }, + ], + } + + def make_farmos_log_menu(self, request): + config = request.wutta_config + app = config.get_app() + return { + "title": "Logs", + "type": "menu", + "items": [ + { + "title": "Activity", + "route": "farmos_logs_activity", + "perm": "farmos_logs_activity.list", + }, + { + "title": "Harvest", + "route": "farmos_logs_harvest", + "perm": "farmos_logs_harvest.list", + }, + { + "title": "Medical", + "route": "farmos_logs_medical", + "perm": "farmos_logs_medical.list", + }, + { + "title": "Observation", + "route": "farmos_logs_observation", + "perm": "farmos_logs_observation.list", + }, + {"type": "sep"}, + { + "title": "Log Types", + "route": "farmos_log_types", + "perm": "farmos_log_types.list", + }, + { + "title": "Quantity Types", + "route": "farmos_quantity_types", + "perm": "farmos_quantity_types.list", + }, + { + "title": "Standard Quantities", + "route": "farmos_quantities_standard", + "perm": "farmos_quantities_standard.list", + }, + { + "title": "Units", + "route": "farmos_units", + "perm": "farmos_units.list", + }, + ], + } + + def make_farmos_other_menu(self, request): + config = request.wutta_config + app = config.get_app() + return { + "title": "farmOS", + "type": "menu", + "items": [ + { + "title": "Go to farmOS", + "url": app.get_farmos_url(), + "target": "_blank", + }, {"type": "sep"}, { "title": "Users", diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako new file mode 100644 index 0000000..3760577 --- /dev/null +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -0,0 +1,49 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> + +<%def name="form_content()"> + ${parent.form_content()} + +

farmOS

+
+ + + + + + + + + % for value, label in enum.FARMOS_INTEGRATION_MODE.items(): + + % endfor + + + + + Use farmOS-style grid links + + <${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}"> + + + + +
+ diff --git a/src/wuttafarm/web/templates/quick/form.mako b/src/wuttafarm/web/templates/quick/form.mako new file mode 100644 index 0000000..4a4f75c --- /dev/null +++ b/src/wuttafarm/web/templates/quick/form.mako @@ -0,0 +1,14 @@ +<%inherit file="/form.mako" /> + +<%def name="title()">${index_title} » ${form_title} + +<%def name="content_title()">${form_title} + +<%def name="render_form_tag()"> + +

+ ${help_text} +

+ + ${parent.render_form_tag()} + diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py index 65d637d..2d51851 100644 --- a/src/wuttafarm/web/util.py +++ b/src/wuttafarm/web/util.py @@ -23,6 +23,8 @@ Misc. utilities for web app """ +from webhelpers2.html import HTML + def save_farmos_oauth2_token(request, token): """ @@ -38,3 +40,22 @@ def save_farmos_oauth2_token(request, token): # save token to user session request.session["farmos.oauth2.token"] = token + + +def use_farmos_style_grid_links(config): + return config.get_bool(f"{config.appname}.farmos_style_grid_links", default=True) + + +def render_quantity_objects(quantities): + items = [] + for quantity in quantities: + text = render_quantity_object(quantity) + items.append(HTML.tag("li", c=text)) + return HTML.tag("ul", c=items) + + +def render_quantity_object(quantity): + measure = quantity["measure_name"] + value = quantity["value_decimal"] + unit = quantity["unit_name"] + return f"( {measure} ) {value} {unit}" diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index bb710a2..0d58a72 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -29,6 +29,10 @@ from .master import WuttaFarmMasterView def includeme(config): + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + enum = app.enum + mode = app.get_farmos_integration_mode() # wuttaweb core essential.defaults( @@ -36,23 +40,32 @@ def includeme(config): **{ "wuttaweb.views.auth": "wuttafarm.web.views.auth", "wuttaweb.views.common": "wuttafarm.web.views.common", + "wuttaweb.views.settings": "wuttafarm.web.views.settings", "wuttaweb.views.users": "wuttafarm.web.views.users", } ) # native table views - config.include("wuttafarm.web.views.asset_types") - config.include("wuttafarm.web.views.assets") - config.include("wuttafarm.web.views.land") - config.include("wuttafarm.web.views.structures") - config.include("wuttafarm.web.views.animals") - config.include("wuttafarm.web.views.groups") - config.include("wuttafarm.web.views.plants") - config.include("wuttafarm.web.views.logs") - config.include("wuttafarm.web.views.logs_activity") - config.include("wuttafarm.web.views.logs_harvest") - config.include("wuttafarm.web.views.logs_medical") - config.include("wuttafarm.web.views.logs_observation") + if mode != enum.FARMOS_INTEGRATION_MODE_WRAPPER: + config.include("wuttafarm.web.views.units") + config.include("wuttafarm.web.views.quantities") + config.include("wuttafarm.web.views.asset_types") + config.include("wuttafarm.web.views.assets") + config.include("wuttafarm.web.views.land") + config.include("wuttafarm.web.views.structures") + config.include("wuttafarm.web.views.animals") + config.include("wuttafarm.web.views.groups") + config.include("wuttafarm.web.views.plants") + config.include("wuttafarm.web.views.logs") + config.include("wuttafarm.web.views.logs_activity") + config.include("wuttafarm.web.views.logs_harvest") + config.include("wuttafarm.web.views.logs_medical") + config.include("wuttafarm.web.views.logs_observation") + + # quick form views + # (nb. these work with all integration modes) + config.include("wuttafarm.web.views.quick") # views for farmOS - config.include("wuttafarm.web.views.farmos") + if mode != enum.FARMOS_INTEGRATION_MODE_NONE: + config.include("wuttafarm.web.views.farmos") diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 72a05ee..76e0335 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -23,6 +23,8 @@ Master view for Animals """ +from webhelpers2.html import tags + from wuttaweb.forms.schema import WuttaDictEnum from wuttafarm.db.model import AnimalType, AnimalAsset @@ -153,11 +155,11 @@ class AnimalAssetView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", + "produces_eggs", "animal_type", "birthdate", "is_sterile", "sex", - "produces_eggs", "archived", ] @@ -165,9 +167,9 @@ class AnimalAssetView(AssetMasterView): "asset_name", "animal_type", "birthdate", + "produces_eggs", "sex", "is_sterile", - "produces_eggs", "notes", "asset_type", "archived", @@ -189,6 +191,10 @@ class AnimalAssetView(AssetMasterView): 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) + if self.farmos_style_grid_links: + g.set_renderer("animal_type", self.render_animal_type_for_grid) + else: + g.set_link("animal_type") # birthdate g.set_renderer("birthdate", "date") @@ -196,6 +202,10 @@ class AnimalAssetView(AssetMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) + def render_animal_type_for_grid(self, animal, field, value): + url = self.request.route_url("animal_types.view", uuid=animal.animal_type_uuid) + return tags.link_to(value, url) + def configure_form(self, form): f = form super().configure_form(f) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b918839..b78f149 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -278,29 +278,14 @@ class AssetMasterView(WuttaFarmMasterView): buttons = super().get_xref_buttons(asset) if asset.farmos_uuid: - - # TODO - route = None - if asset.asset_type == "animal": - route = "farmos_animals.view" - elif asset.asset_type == "group": - route = "farmos_groups.view" - elif asset.asset_type == "land": - route = "farmos_land_assets.view" - elif asset.asset_type == "plant": - route = "farmos_asset_plant.view" - elif asset.asset_type == "structure": - route = "farmos_structures.view" - - if route: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url(route, uuid=asset.farmos_uuid), - icon_left="eye", - ) + asset_type = self.get_model_class().__wutta_hint__["farmos_asset_type"] + route = f"farmos_{asset_type}_assets.view" + url = self.request.route_url(route, uuid=asset.farmos_uuid) + buttons.append( + self.make_button( + "View farmOS record", primary=True, url=url, icon_left="eye" ) + ) return buttons diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 121e631..f15e92b 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -51,9 +51,6 @@ class CommonView(base.CommonView): site_admin = session.query(model.Role).filter_by(name="Site Admin").first() if site_admin: site_admin_perms = [ - "activity_logs.list", - "activity_logs.view", - "activity_logs.versions", "animal_types.create", "animal_types.edit", "animal_types.list", @@ -68,14 +65,14 @@ class CommonView(base.CommonView): "asset_types.list", "asset_types.view", "asset_types.versions", + "farmos_animal_assets.list", + "farmos_animal_assets.view", "farmos_animal_types.list", "farmos_animal_types.view", - "farmos_animals.list", - "farmos_animals.view", "farmos_asset_types.list", "farmos_asset_types.view", - "farmos_groups.list", - "farmos_groups.view", + "farmos_group_assets.list", + "farmos_group_assets.view", "farmos_land_assets.list", "farmos_land_assets.view", "farmos_land_types.list", @@ -84,17 +81,23 @@ class CommonView(base.CommonView): "farmos_log_types.view", "farmos_logs_activity.list", "farmos_logs_activity.view", + "farmos_logs_harvest.list", + "farmos_logs_harvest.view", + "farmos_logs_medical.list", + "farmos_logs_medical.view", + "farmos_logs_observation.list", + "farmos_logs_observation.view", + "farmos_structure_assets.list", + "farmos_structure_assets.view", "farmos_structure_types.list", "farmos_structure_types.view", - "farmos_structures.list", - "farmos_structures.view", "farmos_users.list", "farmos_users.view", - "group_asests.create", - "group_asests.edit", - "group_asests.list", - "group_asests.view", - "group_asests.versions", + "group_assets.create", + "group_assets.edit", + "group_assets.list", + "group_assets.view", + "group_assets.versions", "land_assets.create", "land_assets.edit", "land_assets.list", @@ -106,6 +109,18 @@ class CommonView(base.CommonView): "log_types.list", "log_types.view", "log_types.versions", + "logs_activity.list", + "logs_activity.view", + "logs_activity.versions", + "logs_harvest.list", + "logs_harvest.view", + "logs_harvest.versions", + "logs_medical.list", + "logs_medical.view", + "logs_medical.versions", + "logs_observation.list", + "logs_observation.view", + "logs_observation.versions", "structure_types.list", "structure_types.view", "structure_types.versions", @@ -114,6 +129,11 @@ class CommonView(base.CommonView): "structure_assets.list", "structure_assets.view", "structure_assets.versions", + "units.create", + "units.edit", + "units.list", + "units.view", + "units.versions", ] for perm in site_admin_perms: auth.grant_permission(site_admin, perm) diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index bda5d03..e59ac1f 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,7 +28,9 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") + config.include("wuttafarm.web.views.farmos.quantities") config.include("wuttafarm.web.views.farmos.asset_types") + config.include("wuttafarm.web.views.farmos.units") config.include("wuttafarm.web.views.farmos.land_types") config.include("wuttafarm.web.views.farmos.land_assets") config.include("wuttafarm.web.views.farmos.structure_types") diff --git a/src/wuttafarm/web/views/farmos/animal_types.py b/src/wuttafarm/web/views/farmos/animal_types.py index 94d02d8..03bd42c 100644 --- a/src/wuttafarm/web/views/farmos/animal_types.py +++ b/src/wuttafarm/web/views/farmos/animal_types.py @@ -23,16 +23,10 @@ View for farmOS animal types """ -import datetime - -import colander - -from wuttaweb.forms.schema import WuttaDateTime - -from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.views.farmos.master import TaxonomyMasterView -class AnimalTypeView(FarmOSMasterView): +class AnimalTypeView(TaxonomyMasterView): """ Master view for Animal Types in farmOS. """ @@ -44,90 +38,14 @@ class AnimalTypeView(FarmOSMasterView): route_prefix = "farmos_animal_types" url_prefix = "/farmOS/animal-types" + farmos_taxonomy_type = "animal_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview" - grid_columns = [ - "name", - "description", - "changed", - ] - - sort_defaults = "name" - - form_fields = [ - "name", - "description", - "changed", - ] - - def get_grid_data(self, columns=None, session=None): - animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type") - return [self.normalize_animal_type(t) for t in animal_types["data"]] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - g.set_searchable("name") - - # changed - g.set_renderer("changed", "datetime") - - def get_instance(self): - animal_type = self.farmos_client.resource.get_id( - "taxonomy_term", "animal_type", self.request.matchdict["uuid"] - ) - self.raw_json = animal_type - return self.normalize_animal_type(animal_type["data"]) - - def get_instance_title(self, animal_type): - return animal_type["name"] - - def normalize_animal_type(self, animal_type): - - if changed := animal_type["attributes"]["changed"]: - changed = datetime.datetime.fromisoformat(changed) - changed = self.app.localtime(changed) - - if description := animal_type["attributes"]["description"]: - description = description["value"] - - return { - "uuid": animal_type["id"], - "drupal_id": animal_type["attributes"]["drupal_internal__tid"], - "name": animal_type["attributes"]["name"], - "description": description or colander.null, - "changed": changed, - } - - def configure_form(self, form): - f = form - super().configure_form(f) - - # description - f.set_widget("description", "notes") - - # changed - f.set_node("changed", WuttaDateTime()) - def get_xref_buttons(self, animal_type): + buttons = super().get_xref_buttons(animal_type) model = self.app.model session = self.Session() - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url( - f"/taxonomy/term/{animal_type['drupal_id']}" - ), - target="_blank", - icon_left="external-link-alt", - ) - ] - if wf_animal_type := ( session.query(model.AnimalType) .filter(model.AnimalType.farmos_uuid == animal_type["uuid"]) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index c9c2887..690e7ee 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -26,152 +26,159 @@ Master view for Farm Animals import datetime import colander +from webhelpers2.html import tags -from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum 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 +from wuttafarm.web.views.farmos.assets import AssetMasterView +from wuttafarm.web.grids import ( + SimpleSorter, + StringFilter, + BooleanFilter, + NullableBooleanFilter, + DateTimeFilter, +) +from wuttafarm.web.forms.schema import FarmOSRef -class AnimalView(FarmOSMasterView): +class AnimalView(AssetMasterView): """ Master view for Farm Animals """ - model_name = "farmos_animal" - model_title = "farmOS Animal" - model_title_plural = "farmOS Animals" + model_name = "farmos_animal_assets" + model_title = "farmOS Animal Asset" + model_title_plural = "farmOS Animal Assets" - route_prefix = "farmos_animals" - url_prefix = "/farmOS/animals" + route_prefix = "farmos_animal_assets" + url_prefix = "/farmOS/assets/animal" + farmos_asset_type = "animal" farmos_refurl_path = "/assets/animal" labels = { "animal_type": "Species / Breed", - "location": "Current Location", + "animal_type_name": "Species / Breed", + "is_sterile": "Sterile", } grid_columns = [ + "thumbnail", + "drupal_id", "name", + "produces_eggs", + "animal_type_name", "birthdate", - "sex", "is_sterile", + "sex", + "groups", + "owners", + "locations", "archived", ] - sort_defaults = "name" - form_fields = [ "name", "animal_type", "birthdate", + "produces_eggs", "sex", "is_sterile", - "archived", - "owners", - "location", "notes", - "raw_image_url", - "large_image_url", - "thumbnail_image_url", + "asset_type_name", + "groups", + "owners", + "locations", + "archived", + "thumbnail_url", + "image_url", + "thumbnail", "image", ] - def get_grid_data(self, columns=None, session=None): - animals = self.farmos_client.resource.get("asset", "animal") - return [self.normalize_animal(a) for a in animals["data"]] + def get_farmos_api_includes(self): + includes = super().get_farmos_api_includes() + includes.add("animal_type") + includes.add("group") + return includes def configure_grid(self, grid): g = grid super().configure_grid(g) + enum = self.app.enum - # name - g.set_link("name") - g.set_searchable("name") + # produces_eggs + g.set_renderer("produces_eggs", "boolean") + g.set_sorter("produces_eggs", SimpleSorter("produces_eggs")) + g.set_filter("produces_eggs", NullableBooleanFilter) + + # animal_type_name + if self.farmos_style_grid_links: + g.set_renderer("animal_type_name", self.render_animal_type_for_grid) + else: + g.set_link("animal_type_name") + g.set_sorter("animal_type_name", SimpleSorter("animal_type.name")) + g.set_filter("animal_type_name", StringFilter, path="animal_type.name") # birthdate g.set_renderer("birthdate", "date") + g.set_sorter("birthdate", SimpleSorter("birthdate")) + g.set_filter("birthdate", DateTimeFilter) + + # sex + g.set_enum("sex", enum.ANIMAL_SEX) + g.set_sorter("sex", SimpleSorter("sex")) + g.set_filter("sex", StringFilter) + + # groups + g.set_label("groups", "Group Membership") + g.set_renderer("groups", self.render_groups_for_grid) # is_sterile g.set_renderer("is_sterile", "boolean") + g.set_sorter("is_sterile", SimpleSorter("is_sterile")) + g.set_filter("is_sterile", BooleanFilter) - # archived - g.set_renderer("archived", "boolean") + def render_animal_type_for_grid(self, animal, field, value): + uuid = animal["animal_type"]["uuid"] + url = self.request.route_url("farmos_animal_types.view", uuid=uuid) + return tags.link_to(value, url) + + def render_groups_for_grid(self, animal, field, value): + groups = [] + for group in animal["group_objects"]: + if self.farmos_style_grid_links: + url = self.request.route_url( + "farmos_group_assets.view", uuid=group["uuid"] + ) + groups.append(tags.link_to(group["name"], url)) + else: + groups.append(group["name"]) + return ", ".join(groups) def get_instance(self): - animal = self.farmos_client.resource.get_id( - "asset", "animal", self.request.matchdict["uuid"] - ) - self.raw_json = animal + data = super().get_instance() - # instance data - data = self.normalize_animal(animal["data"]) - - if relationships := animal["data"].get("relationships"): + if relationships := self.raw_json["data"].get("relationships"): # add animal type - if animal_type := relationships.get("animal_type"): - if animal_type["data"]: - animal_type = self.farmos_client.resource.get_id( - "taxonomy_term", "animal_type", animal_type["data"]["id"] - ) - data["animal_type"] = { - "uuid": animal_type["data"]["id"], - "name": animal_type["data"]["attributes"]["name"], - } - - # add location - if location := relationships.get("location"): - if location["data"]: - location = self.farmos_client.resource.get_id( - "asset", "structure", location["data"][0]["id"] - ) - data["location"] = { - "uuid": location["data"]["id"], - "name": location["data"]["attributes"]["name"], - } - - # add owners - if owner := relationships.get("owner"): - data["owners"] = [] - for owner_data in owner["data"]: - owner = self.farmos_client.resource.get_id( - "user", "user", owner_data["id"] - ) - data["owners"].append( - { - "uuid": owner["data"]["id"], - "display_name": owner["data"]["attributes"]["display_name"], + if not data.get("animal_type"): + if animal_type := relationships.get("animal_type"): + if animal_type["data"]: + animal_type = self.farmos_client.resource.get_id( + "taxonomy_term", "animal_type", animal_type["data"]["id"] + ) + data["animal_type"] = { + "uuid": animal_type["data"]["id"], + "name": animal_type["data"]["attributes"]["name"], } - ) - - # add image urls - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - data["raw_image_url"] = self.app.get_farmos_url( - image["data"]["attributes"]["uri"]["url"] - ) - # nb. other styles available: medium, wide - data["large_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["large"] - data["thumbnail_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["thumbnail"] return data - def get_instance_title(self, animal): - return animal["name"] - - def normalize_animal(self, animal): + def normalize_asset(self, animal, included): + normal = super().normalize_asset(animal, included) birthdate = animal["attributes"]["birthdate"] if birthdate: @@ -184,87 +191,138 @@ class AnimalView(FarmOSMasterView): else: sterile = animal["attributes"]["is_castrated"] - if notes := animal["attributes"]["notes"]: - notes = notes["value"] + animal_type_object = None + group_objects = [] + group_names = [] + if relationships := animal.get("relationships"): - if self.farmos_4x: - archived = animal["attributes"]["archived"] - else: - archived = animal["attributes"]["status"] == "archived" + if animal_type := relationships.get("animal_type"): + if animal_type := included.get(animal_type["data"]["id"]): + animal_type_object = { + "uuid": animal_type["id"], + "name": animal_type["attributes"]["name"], + } - return { - "uuid": animal["id"], - "drupal_id": animal["attributes"]["drupal_internal__id"], - "name": animal["attributes"]["name"], - "birthdate": birthdate, - "sex": animal["attributes"]["sex"] or colander.null, - "is_sterile": sterile, - "location": colander.null, # TODO - "archived": archived, - "notes": notes or colander.null, - } + if groups := relationships.get("group"): + for group in groups["data"]: + if group := included.get(group["id"]): + group = { + "uuid": group["id"], + "name": group["attributes"]["name"], + } + group_objects.append(group) + group_names.append(group["name"]) + + normal.update( + { + "animal_type": animal_type_object, + "animal_type_uuid": animal_type_object["uuid"], + "animal_type_name": animal_type_object["name"], + "group_objects": group_objects, + "group_names": group_names, + "birthdate": birthdate, + "sex": animal["attributes"]["sex"] or colander.null, + "is_sterile": sterile, + "produces_eggs": animal["attributes"].get("produces_eggs"), + } + ) + + return normal + + def get_animal_types(self): + animal_types = [] + result = self.farmos_client.resource.get( + "taxonomy_term", "animal_type", params={"sort": "name"} + ) + for animal_type in result["data"]: + animal_types.append((animal_type["id"], animal_type["attributes"]["name"])) + return animal_types def configure_form(self, form): f = form super().configure_form(f) + enum = self.app.enum animal = f.model_instance # animal_type - f.set_node("animal_type", AnimalTypeType(self.request)) + f.set_node( + "animal_type", + FarmOSRef( + self.request, "farmos_animal_types", values=self.get_animal_types + ), + ) + + # produces_eggs + f.set_node("produces_eggs", colander.Boolean()) # birthdate f.set_node("birthdate", WuttaDateTime()) f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + f.set_required("birthdate", False) + + # sex + if not (self.creating or self.editing) and not animal["sex"]: + pass # TODO: dict enum widget does not handle null values well + else: + f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) + f.set_required("sex", False) # is_sterile f.set_node("is_sterile", colander.Boolean()) - # location - f.set_node("location", StructureType(self.request)) + # groups + if self.creating or self.editing: + f.remove("groups") # TODO - # owners - f.set_node("owners", UsersType(self.request)) + def get_api_payload(self, animal): + payload = super().get_api_payload(animal) - # notes - f.set_widget("notes", "notes") + birthdate = None + if animal["birthdate"]: + birthdate = self.app.localtime(animal["birthdate"]).timestamp() - # archived - f.set_node("archived", colander.Boolean()) + attrs = { + "sex": animal["sex"] or None, + "is_sterile": animal["is_sterile"], + "produces_eggs": animal["produces_eggs"], + "birthdate": birthdate, + } - # image - if url := animal.get("large_image_url"): - f.set_widget("image", ImageWidget("animal image")) - f.set_default("image", url) + rels = { + "animal_type": { + "data": { + "id": animal["animal_type"], + "type": "taxonomy_term--animal_type", + } + } + } + + payload["attributes"].update(attrs) + payload.setdefault("relationships", {}).update(rels) + return payload def get_xref_buttons(self, animal): - model = self.app.model - session = self.Session() + buttons = super().get_xref_buttons(animal) - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url(f"/asset/{animal['drupal_id']}"), - target="_blank", - icon_left="external-link-alt", - ), - ] + if self.app.is_farmos_mirror(): + model = self.app.model + session = self.Session() - if wf_animal := ( - session.query(model.Asset) - .filter(model.Asset.farmos_uuid == animal["uuid"]) - .first() - ): - buttons.append( - self.make_button( - f"View {self.app.get_title()} record", - primary=True, - url=self.request.route_url( - "animal_assets.view", uuid=wf_animal.uuid - ), - icon_left="eye", + if wf_animal := ( + session.query(model.Asset) + .filter(model.Asset.farmos_uuid == animal["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "animal_assets.view", uuid=wf_animal.uuid + ), + icon_left="eye", + ) ) - ) return buttons diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py new file mode 100644 index 0000000..d1ae226 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -0,0 +1,317 @@ +# -*- 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 . +# +################################################################################ +""" +Base class for Asset master views +""" + +import colander +from webhelpers2.html import tags + +from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.forms.schema import FarmOSRefs, FarmOSLocationRefs +from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.web.grids import ( + ResourceData, + StringFilter, + IntegerFilter, + BooleanFilter, + SimpleSorter, +) + + +class AssetMasterView(FarmOSMasterView): + """ + Base class for Asset master views + """ + + farmos_asset_type = None + creatable = True + editable = True + deletable = True + filterable = True + sort_on_backend = True + + labels = { + "name": "Asset Name", + "asset_type_name": "Asset Type", + "owners": "Owner", + "locations": "Location", + "thumbnail_url": "Thumbnail URL", + "image_url": "Image URL", + } + + grid_columns = [ + "thumbnail", + "drupal_id", + "name", + "owners", + "locations", + "archived", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + "archived": {"active": True, "verb": "is_false"}, + } + + def get_grid_data(self, **kwargs): + return ResourceData( + self.config, + self.farmos_client, + f"asset--{self.farmos_asset_type}", + include=",".join(self.get_farmos_api_includes()), + normalizer=self.normalize_asset, + ) + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # thumbnail + g.set_renderer("thumbnail", self.render_grid_thumbnail) + g.set_label("thumbnail", "", column_only=True) + g.set_centered("thumbnail") + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", SimpleSorter("drupal_internal__id")) + g.set_filter("drupal_id", IntegerFilter, path="drupal_internal__id") + + # name + g.set_link("name") + g.set_sorter("name", SimpleSorter("name")) + g.set_filter("name", StringFilter) + + # owners + g.set_renderer("owners", self.render_owners_for_grid) + + # locations + g.set_renderer("locations", self.render_locations_for_grid) + + # archived + g.set_renderer("archived", "boolean") + g.set_sorter("archived", SimpleSorter("archived")) + g.set_filter("archived", BooleanFilter) + + def render_grid_thumbnail(self, obj, field, value): + if url := obj.get("thumbnail_url"): + return tags.image(url, f"thumbnail for {self.get_model_title()}") + return None + + def render_locations_for_grid(self, asset, field, value): + locations = [] + for location in value: + if self.farmos_style_grid_links: + asset_type = location["type"].split("--")[1] + route = f"farmos_{asset_type}_assets.view" + url = self.request.route_url(route, uuid=location["uuid"]) + locations.append(tags.link_to(location["name"], url)) + else: + locations.append(location["name"]) + return ", ".join(locations) + + def grid_row_class(self, asset, data, i): + """ """ + if asset["archived"]: + return "has-background-warning" + return None + + def get_farmos_api_includes(self): + return {"asset_type", "location", "owner", "image"} + + def get_instance(self): + result = self.farmos_client.asset.get_id( + self.farmos_asset_type, + self.request.matchdict["uuid"], + params={"include": ",".join(self.get_farmos_api_includes())}, + ) + self.raw_json = result + included = {obj["id"]: obj for obj in result.get("included", [])} + return self.normalize_asset(result["data"], included) + + def get_instance_title(self, asset): + return asset["name"] + + def normalize_asset(self, asset, included): + + if notes := asset["attributes"]["notes"]: + notes = notes["value"] + + if self.farmos_4x: + archived = asset["attributes"]["archived"] + else: + archived = asset["attributes"]["status"] == "archived" + + asset_type_object = {} + asset_type_name = None + owner_objects = [] + owner_names = [] + location_objects = [] + location_names = [] + thumbnail_url = None + image_url = None + if relationships := asset.get("relationships"): + + if asset_type := relationships.get("asset_type"): + if asset_type := included.get(asset_type["data"]["id"]): + asset_type_object = { + "uuid": asset_type["id"], + "name": asset_type["attributes"]["label"], + } + asset_type_name = asset_type_object["name"] + + if owners := relationships.get("owner"): + for user in owners["data"]: + if user := included.get(user["id"]): + user = { + "uuid": user["id"], + "name": user["attributes"]["name"], + } + owner_objects.append(user) + owner_names.append(user["name"]) + + if locations := relationships.get("location"): + for location in locations["data"]: + if location := included.get(location["id"]): + location = { + "uuid": location["id"], + "type": location["type"], + "name": location["attributes"]["name"], + } + location_objects.append(location) + location_names.append(location["name"]) + + if images := relationships.get("image"): + for image in images["data"]: + if image := included.get(image["id"]): + thumbnail_url = image["attributes"]["image_style_uri"][ + "thumbnail" + ] + image_url = image["attributes"]["image_style_uri"]["large"] + + return { + "uuid": asset["id"], + "drupal_id": asset["attributes"]["drupal_internal__id"], + "name": asset["attributes"]["name"], + "asset_type": asset_type_object, + "asset_type_name": asset_type_name, + "notes": notes or colander.null, + "owners": owner_objects, + "owner_names": owner_names, + "locations": location_objects, + "location_names": location_names, + "archived": archived, + "thumbnail_url": thumbnail_url or colander.null, + "image_url": image_url or colander.null, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + asset = f.model_instance + + # asset_type_name + if self.creating or self.editing: + f.remove("asset_type_name") + + # locations + if self.creating or self.editing: + f.remove("locations") + else: + f.set_node("locations", FarmOSLocationRefs(self.request)) + + # owners + if self.creating or self.editing: + f.remove("owners") # TODO + else: + f.set_node("owners", FarmOSRefs(self.request, "farmos_users")) + + # notes + f.set_widget("notes", "notes") + f.set_required("notes", False) + + # archived + f.set_node("archived", colander.Boolean()) + + # thumbnail_url + if self.creating or self.editing: + f.remove("thumbnail_url") + + # image_url + if self.creating or self.editing: + f.remove("image_url") + + # thumbnail + if self.creating or self.editing: + f.remove("thumbnail") + elif asset.get("thumbnail_url"): + f.set_widget("thumbnail", ImageWidget("asset thumbnail")) + f.set_default("thumbnail", asset["thumbnail_url"]) + + # image + if self.creating or self.editing: + f.remove("image") + elif asset.get("image_url"): + f.set_widget("image", ImageWidget("asset image")) + f.set_default("image", asset["image_url"]) + + def persist(self, asset, session=None): + payload = self.get_api_payload(asset) + if self.editing: + payload["id"] = asset["uuid"] + + result = self.farmos_client.asset.send(self.farmos_asset_type, payload) + + if self.creating: + asset["uuid"] = result["data"]["id"] + + def get_api_payload(self, asset): + + attrs = { + "name": asset["name"], + "notes": {"value": asset["notes"] or None}, + "archived": asset["archived"], + } + + if "is_location" in asset: + attrs["is_location"] = asset["is_location"] + + if "is_fixed" in asset: + attrs["is_fixed"] = asset["is_fixed"] + + return {"attributes": attrs} + + def delete_instance(self, asset): + self.farmos_client.asset.delete(self.farmos_asset_type, asset["uuid"]) + + def get_xref_buttons(self, asset): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/asset/{asset['drupal_id']}"), + target="_blank", + icon_left="external-link-alt", + ), + ] diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index ddb7278..8794965 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -41,7 +41,7 @@ class GroupView(FarmOSMasterView): model_title = "farmOS Group" model_title_plural = "farmOS Groups" - route_prefix = "farmos_groups" + route_prefix = "farmos_group_assets" url_prefix = "/farmOS/groups" farmos_refurl_path = "/assets/group" diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index a3e804f..f20eb0e 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -23,14 +23,27 @@ View for farmOS Harvest Logs """ -import datetime +from webhelpers2.html import tags -import colander - -from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum, Notes from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.grids import ( + ResourceData, + SimpleSorter, + StringFilter, + IntegerFilter, + DateTimeFilter, + NullableBooleanFilter, +) +from wuttafarm.web.forms.schema import ( + FarmOSQuantityRefs, + FarmOSAssetRefs, + FarmOSRefs, + LogQuick, +) +from wuttafarm.web.util import render_quantity_objects class LogMasterView(FarmOSMasterView): @@ -39,75 +52,180 @@ class LogMasterView(FarmOSMasterView): """ farmos_log_type = None + filterable = True + sort_on_backend = True + + labels = { + "name": "Log Name", + "log_type_name": "Log Type", + "quantities": "Quantity", + } grid_columns = [ - "name", - "timestamp", "status", + "drupal_id", + "timestamp", + "name", + "assets", + "quantities", + "is_group_assignment", + "owners", ] sort_defaults = ("timestamp", "desc") + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + "status": {"active": True, "verb": "not_equal", "value": "abandoned"}, + } + form_fields = [ "name", "timestamp", - "status", + "assets", + "quantities", "notes", + "status", + "log_type_name", + "owners", + "quick", + "drupal_id", ] - def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.log.get(self.farmos_log_type) - return [self.normalize_log(l) for l in result["data"]] + def get_farmos_api_includes(self): + return {"log_type", "quantity", "asset", "owner"} + + def get_grid_data(self, **kwargs): + return ResourceData( + self.config, + self.farmos_client, + f"log--{self.farmos_log_type}", + include=",".join(self.get_farmos_api_includes()), + normalizer=self.normalize_log, + ) def configure_grid(self, grid): g = grid super().configure_grid(g) + enum = self.app.enum + + # status + g.set_enum("status", enum.LOG_STATUS) + g.set_sorter("status", SimpleSorter("status")) + g.set_filter( + "status", + StringFilter, + choices=enum.LOG_STATUS, + verbs=["equal", "not_equal"], + ) + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", SimpleSorter("drupal_internal__id")) + g.set_filter("drupal_id", IntegerFilter, path="drupal_internal__id") + + # timestamp + g.set_renderer("timestamp", "date") + g.set_link("timestamp") + g.set_sorter("timestamp", SimpleSorter("timestamp")) + g.set_filter("timestamp", DateTimeFilter) # name g.set_link("name") - g.set_searchable("name") + g.set_sorter("name", SimpleSorter("name")) + g.set_filter("name", StringFilter) - # timestamp - g.set_renderer("timestamp", "datetime") + # assets + g.set_renderer("assets", self.render_assets_for_grid) + + # quantities + g.set_renderer("quantities", self.render_quantities_for_grid) + + # is_group_assignment + g.set_renderer("is_group_assignment", "boolean") + g.set_sorter("is_group_assignment", SimpleSorter("is_group_assignment")) + g.set_filter("is_group_assignment", NullableBooleanFilter) + + # owners + g.set_label("owners", "Owner") + g.set_renderer("owners", self.render_owners_for_grid) + + def render_assets_for_grid(self, log, field, value): + assets = [] + for asset in value: + if self.farmos_style_grid_links: + url = self.request.route_url( + f"farmos_{asset['asset_type']}_assets.view", uuid=asset["uuid"] + ) + assets.append(tags.link_to(asset["name"], url)) + else: + assets.append(asset["name"]) + return ", ".join(assets) + + def render_quantities_for_grid(self, log, field, value): + if not value: + return None + return render_quantity_objects(value) + + def grid_row_class(self, log, data, i): + if log["status"] == "pending": + return "has-background-warning" + if log["status"] == "abandoned": + return "has-background-danger" + return None def get_instance(self): - log = self.farmos_client.log.get_id( - self.farmos_log_type, self.request.matchdict["uuid"] + result = self.farmos_client.log.get_id( + self.farmos_log_type, + self.request.matchdict["uuid"], + params={"include": ",".join(self.get_farmos_api_includes())}, ) - self.raw_json = log - return self.normalize_log(log["data"]) + self.raw_json = result + included = {obj["id"]: obj for obj in result.get("included", [])} + return self.normalize_log(result["data"], included) def get_instance_title(self, log): return log["name"] - def normalize_log(self, log): - - if timestamp := log["attributes"]["timestamp"]: - timestamp = datetime.datetime.fromisoformat(timestamp) - timestamp = self.app.localtime(timestamp) - - if notes := log["attributes"]["notes"]: - notes = notes["value"] - - return { - "uuid": log["id"], - "drupal_id": log["attributes"]["drupal_internal__id"], - "name": log["attributes"]["name"], - "timestamp": timestamp, - "status": log["attributes"]["status"], - "notes": notes or colander.null, - } + def normalize_log(self, log, included): + data = self.normal.normalize_farmos_log(log, included) + data.update( + { + "log_type_name": data["log_type"].get("name"), + } + ) + return data def configure_form(self, form): f = form super().configure_form(f) + enum = self.app.enum + log = f.model_instance # timestamp f.set_node("timestamp", WuttaDateTime()) f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) + # assets + f.set_node("assets", FarmOSAssetRefs(self.request)) + + # quantities + f.set_node("quantities", FarmOSQuantityRefs(self.request)) + # notes - f.set_widget("notes", "notes") + f.set_node("notes", Notes()) + + # status + f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) + + # owners + if self.creating or self.editing: + f.remove("owners") # TODO + else: + f.set_node("owners", FarmOSRefs(self.request, "farmos_users")) + + # quick + f.set_node("quick", LogQuick(self.request)) def get_xref_buttons(self, log): model = self.app.model diff --git a/src/wuttafarm/web/views/farmos/logs_harvest.py b/src/wuttafarm/web/views/farmos/logs_harvest.py index 0f39a5a..08b2629 100644 --- a/src/wuttafarm/web/views/farmos/logs_harvest.py +++ b/src/wuttafarm/web/views/farmos/logs_harvest.py @@ -41,6 +41,17 @@ class HarvestLogView(LogMasterView): farmos_log_type = "harvest" farmos_refurl_path = "/logs/harvest" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "name", + "assets", + "quantities", + "is_group_assignment", + "owners", + ] + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index fff3d2c..742ce14 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -23,13 +23,25 @@ Base class for farmOS master views """ +import datetime import json +import colander import markdown +from webhelpers2.html import tags from wuttaweb.views import MasterView +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget -from wuttafarm.web.util import save_farmos_oauth2_token +from wuttafarm.web.util import save_farmos_oauth2_token, use_farmos_style_grid_links +from wuttafarm.web.grids import ( + ResourceData, + StringFilter, + NullableStringFilter, + DateTimeFilter, + SimpleSorter, +) class FarmOSMasterView(MasterView): @@ -50,6 +62,7 @@ class FarmOSMasterView(MasterView): farmos_refurl_path = None labels = { + "drupal_id": "Drupal ID", "raw_image_url": "Raw Image URL", "large_image_url": "Large Image URL", "thumbnail_image_url": "Thumbnail Image URL", @@ -59,7 +72,9 @@ class FarmOSMasterView(MasterView): super().__init__(request, context=context) self.farmos_client = self.get_farmos_client() self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + self.normal = self.app.get_normalizer(self.farmos_client) self.raw_json = None + self.farmos_style_grid_links = use_farmos_style_grid_links(self.config) def get_farmos_client(self): token = self.request.session.get("farmos.oauth2.token") @@ -86,6 +101,16 @@ class FarmOSMasterView(MasterView): return templates + def render_owners_for_grid(self, obj, field, value): + owners = [] + for user in value: + if self.farmos_style_grid_links: + url = self.request.route_url("farmos_users.view", uuid=user["uuid"]) + owners.append(tags.link_to(user["name"], url)) + else: + owners.append(user["name"]) + return ", ".join(owners) + def get_template_context(self, context): if self.listing and self.farmos_refurl_path: @@ -100,3 +125,143 @@ class FarmOSMasterView(MasterView): ) return context + + +class TaxonomyMasterView(FarmOSMasterView): + """ + Base class for farmOS "taxonomy term" views + """ + + farmos_taxonomy_type = None + creatable = True + editable = True + deletable = True + filterable = True + sort_on_backend = True + + grid_columns = [ + "name", + "description", + "changed", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + return ResourceData( + self.config, + self.farmos_client, + f"taxonomy_term--{self.farmos_taxonomy_type}", + normalizer=self.normalize_taxonomy_term, + ) + + def normalize_taxonomy_term(self, term, included): + + if changed := term["attributes"]["changed"]: + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + if description := term["attributes"]["description"]: + description = description["value"] + + return { + "uuid": term["id"], + "drupal_id": term["attributes"]["drupal_internal__tid"], + "name": term["attributes"]["name"], + "description": description or colander.null, + "changed": changed, + } + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_sorter("name", SimpleSorter("name")) + g.set_filter("name", StringFilter) + + # description + g.set_sorter("description", SimpleSorter("description.value")) + g.set_filter("description", NullableStringFilter, path="description.value") + + # changed + g.set_renderer("changed", "datetime") + g.set_sorter("changed", SimpleSorter("changed")) + g.set_filter("changed", DateTimeFilter) + + def get_instance(self): + result = self.farmos_client.resource.get_id( + "taxonomy_term", self.farmos_taxonomy_type, self.request.matchdict["uuid"] + ) + self.raw_json = result + return self.normalize_taxonomy_term(result["data"], {}) + + def get_instance_title(self, term): + return term["name"] + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + f.set_required("description", False) + + # changed + if self.creating or self.editing: + f.remove("changed") + else: + f.set_node("changed", WuttaDateTime()) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + def get_api_payload(self, term): + + attrs = { + "name": term["name"], + } + + if description := term["description"]: + attrs["description"] = {"value": description} + else: + attrs["description"] = None + + return {"attributes": attrs} + + def persist(self, term, session=None): + payload = self.get_api_payload(term) + if self.editing: + payload["id"] = term["uuid"] + + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + + if self.creating: + term["uuid"] = result["data"]["id"] + + def delete_instance(self, term): + self.farmos_client.resource.delete( + "taxonomy_term", self.farmos_taxonomy_type, term["uuid"] + ) + + def get_xref_buttons(self, term): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/taxonomy/term/{term['drupal_id']}"), + target="_blank", + icon_left="external-link-alt", + ) + ] diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py index f02801f..57bf2d4 100644 --- a/src/wuttafarm/web/views/farmos/plants.py +++ b/src/wuttafarm/web/views/farmos/plants.py @@ -30,12 +30,13 @@ import colander from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget +from wuttafarm.web.views.farmos.master import TaxonomyMasterView from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.schema import UsersType, StructureType, FarmOSPlantTypes from wuttafarm.web.forms.widgets import ImageWidget -class PlantTypeView(FarmOSMasterView): +class PlantTypeView(TaxonomyMasterView): """ Master view for Plant Types in farmOS. """ @@ -47,90 +48,14 @@ class PlantTypeView(FarmOSMasterView): route_prefix = "farmos_plant_types" url_prefix = "/farmOS/plant-types" + farmos_taxonomy_type = "plant_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" - grid_columns = [ - "name", - "description", - "changed", - ] - - sort_defaults = "name" - - form_fields = [ - "name", - "description", - "changed", - ] - - def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.resource.get("taxonomy_term", "plant_type") - return [self.normalize_plant_type(t) for t in result["data"]] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - g.set_searchable("name") - - # changed - g.set_renderer("changed", "datetime") - - def get_instance(self): - plant_type = self.farmos_client.resource.get_id( - "taxonomy_term", "plant_type", self.request.matchdict["uuid"] - ) - self.raw_json = plant_type - return self.normalize_plant_type(plant_type["data"]) - - def get_instance_title(self, plant_type): - return plant_type["name"] - - def normalize_plant_type(self, plant_type): - - if changed := plant_type["attributes"]["changed"]: - changed = datetime.datetime.fromisoformat(changed) - changed = self.app.localtime(changed) - - if description := plant_type["attributes"]["description"]: - description = description["value"] - - return { - "uuid": plant_type["id"], - "drupal_id": plant_type["attributes"]["drupal_internal__tid"], - "name": plant_type["attributes"]["name"], - "description": description or colander.null, - "changed": changed, - } - - def configure_form(self, form): - f = form - super().configure_form(f) - - # description - f.set_widget("description", "notes") - - # changed - f.set_node("changed", WuttaDateTime()) - def get_xref_buttons(self, plant_type): + buttons = super().get_xref_buttons(plant_type) model = self.app.model session = self.Session() - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url( - f"/taxonomy/term/{plant_type['drupal_id']}" - ), - target="_blank", - icon_left="external-link-alt", - ) - ] - if wf_plant_type := ( session.query(model.PlantType) .filter(model.PlantType.farmos_uuid == plant_type["uuid"]) @@ -155,11 +80,11 @@ class PlantAssetView(FarmOSMasterView): Master view for farmOS Plant Assets """ - model_name = "farmos_asset_plant" + model_name = "farmos_plant_assets" model_title = "farmOS Plant Asset" model_title_plural = "farmOS Plant Assets" - route_prefix = "farmos_asset_plant" + route_prefix = "farmos_plant_assets" url_prefix = "/farmOS/assets/plant" farmos_refurl_path = "/assets/plant" diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py new file mode 100644 index 0000000..8aafeea --- /dev/null +++ b/src/wuttafarm/web/views/farmos/quantities.py @@ -0,0 +1,278 @@ +# -*- 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 Quantity Types +""" + +import datetime + +import colander + +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 FarmOSUnitRef + + +class QuantityTypeView(FarmOSMasterView): + """ + View for farmOS Quantity Types + """ + + model_name = "farmos_quantity_type" + model_title = "farmOS Quantity Type" + model_title_plural = "farmOS Quantity Types" + + route_prefix = "farmos_quantity_types" + url_prefix = "/farmOS/quantity-types" + + grid_columns = [ + "label", + "description", + ] + + sort_defaults = "label" + + form_fields = [ + "label", + "description", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get("quantity_type") + return [self.normalize_quantity_type(t) for t in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # label + g.set_link("label") + g.set_searchable("label") + + # description + g.set_searchable("description") + + def get_instance(self): + result = self.farmos_client.resource.get_id( + "quantity_type", "quantity_type", self.request.matchdict["uuid"] + ) + self.raw_json = result + return self.normalize_quantity_type(result["data"]) + + def get_instance_title(self, quantity_type): + return quantity_type["label"] + + def normalize_quantity_type(self, quantity_type): + return { + "uuid": quantity_type["id"], + "drupal_id": quantity_type["attributes"]["drupal_internal__id"], + "label": quantity_type["attributes"]["label"], + "description": quantity_type["attributes"]["description"], + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + def get_xref_buttons(self, quantity_type): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_quantity_type := ( + session.query(model.QuantityType) + .filter(model.QuantityType.farmos_uuid == quantity_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "quantity_types.view", uuid=wf_quantity_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +class QuantityMasterView(FarmOSMasterView): + """ + Base class for Quantity views + """ + + farmos_quantity_type = None + + grid_columns = [ + "measure", + "value", + "label", + "changed", + ] + + sort_defaults = ("changed", "desc") + + form_fields = [ + "measure", + "value", + "units", + "label", + "created", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get("quantity", self.farmos_quantity_type) + return [self.normalize_quantity(t) for t in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # value + g.set_link("value") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + quantity = self.farmos_client.resource.get_id( + "quantity", self.farmos_quantity_type, self.request.matchdict["uuid"] + ) + self.raw_json = quantity + + data = self.normalize_quantity(quantity["data"]) + + if relationships := quantity["data"].get("relationships"): + + # add units + if units := relationships.get("units"): + if units["data"]: + unit = self.farmos_client.resource.get_id( + "taxonomy_term", "unit", units["data"]["id"] + ) + data["units"] = { + "uuid": unit["data"]["id"], + "name": unit["data"]["attributes"]["name"], + } + + return data + + def get_instance_title(self, quantity): + return quantity["value"] + + def normalize_quantity(self, quantity): + + if created := quantity["attributes"]["created"]: + created = datetime.datetime.fromisoformat(created) + created = self.app.localtime(created) + + if changed := quantity["attributes"]["changed"]: + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + return { + "uuid": quantity["id"], + "drupal_id": quantity["attributes"]["drupal_internal__id"], + "measure": quantity["attributes"]["measure"], + "value": quantity["attributes"]["value"], + "label": quantity["attributes"]["label"] or colander.null, + "created": created, + "changed": changed, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # created + f.set_node("created", WuttaDateTime(self.request)) + f.set_widget("created", WuttaDateTimeWidget(self.request)) + + # changed + f.set_node("changed", WuttaDateTime(self.request)) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + # units + f.set_node("units", FarmOSUnitRef()) + + +class StandardQuantityView(QuantityMasterView): + """ + View for farmOS Standard Quantities + """ + + model_name = "farmos_standard_quantity" + model_title = "farmOS Standard Quantity" + model_title_plural = "farmOS Standard Quantities" + + route_prefix = "farmos_quantities_standard" + url_prefix = "/farmOS/quantities/standard" + + farmos_quantity_type = "standard" + + def get_xref_buttons(self, standard_quantity): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_standard_quantity := ( + session.query(model.StandardQuantity) + .join(model.Quantity) + .filter(model.Quantity.farmos_uuid == standard_quantity["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "quantities_standard.view", uuid=wf_standard_quantity.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) + QuantityTypeView.defaults(config) + + StandardQuantityView = kwargs.get( + "StandardQuantityView", base["StandardQuantityView"] + ) + StandardQuantityView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py index 550f432..b6dc97b 100644 --- a/src/wuttafarm/web/views/farmos/structures.py +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -39,11 +39,11 @@ class StructureView(FarmOSMasterView): View for farmOS Structures """ - model_name = "farmos_structure" + model_name = "farmos_structure_asset" model_title = "farmOS Structure" model_title_plural = "farmOS Structures" - route_prefix = "farmos_structures" + route_prefix = "farmos_structure_assets" url_prefix = "/farmOS/structures" farmos_refurl_path = "/assets/structure" diff --git a/src/wuttafarm/web/views/farmos/units.py b/src/wuttafarm/web/views/farmos/units.py new file mode 100644 index 0000000..397614d --- /dev/null +++ b/src/wuttafarm/web/views/farmos/units.py @@ -0,0 +1,74 @@ +# -*- 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 units +""" + +from wuttafarm.web.views.farmos.master import TaxonomyMasterView + + +class UnitView(TaxonomyMasterView): + """ + Master view for Units in farmOS. + """ + + model_name = "farmos_unit" + model_title = "farmOS Unit" + model_title_plural = "farmOS Units" + + route_prefix = "farmos_units" + url_prefix = "/farmOS/units" + + farmos_taxonomy_type = "unit" + farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" + + def get_xref_buttons(self, unit): + buttons = super().get_xref_buttons(unit) + model = self.app.model + session = self.Session() + + if wf_unit := ( + session.query(model.Unit) + .filter(model.Unit.farmos_uuid == unit["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("units.view", uuid=wf_unit.uuid), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + UnitView = kwargs.get("UnitView", base["UnitView"]) + UnitView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index cf77967..eeef49e 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -175,15 +175,21 @@ class LogMasterView(WuttaFarmMasterView): Base class for Asset master views """ + labels = { + "message": "Log Name", + "owners": "Owner", + } + grid_columns = [ "status", "drupal_id", "timestamp", "message", "assets", - "location", + # "location", "quantity", "is_group_assignment", + "owners", ] sort_defaults = ("timestamp", "desc") diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 0e25a30..2250d1b 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -27,6 +27,8 @@ from webhelpers2.html import tags from wuttaweb.views import MasterView +from wuttafarm.web.util import use_farmos_style_grid_links + class WuttaFarmMasterView(MasterView): """ @@ -49,6 +51,10 @@ class WuttaFarmMasterView(MasterView): "thumbnail_url": "Thumbnail URL", } + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.farmos_style_grid_links = use_farmos_style_grid_links(self.config) + def get_farmos_url(self, obj): return None @@ -99,4 +105,4 @@ class WuttaFarmMasterView(MasterView): def persist(self, obj, session=None): super().persist(obj, session) - self.app.export_to_farmos(obj, require=False) + self.app.auto_sync_to_farmos(obj, require=False) diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index d92949a..4bd32c6 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -183,8 +183,17 @@ class PlantAssetView(AssetMasterView): plant = f.model_instance # plant_types - f.set_node("plant_types", PlantTypeRefs(self.request)) - f.set_default("plant_types", [t.plant_type_uuid for t in plant._plant_types]) + if self.creating or self.editing: + f.remove("plant_types") # TODO: add support for this + else: + f.set_node("plant_types", PlantTypeRefs(self.request)) + f.set_default( + "plant_types", [t.plant_type_uuid for t in plant._plant_types] + ) + + # season + if self.creating or self.editing: + f.remove("season") # TODO: add support for this def defaults(config, **kwargs): diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py new file mode 100644 index 0000000..7d75290 --- /dev/null +++ b/src/wuttafarm/web/views/quantities.py @@ -0,0 +1,293 @@ +# -*- 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 Quantities +""" + +from collections import OrderedDict + +from wuttaweb.db import Session + +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.db.model import QuantityType, Quantity, StandardQuantity +from wuttafarm.web.forms.schema import UnitRef + + +def get_quantity_type_enum(config): + app = config.get_app() + model = app.model + session = Session() + quantity_types = OrderedDict() + query = session.query(model.QuantityType).order_by(model.QuantityType.name) + for quantity_type in query: + quantity_types[quantity_type.drupal_id] = quantity_type.name + return quantity_types + + +class QuantityTypeView(WuttaFarmMasterView): + """ + Master view for Quantity Types + """ + + model_class = QuantityType + route_prefix = "quantity_types" + url_prefix = "/quantity-types" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, quantity_type): + buttons = super().get_xref_buttons(quantity_type) + + if quantity_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_quantity_types.view", uuid=quantity_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +class QuantityMasterView(WuttaFarmMasterView): + """ + Base class for Quantity master views + """ + + grid_columns = [ + "drupal_id", + "as_text", + "quantity_type", + "measure", + "value", + "units", + "label", + ] + + sort_defaults = ("drupal_id", "desc") + + form_fields = [ + "quantity_type", + "as_text", + "measure", + "value", + "units", + "label", + "farmos_uuid", + "drupal_id", + ] + + def get_query(self, session=None): + """ """ + model = self.app.model + model_class = self.get_model_class() + session = session or self.Session() + query = session.query(model_class) + if model_class is not model.Quantity: + query = query.join(model.Quantity) + query = query.join(model.Measure).join(model.Unit) + return query + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + model_class = self.get_model_class() + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", model.Quantity.drupal_id) + + # as_text + g.set_renderer("as_text", self.render_as_text_for_grid) + g.set_link("as_text") + + # quantity_type + if model_class is not model.Quantity: + g.remove("quantity_type") + else: + g.set_enum("quantity_type", get_quantity_type_enum(self.config)) + + # measure + g.set_sorter("measure", model.Measure.name) + + # value + g.set_renderer("value", self.render_value_for_grid) + + # units + g.set_sorter("units", model.Unit.name) + + # label + g.set_sorter("label", model.Quantity.label) + + # view action links to final quantity record + if model_class is model.Quantity: + + def quantity_url(quantity, i): + return self.request.route_url( + f"quantities_{quantity.quantity_type_id}.view", uuid=quantity.uuid + ) + + g.add_action("view", icon="eye", url=quantity_url) + + def render_as_text_for_grid(self, quantity, field, value): + return quantity.render_as_text(self.config) + + def render_value_for_grid(self, quantity, field, value): + value = quantity.value_numerator / quantity.value_denominator + return self.app.render_quantity(value) + + def get_instance_title(self, quantity): + return quantity.render_as_text(self.config) + + def configure_form(self, form): + f = form + super().configure_form(f) + quantity = form.model_instance + + # as_text + if self.creating or self.editing: + f.remove("as_text") + else: + f.set_default("as_text", quantity.render_as_text(self.config)) + + # quantity_type + if self.creating: + f.remove("quantity_type") + else: + f.set_readonly("quantity_type") + f.set_default("quantity_type", quantity.quantity_type.name) + + # measure + if self.creating: + f.remove("measure") + else: + f.set_readonly("measure") + f.set_default("measure", quantity.measure.name) + + # value + if self.creating: + f.remove("value") + else: + value = quantity.value_numerator / quantity.value_denominator + value = self.app.render_quantity(value) + f.set_default( + "value", + f"{value} ({quantity.value_numerator} / {quantity.value_denominator})", + ) + + # units + if self.creating: + f.remove("units") + else: + f.set_readonly("units") + f.set_node("units", UnitRef(self.request)) + # TODO: ugh + f.set_default("units", quantity.quantity.units) + + def get_xref_buttons(self, quantity): + buttons = super().get_xref_buttons(quantity) + + if quantity.farmos_uuid: + url = self.request.route_url( + f"farmos_quantities_{quantity.quantity_type_id}.view", + uuid=quantity.farmos_uuid, + ) + buttons.append( + self.make_button( + "View farmOS record", primary=True, url=url, icon_left="eye" + ) + ) + + return buttons + + +class QuantityView(QuantityMasterView): + """ + Master view for All Quantities + """ + + model_class = Quantity + route_prefix = "quantities" + url_prefix = "/quantities" + + viewable = False + creatable = False + editable = False + deletable = False + model_is_versioned = False + + +class StandardQuantityView(QuantityMasterView): + """ + Master view for Standard Quantities + """ + + model_class = StandardQuantity + route_prefix = "quantities_standard" + url_prefix = "/quantities/standard" + + +def defaults(config, **kwargs): + base = globals() + + QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) + QuantityTypeView.defaults(config) + + QuantityView = kwargs.get("QuantityView", base["QuantityView"]) + QuantityView.defaults(config) + + StandardQuantityView = kwargs.get( + "StandardQuantityView", base["StandardQuantityView"] + ) + StandardQuantityView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/quick/__init__.py b/src/wuttafarm/web/views/quick/__init__.py new file mode 100644 index 0000000..92595e1 --- /dev/null +++ b/src/wuttafarm/web/views/quick/__init__.py @@ -0,0 +1,30 @@ +# -*- 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 . +# +################################################################################ +""" +Quick Form views for farmOS +""" + +from .base import QuickFormView + + +def includeme(config): + config.include("wuttafarm.web.views.quick.eggs") diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py new file mode 100644 index 0000000..2fb73e4 --- /dev/null +++ b/src/wuttafarm/web/views/quick/base.py @@ -0,0 +1,156 @@ +# -*- 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 . +# +################################################################################ +""" +Base class for Quick Form views +""" + +import logging + +from pyramid.renderers import render_to_response + +from wuttaweb.views import View + +from wuttafarm.web.util import save_farmos_oauth2_token + + +log = logging.getLogger(__name__) + + +class QuickFormView(View): + """ + Base class for quick form views. + """ + + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.farmos_client = self.get_farmos_client() + self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + self.normal = self.app.get_normalizer(self.farmos_client) + + @classmethod + def get_route_slug(cls): + return cls.route_slug + + @classmethod + def get_url_slug(cls): + return cls.url_slug + + @classmethod + def get_form_title(cls): + return cls.form_title + + def __call__(self): + form = self.make_quick_form() + + if form.validate(): + try: + result = self.save_quick_form(form) + except Exception as err: + log.warning("failed to save 'edit' form", exc_info=True) + self.request.session.flash( + f"Save failed: {self.app.render_error(err)}", "error" + ) + else: + return self.redirect_after_save(result) + + return self.render_to_response({"form": form}) + + def make_quick_form(self): + raise NotImplementedError + + def save_quick_form(self, form): + raise NotImplementedError + + def redirect_after_save(self, result): + return self.redirect(self.request.current_route_url()) + + def render_to_response(self, context): + + defaults = { + "index_title": "Quick Form", + "form_title": self.get_form_title(), + "help_text": self.__doc__.strip(), + } + + defaults.update(context) + context = defaults + + # supplement context further if needed + context = self.get_template_context(context) + + page_templates = self.get_page_templates() + mako_path = page_templates[0] + try: + render_to_response(mako_path, context, request=self.request) + except IOError: + + # try one or more fallback templates + for fallback in page_templates[1:]: + try: + return render_to_response(fallback, context, request=self.request) + except IOError: + pass + + # if we made it all the way here, then we found no + # templates at all, in which case re-attempt the first and + # let that error raise on up + return render_to_response(mako_path, context, request=self.request) + + def get_page_templates(self): + route_slug = self.get_route_slug() + page_templates = [f"/quick/{route_slug}.mako"] + page_templates.extend(self.get_fallback_templates()) + return page_templates + + def get_fallback_templates(self): + return ["/quick/form.mako"] + + def get_template_context(self, context): + return context + + def get_farmos_client(self): + token = self.request.session.get("farmos.oauth2.token") + if not token: + raise self.forbidden() + + # nb. must give a *copy* of the token to farmOS client, since + # it will mutate it in-place and we don't want that to happen + # for our original copy in the user session. (otherwise the + # auto-refresh will not work correctly for subsequent calls.) + token = dict(token) + + def token_updater(token): + save_farmos_oauth2_token(self.request, token) + + return self.app.get_farmos_client(token=token, token_updater=token_updater) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + route_slug = cls.get_route_slug() + url_slug = cls.get_url_slug() + + config.add_route(f"quick.{route_slug}", f"/quick/{url_slug}") + config.add_view(cls, route_name=f"quick.{route_slug}") diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py new file mode 100644 index 0000000..aa663b6 --- /dev/null +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -0,0 +1,243 @@ +# -*- 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 . +# +################################################################################ +""" +Quick Form for "Eggs" +""" + +import json + +import colander +from deform.widget import SelectWidget + +from farmOS.subrequests import Action, Subrequest, SubrequestsBlueprint, Format + +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views.quick import QuickFormView + + +class EggsQuickForm(QuickFormView): + """ + Use this form to record an egg harvest. A harvest log will be + created with standard details filled in. + """ + + form_title = "Eggs" + route_slug = "eggs" + url_slug = "eggs" + + _layer_assets = None + + def make_quick_form(self): + f = self.make_form( + fields=[ + "timestamp", + "count", + "asset", + "notes", + ], + labels={ + "timestamp": "Date", + "count": "Quantity", + "asset": "Layer Asset", + }, + ) + + # timestamp + f.set_node("timestamp", WuttaDateTime()) + f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) + f.set_default("timestamp", self.app.make_utc()) + + # count + f.set_node("count", colander.Integer()) + + # asset + assets = self.get_layer_assets() + values = [(a["uuid"], a["name"]) for a in assets] + f.set_widget("asset", SelectWidget(values=values)) + if len(assets) == 1: + f.set_default("asset", assets[0]["uuid"]) + + # notes + f.set_widget("notes", "notes") + f.set_required("notes", False) + + return f + + def get_layer_assets(self): + if self._layer_assets is not None: + return self._layer_assets + + assets = [] + params = { + "filter[produces_eggs]": 1, + "sort": "name", + } + + def normalize(asset): + return { + "uuid": asset["id"], + "name": asset["attributes"]["name"], + "type": asset["type"], + } + + result = self.farmos_client.asset.get("animal", params=params) + assets.extend([normalize(a) for a in result["data"]]) + + result = self.farmos_client.asset.get("group", params=params) + assets.extend([normalize(a) for a in result["data"]]) + + assets.sort(key=lambda a: a["name"]) + self._layer_assets = assets + return assets + + def save_quick_form(self, form): + + response = self.save_to_farmos(form) + log = json.loads(response["create-log#body{0}"]["body"]) + + if self.app.is_farmos_mirror(): + quantity = json.loads(response["create-quantity"]["body"]) + self.app.auto_sync_from_farmos(quantity["data"], "StandardQuantity") + self.app.auto_sync_from_farmos(log["data"], "HarvestLog") + + return log + + def save_to_farmos(self, form): + data = form.validated + + assets = self.get_layer_assets() + assets = {a["uuid"]: a for a in assets} + asset = assets[data["asset"]] + + # TODO: make this configurable? + unit_name = "egg(s)" + + unit = {"data": {"type": "taxonomy_term--unit"}} + new_unit = None + + result = self.farmos_client.resource.get( + "taxonomy_term", + "unit", + params={ + "filter[name]": unit_name, + }, + ) + if result["data"]: + unit["data"]["id"] = result["data"][0]["id"] + else: + payload = dict(unit) + payload["data"]["attributes"] = {"name": unit_name} + new_unit = Subrequest( + action=Action.create, + requestId="create-unit", + endpoint="api/taxonomy_term/unit", + body=payload, + ) + unit["data"]["id"] = "{{create-unit.body@$.data.id}}" + + quantity = { + "data": { + "type": "quantity--standard", + "attributes": { + "measure": "count", + "value": { + "numerator": data["count"], + "denominator": 1, + }, + }, + "relationships": { + "units": unit, + }, + }, + } + + kw = {} + if new_unit: + kw["waitFor"] = ["create-unit"] + new_quantity = Subrequest( + action=Action.create, + requestId="create-quantity", + endpoint="api/quantity/standard", + body=quantity, + **kw, + ) + + notes = None + if data["notes"]: + notes = {"value": data["notes"]} + + log = { + "data": { + "type": "log--harvest", + "attributes": { + "name": f"Collected {data['count']} {unit_name}", + "notes": notes, + "quick": ["eggs"], + }, + "relationships": { + "asset": { + "data": [ + { + "id": asset["uuid"], + "type": asset["type"], + }, + ], + }, + "quantity": { + "data": [ + { + "id": "{{create-quantity.body@$.data.id}}", + "type": "quantity--standard", + }, + ], + }, + }, + }, + } + + new_log = Subrequest( + action=Action.create, + requestId="create-log", + waitFor=["create-quantity"], + endpoint="api/log/harvest", + body=log, + ) + + blueprints = [new_quantity, new_log] + if new_unit: + blueprints.insert(0, new_unit) + blueprint = SubrequestsBlueprint.parse_obj(blueprints) + response = self.farmos_client.subrequests.send(blueprint, format=Format.json) + return response + + def redirect_after_save(self, result): + return self.redirect( + self.request.route_url( + "farmos_logs_harvest.view", uuid=result["data"]["id"] + ) + ) + + +def includeme(config): + EggsQuickForm.defaults(config) diff --git a/src/wuttafarm/web/views/settings.py b/src/wuttafarm/web/views/settings.py new file mode 100644 index 0000000..86d7a0c --- /dev/null +++ b/src/wuttafarm/web/views/settings.py @@ -0,0 +1,85 @@ +# -*- 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 . +# +################################################################################ +""" +Custom views for Settings +""" + +from webhelpers2.html import tags + +from wuttaweb.views import settings as base + +from wuttafarm.web.util import use_farmos_style_grid_links + + +class AppInfoView(base.AppInfoView): + """ + Custom appinfo view + """ + + def get_appinfo_dict(self): + info = super().get_appinfo_dict() + enum = self.app.enum + + mode = self.config.get( + f"{self.app.appname}.farmos_integration_mode", default="wrapper" + ) + + info["farmos_integration"] = { + "label": "farmOS Integration", + "value": enum.FARMOS_INTEGRATION_MODE.get(mode, mode), + } + + url = self.app.get_farmos_url() + info["farmos_url"] = { + "label": "farmOS URL", + "value": tags.link_to(url, url, target="_blank"), + } + + return info + + def configure_get_simple_settings(self): # pylint: disable=empty-docstring + simple_settings = super().configure_get_simple_settings() + simple_settings.extend( + [ + {"name": "farmos.url.base"}, + { + "name": f"{self.app.appname}.farmos_integration_mode", + "default": self.app.get_farmos_integration_mode(), + }, + { + "name": f"{self.app.appname}.farmos_style_grid_links", + "type": bool, + "default": use_farmos_style_grid_links(self.config), + }, + ] + ) + return simple_settings + + +def defaults(config, **kwargs): + local = globals() + AppInfoView = kwargs.get("AppInfoView", local["AppInfoView"]) + base.defaults(config, **{"AppInfoView": AppInfoView}) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py new file mode 100644 index 0000000..3b86426 --- /dev/null +++ b/src/wuttafarm/web/views/units.py @@ -0,0 +1,131 @@ +# -*- 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 Units +""" + +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.db.model import Measure, Unit + + +class MeasureView(WuttaFarmMasterView): + """ + Master view for Measures + """ + + model_class = Measure + route_prefix = "measures" + url_prefix = "/measures" + + grid_columns = [ + "name", + "drupal_id", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + +class UnitView(WuttaFarmMasterView): + """ + Master view for Units + """ + + model_class = Unit + route_prefix = "units" + url_prefix = "/units" + + farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_farmos_url(self, unit): + return self.app.get_farmos_url(f"/taxonomy/term/{unit.drupal_id}") + + def get_xref_buttons(self, unit): + buttons = super().get_xref_buttons(unit) + + if unit.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_units.view", uuid=unit.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + MeasureView = kwargs.get("MeasureView", base["MeasureView"]) + MeasureView.defaults(config) + + UnitView = kwargs.get("UnitView", base["UnitView"]) + UnitView.defaults(config) + + +def includeme(config): + defaults(config)