Compare commits

...

18 commits

Author SHA1 Message Date
e7ef5c3d32 feat: add common normalizer to simplify code in view, importer etc.
only the "log" normalizer exists so far, but will add more..
2026-02-24 16:19:26 -06:00
1a6870b8fe feat: overhaul farmOS log views; add Eggs quick form
probably a few other changes...i'm tired and need a savepoint
2026-02-24 16:19:24 -06:00
ad6ac13d50 feat: add basic CRUD for direct API views: animal types, animal assets 2026-02-21 18:38:08 -06:00
c976d94bdd fix: add grid filter for animal birthdate 2026-02-20 21:37:57 -06:00
5d7dea5a84 fix: add thumbnail to farmOS asset base view 2026-02-20 20:52:08 -06:00
e5e3d38365 fix: add setting to toggle "farmOS-style grid links"
not sure yet if users prefer farmOS style, but will assume so by
default just to be safe.  but i want the "traditional" behavior
myself, so setting is needed either way
2026-02-20 20:38:31 -06:00
1af2b695dc feat: use 'include' API param for better Animal Assets grid data
this commit also renames all farmOS asset routes, for some reason.  at
least now they are consistent
2026-02-20 19:21:49 -06:00
bbb1207b27 feat: add backend filters, sorting for farmOS animal types, assets
could not add pagination due to quirks with how Drupal JSONAPI works
for that.  but so far it looks like we can add filter/sort to all of
the farmOS grids..now just need to do it
2026-02-20 16:10:44 -06:00
9cfa91e091 fix: standardize a bit more for the farmOS Animal Assets view 2026-02-20 14:53:14 -06:00
87101d6b04 feat: include/exclude certain views, menus based on integration mode
refs: #3
2026-02-20 14:53:14 -06:00
1f254ca775 fix: set *default* instead of configured menu handler 2026-02-20 14:53:14 -06:00
d884a761ad fix: expose farmOS integration mode, URL in app settings
although as of now changing the integration mode setting will not
actually change any behavior.. but it will

refs: #3
2026-02-20 14:53:14 -06:00
cfe2e4b7b4 feat: add Standard Quantities table, views, import 2026-02-20 14:53:14 -06:00
c93660ec4a feat: add Quantity Types table, views, import 2026-02-20 14:53:14 -06:00
0a0d43aa9f feat: add Units table, views, import/export 2026-02-20 14:53:13 -06:00
bc0836fc3c fix: reword some menu entries 2026-02-18 19:31:58 -06:00
e7b493d7c9 fix: add WuttaFarm -> farmOS export for Plant Assets 2026-02-18 19:31:41 -06:00
185cd86efb fix: fix default admin user perms, per new log schema 2026-02-18 19:09:39 -06:00
45 changed files with 4783 additions and 530 deletions

View file

@ -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):
"""

View file

@ -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",
)

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 ""

View file

@ -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"),

View file

@ -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"

View file

@ -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
):

View file

@ -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",
]

199
src/wuttafarm/normal.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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,
}

View file

@ -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):

View file

@ -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"]
),
)

300
src/wuttafarm/web/grids.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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

View file

@ -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",

View file

@ -0,0 +1,49 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/appinfo/configure.mako" />
<%def name="form_content()">
${parent.form_content()}
<h3 class="block is-size-3">farmOS</h3>
<div class="block" style="padding-left: 2rem; width: 50%;">
<b-field label="farmOS URL">
<b-input name="farmos.url.base"
v-model="simpleSettings['farmos.url.base']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="farmOS Integration Mode">
<b-select name="${app.appname}.farmos_integration_mode"
v-model="simpleSettings['${app.appname}.farmos_integration_mode']"
@input="settingsNeedSaved = true">
% for value, label in enum.FARMOS_INTEGRATION_MODE.items():
<option value="${value}">${label}</option>
% endfor
</b-select>
</b-field>
<b-checkbox name="${app.appname}.farmos_style_grid_links"
v-model="simpleSettings['${app.appname}.farmos_style_grid_links']"
native-value="true"
@input="settingsNeedSaved = true">
Use farmOS-style grid links
</b-checkbox>
<${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}">
<b-icon pack="fas" icon="info-circle" />
<template #content>
<p class="block">
If set, certain column values in a grid may link
to <span class="has-text-weight-bold">related</span>
records.
</p>
<p class="block">
If not set, column values will only link to view the
<span class="has-text-weight-bold">current</span> record.
</p>
</template>
</${b}-tooltip>
</div>
</%def>

View file

@ -0,0 +1,14 @@
<%inherit file="/form.mako" />
<%def name="title()">${index_title} &raquo; ${form_title}</%def>
<%def name="content_title()">${form_title}</%def>
<%def name="render_form_tag()">
<p class="block">
${help_text}
</p>
${parent.render_form_tag()}
</%def>

View file

@ -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}"

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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"])

View file

@ -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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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",
),
]

View file

@ -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"

View file

@ -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

View file

@ -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()

View file

@ -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",
)
]

View file

@ -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"

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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"

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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")

View file

@ -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)

View file

@ -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):

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Quick Form views for farmOS
"""
from .base import QuickFormView
def includeme(config):
config.include("wuttafarm.web.views.quick.eggs")

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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}")

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)