feat: add schema, edit/sync support for Equipment Assets

This commit is contained in:
Lance Edgar 2026-03-09 20:32:53 -05:00
parent 03f6da8ab7
commit 42c73375ac
17 changed files with 1182 additions and 48 deletions

View file

@ -0,0 +1,218 @@
"""add EquipmentAsset
Revision ID: e9b8664e1f39
Revises: e5b27eac471c
Create Date: 2026-03-09 18:05:54.917562
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "e9b8664e1f39"
down_revision: Union[str, None] = "e5b27eac471c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_equipment
op.create_table(
"asset_equipment",
sa.Column("manufacturer", sa.String(length=255), nullable=True),
sa.Column("model", sa.String(length=255), nullable=True),
sa.Column("serial_number", sa.String(length=255), nullable=True),
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_equipment_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_equipment")),
)
op.create_table(
"asset_equipment_version",
sa.Column(
"manufacturer", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column("model", sa.String(length=255), autoincrement=False, nullable=True),
sa.Column(
"serial_number", sa.String(length=255), autoincrement=False, nullable=True
),
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_asset_equipment_version")
),
)
op.create_index(
op.f("ix_asset_equipment_version_end_transaction_id"),
"asset_equipment_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_equipment_version_operation_type"),
"asset_equipment_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_equipment_version_pk_transaction_id",
"asset_equipment_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_equipment_version_pk_validity",
"asset_equipment_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_equipment_version_transaction_id"),
"asset_equipment_version",
["transaction_id"],
unique=False,
)
# asset_equipment_equipment_type
op.create_table(
"asset_equipment_equipment_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("equipment_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("equipment_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["equipment_asset_uuid"],
["asset_equipment.uuid"],
name=op.f(
"fk_asset_equipment_equipment_type_equipment_asset_uuid_asset_equipment"
),
),
sa.ForeignKeyConstraint(
["equipment_type_uuid"],
["equipment_type.uuid"],
name=op.f(
"fk_asset_equipment_equipment_type_equipment_type_uuid_equipment_type"
),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_equipment_equipment_type")),
)
op.create_table(
"asset_equipment_equipment_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"equipment_asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"equipment_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_asset_equipment_equipment_type_version"),
),
)
op.create_index(
op.f("ix_asset_equipment_equipment_type_version_end_transaction_id"),
"asset_equipment_equipment_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_equipment_equipment_type_version_operation_type"),
"asset_equipment_equipment_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_equipment_equipment_type_version_pk_transaction_id",
"asset_equipment_equipment_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_equipment_equipment_type_version_pk_validity",
"asset_equipment_equipment_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_equipment_equipment_type_version_transaction_id"),
"asset_equipment_equipment_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# asset_equipment_equipment_type
op.drop_index(
op.f("ix_asset_equipment_equipment_type_version_transaction_id"),
table_name="asset_equipment_equipment_type_version",
)
op.drop_index(
"ix_asset_equipment_equipment_type_version_pk_validity",
table_name="asset_equipment_equipment_type_version",
)
op.drop_index(
"ix_asset_equipment_equipment_type_version_pk_transaction_id",
table_name="asset_equipment_equipment_type_version",
)
op.drop_index(
op.f("ix_asset_equipment_equipment_type_version_operation_type"),
table_name="asset_equipment_equipment_type_version",
)
op.drop_index(
op.f("ix_asset_equipment_equipment_type_version_end_transaction_id"),
table_name="asset_equipment_equipment_type_version",
)
op.drop_table("asset_equipment_equipment_type_version")
op.drop_table("asset_equipment_equipment_type")
# asset_equipment
op.drop_index(
op.f("ix_asset_equipment_version_transaction_id"),
table_name="asset_equipment_version",
)
op.drop_index(
"ix_asset_equipment_version_pk_validity", table_name="asset_equipment_version"
)
op.drop_index(
"ix_asset_equipment_version_pk_transaction_id",
table_name="asset_equipment_version",
)
op.drop_index(
op.f("ix_asset_equipment_version_operation_type"),
table_name="asset_equipment_version",
)
op.drop_index(
op.f("ix_asset_equipment_version_end_transaction_id"),
table_name="asset_equipment_version",
)
op.drop_table("asset_equipment_version")
op.drop_table("asset_equipment")

View file

@ -42,7 +42,7 @@ from .quantities import (
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
from .asset_equipment import EquipmentType from .asset_equipment import EquipmentType, EquipmentAsset, EquipmentAssetEquipmentType
from .asset_animal import AnimalType, AnimalAsset from .asset_animal import AnimalType, AnimalAsset
from .asset_group import GroupAsset from .asset_group import GroupAsset
from .asset_plant import ( from .asset_plant import (

View file

@ -23,14 +23,19 @@
Model definition for Equipment Model definition for Equipment
""" """
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
from wuttafarm.db.model.taxonomy import TaxonomyMixin from wuttafarm.db.model.taxonomy import TaxonomyMixin
from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies
class EquipmentType(TaxonomyMixin, model.Base): class EquipmentType(TaxonomyMixin, model.Base):
""" """
Represents a "equipment type" (taxonomy term) from farmOS Represents an "equipment type" (taxonomy term) from farmOS
""" """
__tablename__ = "equipment_type" __tablename__ = "equipment_type"
@ -39,3 +44,90 @@ class EquipmentType(TaxonomyMixin, model.Base):
"model_title": "Equipment Type", "model_title": "Equipment Type",
"model_title_plural": "Equipment Types", "model_title_plural": "Equipment Types",
} }
_equipment_assets = orm.relationship(
"EquipmentAssetEquipmentType",
cascade_backrefs=False,
back_populates="equipment_type",
)
class EquipmentAsset(AssetMixin, model.Base):
"""
Represents an equipment asset from farmOS
"""
__tablename__ = "asset_equipment"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Equipment Asset",
"model_title_plural": "Equipment Assets",
"farmos_asset_type": "equipment",
}
manufacturer = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Name of the manufacturer, if applicable.
""",
)
model = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Model name for the equipment, if applicable.
""",
)
serial_number = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Serial number for the equipment, if applicable.
""",
)
_equipment_types = orm.relationship(
"EquipmentAssetEquipmentType",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="equipment_asset",
)
equipment_types = association_proxy(
"_equipment_types",
"equipment_type",
creator=lambda pt: EquipmentAssetEquipmentType(equipment_type=pt),
)
add_asset_proxies(EquipmentAsset)
class EquipmentAssetEquipmentType(model.Base):
"""
Associates one or more equipment types with an equipment asset.
"""
__tablename__ = "asset_equipment_equipment_type"
__versioned__ = {}
uuid = model.uuid_column()
equipment_asset_uuid = model.uuid_fk_column("asset_equipment.uuid", nullable=False)
equipment_asset = orm.relationship(
EquipmentAsset,
foreign_keys=equipment_asset_uuid,
back_populates="_equipment_types",
)
equipment_type_uuid = model.uuid_fk_column("equipment_type.uuid", nullable=False)
equipment_type = orm.relationship(
EquipmentType,
doc="""
Reference to the equipment type.
""",
back_populates="_equipment_assets",
)

View file

@ -325,6 +325,68 @@ class EquipmentTypeImporter(ToFarmOSTaxonomy):
farmos_taxonomy_type = "equipment_type" farmos_taxonomy_type = "equipment_type"
class EquipmentAssetImporter(ToFarmOSAsset):
model_title = "EquipmentAsset"
farmos_asset_type = "equipment"
supported_fields = [
"uuid",
"asset_name",
"manufacturer",
"model",
"serial_number",
"equipment_type_uuids",
"is_location",
"is_fixed",
"notes",
"archived",
]
def normalize_target_object(self, equipment):
data = super().normalize_target_object(equipment)
data.update(
{
"manufacturer": equipment["attributes"]["manufacturer"],
"model": equipment["attributes"]["model"],
"serial_number": equipment["attributes"]["serial_number"],
"equipment_type_uuids": [
UUID(etype["id"])
for etype in equipment["relationships"]["equipment_type"]["data"]
],
}
)
return data
def get_asset_payload(self, source_data):
payload = super().get_asset_payload(source_data)
attrs = {}
if "manufacturer" in self.fields:
attrs["manufacturer"] = source_data["manufacturer"]
if "model" in self.fields:
attrs["model"] = source_data["model"]
if "serial_number" in self.fields:
attrs["serial_number"] = source_data["serial_number"]
rels = {}
if "equipment_type_uuids" in self.fields:
rels["equipment_type"] = {"data": []}
for uuid in source_data["equipment_type_uuids"]:
rels["equipment_type"]["data"].append(
{
"id": str(uuid),
"type": "taxonomy_term--equipment_type",
}
)
payload["attributes"].update(attrs)
if rels:
payload.setdefault("relationships", {}).update(rels)
return payload
class MaterialTypeImporter(ToFarmOSTaxonomy): class MaterialTypeImporter(ToFarmOSTaxonomy):
model_title = "MaterialType" model_title = "MaterialType"

View file

@ -100,6 +100,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
importers["StructureAsset"] = StructureAssetImporter importers["StructureAsset"] = StructureAssetImporter
importers["WaterAsset"] = WaterAssetImporter importers["WaterAsset"] = WaterAssetImporter
importers["EquipmentType"] = EquipmentTypeImporter importers["EquipmentType"] = EquipmentTypeImporter
importers["EquipmentAsset"] = EquipmentAssetImporter
importers["AnimalType"] = AnimalTypeImporter importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter importers["GroupAsset"] = GroupAssetImporter
@ -235,6 +236,44 @@ class EquipmentTypeImporter(
source_model_class = model.EquipmentType source_model_class = model.EquipmentType
class EquipmentAssetImporter(
FromWuttaFarmAsset, farmos_importing.model.EquipmentAssetImporter
):
"""
WuttaFarm farmOS API exporter for Equipment Assets
"""
source_model_class = model.EquipmentAsset
def get_supported_fields(self):
fields = list(super().get_supported_fields())
print(fields)
fields.extend(
[
"manufacturer",
"model",
"serial_number",
"equipment_type_uuids",
]
)
return fields
def normalize_source_object(self, equipment):
data = super().normalize_source_object(equipment)
data.update(
{
"manufacturer": equipment.manufacturer,
"model": equipment.model,
"serial_number": equipment.serial_number,
"equipment_type_uuids": [
etype.farmos_uuid for etype in equipment.equipment_types
],
}
)
return data
class AnimalTypeImporter( class AnimalTypeImporter(
FromWuttaFarmTaxonomy, farmos_importing.model.AnimalTypeImporter FromWuttaFarmTaxonomy, farmos_importing.model.AnimalTypeImporter
): ):

View file

@ -108,6 +108,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["StructureAsset"] = StructureAssetImporter importers["StructureAsset"] = StructureAssetImporter
importers["WaterAsset"] = WaterAssetImporter importers["WaterAsset"] = WaterAssetImporter
importers["EquipmentType"] = EquipmentTypeImporter importers["EquipmentType"] = EquipmentTypeImporter
importers["EquipmentAsset"] = EquipmentAssetImporter
importers["AnimalType"] = AnimalTypeImporter importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter importers["GroupAsset"] = GroupAssetImporter
@ -489,6 +490,111 @@ class AssetTypeImporter(FromFarmOS, ToWutta):
} }
class EquipmentAssetImporter(AssetImporterBase):
"""
farmOS API WuttaFarm importer for Equipment Assets
"""
model_class = model.EquipmentAsset
def get_supported_fields(self):
fields = list(super().get_supported_fields())
fields.extend(
[
"equipment_types",
]
)
return fields
def setup(self):
super().setup()
model = self.app.model
self.equipment_types_by_farmos_uuid = {}
for equipment_type in self.target_session.query(model.EquipmentType):
if equipment_type.farmos_uuid:
self.equipment_types_by_farmos_uuid[equipment_type.farmos_uuid] = (
equipment_type
)
def normalize_source_object(self, equipment):
""" """
data = super().normalize_source_object(equipment)
equipment_types = []
if relationships := equipment.get("relationships"):
if equipment_type := relationships.get("equipment_type"):
equipment_types = []
for equipment_type in equipment_type["data"]:
if wf_equipment_type := self.equipment_types_by_farmos_uuid.get(
UUID(equipment_type["id"])
):
equipment_types.append(wf_equipment_type.uuid)
else:
log.warning(
"equipment type not found: %s", equipment_type["id"]
)
data.update(
{
"manufacturer": equipment["attributes"]["manufacturer"],
"model": equipment["attributes"]["model"],
"serial_number": equipment["attributes"]["serial_number"],
"equipment_types": set(equipment_types),
}
)
return data
def normalize_target_object(self, equipment):
data = super().normalize_target_object(equipment)
if "equipment_types" in self.fields:
data["equipment_types"] = set(
[etype.uuid for etype in equipment.equipment_types]
)
return data
def update_target_object(self, equipment, source_data, target_data=None):
model = self.app.model
equipment = super().update_target_object(equipment, source_data, target_data)
if "equipment_types" in self.fields:
if (
not target_data
or target_data["equipment_types"] != source_data["equipment_types"]
):
for uuid in source_data["equipment_types"]:
if not target_data or uuid not in target_data["equipment_types"]:
self.target_session.flush()
equipment._equipment_types.append(
model.EquipmentAssetEquipmentType(equipment_type_uuid=uuid)
)
if target_data:
for uuid in target_data["equipment_types"]:
if uuid not in source_data["equipment_types"]:
equipment_type = (
self.target_session.query(
model.EquipmentAssetEquipmentType
)
.filter(
model.EquipmentAssetEquipmentType.equipment_asset
== equipment
)
.filter(
model.EquipmentAssetEquipmentType.equipment_type_uuid
== uuid
)
.one()
)
self.target_session.delete(equipment_type)
return equipment
class GroupAssetImporter(AssetImporterBase): class GroupAssetImporter(AssetImporterBase):
""" """
farmOS API WuttaFarm importer for Group Assets farmOS API WuttaFarm importer for Group Assets

View file

@ -28,7 +28,7 @@ import json
import colander import colander
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttaweb.forms.schema import ObjectRef, WuttaSet from wuttaweb.forms.schema import ObjectRef, WuttaSet, WuttaList
from wuttaweb.forms.widgets import NotesWidget from wuttaweb.forms.widgets import NotesWidget
@ -216,6 +216,35 @@ class FarmOSQuantityRefs(WuttaSet):
return FarmOSQuantityRefsWidget(**kwargs) return FarmOSQuantityRefsWidget(**kwargs)
class FarmOSTaxonomyTerms(colander.SchemaType):
"""
Schema type which can represent multiple taxonomy terms.
"""
route_prefix = None
def __init__(self, request, route_prefix=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
if route_prefix:
self.route_prefix = route_prefix
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
return appstruct
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSTaxonomyTermsWidget
return FarmOSTaxonomyTermsWidget(self.request, self.route_prefix, **kwargs)
class FarmOSEquipmentTypeRefs(FarmOSTaxonomyTerms):
route_prefix = "farmos_equipment_types"
class FarmOSPlantTypes(colander.SchemaType): class FarmOSPlantTypes(colander.SchemaType):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
@ -260,6 +289,35 @@ class LandTypeRef(ObjectRef):
return self.request.route_url("land_types.view", uuid=land_type.uuid) return self.request.route_url("land_types.view", uuid=land_type.uuid)
class TaxonomyTermRefs(WuttaList):
"""
Generic schema type for a field which can reference multiple
taxonomy terms.
"""
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
terms = []
for term in appstruct:
terms.append(
{
"uuid": str(term.uuid),
"name": term.name,
}
)
return terms
class EquipmentTypeRefs(TaxonomyTermRefs):
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import EquipmentTypeRefsWidget
return EquipmentTypeRefsWidget(self.request, **kwargs)
class PlantTypeRefs(WuttaSet): class PlantTypeRefs(WuttaSet):
""" """
Schema type for Plant Types field (on a Plant Asset). Schema type for Plant Types field (on a Plant Asset).

View file

@ -33,6 +33,7 @@ from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttafarm.web.util import render_quantity_objects from wuttafarm.web.util import render_quantity_objects
from wuttafarm.db.model import EquipmentType
class ImageWidget(Widget): class ImageWidget(Widget):
@ -228,6 +229,38 @@ class FarmOSUnitRefWidget(Widget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
class FarmOSTaxonomyTermsWidget(Widget):
"""
Widget to display a field which can reference multiple taxonomy
terms.
"""
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 term in cstruct:
link = tags.link_to(
term["name"],
self.request.route_url(
f"{self.route_prefix}.view", uuid=term["uuid"]
),
)
links.append(HTML.tag("li", c=link))
return HTML.tag("ul", c=links)
return super().serialize(field, cstruct, **kw)
class FarmOSPlantTypesWidget(Widget): class FarmOSPlantTypesWidget(Widget):
""" """
Widget to display a farmOS "plant types" field. Widget to display a farmOS "plant types" field.
@ -258,6 +291,88 @@ class FarmOSPlantTypesWidget(Widget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
class TaxonomyTermRefsWidget(Widget):
"""
Generic (incomplete) widget for fields which can reference
multiple taxonomy terms.
This widget can handle typical read-only scenarios but the
editable mode is not implemented.
"""
route_prefix = None
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()
@classmethod
def get_route_prefix(cls):
return cls.route_prefix
@classmethod
def get_permission_prefix(cls):
return cls.route_prefix
def serialize(self, field, cstruct, **kw):
""" """
if not cstruct:
cstruct = []
if readonly := kw.get("readonly", self.readonly):
items = []
route_prefix = self.get_route_prefix()
for term in cstruct:
url = self.request.route_url(f"{route_prefix}.view", uuid=term["uuid"])
link = tags.link_to(term["name"], url)
items.append(HTML.tag("li", c=link))
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)
model = self.app.model
session = Session()
terms = []
query = session.query(self.model_class).order_by(self.model_class.name)
for term in query:
terms.append(
{
"uuid": str(term.uuid),
"name": term.name,
}
)
values["terms"] = terms
permission_prefix = self.get_permission_prefix()
if self.request.has_perm(f"{permission_prefix}.create"):
values["can_create"] = True
return values
def deserialize(self, field, pstruct):
""" """
if not pstruct:
return colander.null
return json.loads(pstruct)
class EquipmentTypeRefsWidget(TaxonomyTermRefsWidget):
"""
Widget for Equipment Types field.
"""
model_class = EquipmentType
route_prefix = "equipment_types"
template = "equipmenttyperefs"
class PlantTypeRefsWidget(Widget): class PlantTypeRefsWidget(Widget):
""" """
Widget for Plant Types field (on a Plant Asset). Widget for Plant Types field (on a Plant Asset).

View file

@ -92,6 +92,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "animal_assets", "route": "animal_assets",
"perm": "animal_assets.list", "perm": "animal_assets.list",
}, },
{
"title": "Equipment",
"route": "equipment_assets",
"perm": "equipment_assets.list",
},
{ {
"title": "Group", "title": "Group",
"route": "group_assets", "route": "group_assets",
@ -249,6 +254,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_animal_assets", "route": "farmos_animal_assets",
"perm": "farmos_animal_assets.list", "perm": "farmos_animal_assets.list",
}, },
{
"title": "Equipment Assets",
"route": "farmos_equipment_assets",
"perm": "farmos_equipment_assets.list",
},
{ {
"title": "Group Assets", "title": "Group Assets",
"route": "farmos_group_assets", "route": "farmos_group_assets",
@ -383,6 +393,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_animal_assets", "route": "farmos_animal_assets",
"perm": "farmos_animal_assets.list", "perm": "farmos_animal_assets.list",
}, },
{
"title": "Equipment",
"route": "farmos_equipment_assets",
"perm": "farmos_equipment_assets.list",
},
{ {
"title": "Group", "title": "Group",
"route": "farmos_group_assets", "route": "farmos_group_assets",

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="">
<equipment-types-picker tal:attributes="name name;
v-model vmodel;
:equipment-types terms;
:can-create str(can_create).lower();" />
</div>

View file

@ -1,5 +1,7 @@
<%def name="make_wuttafarm_components()"> <%def name="make_wuttafarm_components()">
${self.make_taxonomy_terms_picker_component()}
${self.make_equipment_types_picker_component()}
${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_material_types_picker_component()}
@ -9,6 +11,216 @@
${self.make_seasons_picker_component()} ${self.make_seasons_picker_component()}
</%def> </%def>
<%def name="make_taxonomy_terms_picker_component()">
<script type="text/x-template" id="taxonomy-terms-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>
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="createInit()">
New
</b-button>
</div>
<${b}-table :data="value">
<${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="removeTerm(props.row)">
<i class="fas fa-trash" /> &nbsp; Remove
</a>
</${b}-table-column>
</${b}-table>
<${b}-modal v-if="canCreate"
has-modal-card
% if request.use_oruga:
v-model:active="createShowDialog"
% else:
:active.sync="createShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">New {{ termTitle }} </p>
</header>
<section class="modal-card-body">
<b-field label="Name" horizontal>
<b-input v-model="createName"
ref="createName"
expanded
@keydown.native="createNameKeydown" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="createSave()"
:disabled="createSaving || !createName"
icon-pack="fas"
icon-left="save">
{{ createSaving ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="createShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
</div>
</script>
<script>
const TaxonomyTermsPicker = {
template: '#taxonomy-terms-picker-template',
mixins: [WuttaRequestMixin],
props: {
name: String,
value: Array,
termTitle: String,
terms: Array,
canCreate: Boolean,
createUrl: String,
},
data() {
return {
addName: '',
createShowDialog: false,
createName: null,
createSaving: false,
}
},
computed: {
addNameData() {
if (!this.addName) {
return this.terms
}
return this.terms.filter((term) => {
return term.name.toLowerCase().indexOf(this.addName.toLowerCase()) >= 0
})
},
},
methods: {
addNameSelected(option) {
if (!option) {
return
}
const uuids = this.value.map((term) => {
return term.uuid
})
if (!uuids.includes(option.uuid)) {
this.value.push(option)
this.$emit('input', this.value)
}
this.addName = null
},
createInit() {
this.createName = this.addName
this.createShowDialog = true
this.$nextTick(() => {
this.$refs.createName.focus()
})
},
createNameKeydown(event) {
// nb. must prevent main form submit on ENTER
// (since ultimately this lives within an outer form)
// but also we can submit the modal pseudo-form
if (event.which == 13) {
event.preventDefault()
this.createSave()
}
},
createSave() {
this.createSaving = true
const params = {name: this.createName}
this.wuttaPOST(this.createUrl, params, response => {
this.value.push(response.data)
this.$emit('input', this.value)
this.addName = null
this.createSaving = false
this.createShowDialog = false
}, response => {
this.createSaving = false
})
},
removeTerm(term) {
const value = Array.from(this.value)
const i = value.indexOf(term)
value.splice(i, 1)
this.$emit('input', value)
},
},
}
Vue.component('taxonomy-terms-picker', TaxonomyTermsPicker)
<% request.register_component('taxonomy-terms-picker', 'TaxonomyTermsPicker') %>
</script>
</%def>
<%def name="make_equipment_types_picker_component()">
<script type="text/x-template" id="equipment-types-picker-template">
<taxonomy-terms-picker v-model="proxyValue"
:name="name"
term-title="Equipment Type"
:terms="equipmentTypes"
:can-create="canCreate"
create-url="${url('equipment_types.ajax_create')}" />
</script>
<script>
const EquipmentTypesPicker = {
template: '#equipment-types-picker-template',
props: {
name: String,
value: Array,
equipmentTypes: Array,
canCreate: Boolean,
},
data() {
return {
proxyValue: this.value || [],
}
},
}
Vue.component('equipment-types-picker', EquipmentTypesPicker)
<% request.register_component('equipment-types-picker', 'EquipmentTypesPicker') %>
</script>
</%def>
<%def name="make_assets_picker_component()"> <%def name="make_assets_picker_component()">
<script type="text/x-template" id="assets-picker-template"> <script type="text/x-template" id="assets-picker-template">
<div> <div>

View file

@ -83,9 +83,9 @@ class AssetMasterView(WuttaFarmMasterView):
"notes", "notes",
"asset_type", "asset_type",
"owners", "owners",
"locations",
"is_location", "is_location",
"is_fixed", "is_fixed",
"locations",
"groups", "groups",
"archived", "archived",
"drupal_id", "drupal_id",

View file

@ -23,8 +23,10 @@
Master view for Plants Master view for Plants
""" """
from wuttafarm.db.model import EquipmentType from wuttafarm.db.model import EquipmentType, EquipmentAsset
from wuttafarm.web.views import TaxonomyMasterView from wuttafarm.web.views import TaxonomyMasterView
from wuttafarm.web.views.assets import AssetMasterView
from wuttafarm.web.forms.schema import EquipmentTypeRefs
class EquipmentTypeView(TaxonomyMasterView): class EquipmentTypeView(TaxonomyMasterView):
@ -42,12 +44,79 @@ class EquipmentTypeView(TaxonomyMasterView):
farmos_refurl_path = "/admin/structure/taxonomy/manage/equipment_type/overview" farmos_refurl_path = "/admin/structure/taxonomy/manage/equipment_type/overview"
class EquipmentAssetView(AssetMasterView):
"""
Master view for Equipment Assets
"""
model_class = EquipmentAsset
route_prefix = "equipment_assets"
url_prefix = "/assets/equipment"
farmos_bundle = "equipment"
farmos_refurl_path = "/assets/equipment"
labels = {
"equipment_types": "Equipment Type",
}
def configure_form(self, form):
f = form
super().configure_form(f)
equipment = f.model_instance
# equipment_types
f.fields.insert_after("asset_name", "equipment_types")
f.set_node("equipment_types", EquipmentTypeRefs(self.request))
if not self.creating:
# nb. must explcitly declare value for non-standard field
f.set_default("equipment_types", equipment.equipment_types)
# manufacturer
f.fields.insert_after("equipment_types", "manufacturer")
# model
f.fields.insert_after("manufacturer", "model")
# serial_number
f.fields.insert_after("model", "serial_number")
def objectify(self, form):
equipment = super().objectify(form)
data = form.validated
self.set_equipment_types(equipment, data["equipment_types"])
return equipment
def set_equipment_types(self, equipment, desired):
model = self.app.model
session = self.Session()
current = [str(etype.uuid) for etype in equipment.equipment_types]
for etype in desired:
if etype["uuid"] not in current:
equipment_type = session.get(model.EquipmentType, etype["uuid"])
assert equipment_type
equipment.equipment_types.append(equipment_type)
desired = [etype["uuid"] for etype in desired]
for uuid in current:
if uuid not in desired:
equipment_type = session.get(model.EquipmentType, uuid)
assert equipment_type
equipment.equipment_types.remove(equipment_type)
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()
EquipmentTypeView = kwargs.get("EquipmentTypeView", base["EquipmentTypeView"]) EquipmentTypeView = kwargs.get("EquipmentTypeView", base["EquipmentTypeView"])
EquipmentTypeView.defaults(config) EquipmentTypeView.defaults(config)
EquipmentAssetView = kwargs.get("EquipmentAssetView", base["EquipmentAssetView"])
EquipmentAssetView.defaults(config)
def includeme(config): def includeme(config):
defaults(config) defaults(config)

View file

@ -39,7 +39,7 @@ from wuttafarm.web.grids import (
NullableBooleanFilter, NullableBooleanFilter,
DateTimeFilter, DateTimeFilter,
) )
from wuttafarm.web.forms.schema import FarmOSRef, FarmOSAssetRefs from wuttafarm.web.forms.schema import FarmOSRef
class AnimalView(AssetMasterView): class AnimalView(AssetMasterView):
@ -99,8 +99,7 @@ class AnimalView(AssetMasterView):
def get_farmos_api_includes(self): def get_farmos_api_includes(self):
includes = super().get_farmos_api_includes() includes = super().get_farmos_api_includes()
includes.add("animal_type") includes.update(["animal_type"])
includes.add("group")
return includes return includes
def configure_grid(self, grid): def configure_grid(self, grid):
@ -131,10 +130,6 @@ class AnimalView(AssetMasterView):
g.set_sorter("sex", SimpleSorter("sex")) g.set_sorter("sex", SimpleSorter("sex"))
g.set_filter("sex", StringFilter) g.set_filter("sex", StringFilter)
# groups
g.set_label("groups", "Group Membership")
g.set_renderer("groups", self.render_groups_for_grid)
# is_sterile # is_sterile
g.set_renderer("is_sterile", "boolean") g.set_renderer("is_sterile", "boolean")
g.set_sorter("is_sterile", SimpleSorter("is_sterile")) g.set_sorter("is_sterile", SimpleSorter("is_sterile"))
@ -145,18 +140,6 @@ class AnimalView(AssetMasterView):
url = self.request.route_url("farmos_animal_types.view", uuid=uuid) url = self.request.route_url("farmos_animal_types.view", uuid=uuid)
return tags.link_to(value, url) return tags.link_to(value, url)
def render_groups_for_grid(self, animal, field, value):
groups = []
for group in animal["groups"]:
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): def get_instance(self):
data = super().get_instance() data = super().get_instance()
@ -192,8 +175,6 @@ class AnimalView(AssetMasterView):
sterile = animal["attributes"]["is_castrated"] sterile = animal["attributes"]["is_castrated"]
animal_type_object = None animal_type_object = None
group_objects = []
group_names = []
if relationships := animal.get("relationships"): if relationships := animal.get("relationships"):
if animal_type := relationships.get("animal_type"): if animal_type := relationships.get("animal_type"):
@ -203,24 +184,11 @@ class AnimalView(AssetMasterView):
"name": animal_type["attributes"]["name"], "name": animal_type["attributes"]["name"],
} }
if groups := relationships.get("group"):
for group in groups["data"]:
if group := included.get(group["id"]):
group = {
"uuid": group["id"],
"name": group["attributes"]["name"],
"asset_type": "group",
}
group_objects.append(group)
group_names.append(group["name"])
normal.update( normal.update(
{ {
"animal_type": animal_type_object, "animal_type": animal_type_object,
"animal_type_uuid": animal_type_object["uuid"], "animal_type_uuid": animal_type_object["uuid"],
"animal_type_name": animal_type_object["name"], "animal_type_name": animal_type_object["name"],
"groups": group_objects,
"group_names": group_names,
"birthdate": birthdate, "birthdate": birthdate,
"sex": animal["attributes"]["sex"] or colander.null, "sex": animal["attributes"]["sex"] or colander.null,
"is_sterile": sterile, "is_sterile": sterile,
@ -271,12 +239,6 @@ class AnimalView(AssetMasterView):
# is_sterile # is_sterile
f.set_node("is_sterile", colander.Boolean()) f.set_node("is_sterile", colander.Boolean())
# groups
if self.creating or self.editing:
f.remove("groups") # TODO
else:
f.set_node("groups", FarmOSAssetRefs(self.request))
def get_api_payload(self, animal): def get_api_payload(self, animal):
payload = super().get_api_payload(animal) payload = super().get_api_payload(animal)

View file

@ -28,7 +28,7 @@ import requests
from webhelpers2.html import tags from webhelpers2.html import tags
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.schema import FarmOSRefs, FarmOSLocationRefs from wuttafarm.web.forms.schema import FarmOSRefs, FarmOSAssetRefs, FarmOSLocationRefs
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.grids import ( from wuttafarm.web.grids import (
ResourceData, ResourceData,
@ -80,12 +80,13 @@ class AssetMasterView(FarmOSMasterView):
"name", "name",
"notes", "notes",
"asset_type_name", "asset_type_name",
"owners",
"is_location", "is_location",
"is_fixed", "is_fixed",
"owners",
"locations", "locations",
"groups", "groups",
"archived", "archived",
"drupal_id",
"thumbnail_url", "thumbnail_url",
"image_url", "image_url",
"thumbnail", "thumbnail",
@ -127,6 +128,10 @@ class AssetMasterView(FarmOSMasterView):
# locations # locations
g.set_renderer("locations", self.render_locations_for_grid) g.set_renderer("locations", self.render_locations_for_grid)
# groups
g.set_label("groups", "Group Membership")
g.set_renderer("groups", self.render_assets_for_grid)
# archived # archived
g.set_renderer("archived", "boolean") g.set_renderer("archived", "boolean")
g.set_sorter("archived", SimpleSorter("archived")) g.set_sorter("archived", SimpleSorter("archived"))
@ -137,6 +142,20 @@ class AssetMasterView(FarmOSMasterView):
return tags.image(url, f"thumbnail for {self.get_model_title()}") return tags.image(url, f"thumbnail for {self.get_model_title()}")
return None return None
def render_assets_for_grid(self, log, field, value):
if not value:
return ""
assets = []
for asset in value:
if self.farmos_style_grid_links:
route = f"farmos_{asset['asset_type']}_assets.view"
url = self.request.route_url(route, uuid=asset["uuid"])
assets.append(tags.link_to(asset["name"], url))
else:
assets.append(asset["name"])
return ", ".join(assets)
def render_locations_for_grid(self, asset, field, value): def render_locations_for_grid(self, asset, field, value):
locations = [] locations = []
for location in value: for location in value:
@ -156,7 +175,7 @@ class AssetMasterView(FarmOSMasterView):
return None return None
def get_farmos_api_includes(self): def get_farmos_api_includes(self):
return {"asset_type", "location", "owner", "image"} return {"asset_type", "location", "group", "owner", "image"}
def get_instance(self): def get_instance(self):
try: try:
@ -192,6 +211,7 @@ class AssetMasterView(FarmOSMasterView):
owner_names = [] owner_names = []
location_objects = [] location_objects = []
location_names = [] location_names = []
group_objects = []
thumbnail_url = None thumbnail_url = None
image_url = None image_url = None
if relationships := asset.get("relationships"): if relationships := asset.get("relationships"):
@ -225,6 +245,16 @@ class AssetMasterView(FarmOSMasterView):
location_objects.append(location) location_objects.append(location)
location_names.append(location["name"]) location_names.append(location["name"])
if groups := relationships.get("group"):
for group in groups["data"]:
if group := included.get(group["id"]):
group = {
"uuid": group["id"],
"name": group["attributes"]["name"],
"asset_type": "group",
}
group_objects.append(group)
if images := relationships.get("image"): if images := relationships.get("image"):
for image in images["data"]: for image in images["data"]:
if image := included.get(image["id"]): if image := included.get(image["id"]):
@ -246,6 +276,7 @@ class AssetMasterView(FarmOSMasterView):
"owner_names": owner_names, "owner_names": owner_names,
"locations": location_objects, "locations": location_objects,
"location_names": location_names, "location_names": location_names,
"groups": group_objects,
"archived": archived, "archived": archived,
"thumbnail_url": thumbnail_url or colander.null, "thumbnail_url": thumbnail_url or colander.null,
"image_url": image_url or colander.null, "image_url": image_url or colander.null,
@ -267,6 +298,12 @@ class AssetMasterView(FarmOSMasterView):
f.set_label("locations", "Current Location") f.set_label("locations", "Current Location")
f.set_node("locations", FarmOSLocationRefs(self.request)) f.set_node("locations", FarmOSLocationRefs(self.request))
# groups
if self.creating or self.editing:
f.remove("groups") # TODO
else:
f.set_node("groups", FarmOSAssetRefs(self.request))
# owners # owners
if self.creating or self.editing: if self.creating or self.editing:
f.remove("owners") # TODO f.remove("owners") # TODO

View file

@ -23,7 +23,11 @@
Master view for farmOS Equipment Master view for farmOS Equipment
""" """
from webhelpers2.html import tags
from wuttafarm.web.views.farmos.master import TaxonomyMasterView from wuttafarm.web.views.farmos.master import TaxonomyMasterView
from wuttafarm.web.views.farmos.assets import AssetMasterView
from wuttafarm.web.forms.schema import FarmOSEquipmentTypeRefs
class EquipmentTypeView(TaxonomyMasterView): class EquipmentTypeView(TaxonomyMasterView):
@ -65,12 +69,143 @@ class EquipmentTypeView(TaxonomyMasterView):
return buttons return buttons
class EquipmentAssetView(AssetMasterView):
"""
Master view for farmOS Equipment Assets
"""
model_name = "farmos_equipment_assets"
model_title = "farmOS Equipment Asset"
model_title_plural = "farmOS Equipment Assets"
route_prefix = "farmos_equipment_assets"
url_prefix = "/farmOS/assets/equipment"
farmos_asset_type = "equipment"
farmos_refurl_path = "/assets/equipment"
labels = {
"equipment_types": "Equipment Type",
}
grid_columns = [
"thumbnail",
"drupal_id",
"name",
"equipment_types",
"manufacturer",
"model",
"serial_number",
"groups",
"owners",
"archived",
]
def get_farmos_api_includes(self):
includes = super().get_farmos_api_includes()
includes.update(["equipment_type"])
return includes
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# equipment_types
g.set_renderer("equipment_types", self.render_equipment_types_for_grid)
def render_equipment_types_for_grid(self, equipment, field, value):
if self.farmos_style_grid_links:
links = []
for etype in value:
url = self.request.route_url(
f"farmos_equipment_types.view", uuid=etype["uuid"]
)
links.append(tags.link_to(etype["name"], url))
return ", ".join(links)
return ", ".join([etype["name"] for etype in value])
def normalize_asset(self, equipment, included):
data = super().normalize_asset(equipment, included)
equipment_type_objects = []
rels = equipment["relationships"]
for etype in rels["equipment_type"]["data"]:
uuid = etype["id"]
equipment_type = {
"uuid": uuid,
"type": etype["type"],
}
if etype := included.get(uuid):
equipment_type.update(
{
"name": etype["attributes"]["name"],
}
)
equipment_type_objects.append(equipment_type)
data.update(
{
"manufacturer": equipment["attributes"]["manufacturer"],
"model": equipment["attributes"]["model"],
"serial_number": equipment["attributes"]["serial_number"],
"equipment_types": equipment_type_objects,
}
)
return data
def configure_form(self, form):
f = form
super().configure_form(f)
# equipment_types
f.fields.insert_after("name", "equipment_types")
f.set_node("equipment_types", FarmOSEquipmentTypeRefs(self.request))
# manufacturer
f.fields.insert_after("equipment_types", "manufacturer")
# model
f.fields.insert_after("manufacturer", "model")
# serial_number
f.fields.insert_after("model", "serial_number")
def get_xref_buttons(self, equipment):
model = self.app.model
session = self.Session()
buttons = super().get_xref_buttons(equipment)
if wf_equipment := (
session.query(model.Asset)
.filter(model.Asset.farmos_uuid == equipment["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url(
"equipment_assets.view", uuid=wf_equipment.uuid
),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()
EquipmentTypeView = kwargs.get("EquipmentTypeView", base["EquipmentTypeView"]) EquipmentTypeView = kwargs.get("EquipmentTypeView", base["EquipmentTypeView"])
EquipmentTypeView.defaults(config) EquipmentTypeView.defaults(config)
EquipmentAssetView = kwargs.get("EquipmentAssetView", base["EquipmentAssetView"])
EquipmentAssetView.defaults(config)
def includeme(config): def includeme(config):
defaults(config) defaults(config)

View file

@ -26,6 +26,7 @@ Base class for WuttaFarm master views
from webhelpers2.html import tags from webhelpers2.html import tags
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.util import get_form_data
from wuttafarm.web.util import use_farmos_style_grid_links, get_farmos_client_for_user from wuttafarm.web.util import use_farmos_style_grid_links, get_farmos_client_for_user