feat: add edit/sync support for Material Types + Material Quantities

This commit is contained in:
Lance Edgar 2026-03-09 14:36:53 -05:00
parent 6bc5f06f7a
commit dfc8dc0de3
20 changed files with 1075 additions and 86 deletions

View file

@ -177,6 +177,48 @@ class WuttaFarmAppHandler(base.AppHandler):
with self.short_session(session=session) as sess: with self.short_session(session=session) as sess:
return sess.query(model.Unit).order_by(model.Unit.name).all() return sess.query(model.Unit).order_by(model.Unit.name).all()
def get_material_types(self, session=None):
"""
Returns a list of all known material types.
"""
model = self.model
with self.short_session(session=session) as sess:
return (
sess.query(model.MaterialType).order_by(model.MaterialType.name).all()
)
def get_quantity_models(self):
model = self.model
return {
"standard": model.StandardQuantity,
"material": model.MaterialQuantity,
}
def get_true_quantity(self, quantity, require=True):
model = self.model
if not isinstance(quantity, model.Quantity):
if require and not quantity:
raise ValueError(f"quantity is not valid: {quantity}")
return quantity
session = self.get_session(quantity)
models = self.get_quantity_models()
if require and quantity.quantity_type_id not in models:
raise ValueError(
f"quantity has invalid quantity_type_id: {quantity.quantity_type_id}"
)
true_quantity = session.get(models[quantity.quantity_type_id], quantity.uuid)
if require and not true_quantity:
raise ValueError(f"quantity has no true/typed quantity record: {quantity}")
return true_quantity
def make_true_quantity(self, quantity_type_id, **kwargs):
models = self.get_quantity_models()
kwargs["quantity_type_id"] = quantity_type_id
return models[quantity_type_id](**kwargs)
def auto_sync_to_farmos(self, obj, model_name=None, client=None, require=True): def auto_sync_to_farmos(self, obj, model_name=None, client=None, require=True):
""" """
Export the given object to farmOS, using configured handler. Export the given object to farmOS, using configured handler.

View file

@ -0,0 +1,211 @@
"""add MaterialQuantity
Revision ID: 9c53513f8862
Revises: 1c89f3fbb521
Create Date: 2026-03-08 18:14:05.587678
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "9c53513f8862"
down_revision: Union[str, None] = "1c89f3fbb521"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# quantity_material
op.create_table(
"quantity_material",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["quantity.uuid"], name=op.f("fk_quantity_material_uuid_quantity")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_material")),
)
op.create_table(
"quantity_material_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_material_version")
),
)
op.create_index(
op.f("ix_quantity_material_version_end_transaction_id"),
"quantity_material_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_material_version_operation_type"),
"quantity_material_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_quantity_material_version_pk_transaction_id",
"quantity_material_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_quantity_material_version_pk_validity",
"quantity_material_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_material_version_transaction_id"),
"quantity_material_version",
["transaction_id"],
unique=False,
)
# quantity_material_material_type
op.create_table(
"quantity_material_material_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("quantity_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("material_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["material_type_uuid"],
["material_type.uuid"],
name=op.f(
"fk_quantity_material_material_type_material_type_uuid_material_type"
),
),
sa.ForeignKeyConstraint(
["quantity_uuid"],
["quantity_material.uuid"],
name=op.f(
"fk_quantity_material_material_type_quantity_uuid_quantity_material"
),
),
sa.PrimaryKeyConstraint(
"uuid", name=op.f("pk_quantity_material_material_type")
),
)
op.create_table(
"quantity_material_material_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"quantity_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"material_type_uuid",
wuttjamaican.db.util.UUID(),
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_material_material_type_version"),
),
)
op.create_index(
op.f("ix_quantity_material_material_type_version_end_transaction_id"),
"quantity_material_material_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_material_material_type_version_operation_type"),
"quantity_material_material_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_quantity_material_material_type_version_pk_transaction_id",
"quantity_material_material_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_quantity_material_material_type_version_pk_validity",
"quantity_material_material_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_quantity_material_material_type_version_transaction_id"),
"quantity_material_material_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# quantity_material_material_type
op.drop_index(
op.f("ix_quantity_material_material_type_version_transaction_id"),
table_name="quantity_material_material_type_version",
)
op.drop_index(
"ix_quantity_material_material_type_version_pk_validity",
table_name="quantity_material_material_type_version",
)
op.drop_index(
"ix_quantity_material_material_type_version_pk_transaction_id",
table_name="quantity_material_material_type_version",
)
op.drop_index(
op.f("ix_quantity_material_material_type_version_operation_type"),
table_name="quantity_material_material_type_version",
)
op.drop_index(
op.f("ix_quantity_material_material_type_version_end_transaction_id"),
table_name="quantity_material_material_type_version",
)
op.drop_table("quantity_material_material_type_version")
op.drop_table("quantity_material_material_type")
# quantity_material
op.drop_index(
op.f("ix_quantity_material_version_transaction_id"),
table_name="quantity_material_version",
)
op.drop_index(
"ix_quantity_material_version_pk_validity",
table_name="quantity_material_version",
)
op.drop_index(
"ix_quantity_material_version_pk_transaction_id",
table_name="quantity_material_version",
)
op.drop_index(
op.f("ix_quantity_material_version_operation_type"),
table_name="quantity_material_version",
)
op.drop_index(
op.f("ix_quantity_material_version_end_transaction_id"),
table_name="quantity_material_version",
)
op.drop_table("quantity_material_version")
op.drop_table("quantity_material")

View file

@ -32,7 +32,13 @@ from .users import WuttaFarmUser
# wuttafarm proper models # wuttafarm proper models
from .unit import Unit, Measure from .unit import Unit, Measure
from .material_type import MaterialType from .material_type import MaterialType
from .quantities import QuantityType, Quantity, StandardQuantity from .quantities import (
QuantityType,
Quantity,
StandardQuantity,
MaterialQuantity,
MaterialQuantityMaterialType,
)
from .asset import AssetType, Asset, AssetParent from .asset import AssetType, Asset, AssetParent
from .asset_land import LandType, LandAsset from .asset_land import LandType, LandAsset
from .asset_structure import StructureType, StructureAsset from .asset_structure import StructureType, StructureAsset

View file

@ -24,6 +24,8 @@ Model definition for Material Types
""" """
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model from wuttjamaican.db import model
@ -76,5 +78,23 @@ class MaterialType(model.Base):
""", """,
) )
_quantities = orm.relationship(
"MaterialQuantityMaterialType",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="material_type",
)
def _make_material_quantity(qty):
from wuttafarm.db.model import MaterialQuantityMaterialType
return MaterialQuantityMaterialType(quantity=qty)
quantities = association_proxy(
"_quantities",
"quantity",
creator=_make_material_quantity,
)
def __str__(self): def __str__(self):
return self.name or "" return self.name or ""

View file

@ -211,6 +211,9 @@ class QuantityMixin:
cascade_backrefs=False, cascade_backrefs=False,
) )
def get_value_decimal(self):
return self.quantity.get_value_decimal()
def render_as_text(self, config=None): def render_as_text(self, config=None):
return self.quantity.render_as_text(config) return self.quantity.render_as_text(config)
@ -249,3 +252,64 @@ class StandardQuantity(QuantityMixin, model.Base):
add_quantity_proxies(StandardQuantity) add_quantity_proxies(StandardQuantity)
class MaterialQuantity(QuantityMixin, model.Base):
"""
Represents a Material Quantity from farmOS
"""
__tablename__ = "quantity_material"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Material Quantity",
"model_title_plural": "Material Quantities",
"farmos_quantity_type": "material",
}
_material_types = orm.relationship(
"MaterialQuantityMaterialType",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="quantity",
)
material_types = association_proxy(
"_material_types",
"material_type",
creator=lambda mtype: MaterialQuantityMaterialType(material_type=mtype),
)
def render_as_text(self, config=None):
text = super().render_as_text(config)
mtypes = ", ".join([str(mt) for mt in self.material_types])
return f"{mtypes} {text}"
add_quantity_proxies(MaterialQuantity)
class MaterialQuantityMaterialType(model.Base):
"""
Represents a "material quantity's material type relationship" from
farmOS.
"""
__tablename__ = "quantity_material_material_type"
__versioned__ = {}
uuid = model.uuid_column()
quantity_uuid = model.uuid_fk_column("quantity_material.uuid", nullable=False)
quantity = orm.relationship(
MaterialQuantity,
foreign_keys=quantity_uuid,
back_populates="_material_types",
)
material_type_uuid = model.uuid_fk_column("material_type.uuid", nullable=False)
material_type = orm.relationship(
"MaterialType",
foreign_keys=material_type_uuid,
back_populates="_quantities",
)

View file

@ -617,6 +617,49 @@ class ToFarmOSQuantity(ToFarmOS):
return payload return payload
class MaterialQuantityImporter(ToFarmOSQuantity):
model_title = "MaterialQuantity"
farmos_quantity_type = "material"
def get_supported_fields(self):
fields = list(super().get_supported_fields())
fields.extend(
[
"material_types",
]
)
return fields
def normalize_target_object(self, quantity):
data = super().normalize_target_object(quantity)
if "material_types" in self.fields:
data["material_types"] = [
UUID(mtype["id"])
for mtype in quantity["relationships"]["material_type"]["data"]
]
return data
def get_quantity_payload(self, source_data):
payload = super().get_quantity_payload(source_data)
rels = {}
if "material_types" in self.fields:
rels["material_type"] = {"data": []}
for uuid in source_data["material_types"]:
rels["material_type"]["data"].append(
{
"id": str(uuid),
"type": "taxonomy_term--material_type",
}
)
payload.setdefault("relationships", {}).update(rels)
return payload
class StandardQuantityImporter(ToFarmOSQuantity): class StandardQuantityImporter(ToFarmOSQuantity):
model_title = "StandardQuantity" model_title = "StandardQuantity"

View file

@ -106,6 +106,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
importers["PlantAsset"] = PlantAssetImporter importers["PlantAsset"] = PlantAssetImporter
importers["Unit"] = UnitImporter importers["Unit"] = UnitImporter
importers["MaterialType"] = MaterialTypeImporter importers["MaterialType"] = MaterialTypeImporter
importers["MaterialQuantity"] = MaterialQuantityImporter
importers["StandardQuantity"] = StandardQuantityImporter importers["StandardQuantity"] = StandardQuantityImporter
importers["ActivityLog"] = ActivityLogImporter importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter importers["HarvestLog"] = HarvestLogImporter
@ -449,6 +450,24 @@ class FromWuttaFarmQuantity(FromWuttaFarm):
} }
class MaterialQuantityImporter(
FromWuttaFarmQuantity, farmos_importing.model.MaterialQuantityImporter
):
"""
WuttaFarm farmOS API exporter for Material Quantities
"""
source_model_class = model.MaterialQuantity
def normalize_source_object(self, quantity):
data = super().normalize_source_object(quantity)
if "material_types" in self.fields:
data["material_types"] = [mt.farmos_uuid for mt in quantity.material_types]
return data
class StandardQuantityImporter( class StandardQuantityImporter(
FromWuttaFarmQuantity, farmos_importing.model.StandardQuantityImporter FromWuttaFarmQuantity, farmos_importing.model.StandardQuantityImporter
): ):

View file

@ -117,6 +117,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["MaterialType"] = MaterialTypeImporter importers["MaterialType"] = MaterialTypeImporter
importers["QuantityType"] = QuantityTypeImporter importers["QuantityType"] = QuantityTypeImporter
importers["StandardQuantity"] = StandardQuantityImporter importers["StandardQuantity"] = StandardQuantityImporter
importers["MaterialQuantity"] = MaterialQuantityImporter
importers["LogType"] = LogTypeImporter importers["LogType"] = LogTypeImporter
importers["ActivityLog"] = ActivityLogImporter importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter importers["HarvestLog"] = HarvestLogImporter
@ -1419,3 +1420,76 @@ class StandardQuantityImporter(QuantityImporterBase):
"units_uuid", "units_uuid",
"label", "label",
] ]
class MaterialQuantityImporter(QuantityImporterBase):
"""
farmOS API WuttaFarm importer for Material Quantities
"""
model_class = model.MaterialQuantity
def get_supported_fields(self):
fields = list(super().get_supported_fields())
fields.extend(
[
"material_types",
]
)
return fields
def normalize_source_object(self, quantity):
""" """
data = super().normalize_source_object(quantity)
if "material_types" in self.fields:
data["material_types"] = [
UUID(mtype["id"])
for mtype in quantity["relationships"]["material_type"]["data"]
]
return data
def normalize_target_object(self, quantity):
data = super().normalize_target_object(quantity)
if "material_types" in self.fields:
data["material_types"] = [
mtype.farmos_uuid for mtype in quantity.material_types
]
return data
def update_target_object(self, quantity, source_data, target_data=None):
model = self.app.model
quantity = super().update_target_object(quantity, source_data, target_data)
if "material_types" in self.fields:
if (
not target_data
or target_data["material_types"] != source_data["material_types"]
):
for farmos_uuid in source_data["material_types"]:
if (
not target_data
or farmos_uuid not in target_data["material_types"]
):
mtype = (
self.target_session.query(model.MaterialType)
.filter(model.MaterialType.farmos_uuid == farmos_uuid)
.one()
)
quantity.material_types.append(mtype)
if target_data:
for farmos_uuid in target_data["material_types"]:
if farmos_uuid not in source_data["material_types"]:
mtype = (
self.target_session.query(model.MaterialType)
.filter(model.MaterialType.farmos_uuid == farmos_uuid)
.one()
)
quantity.material_types.remove(mtype)
return quantity

View file

@ -260,27 +260,29 @@ class Normalizer(GenericHandler):
measure_id = attrs["measure"] measure_id = attrs["measure"]
quantity_objects.append( quantity_object = {
{ "uuid": quantity["id"],
"uuid": quantity["id"], "drupal_id": attrs["drupal_internal__id"],
"drupal_id": attrs["drupal_internal__id"], "quantity_type_uuid": rels["quantity_type"]["data"]["id"],
"quantity_type_uuid": rels["quantity_type"]["data"][ "quantity_type_id": rels["quantity_type"]["data"]["meta"][
"id" "drupal_internal__target_id"
], ],
"quantity_type_id": rels["quantity_type"]["data"][ "measure_id": measure_id,
"meta" "measure_name": self.get_farmos_measure_name(measure_id),
]["drupal_internal__target_id"], "value_numerator": value["numerator"],
"measure_id": measure_id, "value_decimal": value["decimal"],
"measure_name": self.get_farmos_measure_name( "value_denominator": value["denominator"],
measure_id "unit_uuid": unit_uuid,
), "unit_name": unit["attributes"]["name"],
"value_numerator": value["numerator"], }
"value_decimal": value["decimal"], if quantity_object["quantity_type_id"] == "material":
"value_denominator": value["denominator"], quantity_object["material_types"] = [
"unit_uuid": unit_uuid, {"uuid": mtype["id"]}
"unit_name": unit["attributes"]["name"], for mtype in quantity["relationships"]["material_type"][
} "data"
) ]
]
quantity_objects.append(quantity_object)
if owners := relationships.get("owner"): if owners := relationships.get("owner"):
for user in owners["data"]: for user in owners["data"]:

View file

@ -164,10 +164,9 @@ class FarmOSRefs(WuttaSet):
self.route_prefix = route_prefix self.route_prefix = route_prefix
def serialize(self, node, appstruct): def serialize(self, node, appstruct):
if appstruct is colander.null: if not appstruct:
return colander.null return colander.null
return appstruct
return json.dumps(appstruct)
def widget_maker(self, **kwargs): def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSRefsWidget from wuttafarm.web.forms.widgets import FarmOSRefsWidget
@ -288,6 +287,37 @@ class PlantTypeRefs(WuttaSet):
return PlantTypeRefsWidget(self.request, **kwargs) return PlantTypeRefsWidget(self.request, **kwargs)
class MaterialTypeRefs(colander.List):
"""
Schema type for Material Types field (on a Material Asset).
"""
def __init__(self, request):
super().__init__()
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
mtypes = []
for mtype in appstruct:
mtypes.append(
{
"uuid": mtype.uuid.hex,
"name": mtype.name,
}
)
return mtypes
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import MaterialTypeRefsWidget
return MaterialTypeRefsWidget(self.request, **kwargs)
class SeasonRefs(WuttaSet): class SeasonRefs(WuttaSet):
""" """
Schema type for Plant Types field (on a Plant Asset). Schema type for Plant Types field (on a Plant Asset).
@ -454,22 +484,36 @@ class QuantityRefs(colander.List):
quantities = [] quantities = []
for qty in appstruct: for qty in appstruct:
quantities.append(
{ quantity = {
"uuid": qty.uuid.hex, "uuid": qty.uuid.hex,
"quantity_type": { "quantity_type": {
"id": qty.quantity_type_id, "drupal_id": qty.quantity_type_id,
"name": qty.quantity_type.name, "name": qty.quantity_type.name,
}, },
"measure": qty.measure_id, "measure": qty.measure_id,
"value": qty.get_value_decimal(), "value": qty.get_value_decimal(),
"units": { "units": {
"uuid": qty.units.uuid.hex, "uuid": qty.units.uuid.hex,
"name": qty.units.name, "name": qty.units.name,
}, },
"as_text": qty.render_as_text(self.config), "as_text": qty.render_as_text(self.config),
} # nb. always include this regardless of quantity type,
) # for sake of easier frontend logic
"material_types": [],
}
if qty.quantity_type_id == "material":
quantity["material_types"] = []
for mtype in qty.material_types:
quantity["material_types"].append(
{
"uuid": mtype.uuid.hex,
"name": mtype.name,
}
)
quantities.append(quantity)
return quantities return quantities

View file

@ -124,7 +124,7 @@ class FarmOSRefsWidget(Widget):
return HTML.tag("span") return HTML.tag("span")
links = [] links = []
for obj in json.loads(cstruct): for obj in cstruct:
url = self.request.route_url( url = self.request.route_url(
f"{self.route_prefix}.view", uuid=obj["uuid"] f"{self.route_prefix}.view", uuid=obj["uuid"]
) )
@ -332,6 +332,72 @@ class PlantTypeRefsWidget(Widget):
return set(pstruct.split(",")) return set(pstruct.split(","))
class MaterialTypeRefsWidget(Widget):
"""
Widget for Material Types field (on a Material Asset).
"""
template = "materialtyperefs"
values = ()
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
if not cstruct:
cstruct = []
if readonly := kw.get("readonly", self.readonly):
items = []
for mtype in cstruct:
items.append(
HTML.tag(
"li",
c=tags.link_to(
mtype["name"],
self.request.route_url(
"material_types.view", uuid=mtype["uuid"]
),
),
)
)
return HTML.tag("ul", c=items)
tmpl_values = self.get_template_values(field, cstruct, kw)
return field.renderer(self.template, **tmpl_values)
def get_template_values(self, field, cstruct, kw):
""" """
values = super().get_template_values(field, cstruct, kw)
session = Session()
material_types = []
for mtype in self.app.get_material_types(session):
material_types.append(
{
"uuid": mtype.uuid.hex,
"name": mtype.name,
}
)
values["material_types"] = json.dumps(material_types)
return values
def deserialize(self, field, pstruct):
""" """
if not pstruct:
return []
return json.loads(pstruct)
class SeasonRefsWidget(Widget): class SeasonRefsWidget(Widget):
""" """
Widget for Seasons field (on a Plant Asset). Widget for Seasons field (on a Plant Asset).
@ -550,11 +616,10 @@ class QuantityRefsWidget(Widget):
return "" return ""
quantities = [] quantities = []
for qty in cstruct: for qty in cstruct:
# TODO: support more quantity types
url = self.request.route_url( url = self.request.route_url(
"quantities_standard.view", uuid=qty["uuid"] f"quantities_{qty['quantity_type']['drupal_id']}.view",
uuid=qty["uuid"],
) )
quantities.append(HTML.tag("li", c=tags.link_to(qty["as_text"], url))) quantities.append(HTML.tag("li", c=tags.link_to(qty["as_text"], url)))
@ -570,17 +635,25 @@ class QuantityRefsWidget(Widget):
qtypes = [] qtypes = []
for qtype in self.app.get_quantity_types(session): for qtype in self.app.get_quantity_types(session):
# TODO: add support for other quantity types qtypes.append(
if qtype.drupal_id == "standard": {
qtypes.append( "uuid": qtype.uuid.hex,
{ "drupal_id": qtype.drupal_id,
"uuid": qtype.uuid.hex, "name": qtype.name,
"drupal_id": qtype.drupal_id, }
"name": qtype.name, )
}
)
values["quantity_types"] = qtypes values["quantity_types"] = qtypes
material_types = []
for mtype in self.app.get_material_types(session):
material_types.append(
{
"uuid": mtype.uuid.hex,
"name": mtype.name,
}
)
values["material_types"] = material_types
measures = [] measures = []
for measure in self.app.get_measures(session): for measure in self.app.get_measures(session):
measures.append( measures.append(

View file

@ -182,6 +182,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "quantities", "route": "quantities",
"perm": "quantities.list", "perm": "quantities.list",
}, },
{
"title": "Material Quantities",
"route": "quantities_material",
"perm": "quantities_material.list",
},
{ {
"title": "Standard Quantities", "title": "Standard Quantities",
"route": "quantities_standard", "route": "quantities_standard",
@ -322,6 +327,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_quantity_types", "route": "farmos_quantity_types",
"perm": "farmos_quantity_types.list", "perm": "farmos_quantity_types.list",
}, },
{
"title": "Material Quantities",
"route": "farmos_quantities_material",
"perm": "farmos_quantities_material.list",
},
{ {
"title": "Standard Quantities", "title": "Standard Quantities",
"route": "farmos_quantities_standard", "route": "farmos_quantities_standard",
@ -451,6 +461,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_quantity_types", "route": "farmos_quantity_types",
"perm": "farmos_quantity_types.list", "perm": "farmos_quantity_types.list",
}, },
{
"title": "Material Quantities",
"route": "farmos_quantities_material",
"perm": "farmos_quantities_material.list",
},
{ {
"title": "Standard Quantities", "title": "Standard Quantities",
"route": "farmos_quantities_standard", "route": "farmos_quantities_standard",

View file

@ -0,0 +1,13 @@
<div tal:define="
name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;
can_create can_create|False;"
tal:omit-tag="">
<material-types-picker tal:attributes="name name;
v-model vmodel;
:material-types material_types;
:can-create str(can_create).lower();" />
</div>

View file

@ -7,6 +7,7 @@
<quantities-editor tal:attributes="name name; <quantities-editor tal:attributes="name name;
v-model vmodel; v-model vmodel;
:quantity-types quantity_types; :quantity-types quantity_types;
:material-types material_types;
:measures measures; :measures measures;
:units units;" /> :units units;" />

View file

@ -2,6 +2,7 @@
<%def name="make_wuttafarm_components()"> <%def name="make_wuttafarm_components()">
${self.make_assets_picker_component()} ${self.make_assets_picker_component()}
${self.make_animal_type_picker_component()} ${self.make_animal_type_picker_component()}
${self.make_material_types_picker_component()}
${self.make_quantity_editor_component()} ${self.make_quantity_editor_component()}
${self.make_quantities_editor_component()} ${self.make_quantities_editor_component()}
${self.make_plant_types_picker_component()} ${self.make_plant_types_picker_component()}
@ -241,10 +242,134 @@
</script> </script>
</%def> </%def>
<%def name="make_material_types_picker_component()">
<script type="text/x-template" id="material-types-picker-template">
<div>
<input type="hidden" :name="name" :value="JSON.stringify(value)" />
<div style="display: flex; gap: 0.5rem; align-items: center;">
<span>Add:</span>
<b-autocomplete v-model="addName"
ref="addName"
:data="addNameData"
field="name"
open-on-focus
keep-first
@select="addNameSelected"
clear-on-select
style="flex-grow: 1;">
<template #empty>No results found</template>
</b-autocomplete>
</div>
<${b}-table :data="materialTypeData">
<${b}-table-column field="name" v-slot="props">
<span>{{ props.row.name }}</span>
</${b}-table-column>
<${b}-table-column v-slot="props">
<a href="#"
class="has-text-danger"
@click.prevent="removeMaterialType(props.row)">
<i class="fas fa-trash" /> &nbsp; Remove
</a>
</${b}-table-column>
</${b}-table>
</div>
</script>
<script>
const MaterialTypesPicker = {
template: '#material-types-picker-template',
props: {
name: String,
value: Array,
materialTypes: Array,
},
data() {
return {
addName: '',
}
},
computed: {
materialTypeData() {
const data = []
if (this.value) {
const mtypes = new Map(this.materialTypes.map((mtype) => {
return [mtype.uuid, mtype.name]
}))
for (const mtype of this.value) {
if (mtypes.has(mtype.uuid)) {
data.push(mtype)
}
}
}
return data
},
addNameData() {
if (!this.addName) {
return this.materialTypes
}
return this.materialTypes.filter((mtype) => {
return mtype.name.toLowerCase().indexOf(this.addName.toLowerCase()) >= 0
})
},
},
methods: {
focus() {
this.$refs.addName.focus()
},
addNameSelected(option) {
if (!option) {
return
}
const value = Array.from(this.value || [])
if (!value.includes(option)) {
value.push(option)
this.$emit('input', value)
}
this.addName = null
},
removeMaterialType(ptype) {
const value = Array.from(this.value)
const i = value.indexOf(ptype)
value.splice(i, 1)
this.$emit('input', value)
},
},
}
Vue.component('material-types-picker', MaterialTypesPicker)
<% request.register_component('material-types-picker', 'MaterialTypesPicker') %>
</script>
</%def>
<%def name="make_quantity_editor_component()"> <%def name="make_quantity_editor_component()">
<script type="text/x-template" id="quantity-editor-template"> <script type="text/x-template" id="quantity-editor-template">
<div> <div>
<b-field v-if="quantityType == 'material'"
label="Material Types"
horizontal>
<material-types-picker v-model="value.material_types"
ref="materials"
:material-types="materialTypes" />
</b-field>
<b-field label="Measure" horizontal <b-field label="Measure" horizontal
## TODO: why is this needed? ## TODO: why is this needed?
style="margin-bottom: 1rem;"> style="margin-bottom: 1rem;">
@ -306,6 +431,8 @@
props: { props: {
name: String, name: String,
value: Object, value: Object,
quantityType: String,
materialTypes: Array,
measures: Array, measures: Array,
units: Array, units: Array,
creating: { creating: {
@ -314,8 +441,6 @@
} }
}, },
data() { data() {
return { return {
measure: this.value.measure, measure: this.value.measure,
valueAmount: this.value.value, valueAmount: this.value.value,
@ -349,8 +474,12 @@
}, },
methods: { methods: {
focusMeasure() { focusForCreate() {
this.$refs.measure.focus() if (this.value.quantity_type.drupal_id == 'material') {
this.$refs.materials.focus()
} else {
this.$refs.measure.focus()
}
}, },
focusValue() { focusValue() {
@ -439,6 +568,8 @@
<template #detail="props"> <template #detail="props">
<quantity-editor v-model="props.row" <quantity-editor v-model="props.row"
:quantity-type="props.row.quantity_type.drupal_id"
:material-types="materialTypes"
:measures="measures" :measures="measures"
:units="units" :units="units"
@save="editSave" @save="editSave"
@ -468,6 +599,8 @@
<quantity-editor v-show="creating" <quantity-editor v-show="creating"
v-model="newQuantity" v-model="newQuantity"
:quantity-type="quantityType"
:material-types="materialTypes"
ref="newQuantity" ref="newQuantity"
creating creating
@save="createSave" @save="createSave"
@ -484,6 +617,7 @@
name: String, name: String,
value: Array, value: Array,
quantityTypes: Array, quantityTypes: Array,
materialTypes: Array,
defaultQuantityType: { defaultQuantityType: {
type: String, type: String,
default: 'standard', default: 'standard',
@ -530,7 +664,7 @@
qty = Object.fromEntries(Object.entries(qty)) qty = Object.fromEntries(Object.entries(qty))
qty.uuid = 'new_' + this.newCounter++ qty.uuid = 'new_' + this.newCounter++
qty.as_text = "( " + this.measureMap[qty.measure] + " ) " + qty.value + " " + qty.units.name qty.as_text = this.getQuantityText(qty)
const value = Array.from(this.value || []) const value = Array.from(this.value || [])
value.push(qty) value.push(qty)
@ -557,7 +691,7 @@
this.creating = true this.creating = true
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.newQuantity.focusMeasure() this.$refs.newQuantity.focusForCreate()
}) })
}, },
@ -566,11 +700,25 @@
this.editing[qty.uuid] = true this.editing[qty.uuid] = true
}, },
editSave(row) { getQuantityText(qty) {
row.as_text = "( " + this.measureMap[row.measure] + " ) " + row.value + " " + row.units.name let text = "( " + this.measureMap[qty.measure] + " ) "
+ qty.value + " " + qty.units.name
if (qty.quantity_type.drupal_id == 'material') {
const materials = qty.material_types.map((mtype) => {
return mtype.name
}).join(', ')
text = materials + " " + text
}
return text
},
editSave(qty) {
qty.as_text = this.getQuantityText(qty)
this.$emit('input', this.value) this.$emit('input', this.value)
this.editing[row.uuid] = false this.editing[qty.uuid] = false
this.$refs.table.closeDetailRow(row) this.$refs.table.closeDetailRow(qty)
}, },
editCancel(qty) { editCancel(qty) {

View file

@ -78,4 +78,10 @@ def render_quantity_object(quantity):
measure = quantity["measure_name"] measure = quantity["measure_name"]
value = quantity["value_decimal"] value = quantity["value_decimal"]
unit = quantity["unit_name"] unit = quantity["unit_name"]
return f"( {measure} ) {value} {unit}" text = f"( {measure} ) {value} {unit}"
if quantity["quantity_type_id"] == "material":
materials = ", ".join([mtype["name"] for mtype in quantity["material_types"]])
return f"{materials} {text}"
return text

View file

@ -45,7 +45,7 @@ from wuttafarm.web.forms.schema import (
LogQuick, LogQuick,
Notes, Notes,
) )
from wuttafarm.web.util import render_quantity_objects from wuttafarm.web.util import render_quantity_objects, render_quantity_object
class LogMasterView(FarmOSMasterView): class LogMasterView(FarmOSMasterView):
@ -199,7 +199,20 @@ class LogMasterView(FarmOSMasterView):
) )
self.raw_json = result self.raw_json = result
included = {obj["id"]: obj for obj in result.get("included", [])} included = {obj["id"]: obj for obj in result.get("included", [])}
return self.normalize_log(result["data"], included) instance = self.normalize_log(result["data"], included)
for qty in instance["quantities"]:
if qty["quantity_type_id"] == "material":
for mtype in qty["material_types"]:
result = self.farmos_client.resource.get_id(
"taxonomy_term", "material_type", mtype["uuid"]
)
mtype["name"] = result["data"]["attributes"]["name"]
qty["as_text"] = render_quantity_object(qty)
return instance
def get_instance_title(self, log): def get_instance_title(self, log):
return log["name"] return log["name"]

View file

@ -32,7 +32,7 @@ from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.schema import FarmOSUnitRef from wuttafarm.web.forms.schema import FarmOSUnitRef, FarmOSRefs
from wuttafarm.web.grids import ResourceData from wuttafarm.web.grids import ResourceData
@ -143,6 +143,7 @@ class QuantityMasterView(FarmOSMasterView):
sort_defaults = ("drupal_id", "desc") sort_defaults = ("drupal_id", "desc")
form_fields = [ form_fields = [
"quantity_type_name",
"measure", "measure",
"value", "value",
"units", "units",
@ -207,18 +208,23 @@ class QuantityMasterView(FarmOSMasterView):
def get_instance(self): def get_instance(self):
# TODO: this pattern should be repeated for other views # TODO: this pattern should be repeated for other views
try: try:
quantity = self.farmos_client.resource.get_id( result = self.farmos_client.resource.get_id(
"quantity", self.farmos_quantity_type, self.request.matchdict["uuid"] "quantity",
self.farmos_quantity_type,
self.request.matchdict["uuid"],
params={"include": self.get_farmos_api_includes()},
) )
except requests.HTTPError as exc: except requests.HTTPError as exc:
if exc.response.status_code == 404: if exc.response.status_code == 404:
raise self.notfound() raise self.notfound()
self.raw_json = quantity self.raw_json = result
data = self.normalize_quantity(quantity["data"]) included = {obj["id"]: obj for obj in result.get("included", [])}
assert included
data = self.normalize_quantity(result["data"], included)
if relationships := quantity["data"].get("relationships"): if relationships := result["data"].get("relationships"):
# add units # add units
if units := relationships.get("units"): if units := relationships.get("units"):
@ -286,6 +292,11 @@ class QuantityMasterView(FarmOSMasterView):
f = form f = form
super().configure_form(f) super().configure_form(f)
# quantity_type_name
f.set_label("quantity_type_name", "Quantity Type")
f.set_readonly("quantity_type_name")
f.set_default("quantity_type_name", self.farmos_quantity_type.capitalize())
# created # created
f.set_node("created", WuttaDateTime(self.request)) f.set_node("created", WuttaDateTime(self.request))
f.set_widget("created", WuttaDateTimeWidget(self.request)) f.set_widget("created", WuttaDateTimeWidget(self.request))
@ -338,6 +349,90 @@ class StandardQuantityView(QuantityMasterView):
return buttons return buttons
class MaterialQuantityView(QuantityMasterView):
"""
View for farmOS Material Quantities
"""
model_name = "farmos_material_quantity"
model_title = "farmOS Material Quantity"
model_title_plural = "farmOS Material Quantities"
route_prefix = "farmos_quantities_material"
url_prefix = "/farmOS/quantities/material"
farmos_quantity_type = "material"
farmos_refurl_path = "/log-quantities/material"
def get_farmos_api_includes(self):
includes = super().get_farmos_api_includes()
includes.update({"material_type"})
return includes
def normalize_quantity(self, quantity, included={}):
normal = super().normalize_quantity(quantity, included)
material_type_objects = []
material_type_uuids = []
if relationships := quantity["relationships"]:
if material_types := relationships["material_type"]["data"]:
for mtype in material_types:
uuid = mtype["id"]
material_type_uuids.append(uuid)
material_type = {
"uuid": uuid,
"type": mtype["type"],
}
if mtype := included.get(uuid):
material_type.update(
{
"name": mtype["attributes"]["name"],
}
)
material_type_objects.append(material_type)
normal.update(
{
"material_types": material_type_objects,
"material_type_uuids": material_type_uuids,
}
)
return normal
def configure_form(self, form):
f = form
super().configure_form(f)
# material_types
f.fields.insert_before("measure", "material_types")
f.set_node("material_types", FarmOSRefs(self.request, "farmos_material_types"))
def get_xref_buttons(self, material_quantity):
model = self.app.model
session = self.Session()
buttons = []
if wf_material_quantity := (
session.query(model.MaterialQuantity)
.join(model.Quantity)
.filter(model.Quantity.farmos_uuid == material_quantity["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url(
"quantities_material.view", uuid=wf_material_quantity.uuid
),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()
@ -349,6 +444,11 @@ def defaults(config, **kwargs):
) )
StandardQuantityView.defaults(config) StandardQuantityView.defaults(config)
MaterialQuantityView = kwargs.get(
"MaterialQuantityView", base["MaterialQuantityView"]
)
MaterialQuantityView.defaults(config)
def includeme(config): def includeme(config):
defaults(config) defaults(config)

View file

@ -290,7 +290,9 @@ class LogMasterView(WuttaFarmMasterView):
f.set_node("quantities", QuantityRefs(self.request)) f.set_node("quantities", QuantityRefs(self.request))
if not self.creating: if not self.creating:
# nb. must explicity declare value for non-standard field # nb. must explicity declare value for non-standard field
f.set_default("quantities", log.quantities) f.set_default(
"quantities", [self.app.get_true_quantity(q) for q in log.quantities]
)
# notes # notes
f.set_widget("notes", "notes") f.set_widget("notes", "notes")
@ -389,15 +391,15 @@ class LogMasterView(WuttaFarmMasterView):
model = self.app.model model = self.app.model
session = self.Session() session = self.Session()
current = {qty.uuid.hex: qty for qty in log.quantities} current = {
qty.uuid.hex: self.app.get_true_quantity(qty) for qty in log.quantities
}
for new_qty in desired: for new_qty in desired:
units = session.get(model.Unit, new_qty["units"]["uuid"]) units = session.get(model.Unit, new_qty["units"]["uuid"])
assert units assert units
if new_qty["uuid"].startswith("new_"): if new_qty["uuid"].startswith("new_"):
assert new_qty["quantity_type"]["drupal_id"] == "standard" qty = self.app.make_true_quantity(
factory = model.StandardQuantity new_qty["quantity_type"]["drupal_id"],
qty = factory(
quantity_type_id=new_qty["quantity_type"]["drupal_id"],
measure_id=new_qty["measure"], measure_id=new_qty["measure"],
value_numerator=int(new_qty["value"]), value_numerator=int(new_qty["value"]),
value_denominator=1, value_denominator=1,
@ -413,6 +415,8 @@ class LogMasterView(WuttaFarmMasterView):
old_qty.value_numerator = int(new_qty["value"]) old_qty.value_numerator = int(new_qty["value"])
old_qty.value_denominator = 1 old_qty.value_denominator = 1
old_qty.units = units old_qty.units = units
if old_qty.quantity_type_id == "material":
self.set_material_types(old_qty, new_qty["material_types"])
desired = [qty["uuid"] for qty in desired] desired = [qty["uuid"] for qty in desired]
for old_qty in list(log.quantities): for old_qty in list(log.quantities):
@ -421,6 +425,22 @@ class LogMasterView(WuttaFarmMasterView):
if old_qty.uuid and old_qty.uuid.hex not in desired: if old_qty.uuid and old_qty.uuid.hex not in desired:
log.quantities.remove(old_qty) log.quantities.remove(old_qty)
def set_material_types(self, quantity, desired):
model = self.app.model
session = self.Session()
current = {mtype.uuid: mtype for mtype in quantity.material_types}
for new_mtype in desired:
mtype = session.get(model.MaterialType, new_mtype["uuid"])
assert mtype
if mtype.uuid not in current:
quantity.material_types.append(mtype)
desired = [mtype["uuid"] for mtype in desired]
for old_mtype in current.values():
if old_mtype.uuid.hex not in desired:
quantity.material_types.remove(old_mtype)
def auto_sync_to_farmos(self, client, log): def auto_sync_to_farmos(self, client, log):
model = self.app.model model = self.app.model
session = self.Session() session = self.Session()
@ -429,11 +449,8 @@ class LogMasterView(WuttaFarmMasterView):
session.flush() session.flush()
for qty in log.quantities: for qty in log.quantities:
# TODO: support more quantity types qty = self.app.get_true_quantity(qty)
if qty.quantity_type_id == "standard": self.app.auto_sync_to_farmos(qty, client=client)
qty = session.get(model.StandardQuantity, qty.uuid)
assert qty
self.app.auto_sync_to_farmos(qty, client=client)
self.app.auto_sync_to_farmos(log, client=client) self.app.auto_sync_to_farmos(log, client=client)

View file

@ -30,8 +30,13 @@ from webhelpers2.html import tags
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import QuantityType, Quantity, StandardQuantity from wuttafarm.db.model import (
from wuttafarm.web.forms.schema import UnitRef, LogRef QuantityType,
Quantity,
StandardQuantity,
MaterialQuantity,
)
from wuttafarm.web.forms.schema import UnitRef, LogRef, MaterialTypeRefs
from wuttafarm.util import get_log_type_enum from wuttafarm.util import get_log_type_enum
@ -370,6 +375,74 @@ class StandardQuantityView(QuantityMasterView):
farmos_refurl_path = "/log-quantities/standard" farmos_refurl_path = "/log-quantities/standard"
class MaterialQuantityView(QuantityMasterView):
"""
Master view for Material Quantities
"""
model_class = MaterialQuantity
route_prefix = "quantities_material"
url_prefix = "/quantities/material"
farmos_bundle = "material"
farmos_refurl_path = "/log-quantities/material"
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# material_types
g.columns.append("material_types")
g.set_label("material_types", "Material Type", column_only=True)
g.set_renderer("material_types", self.render_material_types_for_grid)
def render_material_types_for_grid(self, quantity, field, value):
if self.farmos_style_grid_links:
links = []
for mtype in quantity.material_types:
url = self.request.route_url("material_types.view", uuid=mtype.uuid)
links.append(tags.link_to(str(mtype), url))
return ", ".join(links)
return ", ".join([str(mtype) for mtype in quantity.material_types])
def configure_form(self, form):
f = form
super().configure_form(f)
quantity = form.model_instance
# material_types
f.fields.insert_after("quantity_type", "material_types")
f.set_node("material_types", MaterialTypeRefs(self.request))
if not self.creating:
f.set_default("material_types", quantity.material_types)
def objectify(self, form):
quantity = super().objectify(form)
data = form.validated
self.set_material_types(quantity, data["material_types"])
return quantity
def set_material_types(self, quantity, desired):
model = self.app.model
session = self.Session()
current = {mt.uuid.hex: mt for mt in quantity.material_types}
for mtype in desired:
if mtype["uuid"] not in current:
mtype = session.get(model.MaterialType, mtype["uuid"])
assert mtype
quantity.material_types.append(mtype)
desired = [mtype["uuid"] for mtype in desired]
for uuid, mtype in current.items():
if uuid not in desired:
quantity.material_types.remove(mtype)
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()
@ -384,6 +457,11 @@ def defaults(config, **kwargs):
) )
StandardQuantityView.defaults(config) StandardQuantityView.defaults(config)
MaterialQuantityView = kwargs.get(
"MaterialQuantityView", base["MaterialQuantityView"]
)
MaterialQuantityView.defaults(config)
def includeme(config): def includeme(config):
defaults(config) defaults(config)