Compare commits

...

32 commits

Author SHA1 Message Date
a5b699a52a bump: version 0.11.1 → 0.11.2 2026-03-21 20:21:52 -05:00
9707c36553 fix: use separate thread to sync changes to farmOS
i.e. when creating or editing an asset/log, or submitting quick eggs form
2026-03-21 20:18:32 -05:00
969497826d fix: avoid error if asset has no geometry 2026-03-21 15:24:36 -05:00
f0fa189bcd bump: version 0.11.0 → 0.11.1 2026-03-21 15:11:36 -05:00
cc4b94a7b8 fix: improve behavior when deleting mirrored record from farmOS
in some cases (maybe just dev?) the record does not exist in farmOS;
if so we should silently ignore.

and there seemed to be a problem with the sequence of events:

- user clicks delete in WF
- record is deleted from WF DB
- delete request sent to farmOS API
- webhook on farmOS side calls back to WF webhook URI

somewhere in there, in practice things seemed to hang after user
clicks delete.  i suppose the thread handling user's request is "tied
up" somehow, such that the webhook receiver can't process that
request?  that doesn't exactly make sense to me, but if we split off
to a separate thread to request the farmOS deletion, things seem to
work okay.  so maybe that idea is more accurate than i'd expect
2026-03-21 15:06:26 -05:00
ca5e1420e4 fix: use correct uuid when processing webhook to delete record 2026-03-21 15:03:06 -05:00
f9d9923acf bump: version 0.10.0 → 0.11.0 2026-03-15 10:08:27 -05:00
eee2a1df65 feat: show basic map for "fixed" assets
this is just to get our foot in the door so to speak.  not sure yet
how sophisticated this map needs to be etc. but thought it would be
nice to at least show something..since the data is available
2026-03-14 23:07:07 -05:00
d65de5e8ce fix: include LogQuantity changes when viewing Log revision 2026-03-11 18:29:25 -05:00
bd7d412b97 bump: version 0.9.0 → 0.10.0 2026-03-11 16:05:28 -05:00
0f3ef5227b feat: add support for webhooks module in farmOS
this lets farmOS send a POST request to a webhook URL in our app,
which then records a "stub" record in a change queue table.  from
there a daemon should process the queue and import/delete records as
needed in our app DB.

this all requires setup on the farmOS side as well..those details will
be documented elsewhere (eventually!)
2026-03-11 09:19:16 -05:00
190efb7bea fix: remove print statement 2026-03-10 11:19:36 -05:00
8baf140c70 bump: version 0.8.0 → 0.9.0 2026-03-10 10:59:27 -05:00
3bacb884dc fix: avoid error when material type is unknown
not sure if this is a unique situation in my dev environment or
what..but i keep encountering this in dev, so might as well add it.
better safe than sorry
2026-03-10 10:52:35 -05:00
f48cf55963 feat: add schema, edit/sync support for Seeding Logs 2026-03-10 10:25:38 -05:00
42c73375ac feat: add schema, edit/sync support for Equipment Assets 2026-03-09 20:40:12 -05:00
03f6da8ab7 feat: add schema, edit/sync support for Equipment Types 2026-03-09 15:58:41 -05:00
d9211c1713 feat: add schema, edit/sync support for Water Assets 2026-03-09 15:36:51 -05:00
dfc8dc0de3 feat: add edit/sync support for Material Types + Material Quantities 2026-03-09 14:36:53 -05:00
6bc5f06f7a fix: improve behavior when deleting a Standard Quantity 2026-03-08 16:01:12 -05:00
1d5499686f fix: cleanup grid views for All, Standard Quantities 2026-03-08 15:50:36 -05:00
b2a7184937 feat: add edit/sync support for Material Types 2026-03-08 15:05:24 -05:00
a355e9e1b7 fix: add ordinal for sorting Measures 2026-03-08 13:24:56 -05:00
a43f98c304 feat: add edit/sync support for Log Quantities
er, just Standard Quantities so far..and just supported enough to move
the ball forward, it still needs lots more polish
2026-03-08 12:27:05 -05:00
1d303a818c feat: add edit/sync support for Log.groups 2026-03-07 10:40:42 -06:00
797c045f67 feat: add edit/sync support for Log.locations
also set `Log.owners` to current user, when creating new log
2026-03-07 10:27:45 -06:00
6d80937e0c feat: expose Assets field when editing a Log record 2026-03-06 21:59:38 -06:00
45fd5556f2 feat: add edit/sync support for Plant Seasons 2026-03-06 21:38:23 -06:00
e61043b9d9 feat: add edit/sync support for asset parents
plus several changes to API calls, use iterate() instead of get()

also some changes to better share code for asset importers
2026-03-06 19:52:20 -06:00
d46ba43d11 fix: expose is_location and is_fixed for editing on Animal Asset 2026-03-05 20:44:06 -06:00
3336294b3b fix: allow "N/A" option for animal sex 2026-03-05 20:30:39 -06:00
aecbfc6c02 fix: fix Assets column for All Logs subgrid when viewing asset 2026-03-05 20:06:17 -06:00
67 changed files with 7083 additions and 507 deletions

View file

@ -5,6 +5,67 @@ All notable changes to WuttaFarm will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.11.2 (2026-03-21)
### Fix
- use separate thread to sync changes to farmOS
- avoid error if asset has no geometry
## v0.11.1 (2026-03-21)
### Fix
- improve behavior when deleting mirrored record from farmOS
- use correct uuid when processing webhook to delete record
## v0.11.0 (2026-03-15)
### Feat
- show basic map for "fixed" assets
### Fix
- include LogQuantity changes when viewing Log revision
## v0.10.0 (2026-03-11)
### Feat
- add support for webhooks module in farmOS
### Fix
- remove print statement
## v0.9.0 (2026-03-10)
### Feat
- add schema, edit/sync support for Seeding Logs
- add schema, edit/sync support for Equipment Assets
- add schema, edit/sync support for Equipment Types
- add schema, edit/sync support for Water Assets
- add edit/sync support for Material Types + Material Quantities
- add edit/sync support for Material Types
- add edit/sync support for Log Quantities
- add edit/sync support for `Log.groups`
- add edit/sync support for `Log.locations`
- expose Assets field when editing a Log record
- add edit/sync support for Plant Seasons
- add edit/sync support for asset parents
### Fix
- avoid error when material type is unknown
- improve behavior when deleting a Standard Quantity
- cleanup grid views for All, Standard Quantities
- add ordinal for sorting Measures
- expose `is_location` and `is_fixed` for editing on Animal Asset
- allow "N/A" option for animal sex
- fix Assets column for All Logs subgrid when viewing asset
## v0.8.0 (2026-03-04) ## v0.8.0 (2026-03-04)
### Feat ### Feat

View file

@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaFarm" name = "WuttaFarm"
version = "0.8.0" version = "0.11.2"
description = "Web app to integrate with and extend farmOS" description = "Web app to integrate with and extend farmOS"
readme = "README.md" readme = "README.md"
authors = [ authors = [
@ -34,7 +34,7 @@ dependencies = [
"pyramid_exclog", "pyramid_exclog",
"uvicorn[standard]", "uvicorn[standard]",
"WuttaSync", "WuttaSync",
"WuttaWeb[continuum]>=0.29.0", "WuttaWeb[continuum]>=0.30.1",
] ]

View file

@ -151,6 +151,74 @@ class WuttaFarmAppHandler(base.AppHandler):
factory = self.load_object(spec) factory = self.load_object(spec)
return factory(self.config, farmos_client) return factory(self.config, farmos_client)
def get_quantity_types(self, session=None):
"""
Returns a list of all known quantity types.
"""
model = self.model
with self.short_session(session=session) as sess:
return (
sess.query(model.QuantityType).order_by(model.QuantityType.name).all()
)
def get_measures(self, session=None):
"""
Returns a list of all known measures.
"""
model = self.model
with self.short_session(session=session) as sess:
return sess.query(model.Measure).order_by(model.Measure.ordinal).all()
def get_units(self, session=None):
"""
Returns a list of all known units.
"""
model = self.model
with self.short_session(session=session) as sess:
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.
@ -202,6 +270,7 @@ class WuttaFarmAppHandler(base.AppHandler):
then nothing will happen / import is silently skipped when then nothing will happen / import is silently skipped when
there is no such importer. there is no such importer.
""" """
model = self.app.model
handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos") handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos")
if model_name not in handler.importers: if model_name not in handler.importers:
@ -212,6 +281,10 @@ class WuttaFarmAppHandler(base.AppHandler):
# nb. begin txn to establish the API client # nb. begin txn to establish the API client
handler.begin_source_transaction(client) handler.begin_source_transaction(client)
with self.short_session(commit=True) as session: with self.short_session(commit=True) as session:
if user := session.query(model.User).filter_by(username="farmos").first():
session.info["continuum_user_id"] = user.uuid
handler.target_session = session handler.target_session = session
importer = handler.get_importer(model_name, caches_target=False) importer = handler.get_importer(model_name, caches_target=False)
normal = importer.normalize_source_object(obj) normal = importer.normalize_source_object(obj)

View file

@ -29,3 +29,4 @@ from .base import wuttafarm_typer
from . import export_farmos from . import export_farmos
from . import import_farmos from . import import_farmos
from . import install from . import install
from . import process_webhooks

View file

@ -0,0 +1,180 @@
# -*- 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/>.
#
################################################################################
"""
WuttaFarm CLI
"""
import logging
import time
import typer
from oauthlib.oauth2 import BackendApplicationClient
from requests_oauthlib import OAuth2Session
from wuttafarm.cli import wuttafarm_typer
log = logging.getLogger(__name__)
class ChangeProcessor:
def __init__(self, config):
self.config = config
self.app = config.get_app()
def process_change(self, change):
if change.deleted:
self.delete_record(change)
else:
self.import_record(change)
def import_record(self, change):
token = self.get_farmos_oauth2_token()
client = self.app.get_farmos_client(token=token)
full_type = f"{change.entity_type}--{change.bundle}"
record = client.resource.get_id(
change.entity_type, change.bundle, change.farmos_uuid
)
importer_map = self.get_importer_map()
model_name = importer_map[full_type]
self.app.auto_sync_from_farmos(record["data"], model_name, client=client)
def delete_record(self, change):
model = self.app.model
handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos")
importer_map = self.get_importer_map()
full_type = f"{change.entity_type}--{change.bundle}"
model_name = importer_map[full_type]
token = self.get_farmos_oauth2_token()
client = self.app.get_farmos_client(token=token)
# nb. begin txn to establish the API client
handler.begin_source_transaction(client)
with self.app.short_session(commit=True) as session:
handler.target_session = session
importer = handler.get_importer(model_name, caches_target=False)
# try to attribute change to 'farmos' user
if user := session.query(model.User).filter_by(username="farmos").first():
session.info["continuum_user_id"] = user.uuid
# only support importers with farmos_uuid as key
# (pretty sure that covers us..can revise if needed)
if importer.get_keys() != ["farmos_uuid"]:
log.warning(
"unsupported keys for %s importer: %s",
model_name,
importer.get_keys(),
)
return
# delete corresponding record from our app
if obj := importer.get_target_object((change.farmos_uuid,)):
importer.delete_target_object(obj)
# TODO: this should live elsewhere
def get_farmos_oauth2_token(self):
client_id = self.config.get(
"farmos.oauth2.importing.client_id", default="wuttafarm"
)
client_secret = self.config.require("farmos.oauth2.importing.client_secret")
scope = self.config.get("farmos.oauth2.importing.scope", default="farm_manager")
client = BackendApplicationClient(client_id=client_id)
oauth = OAuth2Session(client=client)
return oauth.fetch_token(
token_url=self.app.get_farmos_url("/oauth/token"),
include_client_id=True,
client_secret=client_secret,
scope=scope,
)
# TODO: this should live elsewhere
def get_importer_map(self):
return {
"asset--animal": "AnimalAsset",
"asset--equipment": "EquipmentAsset",
"asset--group": "GroupAsset",
"asset--land": "LandAsset",
"asset--plant": "PlantAsset",
"asset--structure": "StructureAsset",
"asset--water": "WaterAsset",
"log--activity": "ActivityLog",
"log--harvest": "HarvestLog",
"log--medical": "MedicalLog",
"log--observation": "ObservationLog",
"log--seeding": "SeedingLog",
"quantity--material": "MaterialQuantity",
"quantity--standard": "StandardQuantity",
"taxonomy_term--animal_type": "AnimalType",
"taxonomy_term--equipment_type": "EquipmentType",
"taxonomy_term--plant_type": "PlantType",
"taxonomy_term--season": "Season",
"taxonomy_term--material_type": "MaterialType",
"taxonomy_term--unit": "Unit",
}
@wuttafarm_typer.command()
def process_webhooks(
ctx: typer.Context,
):
"""
Process incoming webhook requests from farmOS.
"""
config = ctx.parent.wutta_config
app = config.get_app()
model = app.model
processor = ChangeProcessor(config)
while True:
with app.short_session(commit=True) as session:
query = session.query(model.WebhookChange).order_by(
model.WebhookChange.received
)
# nb. fetch (at most) 2 changes instead of just 1;
# this will control time delay behavior below
if changes := query[:2]:
# process first change
change = changes[0]
log.info("processing webhook change: %s", change)
processor.process_change(change)
session.delete(change)
# minimal time delay if 2nd change exists
if len(changes) == 2:
time.sleep(0.1)
continue
# nothing in queue, so wait 1 sec before checking again
time.sleep(1)

View file

@ -0,0 +1,116 @@
"""add MaterialType
Revision ID: 1c89f3fbb521
Revises: 82a497e30a97
Create Date: 2026-03-08 14:38:04.538621
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "1c89f3fbb521"
down_revision: Union[str, None] = "82a497e30a97"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# material_type
op.create_table(
"material_type",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_material_type")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_material_type_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_material_type_farmos_uuid")),
)
op.create_table(
"material_type_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column(
"description", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_material_type_version")
),
)
op.create_index(
op.f("ix_material_type_version_end_transaction_id"),
"material_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_material_type_version_operation_type"),
"material_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_material_type_version_pk_transaction_id",
"material_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_material_type_version_pk_validity",
"material_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_material_type_version_transaction_id"),
"material_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# material_type
op.drop_index(
op.f("ix_material_type_version_transaction_id"),
table_name="material_type_version",
)
op.drop_index(
"ix_material_type_version_pk_validity", table_name="material_type_version"
)
op.drop_index(
"ix_material_type_version_pk_transaction_id", table_name="material_type_version"
)
op.drop_index(
op.f("ix_material_type_version_operation_type"),
table_name="material_type_version",
)
op.drop_index(
op.f("ix_material_type_version_end_transaction_id"),
table_name="material_type_version",
)
op.drop_table("material_type_version")
op.drop_table("material_type")

View file

@ -0,0 +1,37 @@
"""add Measure.ordinal
Revision ID: 82a497e30a97
Revises: c5183b781d34
Create Date: 2026-03-08 13:15:36.917747
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "82a497e30a97"
down_revision: Union[str, None] = "c5183b781d34"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# measure
op.add_column("measure", sa.Column("ordinal", sa.Integer(), nullable=True))
op.add_column(
"measure_version",
sa.Column("ordinal", sa.Integer(), autoincrement=False, nullable=True),
)
def downgrade() -> None:
# measure
op.drop_column("measure_version", "ordinal")
op.drop_column("measure", "ordinal")

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

@ -0,0 +1,205 @@
"""add plant seasons
Revision ID: c5183b781d34
Revises: 5f474125a80e
Create Date: 2026-03-06 20:18:40.160531
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "c5183b781d34"
down_revision: Union[str, None] = "5f474125a80e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# season
op.create_table(
"season",
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_season")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_season_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_season_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_season_name")),
)
op.create_table(
"season_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_season_version")
),
)
op.create_index(
op.f("ix_season_version_end_transaction_id"),
"season_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_season_version_operation_type"),
"season_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_season_version_pk_transaction_id",
"season_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_season_version_pk_validity",
"season_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_season_version_transaction_id"),
"season_version",
["transaction_id"],
unique=False,
)
# asset_plant_season
op.create_table(
"asset_plant_season",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("plant_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("season_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["plant_asset_uuid"],
["asset_plant.uuid"],
name=op.f("fk_asset_plant_season_plant_asset_uuid_asset_plant"),
),
sa.ForeignKeyConstraint(
["season_uuid"],
["season.uuid"],
name=op.f("fk_asset_plant_season_season_uuid_season"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_plant_season")),
)
op.create_table(
"asset_plant_season_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"plant_asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"season_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_plant_season_version")
),
)
op.create_index(
op.f("ix_asset_plant_season_version_end_transaction_id"),
"asset_plant_season_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_plant_season_version_operation_type"),
"asset_plant_season_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_plant_season_version_pk_transaction_id",
"asset_plant_season_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_plant_season_version_pk_validity",
"asset_plant_season_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_plant_season_version_transaction_id"),
"asset_plant_season_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# asset_plant_season
op.drop_index(
op.f("ix_asset_plant_season_version_transaction_id"),
table_name="asset_plant_season_version",
)
op.drop_index(
"ix_asset_plant_season_version_pk_validity",
table_name="asset_plant_season_version",
)
op.drop_index(
"ix_asset_plant_season_version_pk_transaction_id",
table_name="asset_plant_season_version",
)
op.drop_index(
op.f("ix_asset_plant_season_version_operation_type"),
table_name="asset_plant_season_version",
)
op.drop_index(
op.f("ix_asset_plant_season_version_end_transaction_id"),
table_name="asset_plant_season_version",
)
op.drop_table("asset_plant_season_version")
op.drop_table("asset_plant_season")
# season
op.drop_index(op.f("ix_season_version_transaction_id"), table_name="season_version")
op.drop_index("ix_season_version_pk_validity", table_name="season_version")
op.drop_index("ix_season_version_pk_transaction_id", table_name="season_version")
op.drop_index(op.f("ix_season_version_operation_type"), table_name="season_version")
op.drop_index(
op.f("ix_season_version_end_transaction_id"), table_name="season_version"
)
op.drop_table("season_version")
op.drop_table("season")

View file

@ -0,0 +1,108 @@
"""add SeedingLog
Revision ID: dca5b48a5562
Revises: e9b8664e1f39
Create Date: 2026-03-10 09:52:13.999777
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "dca5b48a5562"
down_revision: Union[str, None] = "e9b8664e1f39"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_seeding
op.create_table(
"log_seeding",
sa.Column("source", sa.String(length=255), nullable=True),
sa.Column("purchase_date", sa.DateTime(), nullable=True),
sa.Column("lot_number", sa.String(length=255), nullable=True),
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["log.uuid"], name=op.f("fk_log_seeding_uuid_log")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_seeding")),
)
op.create_table(
"log_seeding_version",
sa.Column("source", sa.String(length=255), autoincrement=False, nullable=True),
sa.Column("purchase_date", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column(
"lot_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_log_seeding_version")
),
)
op.create_index(
op.f("ix_log_seeding_version_end_transaction_id"),
"log_seeding_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_seeding_version_operation_type"),
"log_seeding_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_seeding_version_pk_transaction_id",
"log_seeding_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_seeding_version_pk_validity",
"log_seeding_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_seeding_version_transaction_id"),
"log_seeding_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_seeding
op.drop_index(
op.f("ix_log_seeding_version_transaction_id"), table_name="log_seeding_version"
)
op.drop_index(
"ix_log_seeding_version_pk_validity", table_name="log_seeding_version"
)
op.drop_index(
"ix_log_seeding_version_pk_transaction_id", table_name="log_seeding_version"
)
op.drop_index(
op.f("ix_log_seeding_version_operation_type"), table_name="log_seeding_version"
)
op.drop_index(
op.f("ix_log_seeding_version_end_transaction_id"),
table_name="log_seeding_version",
)
op.drop_table("log_seeding_version")
op.drop_table("log_seeding")

View file

@ -0,0 +1,41 @@
"""add WebhookChange
Revision ID: dd4d4142b96d
Revises: dca5b48a5562
Create Date: 2026-03-10 22:31:54.324952
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "dd4d4142b96d"
down_revision: Union[str, None] = "dca5b48a5562"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# webhook_change
op.create_table(
"webhook_change",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("entity_type", sa.String(length=100), nullable=False),
sa.Column("bundle", sa.String(length=100), nullable=False),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("deleted", sa.Boolean(), nullable=False),
sa.Column("received", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_webhook_change")),
)
def downgrade() -> None:
# webhook_change
op.drop_table("webhook_change")

View file

@ -0,0 +1,100 @@
"""add WaterAsset
Revision ID: de1197d24485
Revises: 9c53513f8862
Create Date: 2026-03-09 14:59:12.032318
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "de1197d24485"
down_revision: Union[str, None] = "9c53513f8862"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_water
op.create_table(
"asset_water",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_water_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_water")),
)
op.create_table(
"asset_water_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_asset_water_version")
),
)
op.create_index(
op.f("ix_asset_water_version_end_transaction_id"),
"asset_water_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_water_version_operation_type"),
"asset_water_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_water_version_pk_transaction_id",
"asset_water_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_water_version_pk_validity",
"asset_water_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_water_version_transaction_id"),
"asset_water_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# asset_water
op.drop_index(
op.f("ix_asset_water_version_transaction_id"), table_name="asset_water_version"
)
op.drop_index(
"ix_asset_water_version_pk_validity", table_name="asset_water_version"
)
op.drop_index(
"ix_asset_water_version_pk_transaction_id", table_name="asset_water_version"
)
op.drop_index(
op.f("ix_asset_water_version_operation_type"), table_name="asset_water_version"
)
op.drop_index(
op.f("ix_asset_water_version_end_transaction_id"),
table_name="asset_water_version",
)
op.drop_table("asset_water_version")
op.drop_table("asset_water")

View file

@ -0,0 +1,118 @@
"""add EquipmentType
Revision ID: e5b27eac471c
Revises: de1197d24485
Create Date: 2026-03-09 15:45:35.047694
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "e5b27eac471c"
down_revision: Union[str, None] = "de1197d24485"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# equipment_type
op.create_table(
"equipment_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("drupal_id", sa.Integer(), nullable=True),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_equipment_type")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_equipment_type_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_equipment_type_farmos_uuid")),
sa.UniqueConstraint("name", name=op.f("uq_equipment_type_name")),
)
op.create_table(
"equipment_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("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"farmos_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_equipment_type_version")
),
)
op.create_index(
op.f("ix_equipment_type_version_end_transaction_id"),
"equipment_type_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_equipment_type_version_operation_type"),
"equipment_type_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_equipment_type_version_pk_transaction_id",
"equipment_type_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_equipment_type_version_pk_validity",
"equipment_type_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_equipment_type_version_transaction_id"),
"equipment_type_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# equipment_type
op.drop_index(
op.f("ix_equipment_type_version_transaction_id"),
table_name="equipment_type_version",
)
op.drop_index(
"ix_equipment_type_version_pk_validity", table_name="equipment_type_version"
)
op.drop_index(
"ix_equipment_type_version_pk_transaction_id",
table_name="equipment_type_version",
)
op.drop_index(
op.f("ix_equipment_type_version_operation_type"),
table_name="equipment_type_version",
)
op.drop_index(
op.f("ix_equipment_type_version_end_transaction_id"),
table_name="equipment_type_version",
)
op.drop_table("equipment_type_version")
op.drop_table("equipment_type")

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

@ -31,15 +31,34 @@ from .users import WuttaFarmUser
# wuttafarm proper models # wuttafarm proper models
from .unit import Unit, Measure from .unit import Unit, Measure
from .quantities import QuantityType, Quantity, StandardQuantity from .material_type import MaterialType
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
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 PlantType, PlantAsset, PlantAssetPlantType from .asset_plant import (
PlantType,
Season,
PlantAsset,
PlantAssetPlantType,
PlantAssetSeason,
)
from .asset_water import WaterAsset
from .log import LogType, Log, LogAsset, LogGroup, LogLocation, LogQuantity, LogOwner from .log import LogType, Log, LogAsset, LogGroup, LogLocation, LogQuantity, LogOwner
from .log_activity import ActivityLog from .log_activity import ActivityLog
from .log_harvest import HarvestLog from .log_harvest import HarvestLog
from .log_medical import MedicalLog from .log_medical import MedicalLog
from .log_observation import ObservationLog from .log_observation import ObservationLog
from .log_seeding import SeedingLog
# misc.
from .webhook import WebhookChange

View file

@ -0,0 +1,133 @@
# -*- 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 Equipment
"""
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model
from wuttafarm.db.model.taxonomy import TaxonomyMixin
from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies
class EquipmentType(TaxonomyMixin, model.Base):
"""
Represents an "equipment type" (taxonomy term) from farmOS
"""
__tablename__ = "equipment_type"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Equipment Type",
"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

@ -91,6 +91,65 @@ class PlantType(model.Base):
return self.name or "" return self.name or ""
class Season(model.Base):
"""
Represents a "season" (taxonomy term) from farmOS
"""
__tablename__ = "season"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Season",
"model_title_plural": "Seasons",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the season.
""",
)
description = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional description for the season.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the season within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the season.
""",
)
_plant_assets = orm.relationship(
"PlantAssetSeason",
cascade_backrefs=False,
back_populates="season",
)
def __str__(self):
return self.name or ""
class PlantAsset(AssetMixin, model.Base): class PlantAsset(AssetMixin, model.Base):
""" """
Represents a plant asset from farmOS Represents a plant asset from farmOS
@ -117,6 +176,19 @@ class PlantAsset(AssetMixin, model.Base):
creator=lambda pt: PlantAssetPlantType(plant_type=pt), creator=lambda pt: PlantAssetPlantType(plant_type=pt),
) )
_seasons = orm.relationship(
"PlantAssetSeason",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="plant_asset",
)
seasons = association_proxy(
"_seasons",
"season",
creator=lambda s: PlantAssetSeason(season=s),
)
add_asset_proxies(PlantAsset) add_asset_proxies(PlantAsset)
@ -146,3 +218,30 @@ class PlantAssetPlantType(model.Base):
""", """,
back_populates="_plant_assets", back_populates="_plant_assets",
) )
class PlantAssetSeason(model.Base):
"""
Associates one or more seasons with a plant asset.
"""
__tablename__ = "asset_plant_season"
__versioned__ = {}
uuid = model.uuid_column()
plant_asset_uuid = model.uuid_fk_column("asset_plant.uuid", nullable=False)
plant_asset = orm.relationship(
PlantAsset,
foreign_keys=plant_asset_uuid,
back_populates="_seasons",
)
season_uuid = model.uuid_fk_column("season.uuid", nullable=False)
season = orm.relationship(
Season,
doc="""
Reference to the season.
""",
back_populates="_plant_assets",
)

View file

@ -0,0 +1,45 @@
# -*- 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 Water Assets
"""
from wuttjamaican.db import model
from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies
class WaterAsset(AssetMixin, model.Base):
"""
Represents a water asset from farmOS
"""
__tablename__ = "asset_water"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Water Asset",
"model_title_plural": "Water Assets",
"farmos_asset_type": "water",
}
add_asset_proxies(WaterAsset)

View file

@ -0,0 +1,71 @@
# -*- 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 Seeding Logs
"""
import sqlalchemy as sa
from wuttjamaican.db import model
from wuttafarm.db.model.log import LogMixin, add_log_proxies
class SeedingLog(LogMixin, model.Base):
"""
Represents a Seeding Log from farmOS
"""
__tablename__ = "log_seeding"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Seeding Log",
"model_title_plural": "Seeding Logs",
"farmos_log_type": "seeding",
}
source = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Where the seed was obtained, if applicable.
""",
)
purchase_date = sa.Column(
sa.DateTime(),
nullable=True,
doc="""
When the seed was purchased, if applicable.
""",
)
lot_number = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Lot number for the seed, if applicable.
""",
)
add_log_proxies(SeedingLog)

View file

@ -0,0 +1,100 @@
# -*- 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 Material Types
"""
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model
class MaterialType(model.Base):
"""
Represents a "material type" (taxonomy term) from farmOS
"""
__tablename__ = "material_type"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Material Type",
"model_title_plural": "Material Types",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
doc="""
Name of the material type.
""",
)
description = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional description for the material type.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the material type within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the material type.
""",
)
_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):
return self.name or ""

View file

@ -181,9 +181,13 @@ class Quantity(model.Base):
creator=make_log_quantity, creator=make_log_quantity,
) )
def get_value_decimal(self):
# TODO: should actually return a decimal here?
return self.value_numerator / self.value_denominator
def render_as_text(self, config=None): def render_as_text(self, config=None):
measure = str(self.measure or self.measure_id or "") measure = str(self.measure or self.measure_id or "")
value = self.value_numerator / self.value_denominator value = self.get_value_decimal()
if config: if config:
app = config.get_app() app = config.get_app()
value = app.render_quantity(value) value = app.render_quantity(value)
@ -200,7 +204,15 @@ class QuantityMixin:
@declared_attr @declared_attr
def quantity(cls): def quantity(cls):
return orm.relationship(Quantity) return orm.relationship(
Quantity,
single_parent=True,
cascade="all, delete-orphan",
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)
@ -240,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

@ -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/>.
#
################################################################################
"""
Base logic for taxonomy term models
"""
import sqlalchemy as sa
from wuttjamaican.db import model
class TaxonomyMixin:
"""
Mixin for taxonomy term models
"""
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name for the taxonomy term.
""",
)
description = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional description for the taxonomy term.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the taxonomy term.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the taxonomy term within farmOS.
""",
)
def __str__(self):
return self.name or ""

View file

@ -42,6 +42,14 @@ class Measure(model.Base):
uuid = model.uuid_column() uuid = model.uuid_column()
ordinal = sa.Column(
sa.Integer(),
nullable=True,
doc="""
Ordinal (sequence number) for the measure.
""",
)
name = sa.Column( name = sa.Column(
sa.String(length=100), sa.String(length=100),
nullable=False, nullable=False,

View file

@ -0,0 +1,61 @@
# -*- 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 webhook changes
"""
import sqlalchemy as sa
from wuttjamaican.db import model
from wuttjamaican.util import make_utc
class WebhookChange(model.Base):
"""
Represents a "change" (create/update/delete) notification which
originated in farmOS and delivered via webhook.
This table serves as a "FIFO queue" for processing the changes.
"""
__tablename__ = "webhook_change"
uuid = model.uuid_column()
entity_type = sa.Column(sa.String(length=100), nullable=False)
bundle = sa.Column(sa.String(length=100), nullable=False)
farmos_uuid = sa.Column(model.UUID(), nullable=False)
deleted = sa.Column(sa.Boolean(), nullable=False)
received = sa.Column(
sa.DateTime(),
nullable=False,
default=make_utc,
doc="""
Date and time when the change was obtained from the watcher thread.
""",
)
def __str__(self):
event_type = "delete" if self.deleted else "create/update"
return f"{event_type} {self.entity_type}--{self.bundle}: {self.farmos_uuid}"

View file

@ -71,13 +71,15 @@ class ToFarmOSTaxonomy(ToFarmOS):
supported_fields = [ supported_fields = [
"uuid", "uuid",
"name", "name",
"description",
] ]
def get_target_objects(self, **kwargs): def get_target_objects(self, **kwargs):
result = self.farmos_client.resource.get( return list(
"taxonomy_term", self.farmos_taxonomy_type self.farmos_client.resource.iterate(
"taxonomy_term", self.farmos_taxonomy_type
)
) )
return result["data"]
def get_target_object(self, key): def get_target_object(self, key):
@ -101,17 +103,24 @@ class ToFarmOSTaxonomy(ToFarmOS):
return result["data"] return result["data"]
def normalize_target_object(self, obj): def normalize_target_object(self, obj):
if description := obj["attributes"]["description"]:
description = description["value"]
return { return {
"uuid": UUID(obj["id"]), "uuid": UUID(obj["id"]),
"name": obj["attributes"]["name"], "name": obj["attributes"]["name"],
"description": description,
} }
def get_term_payload(self, source_data): def get_term_payload(self, source_data):
return {
"attributes": { attrs = {}
"name": source_data["name"], if "name" in self.fields:
} attrs["name"] = source_data["name"]
} if "description" in self.fields:
attrs["description"] = {"value": source_data["description"]}
payload = {"attributes": attrs}
return payload
def create_target_object(self, key, source_data): def create_target_object(self, key, source_data):
if source_data.get("__ignoreme__"): if source_data.get("__ignoreme__"):
@ -127,9 +136,9 @@ class ToFarmOSTaxonomy(ToFarmOS):
normal["_new_object"] = result["data"] normal["_new_object"] = result["data"]
return normal return normal
def update_target_object(self, asset, source_data, target_data=None): def update_target_object(self, term, source_data, target_data=None):
if self.dry_run: if self.dry_run:
return asset return term
payload = self.get_term_payload(source_data) payload = self.get_term_payload(source_data)
payload["id"] = str(source_data["uuid"]) payload["id"] = str(source_data["uuid"])
@ -146,9 +155,12 @@ class ToFarmOSAsset(ToFarmOS):
farmos_asset_type = None farmos_asset_type = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.normal = self.app.get_normalizer(self.farmos_client)
def get_target_objects(self, **kwargs): def get_target_objects(self, **kwargs):
assets = self.farmos_client.asset.get(self.farmos_asset_type) return list(self.farmos_client.asset.iterate(self.farmos_asset_type))
return assets["data"]
def get_target_object(self, key): def get_target_object(self, key):
@ -191,18 +203,17 @@ class ToFarmOSAsset(ToFarmOS):
return self.normalize_target_object(result["data"]) return self.normalize_target_object(result["data"])
def normalize_target_object(self, asset): def normalize_target_object(self, asset):
normal = self.normal.normalize_farmos_asset(asset)
if notes := asset["attributes"]["notes"]:
notes = notes["value"]
return { return {
"uuid": UUID(asset["id"]), "uuid": UUID(normal["uuid"]),
"asset_name": asset["attributes"]["name"], "asset_name": normal["asset_name"],
"is_location": asset["attributes"]["is_location"], "is_location": normal["is_location"],
"is_fixed": asset["attributes"]["is_fixed"], "is_fixed": normal["is_fixed"],
"produces_eggs": asset["attributes"].get("produces_eggs"), # nb. this is only used for certain asset types
"notes": notes, "produces_eggs": normal["produces_eggs"],
"archived": asset["attributes"]["archived"], "parents": [(p["asset_type"], UUID(p["uuid"])) for p in normal["parents"]],
"notes": normal["notes"],
"archived": normal["archived"],
} }
def get_asset_payload(self, source_data): def get_asset_payload(self, source_data):
@ -221,8 +232,18 @@ class ToFarmOSAsset(ToFarmOS):
if "archived" in self.fields: if "archived" in self.fields:
attrs["archived"] = source_data["archived"] attrs["archived"] = source_data["archived"]
payload = {"attributes": attrs} rels = {}
if "parents" in self.fields:
rels["parent"] = {"data": []}
for asset_type, uuid in source_data["parents"]:
rels["parent"]["data"].append(
{
"id": str(uuid),
"type": f"asset--{asset_type}",
}
)
payload = {"attributes": attrs, "relationships": rels}
return payload return payload
@ -245,6 +266,8 @@ class AnimalAssetImporter(ToFarmOSAsset):
"is_sterile", "is_sterile",
"produces_eggs", "produces_eggs",
"birthdate", "birthdate",
"is_location",
"is_fixed",
"notes", "notes",
"archived", "archived",
] ]
@ -296,6 +319,80 @@ class AnimalTypeImporter(ToFarmOSTaxonomy):
farmos_taxonomy_type = "animal_type" farmos_taxonomy_type = "animal_type"
class EquipmentTypeImporter(ToFarmOSTaxonomy):
model_title = "EquipmentType"
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):
model_title = "MaterialType"
farmos_taxonomy_type = "material_type"
class GroupAssetImporter(ToFarmOSAsset): class GroupAssetImporter(ToFarmOSAsset):
model_title = "GroupAsset" model_title = "GroupAsset"
@ -353,6 +450,12 @@ class PlantTypeImporter(ToFarmOSTaxonomy):
farmos_taxonomy_type = "plant_type" farmos_taxonomy_type = "plant_type"
class SeasonImporter(ToFarmOSTaxonomy):
model_title = "Season"
farmos_taxonomy_type = "season"
class PlantAssetImporter(ToFarmOSAsset): class PlantAssetImporter(ToFarmOSAsset):
model_title = "PlantAsset" model_title = "PlantAsset"
@ -362,6 +465,7 @@ class PlantAssetImporter(ToFarmOSAsset):
"uuid", "uuid",
"asset_name", "asset_name",
"plant_type_uuids", "plant_type_uuids",
"season_uuids",
"notes", "notes",
"archived", "archived",
] ]
@ -373,6 +477,9 @@ class PlantAssetImporter(ToFarmOSAsset):
"plant_type_uuids": [ "plant_type_uuids": [
UUID(p["id"]) for p in plant["relationships"]["plant_type"]["data"] UUID(p["id"]) for p in plant["relationships"]["plant_type"]["data"]
], ],
"season_uuids": [
UUID(p["id"]) for p in plant["relationships"]["season"]["data"]
],
} }
) )
return data return data
@ -398,6 +505,15 @@ class PlantAssetImporter(ToFarmOSAsset):
"type": "taxonomy_term--plant_type", "type": "taxonomy_term--plant_type",
} }
) )
if "season_uuids" in self.fields:
rels["season"] = {"data": []}
for uuid in source_data["season_uuids"]:
rels["season"]["data"].append(
{
"id": str(uuid),
"type": "taxonomy_term--season",
}
)
payload["attributes"].update(attrs) payload["attributes"].update(attrs)
if rels: if rels:
@ -443,6 +559,21 @@ class StructureAssetImporter(ToFarmOSAsset):
return payload return payload
class WaterAssetImporter(ToFarmOSAsset):
model_title = "WaterAsset"
farmos_asset_type = "water"
supported_fields = [
"uuid",
"asset_name",
"is_location",
"is_fixed",
"notes",
"archived",
]
############################## ##############################
# quantity importers # quantity importers
############################## ##############################
@ -569,6 +700,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"
@ -597,6 +771,8 @@ class ToFarmOSLog(ToFarmOS):
"notes", "notes",
"quick", "quick",
"assets", "assets",
"locations",
"groups",
"quantities", "quantities",
] ]
@ -605,8 +781,7 @@ class ToFarmOSLog(ToFarmOS):
self.normal = self.app.get_normalizer(self.farmos_client) self.normal = self.app.get_normalizer(self.farmos_client)
def get_target_objects(self, **kwargs): def get_target_objects(self, **kwargs):
result = self.farmos_client.log.get(self.farmos_log_type) return list(self.farmos_client.log.iterate(self.farmos_log_type))
return result["data"]
def get_target_object(self, key): def get_target_object(self, key):
@ -660,6 +835,10 @@ class ToFarmOSLog(ToFarmOS):
"notes": normal["notes"], "notes": normal["notes"],
"quick": normal["quick"], "quick": normal["quick"],
"assets": [(a["asset_type"], UUID(a["uuid"])) for a in normal["assets"]], "assets": [(a["asset_type"], UUID(a["uuid"])) for a in normal["assets"]],
"locations": [
(l["asset_type"], UUID(l["uuid"])) for l in normal["locations"]
],
"groups": [(g["asset_type"], UUID(g["uuid"])) for g in normal["groups"]],
"quantities": [UUID(uuid) for uuid in normal["quantity_uuids"]], "quantities": [UUID(uuid) for uuid in normal["quantity_uuids"]],
} }
@ -692,6 +871,26 @@ class ToFarmOSLog(ToFarmOS):
} }
) )
rels["asset"] = {"data": assets} rels["asset"] = {"data": assets}
if "locations" in self.fields:
locations = []
for asset_type, uuid in source_data["locations"]:
locations.append(
{
"type": f"asset--{asset_type}",
"id": str(uuid),
}
)
rels["location"] = {"data": locations}
if "groups" in self.fields:
groups = []
for asset_type, uuid in source_data["groups"]:
groups.append(
{
"type": f"asset--{asset_type}",
"id": str(uuid),
}
)
rels["group"] = {"data": groups}
if "quantities" in self.fields: if "quantities" in self.fields:
quantities = [] quantities = []
for uuid in source_data["quantities"]: for uuid in source_data["quantities"]:
@ -756,3 +955,48 @@ class ObservationLogImporter(ToFarmOSLog):
model_title = "ObservationLog" model_title = "ObservationLog"
farmos_log_type = "observation" farmos_log_type = "observation"
class SeedingLogImporter(ToFarmOSLog):
model_title = "SeedingLog"
farmos_log_type = "seeding"
def get_supported_fields(self):
fields = list(super().get_supported_fields())
fields.extend(
[
"source",
"purchase_date",
"lot_number",
]
)
return fields
def normalize_target_object(self, log):
data = super().normalize_target_object(log)
data.update(
{
"source": log["attributes"]["source"],
"purchase_date": self.normalize_datetime(
log["attributes"]["purchase_date"]
),
"lot_number": log["attributes"]["lot_number"],
}
)
return data
def get_log_payload(self, source_data):
payload = super().get_log_payload(source_data)
attrs = {}
if "source" in self.fields:
attrs["source"] = source_data["source"]
if "purchase_date" in self.fields:
attrs["purchase_date"] = self.format_datetime(source_data["purchase_date"])
if "lot_number" in self.fields:
attrs["lot_number"] = source_data["lot_number"]
if attrs:
payload["attributes"].update(attrs)
return payload

View file

@ -98,17 +98,24 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
importers = super().define_importers() importers = super().define_importers()
importers["LandAsset"] = LandAssetImporter importers["LandAsset"] = LandAssetImporter
importers["StructureAsset"] = StructureAssetImporter importers["StructureAsset"] = StructureAssetImporter
importers["WaterAsset"] = WaterAssetImporter
importers["EquipmentType"] = EquipmentTypeImporter
importers["EquipmentAsset"] = EquipmentAssetImporter
importers["AnimalType"] = AnimalTypeImporter importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter importers["GroupAsset"] = GroupAssetImporter
importers["PlantType"] = PlantTypeImporter importers["PlantType"] = PlantTypeImporter
importers["Season"] = SeasonImporter
importers["PlantAsset"] = PlantAssetImporter importers["PlantAsset"] = PlantAssetImporter
importers["Unit"] = UnitImporter importers["Unit"] = UnitImporter
importers["MaterialType"] = MaterialTypeImporter
importers["MaterialQuantity"] = MaterialQuantityImporter
importers["StandardQuantity"] = StandardQuantityImporter importers["StandardQuantity"] = StandardQuantityImporter
importers["ActivityLog"] = ActivityLogImporter importers["ActivityLog"] = ActivityLogImporter
importers["HarvestLog"] = HarvestLogImporter importers["HarvestLog"] = HarvestLogImporter
importers["MedicalLog"] = MedicalLogImporter importers["MedicalLog"] = MedicalLogImporter
importers["ObservationLog"] = ObservationLogImporter importers["ObservationLog"] = ObservationLogImporter
importers["SeedingLog"] = SeedingLogImporter
return importers return importers
@ -134,60 +141,156 @@ class FromWuttaFarm(FromWutta):
return obj return obj
class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImporter): class FromWuttaFarmAsset(FromWuttaFarm):
"""
Base class for WuttaFarm farmOS API asset exporters
"""
supported_fields = [
"uuid",
"asset_name",
"is_location",
"is_fixed",
"parents",
"notes",
"archived",
]
def normalize_source_object(self, asset):
return {
"uuid": asset.farmos_uuid or self.app.make_true_uuid(),
"asset_name": asset.asset_name,
"is_location": asset.is_location,
"is_fixed": asset.is_fixed,
"parents": [(p.asset_type, p.farmos_uuid) for p in asset.parents],
"notes": asset.notes,
"archived": asset.archived,
"_src_object": asset,
}
class AnimalAssetImporter(
FromWuttaFarmAsset, farmos_importing.model.AnimalAssetImporter
):
""" """
WuttaFarm farmOS API exporter for Animal Assets WuttaFarm farmOS API exporter for Animal Assets
""" """
source_model_class = model.AnimalAsset source_model_class = model.AnimalAsset
supported_fields = [ def get_supported_fields(self):
"uuid", fields = list(super().get_supported_fields())
"asset_name", fields.extend(
"animal_type_uuid", [
"sex", "animal_type_uuid",
"is_sterile", "sex",
"produces_eggs", "is_sterile",
"birthdate", "produces_eggs",
"notes", "birthdate",
"archived", ]
] )
return fields
def normalize_source_object(self, animal): def normalize_source_object(self, animal):
data = super().normalize_source_object(animal)
data.update(
{
"animal_type_uuid": animal.animal_type.farmos_uuid,
"sex": animal.sex,
"is_sterile": animal.is_sterile,
"produces_eggs": animal.produces_eggs,
"birthdate": animal.birthdate,
}
)
return data
class FromWuttaFarmTaxonomy(FromWuttaFarm):
"""
Base class for taxonomy term exporters
"""
supported_fields = [
"uuid",
"name",
"description",
]
drupal_internal_id_field = "drupal_internal__tid"
def normalize_source_object(self, term):
return { return {
"uuid": animal.farmos_uuid or self.app.make_true_uuid(), "uuid": term.farmos_uuid or self.app.make_true_uuid(),
"asset_name": animal.asset_name, "name": term.name,
"animal_type_uuid": animal.animal_type.farmos_uuid, "description": term.description,
"sex": animal.sex, "_src_object": term,
"is_sterile": animal.is_sterile,
"produces_eggs": animal.produces_eggs,
"birthdate": animal.birthdate,
"notes": animal.notes,
"archived": animal.archived,
"_src_object": animal,
} }
class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporter): class EquipmentTypeImporter(
FromWuttaFarmTaxonomy, farmos_importing.model.EquipmentTypeImporter
):
"""
WuttaFarm farmOS API exporter for Equipment Types
"""
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())
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(
FromWuttaFarmTaxonomy, farmos_importing.model.AnimalTypeImporter
):
""" """
WuttaFarm farmOS API exporter for Animal Types WuttaFarm farmOS API exporter for Animal Types
""" """
source_model_class = model.AnimalType source_model_class = model.AnimalType
supported_fields = [
"uuid",
"name",
]
drupal_internal_id_field = "drupal_internal__tid" class MaterialTypeImporter(
FromWuttaFarmTaxonomy, farmos_importing.model.MaterialTypeImporter
):
"""
WuttaFarm farmOS API exporter for Material Types
"""
def normalize_source_object(self, animal_type): source_model_class = model.MaterialType
return {
"uuid": animal_type.farmos_uuid or self.app.make_true_uuid(),
"name": animal_type.name,
"_src_object": animal_type,
}
class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter): class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter):
@ -212,60 +315,56 @@ class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter):
} }
class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter): class GroupAssetImporter(FromWuttaFarmAsset, farmos_importing.model.GroupAssetImporter):
""" """
WuttaFarm farmOS API exporter for Group Assets WuttaFarm farmOS API exporter for Group Assets
""" """
source_model_class = model.GroupAsset source_model_class = model.GroupAsset
supported_fields = [ def get_supported_fields(self):
"uuid", fields = list(super().get_supported_fields())
"asset_name", fields.extend(
"produces_eggs", [
"notes", "produces_eggs",
"archived", ]
] )
return fields
def normalize_source_object(self, group): def normalize_source_object(self, group):
return { data = super().normalize_source_object(group)
"uuid": group.farmos_uuid or self.app.make_true_uuid(), data.update(
"asset_name": group.asset_name, {
"produces_eggs": group.produces_eggs, "produces_eggs": group.produces_eggs,
"notes": group.notes, }
"archived": group.archived, )
"_src_object": group, return data
}
class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter): class LandAssetImporter(FromWuttaFarmAsset, farmos_importing.model.LandAssetImporter):
""" """
WuttaFarm farmOS API exporter for Land Assets WuttaFarm farmOS API exporter for Land Assets
""" """
source_model_class = model.LandAsset source_model_class = model.LandAsset
supported_fields = [ def get_supported_fields(self):
"uuid", fields = list(super().get_supported_fields())
"asset_name", fields.extend(
"land_type_id", [
"is_location", "land_type_id",
"is_fixed", ]
"notes", )
"archived", return fields
]
def normalize_source_object(self, land): def normalize_source_object(self, land):
return { data = super().normalize_source_object(land)
"uuid": land.farmos_uuid or self.app.make_true_uuid(), data.update(
"asset_name": land.asset_name, {
"land_type_id": land.land_type.drupal_id, "land_type_id": land.land_type.drupal_id,
"is_location": land.is_location, }
"is_fixed": land.is_fixed, )
"notes": land.notes, return data
"archived": land.archived,
"_src_object": land,
}
class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter): class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter):
@ -290,34 +389,58 @@ class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter)
} }
class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter): class SeasonImporter(FromWuttaFarm, farmos_importing.model.SeasonImporter):
"""
WuttaFarm farmOS API exporter for Seasons
"""
source_model_class = model.Season
supported_fields = [
"uuid",
"name",
]
drupal_internal_id_field = "drupal_internal__tid"
def normalize_source_object(self, season):
return {
"uuid": season.farmos_uuid or self.app.make_true_uuid(),
"name": season.name,
"_src_object": season,
}
class PlantAssetImporter(FromWuttaFarmAsset, farmos_importing.model.PlantAssetImporter):
""" """
WuttaFarm farmOS API exporter for Plant Assets WuttaFarm farmOS API exporter for Plant Assets
""" """
source_model_class = model.PlantAsset source_model_class = model.PlantAsset
supported_fields = [ def get_supported_fields(self):
"uuid", fields = list(super().get_supported_fields())
"asset_name", fields.extend(
"plant_type_uuids", [
"notes", "plant_type_uuids",
"archived", "season_uuids",
] ]
)
return fields
def normalize_source_object(self, plant): def normalize_source_object(self, plant):
return { data = super().normalize_source_object(plant)
"uuid": plant.farmos_uuid or self.app.make_true_uuid(), data.update(
"asset_name": plant.asset_name, {
"plant_type_uuids": [t.plant_type.farmos_uuid for t in plant._plant_types], "plant_type_uuids": [pt.farmos_uuid for pt in plant.plant_types],
"notes": plant.notes, "season_uuids": [s.farmos_uuid for s in plant.seasons],
"archived": plant.archived, }
"_src_object": plant, )
} return data
class StructureAssetImporter( class StructureAssetImporter(
FromWuttaFarm, farmos_importing.model.StructureAssetImporter FromWuttaFarmAsset, farmos_importing.model.StructureAssetImporter
): ):
""" """
WuttaFarm farmOS API exporter for Structure Assets WuttaFarm farmOS API exporter for Structure Assets
@ -325,27 +448,31 @@ class StructureAssetImporter(
source_model_class = model.StructureAsset source_model_class = model.StructureAsset
supported_fields = [ def get_supported_fields(self):
"uuid", fields = list(super().get_supported_fields())
"asset_name", fields.extend(
"structure_type_id", [
"is_location", "structure_type_id",
"is_fixed", ]
"notes", )
"archived", return fields
]
def normalize_source_object(self, structure): def normalize_source_object(self, structure):
return { data = super().normalize_source_object(structure)
"uuid": structure.farmos_uuid or self.app.make_true_uuid(), data.update(
"asset_name": structure.asset_name, {
"structure_type_id": structure.structure_type.drupal_id, "structure_type_id": structure.structure_type.drupal_id,
"is_location": structure.is_location, }
"is_fixed": structure.is_fixed, )
"notes": structure.notes, return data
"archived": structure.archived,
"_src_object": structure,
} class WaterAssetImporter(FromWuttaFarmAsset, farmos_importing.model.WaterAssetImporter):
"""
WuttaFarm farmOS API exporter for Water Assets
"""
source_model_class = model.WaterAsset
############################## ##############################
@ -381,6 +508,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
): ):
@ -411,6 +556,8 @@ class FromWuttaFarmLog(FromWuttaFarm):
"notes", "notes",
"quick", "quick",
"assets", "assets",
"locations",
"groups",
"quantities", "quantities",
] ]
@ -425,6 +572,8 @@ class FromWuttaFarmLog(FromWuttaFarm):
"notes": log.notes, "notes": log.notes,
"quick": self.config.parse_list(log.quick) if log.quick else [], "quick": self.config.parse_list(log.quick) if log.quick else [],
"assets": [(a.asset_type, a.farmos_uuid) for a in log.assets], "assets": [(a.asset_type, a.farmos_uuid) for a in log.assets],
"locations": [(l.asset_type, l.farmos_uuid) for l in log.locations],
"groups": [(g.asset_type, g.farmos_uuid) for g in log.groups],
"quantities": [qty.farmos_uuid for qty in log.quantities], "quantities": [qty.farmos_uuid for qty in log.quantities],
"_src_object": log, "_src_object": log,
} }
@ -480,3 +629,22 @@ class ObservationLogImporter(
""" """
source_model_class = model.ObservationLog source_model_class = model.ObservationLog
class SeedingLogImporter(FromWuttaFarmLog, farmos_importing.model.SeedingLogImporter):
"""
WuttaFarm farmOS API exporter for Seeding Logs
"""
source_model_class = model.SeedingLog
def normalize_source_object(self, log):
data = super().normalize_source_object(log)
data.update(
{
"source": log.source,
"purchase_date": log.purchase_date,
"lot_number": log.lot_number,
}
)
return data

View file

@ -106,20 +106,27 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["LandAsset"] = LandAssetImporter importers["LandAsset"] = LandAssetImporter
importers["StructureType"] = StructureTypeImporter importers["StructureType"] = StructureTypeImporter
importers["StructureAsset"] = StructureAssetImporter importers["StructureAsset"] = StructureAssetImporter
importers["WaterAsset"] = WaterAssetImporter
importers["EquipmentType"] = EquipmentTypeImporter
importers["EquipmentAsset"] = EquipmentAssetImporter
importers["AnimalType"] = AnimalTypeImporter importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter importers["GroupAsset"] = GroupAssetImporter
importers["PlantType"] = PlantTypeImporter importers["PlantType"] = PlantTypeImporter
importers["Season"] = SeasonImporter
importers["PlantAsset"] = PlantAssetImporter importers["PlantAsset"] = PlantAssetImporter
importers["Measure"] = MeasureImporter importers["Measure"] = MeasureImporter
importers["Unit"] = UnitImporter importers["Unit"] = UnitImporter
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
importers["MedicalLog"] = MedicalLogImporter importers["MedicalLog"] = MedicalLogImporter
importers["ObservationLog"] = ObservationLogImporter importers["ObservationLog"] = ObservationLogImporter
importers["SeedingLog"] = SeedingLogImporter
return importers return importers
@ -149,6 +156,8 @@ class FromFarmOS(Importer):
:returns: Equivalent naive UTC ``datetime`` :returns: Equivalent naive UTC ``datetime``
""" """
if not dt:
return None
dt = datetime.datetime.fromisoformat(dt) dt = datetime.datetime.fromisoformat(dt)
return self.app.make_utc(dt) return self.app.make_utc(dt)
@ -330,21 +339,20 @@ class AnimalAssetImporter(AssetImporterBase):
model_class = model.AnimalAsset model_class = model.AnimalAsset
supported_fields = [ animal_types_by_farmos_uuid = None
"farmos_uuid",
"drupal_id", def get_supported_fields(self):
"asset_type", fields = list(super().get_supported_fields())
"asset_name", fields.extend(
"animal_type_uuid", [
"sex", "animal_type_uuid",
"is_sterile", "sex",
"produces_eggs", "is_sterile",
"birthdate", "produces_eggs",
"notes", "birthdate",
"archived", ]
"image_url", )
"thumbnail_url", return fields
]
def setup(self): def setup(self):
super().setup() super().setup()
@ -355,6 +363,17 @@ class AnimalAssetImporter(AssetImporterBase):
if animal_type.farmos_uuid: if animal_type.farmos_uuid:
self.animal_types_by_farmos_uuid[animal_type.farmos_uuid] = animal_type self.animal_types_by_farmos_uuid[animal_type.farmos_uuid] = animal_type
def get_animal_type_by_farmos_uuid(self, uuid):
if self.animal_types_by_farmos_uuid is not None:
return self.animal_types_by_farmos_uuid.get(uuid)
model = self.app.model
return (
self.target_session.query(model.AnimalType)
.filter(model.AnimalType.farmos_uuid == uuid)
.first()
)
def normalize_source_object(self, animal): def normalize_source_object(self, animal):
""" """ """ """
animal_type_uuid = None animal_type_uuid = None
@ -362,7 +381,7 @@ class AnimalAssetImporter(AssetImporterBase):
if animal_type := relationships.get("animal_type"): if animal_type := relationships.get("animal_type"):
if animal_type["data"]: if animal_type["data"]:
if wf_animal_type := self.animal_types_by_farmos_uuid.get( if wf_animal_type := self.get_animal_type_by_farmos_uuid(
UUID(animal_type["data"]["id"]) UUID(animal_type["data"]["id"])
): ):
animal_type_uuid = wf_animal_type.uuid animal_type_uuid = wf_animal_type.uuid
@ -399,12 +418,12 @@ class AnimalAssetImporter(AssetImporterBase):
return data return data
class AnimalTypeImporter(FromFarmOS, ToWutta): class TaxonomyImporterBase(FromFarmOS, ToWutta):
""" """
farmOS API WuttaFarm importer for Animal Types farmOS API WuttaFarm importer for taxonomy terms
""" """
model_class = model.AnimalType taxonomy_type = None
supported_fields = [ supported_fields = [
"farmos_uuid", "farmos_uuid",
@ -415,19 +434,50 @@ class AnimalTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type") return list(
return animal_types["data"] self.farmos_client.resource.iterate("taxonomy_term", self.taxonomy_type)
)
def normalize_source_object(self, animal_type): def normalize_source_object(self, term):
""" """ """ """
if description := term["attributes"]["description"]:
description = description["value"]
return { return {
"farmos_uuid": UUID(animal_type["id"]), "farmos_uuid": UUID(term["id"]),
"drupal_id": animal_type["attributes"]["drupal_internal__tid"], "drupal_id": term["attributes"]["drupal_internal__tid"],
"name": animal_type["attributes"]["name"], "name": term["attributes"]["name"],
"description": animal_type["attributes"]["description"], "description": description,
} }
class AnimalTypeImporter(TaxonomyImporterBase):
"""
farmOS API WuttaFarm importer for Animal Types
"""
model_class = model.AnimalType
taxonomy_type = "animal_type"
class MaterialTypeImporter(TaxonomyImporterBase):
"""
farmOS API WuttaFarm importer for Material Types
"""
model_class = model.MaterialType
taxonomy_type = "material_type"
class EquipmentTypeImporter(TaxonomyImporterBase):
"""
farmOS API WuttaFarm importer for Equipment Types
"""
model_class = model.EquipmentType
taxonomy_type = "equipment_type"
class AssetTypeImporter(FromFarmOS, ToWutta): class AssetTypeImporter(FromFarmOS, ToWutta):
""" """
farmOS API WuttaFarm importer for Asset Types farmOS API WuttaFarm importer for Asset Types
@ -444,8 +494,7 @@ class AssetTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
asset_types = self.farmos_client.resource.get("asset_type") return list(self.farmos_client.resource.iterate("asset_type"))
return asset_types["data"]
def normalize_source_object(self, asset_type): def normalize_source_object(self, asset_type):
""" """ """ """
@ -457,6 +506,124 @@ class AssetTypeImporter(FromFarmOS, ToWutta):
} }
class EquipmentAssetImporter(AssetImporterBase):
"""
farmOS API WuttaFarm importer for Equipment Assets
"""
model_class = model.EquipmentAsset
equipment_types_by_farmos_uuid = None
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 get_equipment_type_by_farmos_uuid(self, uuid):
if self.equipment_types_by_farmos_uuid is not None:
return self.equipment_types_by_farmos_uuid.get(uuid)
model = self.app.model
return (
self.target_session.query(model.EquipmentType)
.filter_by(farmos_uuid=uuid)
.first()
)
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.get_equipment_type_by_farmos_uuid(
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
@ -464,20 +631,14 @@ class GroupAssetImporter(AssetImporterBase):
model_class = model.GroupAsset model_class = model.GroupAsset
supported_fields = [ def get_supported_fields(self):
"farmos_uuid", fields = list(super().get_supported_fields())
"drupal_id", fields.extend(
"asset_type", [
"asset_name", "produces_eggs",
"is_location", ]
"is_fixed", )
"produces_eggs", return fields
"notes",
"archived",
"image_url",
"thumbnail_url",
"parents",
]
def normalize_source_object(self, group): def normalize_source_object(self, group):
""" """ """ """
@ -497,18 +658,16 @@ class LandAssetImporter(AssetImporterBase):
model_class = model.LandAsset model_class = model.LandAsset
supported_fields = [ land_types_by_id = None
"farmos_uuid",
"drupal_id", def get_supported_fields(self):
"asset_type", fields = list(super().get_supported_fields())
"asset_name", fields.extend(
"land_type_uuid", [
"is_location", "land_type_uuid",
"is_fixed", ]
"notes", )
"archived", return fields
"parents",
]
def setup(self): def setup(self):
""" """ """ """
@ -519,10 +678,21 @@ class LandAssetImporter(AssetImporterBase):
for land_type in self.target_session.query(model.LandType): for land_type in self.target_session.query(model.LandType):
self.land_types_by_id[land_type.drupal_id] = land_type self.land_types_by_id[land_type.drupal_id] = land_type
def get_land_type_by_id(self, drupal_id):
if self.land_types_by_id is not None:
return self.land_types_by_id.get(drupal_id)
model = self.app.model
return (
self.target_session.query(model.LandType)
.filter_by(drupal_id=drupal_id)
.first()
)
def normalize_source_object(self, land): def normalize_source_object(self, land):
""" """ """ """
land_type_id = land["attributes"]["land_type"] land_type_id = land["attributes"]["land_type"]
land_type = self.land_types_by_id.get(land_type_id) land_type = self.get_land_type_by_id(land_type_id)
if not land_type: if not land_type:
log.warning( log.warning(
"invalid land_type '%s' for farmOS Land Asset: %s", land_type_id, land "invalid land_type '%s' for farmOS Land Asset: %s", land_type_id, land
@ -553,8 +723,7 @@ class LandTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
land_types = self.farmos_client.resource.get("land_type") return list(self.farmos_client.resource.iterate("land_type"))
return land_types["data"]
def normalize_source_object(self, land_type): def normalize_source_object(self, land_type):
""" """ """ """
@ -581,8 +750,7 @@ class PlantTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
result = self.farmos_client.resource.get("taxonomy_term", "plant_type") return list(self.farmos_client.resource.iterate("taxonomy_term", "plant_type"))
return result["data"]
def normalize_source_object(self, plant_type): def normalize_source_object(self, plant_type):
""" """ """ """
@ -594,6 +762,34 @@ class PlantTypeImporter(FromFarmOS, ToWutta):
} }
class SeasonImporter(FromFarmOS, ToWutta):
"""
farmOS API WuttaFarm importer for Seasons
"""
model_class = model.Season
supported_fields = [
"farmos_uuid",
"drupal_id",
"name",
"description",
]
def get_source_objects(self):
""" """
return list(self.farmos_client.resource.iterate("taxonomy_term", "season"))
def normalize_source_object(self, season):
""" """
return {
"farmos_uuid": UUID(season["id"]),
"drupal_id": season["attributes"]["drupal_internal__tid"],
"name": season["attributes"]["name"],
"description": season["attributes"]["description"],
}
class PlantAssetImporter(AssetImporterBase): class PlantAssetImporter(AssetImporterBase):
""" """
farmOS API WuttaFarm importer for Plant Assets farmOS API WuttaFarm importer for Plant Assets
@ -601,17 +797,18 @@ class PlantAssetImporter(AssetImporterBase):
model_class = model.PlantAsset model_class = model.PlantAsset
supported_fields = [ plant_types_by_farmos_uuid = None
"farmos_uuid", seasons_by_farmos_uuid = None
"drupal_id",
"asset_type", def get_supported_fields(self):
"asset_name", fields = list(super().get_supported_fields())
"plant_types", fields.extend(
"notes", [
"archived", "plant_types",
"image_url", "seasons",
"thumbnail_url", ]
] )
return fields
def setup(self): def setup(self):
super().setup() super().setup()
@ -622,25 +819,61 @@ class PlantAssetImporter(AssetImporterBase):
if plant_type.farmos_uuid: if plant_type.farmos_uuid:
self.plant_types_by_farmos_uuid[plant_type.farmos_uuid] = plant_type self.plant_types_by_farmos_uuid[plant_type.farmos_uuid] = plant_type
self.seasons_by_farmos_uuid = {}
for season in self.target_session.query(model.Season):
if season.farmos_uuid:
self.seasons_by_farmos_uuid[season.farmos_uuid] = season
def get_plant_type_by_farmos_uuid(self, uuid):
if self.plant_types_by_farmos_uuid is not None:
return self.plant_types_by_farmos_uuid.get(uuid)
model = self.app.model
return (
self.target_session.query(model.PlantType)
.filter_by(farmos_uuid=uuid)
.first()
)
def get_season_by_farmos_uuid(self, uuid):
if self.seasons_by_farmos_uuid is not None:
return self.seasons_by_farmos_uuid.get(uuid)
model = self.app.model
return (
self.target_session.query(model.Season).filter_by(farmos_uuid=uuid).first()
)
def normalize_source_object(self, plant): def normalize_source_object(self, plant):
""" """ """ """
data = super().normalize_source_object(plant)
plant_types = [] plant_types = []
seasons = []
if relationships := plant.get("relationships"): if relationships := plant.get("relationships"):
if plant_type := relationships.get("plant_type"): if plant_type := relationships.get("plant_type"):
plant_types = [] plant_types = []
for plant_type in plant_type["data"]: for plant_type in plant_type["data"]:
if wf_plant_type := self.plant_types_by_farmos_uuid.get( if wf_plant_type := self.get_plant_type_by_farmos_uuid(
UUID(plant_type["id"]) UUID(plant_type["id"])
): ):
plant_types.append(wf_plant_type.uuid) plant_types.append(wf_plant_type.uuid)
else: else:
log.warning("plant type not found: %s", plant_type["id"]) log.warning("plant type not found: %s", plant_type["id"])
data = super().normalize_source_object(plant) if season := relationships.get("season"):
seasons = []
for season in season["data"]:
if wf_season := self.get_season_by_farmos_uuid(UUID(season["id"])):
seasons.append(wf_season.uuid)
else:
log.warning("season not found: %s", season["id"])
data.update( data.update(
{ {
"plant_types": set(plant_types), "plant_types": set(plant_types),
"seasons": set(seasons),
} }
) )
return data return data
@ -651,6 +884,9 @@ class PlantAssetImporter(AssetImporterBase):
if "plant_types" in self.fields: if "plant_types" in self.fields:
data["plant_types"] = set([pt.uuid for pt in plant.plant_types]) data["plant_types"] = set([pt.uuid for pt in plant.plant_types])
if "seasons" in self.fields:
data["seasons"] = set([s.uuid for s in plant.seasons])
return data return data
def update_target_object(self, plant, source_data, target_data=None): def update_target_object(self, plant, source_data, target_data=None):
@ -683,6 +919,25 @@ class PlantAssetImporter(AssetImporterBase):
) )
self.target_session.delete(plant_type) self.target_session.delete(plant_type)
if "seasons" in self.fields:
if not target_data or target_data["seasons"] != source_data["seasons"]:
for uuid in source_data["seasons"]:
if not target_data or uuid not in target_data["seasons"]:
self.target_session.flush()
plant._seasons.append(model.PlantAssetSeason(season_uuid=uuid))
if target_data:
for uuid in target_data["seasons"]:
if uuid not in source_data["seasons"]:
season = (
self.target_session.query(model.PlantAssetSeason)
.filter(model.PlantAssetSeason.plant_asset == plant)
.filter(model.PlantAssetSeason.season_uuid == uuid)
.one()
)
self.target_session.delete(season)
return plant return plant
@ -693,20 +948,16 @@ class StructureAssetImporter(AssetImporterBase):
model_class = model.StructureAsset model_class = model.StructureAsset
supported_fields = [ structure_types_by_id = None
"farmos_uuid",
"drupal_id", def get_supported_fields(self):
"asset_type", fields = list(super().get_supported_fields())
"asset_name", fields.extend(
"structure_type_uuid", [
"is_location", "structure_type_uuid",
"is_fixed", ]
"notes", )
"archived", return fields
"image_url",
"thumbnail_url",
"parents",
]
def setup(self): def setup(self):
super().setup() super().setup()
@ -716,10 +967,21 @@ class StructureAssetImporter(AssetImporterBase):
for structure_type in self.target_session.query(model.StructureType): for structure_type in self.target_session.query(model.StructureType):
self.structure_types_by_id[structure_type.drupal_id] = structure_type self.structure_types_by_id[structure_type.drupal_id] = structure_type
def get_structure_type_by_id(self, drupal_id):
if self.structure_types_by_id is not None:
return self.structure_types_by_id.get(drupal_id)
model = self.app.model
return (
self.target_session.query(model.StructureType)
.filter_by(drupal_id=drupal_id)
.first()
)
def normalize_source_object(self, structure): def normalize_source_object(self, structure):
""" """ """ """
structure_type_id = structure["attributes"]["structure_type"] structure_type_id = structure["attributes"]["structure_type"]
structure_type = self.structure_types_by_id.get(structure_type_id) structure_type = self.get_structure_type_by_id(structure_type_id)
if not structure_type: if not structure_type:
log.warning( log.warning(
"invalid structure_type '%s' for farmOS Structure Asset: %s", "invalid structure_type '%s' for farmOS Structure Asset: %s",
@ -752,8 +1014,7 @@ class StructureTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
structure_types = self.farmos_client.resource.get("structure_type") return list(self.farmos_client.resource.iterate("structure_type"))
return structure_types["data"]
def normalize_source_object(self, structure_type): def normalize_source_object(self, structure_type):
""" """ """ """
@ -764,6 +1025,14 @@ class StructureTypeImporter(FromFarmOS, ToWutta):
} }
class WaterAssetImporter(AssetImporterBase):
"""
farmOS API WuttaFarm importer for Water Assets
"""
model_class = model.WaterAsset
class UserImporter(FromFarmOS, ToWutta): class UserImporter(FromFarmOS, ToWutta):
""" """
farmOS API WuttaFarm importer for Users farmOS API WuttaFarm importer for Users
@ -791,8 +1060,7 @@ class UserImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
users = self.farmos_client.resource.get("user") return list(self.farmos_client.resource.iterate("user"))
return users["data"]
def normalize_source_object(self, user): def normalize_source_object(self, user):
""" """ """ """
@ -833,6 +1101,7 @@ class MeasureImporter(FromFarmOS, ToWutta):
supported_fields = [ supported_fields = [
"drupal_id", "drupal_id",
"ordinal",
"name", "name",
] ]
@ -843,12 +1112,15 @@ class MeasureImporter(FromFarmOS, ToWutta):
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
self.ordinal = 0
return data["definitions"]["attributes"]["properties"]["measure"]["oneOf"] return data["definitions"]["attributes"]["properties"]["measure"]["oneOf"]
def normalize_source_object(self, measure): def normalize_source_object(self, measure):
""" """ """ """
self.ordinal += 1
return { return {
"drupal_id": measure["const"], "drupal_id": measure["const"],
"ordinal": self.ordinal,
"name": measure["title"], "name": measure["title"],
} }
@ -869,8 +1141,7 @@ class UnitImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
result = self.farmos_client.resource.get("taxonomy_term", "unit") return list(self.farmos_client.resource.iterate("taxonomy_term", "unit"))
return result["data"]
def normalize_source_object(self, unit): def normalize_source_object(self, unit):
""" """ """ """
@ -898,8 +1169,7 @@ class QuantityTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
result = self.farmos_client.resource.get("quantity_type") return list(self.farmos_client.resource.iterate("quantity_type"))
return result["data"]
def normalize_source_object(self, quantity_type): def normalize_source_object(self, quantity_type):
""" """ """ """
@ -927,8 +1197,7 @@ class LogTypeImporter(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
log_types = self.farmos_client.resource.get("log_type") return list(self.farmos_client.resource.iterate("log_type"))
return log_types["data"]
def normalize_source_object(self, log_type): def normalize_source_object(self, log_type):
""" """ """ """
@ -1226,6 +1495,41 @@ class ObservationLogImporter(LogImporterBase):
model_class = model.ObservationLog model_class = model.ObservationLog
class SeedingLogImporter(LogImporterBase):
"""
farmOS API WuttaFarm importer for Seeding Logs
"""
model_class = model.SeedingLog
def get_simple_fields(self):
""" """
fields = list(super().get_simple_fields())
# nb. must explicitly declare proxy fields
fields.extend(
[
"source",
"purchase_date",
"lot_number",
]
)
return fields
def normalize_source_object(self, log):
""" """
data = super().normalize_source_object(log)
data.update(
{
"source": log["attributes"]["source"],
"purchase_date": self.normalize_datetime(
log["attributes"]["purchase_date"]
),
"lot_number": log["attributes"]["lot_number"],
}
)
return data
class QuantityImporterBase(FromFarmOS, ToWutta): class QuantityImporterBase(FromFarmOS, ToWutta):
""" """
Base class for farmOS API WuttaFarm quantity importers Base class for farmOS API WuttaFarm quantity importers
@ -1271,8 +1575,7 @@ class QuantityImporterBase(FromFarmOS, ToWutta):
def get_source_objects(self): def get_source_objects(self):
""" """ """ """
quantity_type = self.get_farmos_quantity_type() quantity_type = self.get_farmos_quantity_type()
result = self.farmos_client.resource.get("quantity", quantity_type) return list(self.farmos_client.resource.iterate("quantity", quantity_type))
return result["data"]
def get_quantity_type_by_farmos_uuid(self, uuid): def get_quantity_type_by_farmos_uuid(self, uuid):
if hasattr(self, "quantity_types_by_farmos_uuid"): if hasattr(self, "quantity_types_by_farmos_uuid"):
@ -1355,3 +1658,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

@ -84,16 +84,41 @@ class Normalizer(GenericHandler):
self._farmos_units = units self._farmos_units = units
return self._farmos_units return self._farmos_units
def normalize_datetime(self, value):
if not value:
return None
value = datetime.datetime.fromisoformat(value)
return self.app.localtime(value)
def normalize_farmos_asset(self, asset, included={}): def normalize_farmos_asset(self, asset, included={}):
""" """ """ """
if notes := asset["attributes"]["notes"]: if notes := asset["attributes"]["notes"]:
notes = notes["value"] notes = notes["value"]
parent_objects = []
parent_uuids = []
owner_objects = [] owner_objects = []
owner_uuids = [] owner_uuids = []
if relationships := asset.get("relationships"): if relationships := asset.get("relationships"):
if parents := relationships.get("parent"):
for parent in parents["data"]:
parent_uuid = parent["id"]
parent_uuids.append(parent_uuid)
parent_object = {
"uuid": parent_uuid,
"type": parent["type"],
"asset_type": parent["type"].split("--")[1],
}
if parent := included.get(parent_uuid):
parent_object.update(
{
"name": parent["attributes"]["name"],
}
)
parent_objects.append(parent_object)
if owners := relationships.get("owner"): if owners := relationships.get("owner"):
for user in owners["data"]: for user in owners["data"]:
user_uuid = user["id"] user_uuid = user["id"]
@ -106,6 +131,11 @@ class Normalizer(GenericHandler):
} }
) )
# if self.farmos_4x:
# archived = asset["attributes"]["archived"]
# else:
# archived = asset["attributes"]["status"] == "archived"
return { return {
"uuid": asset["id"], "uuid": asset["id"],
"drupal_id": asset["attributes"]["drupal_internal__id"], "drupal_id": asset["attributes"]["drupal_internal__id"],
@ -114,6 +144,10 @@ class Normalizer(GenericHandler):
"is_fixed": asset["attributes"]["is_fixed"], "is_fixed": asset["attributes"]["is_fixed"],
"archived": asset["attributes"]["archived"], "archived": asset["attributes"]["archived"],
"notes": notes, "notes": notes,
# nb. this is only used for certain asset types
"produces_eggs": asset["attributes"].get("produces_eggs"),
"parents": parent_objects,
"parent_uuids": parent_uuids,
"owners": owner_objects, "owners": owner_objects,
"owner_uuids": owner_uuids, "owner_uuids": owner_uuids,
} }
@ -121,8 +155,7 @@ class Normalizer(GenericHandler):
def normalize_farmos_log(self, log, included={}): def normalize_farmos_log(self, log, included={}):
if timestamp := log["attributes"]["timestamp"]: if timestamp := log["attributes"]["timestamp"]:
timestamp = datetime.datetime.fromisoformat(timestamp) timestamp = self.normalize_datetime(timestamp)
timestamp = self.app.localtime(timestamp)
if notes := log["attributes"]["notes"]: if notes := log["attributes"]["notes"]:
notes = notes["value"] notes = notes["value"]
@ -232,27 +265,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

@ -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
@ -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
@ -217,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):
@ -261,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).
@ -288,6 +345,62 @@ 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):
"""
Schema type for Plant Types field (on a Plant Asset).
"""
def serialize(self, node, appstruct):
if not appstruct:
return []
return [season.uuid.hex for season in appstruct]
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import SeasonRefsWidget
model = self.app.model
session = Session()
if "values" not in kwargs:
seasons = session.query(model.Season).order_by(model.Season.name).all()
values = [(s.uuid.hex, str(s)) for s in seasons]
kwargs["values"] = values
return SeasonRefsWidget(self.request, **kwargs)
class StructureType(colander.SchemaType): class StructureType(colander.SchemaType):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
@ -372,55 +485,100 @@ class UsersType(colander.SchemaType):
return UsersWidget(self.request, **kwargs) return UsersWidget(self.request, **kwargs)
class AssetParentRefs(WuttaSet):
"""
Schema type for Parents field which references assets.
"""
def serialize(self, node, appstruct):
if not appstruct:
appstruct = []
uuids = [u.hex for u in appstruct]
return json.dumps(uuids)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import AssetParentRefsWidget
return AssetParentRefsWidget(self.request, **kwargs)
class AssetRefs(WuttaSet): class AssetRefs(WuttaSet):
""" """
Schema type for Assets field (on a Log record) Schema type for Assets field (on a Log record)
""" """
def __init__(
self, request, for_asset=None, is_group=None, is_location=None, **kwargs
):
super().__init__(request, **kwargs)
self.is_group = is_group
self.is_location = is_location
self.for_asset = for_asset
def serialize(self, node, appstruct): def serialize(self, node, appstruct):
if not appstruct: if not appstruct:
return colander.null return colander.null
return {asset.uuid for asset in appstruct} return {asset.uuid.hex for asset in appstruct}
def widget_maker(self, **kwargs): def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import AssetRefsWidget from wuttafarm.web.forms.widgets import AssetRefsWidget
model = self.app.model
session = Session()
if "values" not in kwargs:
query = session.query(model.Asset)
if self.is_group is not None:
query = query.join(model.GroupAsset)
if self.is_location is not None:
query = query.filter(model.Asset.is_location == self.is_location)
if self.for_asset:
query = query.filter(model.Asset.uuid != self.for_asset.uuid)
query = query.order_by(model.Asset.asset_name)
values = [(asset.uuid.hex, str(asset)) for asset in query]
kwargs["values"] = values
return AssetRefsWidget(self.request, **kwargs) return AssetRefsWidget(self.request, **kwargs)
class LogQuantityRefs(WuttaSet): class QuantityRefs(colander.List):
""" """
Schema type for Quantities field (on a Log record) Schema type for Quantities field (on a Log record)
""" """
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): def serialize(self, node, appstruct):
if not appstruct: if not appstruct:
return colander.null return colander.null
return {qty.uuid for qty in appstruct} quantities = []
for qty in appstruct:
quantity = {
"uuid": qty.uuid.hex,
"quantity_type": {
"drupal_id": qty.quantity_type_id,
"name": qty.quantity_type.name,
},
"measure": qty.measure_id,
"value": qty.get_value_decimal(),
"units": {
"uuid": qty.units.uuid.hex,
"name": qty.units.name,
},
"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
def widget_maker(self, **kwargs): def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import LogQuantityRefsWidget from wuttafarm.web.forms.widgets import QuantityRefsWidget
return LogQuantityRefsWidget(self.request, **kwargs) return QuantityRefsWidget(self.request, **kwargs)
class OwnerRefs(WuttaSet): class OwnerRefs(WuttaSet):

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):
@ -124,7 +125,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"]
) )
@ -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).
@ -332,6 +447,144 @@ 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):
"""
Widget for Seasons field (on a Plant Asset).
"""
template = "seasonrefs"
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 cstruct in (colander.null, None):
cstruct = ()
if readonly := kw.get("readonly", self.readonly):
items = []
seasons = (
session.query(model.Season)
.filter(model.Season.uuid.in_(cstruct))
.order_by(model.Season.name)
.all()
)
for season in seasons:
items.append(
HTML.tag(
"li",
c=tags.link_to(
str(season),
self.request.route_url("seasons.view", uuid=season.uuid),
),
)
)
return HTML.tag("ul", c=items)
values = kw.get("values", self.values)
if not isinstance(values, sequence_types):
raise TypeError("Values must be a sequence type (list, tuple, or range).")
kw["values"] = _normalize_choices(values)
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)
values["js_values"] = json.dumps(values["values"])
if self.request.has_perm("seasons.create"):
values["can_create"] = True
return values
def deserialize(self, field, pstruct):
""" """
if not pstruct:
return set()
return set(pstruct.split(","))
class StructureWidget(Widget): class StructureWidget(Widget):
""" """
Widget to display a "structure" field. Widget to display a "structure" field.
@ -393,42 +646,20 @@ class UsersWidget(Widget):
############################## ##############################
class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): class AssetRefsWidget(Widget):
"""
Widget for Parents field which references assets.
"""
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
readonly = kw.get("readonly", self.readonly)
if readonly:
parents = []
for uuid in json.loads(cstruct):
parent = session.get(model.Asset, uuid)
parents.append(
HTML.tag(
"li",
c=tags.link_to(
str(parent),
self.request.route_url(
f"{parent.asset_type}_assets.view", uuid=parent.uuid
),
),
)
)
return HTML.tag("ul", c=parents)
return super().serialize(field, cstruct, **kw)
class AssetRefsWidget(WuttaCheckboxChoiceWidget):
""" """
Widget for Assets field (of various kinds). Widget for Assets field (of various kinds).
""" """
template = "assetrefs"
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): def serialize(self, field, cstruct, **kw):
""" """ """ """
model = self.app.model model = self.app.model
@ -452,14 +683,43 @@ class AssetRefsWidget(WuttaCheckboxChoiceWidget):
) )
return HTML.tag("ul", c=assets) return HTML.tag("ul", c=assets)
return super().serialize(field, cstruct, **kw) values = kw.get("values", self.values)
if not isinstance(values, sequence_types):
raise TypeError("Values must be a sequence type (list, tuple, or range).")
kw["values"] = _normalize_choices(values)
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)
values["js_values"] = json.dumps(values["values"])
return values
def deserialize(self, field, pstruct):
""" """
if not pstruct:
return set()
return set(pstruct.split(","))
class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): class QuantityRefsWidget(Widget):
""" """
Widget for Quantities field (on a Log record) Widget for Quantities field (on a Log record)
""" """
template = "quantityrefs"
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): def serialize(self, field, cstruct, **kw):
""" """ """ """
model = self.app.model model = self.app.model
@ -467,24 +727,78 @@ class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget):
readonly = kw.get("readonly", self.readonly) readonly = kw.get("readonly", self.readonly)
if readonly: if readonly:
if not cstruct:
return ""
quantities = [] quantities = []
for uuid in cstruct or []: for qty in cstruct:
qty = session.get(model.Quantity, uuid) url = self.request.route_url(
quantities.append( f"quantities_{qty['quantity_type']['drupal_id']}.view",
HTML.tag( uuid=qty["uuid"],
"li",
c=tags.link_to(
qty.render_as_text(self.config),
# TODO
self.request.route_url(
"quantities_standard.view", uuid=qty.uuid
),
),
)
) )
quantities.append(HTML.tag("li", c=tags.link_to(qty["as_text"], url)))
return HTML.tag("ul", c=quantities) return HTML.tag("ul", c=quantities)
return super().serialize(field, cstruct, **kw) tmpl_values = self.get_template_values(field, cstruct, kw)
return field.renderer(self.template, **tmpl_values)
def get_template_values(self, field, cstruct, kw):
model = self.app.model
session = Session()
values = super().get_template_values(field, cstruct, kw)
qtypes = []
for qtype in self.app.get_quantity_types(session):
qtypes.append(
{
"uuid": qtype.uuid.hex,
"drupal_id": qtype.drupal_id,
"name": qtype.name,
}
)
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 = []
for measure in self.app.get_measures(session):
measures.append(
{
"uuid": measure.uuid.hex,
"drupal_id": measure.drupal_id,
"name": measure.name,
}
)
values["measures"] = measures
units = []
for unit in self.app.get_units(session):
units.append(
{
"uuid": unit.uuid.hex,
"drupal_id": unit.drupal_id,
"name": unit.name,
}
)
values["units"] = units
return values
def deserialize(self, field, pstruct):
""" """
if not pstruct:
return set()
return json.loads(pstruct)
class OwnerRefsWidget(WuttaCheckboxChoiceWidget): class OwnerRefsWidget(WuttaCheckboxChoiceWidget):

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",
@ -112,12 +117,22 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "structure_assets", "route": "structure_assets",
"perm": "structure_assets.list", "perm": "structure_assets.list",
}, },
{
"title": "Water",
"route": "water_assets",
"perm": "water_assets.list",
},
{"type": "sep"}, {"type": "sep"},
{ {
"title": "Animal Types", "title": "Animal Types",
"route": "animal_types", "route": "animal_types",
"perm": "animal_types.list", "perm": "animal_types.list",
}, },
{
"title": "Equipment Types",
"route": "equipment_types",
"perm": "equipment_types.list",
},
{ {
"title": "Land Types", "title": "Land Types",
"route": "land_types", "route": "land_types",
@ -128,6 +143,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "plant_types", "route": "plant_types",
"perm": "plant_types.list", "perm": "plant_types.list",
}, },
{
"title": "Seasons",
"route": "seasons",
"perm": "seasons.list",
},
{ {
"title": "Structure Types", "title": "Structure Types",
"route": "structure_types", "route": "structure_types",
@ -171,12 +191,22 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "logs_observation", "route": "logs_observation",
"perm": "logs_observation.list", "perm": "logs_observation.list",
}, },
{
"title": "Seeding",
"route": "logs_seeding",
"perm": "logs_seeding.list",
},
{"type": "sep"}, {"type": "sep"},
{ {
"title": "All Quantities", "title": "All Quantities",
"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",
@ -188,6 +218,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "log_types", "route": "log_types",
"perm": "log_types.list", "perm": "log_types.list",
}, },
{
"title": "Material Types",
"route": "material_types",
"perm": "material_types.list",
},
{ {
"title": "Measures", "title": "Measures",
"route": "measures", "route": "measures",
@ -224,6 +259,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",
@ -244,6 +284,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_structure_assets", "route": "farmos_structure_assets",
"perm": "farmos_structure_assets.list", "perm": "farmos_structure_assets.list",
}, },
{
"title": "Water Assets",
"route": "farmos_water_assets",
"perm": "farmos_water_assets.list",
},
{"type": "sep"}, {"type": "sep"},
{ {
"title": "Activity Logs", "title": "Activity Logs",
@ -265,12 +310,22 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_logs_observation", "route": "farmos_logs_observation",
"perm": "farmos_logs_observation.list", "perm": "farmos_logs_observation.list",
}, },
{
"title": "Seeding Logs",
"route": "farmos_logs_seeding",
"perm": "farmos_logs_seeding.list",
},
{"type": "sep"}, {"type": "sep"},
{ {
"title": "Animal Types", "title": "Animal Types",
"route": "farmos_animal_types", "route": "farmos_animal_types",
"perm": "farmos_animal_types.list", "perm": "farmos_animal_types.list",
}, },
{
"title": "Equipment Types",
"route": "farmos_equipment_types",
"perm": "farmos_equipment_types.list",
},
{ {
"title": "Land Types", "title": "Land Types",
"route": "farmos_land_types", "route": "farmos_land_types",
@ -281,6 +336,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_plant_types", "route": "farmos_plant_types",
"perm": "farmos_plant_types.list", "perm": "farmos_plant_types.list",
}, },
{
"title": "Seasons",
"route": "farmos_seasons",
"perm": "farmos_seasons.list",
},
{ {
"title": "Structure Types", "title": "Structure Types",
"route": "farmos_structure_types", "route": "farmos_structure_types",
@ -297,11 +357,21 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_log_types", "route": "farmos_log_types",
"perm": "farmos_log_types.list", "perm": "farmos_log_types.list",
}, },
{
"title": "Material Types",
"route": "farmos_material_types",
"perm": "farmos_material_types.list",
},
{ {
"title": "Quantity Types", "title": "Quantity Types",
"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",
@ -333,6 +403,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",
@ -353,12 +428,22 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_structure_assets", "route": "farmos_structure_assets",
"perm": "farmos_structure_assets.list", "perm": "farmos_structure_assets.list",
}, },
{
"title": "Water",
"route": "farmos_water_assets",
"perm": "farmos_water_assets.list",
},
{"type": "sep"}, {"type": "sep"},
{ {
"title": "Animal Types", "title": "Animal Types",
"route": "farmos_animal_types", "route": "farmos_animal_types",
"perm": "farmos_animal_types.list", "perm": "farmos_animal_types.list",
}, },
{
"title": "Equipment Types",
"route": "farmos_equipment_types",
"perm": "farmos_equipment_types.list",
},
{ {
"title": "Land Types", "title": "Land Types",
"route": "farmos_land_types", "route": "farmos_land_types",
@ -369,6 +454,11 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_plant_types", "route": "farmos_plant_types",
"perm": "farmos_plant_types.list", "perm": "farmos_plant_types.list",
}, },
{
"title": "Seasons",
"route": "farmos_seasons",
"perm": "farmos_seasons.list",
},
{ {
"title": "Structure Types", "title": "Structure Types",
"route": "farmos_structure_types", "route": "farmos_structure_types",
@ -410,17 +500,32 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"route": "farmos_logs_observation", "route": "farmos_logs_observation",
"perm": "farmos_logs_observation.list", "perm": "farmos_logs_observation.list",
}, },
{
"title": "Seeding",
"route": "farmos_logs_seeding",
"perm": "farmos_logs_seeding.list",
},
{"type": "sep"}, {"type": "sep"},
{ {
"title": "Log Types", "title": "Log Types",
"route": "farmos_log_types", "route": "farmos_log_types",
"perm": "farmos_log_types.list", "perm": "farmos_log_types.list",
}, },
{
"title": "Material Types",
"route": "farmos_material_types",
"perm": "farmos_material_types.list",
},
{ {
"title": "Quantity Types", "title": "Quantity Types",
"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

@ -57,6 +57,11 @@
</div> </div>
</b-field> </b-field>
<b-field v-if="simpleSettings['${app.appname}.farmos_integration_mode'] == 'mirror'"
label="Webhook URI for farmOS">
<wutta-copyable-text text="${url('webhooks.farmos')}" />
</b-field>
<b-checkbox name="${app.appname}.farmos_style_grid_links" <b-checkbox name="${app.appname}.farmos_style_grid_links"
v-model="simpleSettings['${app.appname}.farmos_style_grid_links']" v-model="simpleSettings['${app.appname}.farmos_style_grid_links']"
native-value="true" native-value="true"

View file

@ -10,5 +10,74 @@
</b-notification> </b-notification>
% endif % endif
${parent.page_content()} <div style="display: flex; margin-right: 0.5rem;">
## main form
<div style="flex-grow: 1;">
${parent.page_content()}
</div>
## location map
% if map_polygon:
<div ref="map" style="flex-grow: 2; height: 500px;" />
% endif
</div>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if map_polygon:
<script>
ThisPageData.map = null
ThisPage.mounted = function() {
this.map = new maplibregl.Map({
container: this.$refs.map,
style: 'https://tiles.openfreemap.org/styles/liberty',
center: ${json.dumps(map_center)|n},
zoom: 16,
})
this.map.on('load', () => {
this.map.addSource('assetGeometry', {
'type': 'geojson',
'data': {
'type': 'Feature',
'geometry': {
'type': 'Polygon',
'coordinates': ${json.dumps(map_polygon)|n},
}
}
})
this.map.addLayer({
'id': 'assetGeometry',
'source': 'assetGeometry',
'type': 'line',
'paint': {
'line-color': 'orange',
'line-width': 2,
},
})
this.map.fitBounds(${json.dumps(map_bounds)|n}, {
linear: true,
})
this.map.addControl(new maplibregl.FullscreenControl())
this.map.addControl(new maplibregl.NavigationControl(), 'top-left')
this.map.addControl(new maplibregl.ScaleControl({
maxWidth: 80,
unit: 'imperial',
}))
})
}
</script>
% endif
</%def> </%def>

View file

@ -1,6 +1,16 @@
<%inherit file="wuttaweb:templates/base.mako" /> <%inherit file="wuttaweb:templates/base.mako" />
<%namespace file="/wuttafarm-components.mako" import="make_wuttafarm_components" /> <%namespace file="/wuttafarm-components.mako" import="make_wuttafarm_components" />
<%def name="head_tags()">
${parent.head_tags()}
## TODO: this likely does not belong in the base template, and should be
## included per template where actually needed. but this is easier for now.
<script src="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js"></script>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css" />
</%def>
<%def name="index_title_controls()"> <%def name="index_title_controls()">
${parent.index_title_controls()} ${parent.index_title_controls()}

View file

@ -0,0 +1,11 @@
<div tal:define="
name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;"
tal:omit-tag="">
<assets-picker tal:attributes="name name;
v-model vmodel;
:assets js_values;" />
</div>

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

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

@ -0,0 +1,14 @@
<div tal:define="
name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;"
tal:omit-tag="">
<quantities-editor tal:attributes="name name;
v-model vmodel;
:quantity-types quantity_types;
:material-types material_types;
:measures measures;
:units units;" />
</div>

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

File diff suppressed because it is too large Load diff

View file

@ -78,4 +78,12 @@ 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.get("name", "???") for mtype in quantity["material_types"]]
)
return f"{materials} {text}"
return text

View file

@ -25,7 +25,7 @@ WuttaFarm Views
from wuttaweb.views import essential from wuttaweb.views import essential
from .master import WuttaFarmMasterView from .master import WuttaFarmMasterView, TaxonomyMasterView
def includeme(config): def includeme(config):
@ -48,19 +48,23 @@ def includeme(config):
# native table views # native table views
if mode != enum.FARMOS_INTEGRATION_MODE_WRAPPER: if mode != enum.FARMOS_INTEGRATION_MODE_WRAPPER:
config.include("wuttafarm.web.views.units") config.include("wuttafarm.web.views.units")
config.include("wuttafarm.web.views.material_types")
config.include("wuttafarm.web.views.quantities") config.include("wuttafarm.web.views.quantities")
config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.asset_types")
config.include("wuttafarm.web.views.assets") config.include("wuttafarm.web.views.assets")
config.include("wuttafarm.web.views.land") config.include("wuttafarm.web.views.land")
config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.structures")
config.include("wuttafarm.web.views.equipment")
config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.animals")
config.include("wuttafarm.web.views.groups") config.include("wuttafarm.web.views.groups")
config.include("wuttafarm.web.views.plants") config.include("wuttafarm.web.views.plants")
config.include("wuttafarm.web.views.water")
config.include("wuttafarm.web.views.logs") config.include("wuttafarm.web.views.logs")
config.include("wuttafarm.web.views.logs_activity") config.include("wuttafarm.web.views.logs_activity")
config.include("wuttafarm.web.views.logs_harvest") config.include("wuttafarm.web.views.logs_harvest")
config.include("wuttafarm.web.views.logs_medical") config.include("wuttafarm.web.views.logs_medical")
config.include("wuttafarm.web.views.logs_observation") config.include("wuttafarm.web.views.logs_observation")
config.include("wuttafarm.web.views.logs_seeding")
# quick form views # quick form views
# (nb. these work with all integration modes) # (nb. these work with all integration modes)
@ -69,3 +73,7 @@ def includeme(config):
# views for farmOS # views for farmOS
if mode != enum.FARMOS_INTEGRATION_MODE_NONE: if mode != enum.FARMOS_INTEGRATION_MODE_NONE:
config.include("wuttafarm.web.views.farmos") config.include("wuttafarm.web.views.farmos")
# webhook views (only for mirror mode)
if mode == enum.FARMOS_INTEGRATION_MODE_MIRROR:
config.include("wuttafarm.web.views.webhooks")

View file

@ -23,9 +23,12 @@
Master view for Animals Master view for Animals
""" """
from collections import OrderedDict
from webhelpers2.html import tags from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttaweb.util import get_form_data from wuttaweb.util import get_form_data
from wuttafarm.db.model import AnimalType, AnimalAsset from wuttafarm.db.model import AnimalType, AnimalAsset
@ -234,27 +237,6 @@ class AnimalAssetView(AssetMasterView):
"archived", "archived",
] ]
form_fields = [
"asset_name",
"animal_type",
"birthdate",
"produces_eggs",
"sex",
"is_sterile",
"notes",
"asset_type",
"owners",
"locations",
"groups",
"archived",
"drupal_id",
"farmos_uuid",
"thumbnail_url",
"image_url",
"thumbnail",
"image",
]
def configure_grid(self, grid): def configure_grid(self, grid):
g = grid g = grid
super().configure_grid(g) super().configure_grid(g)
@ -288,15 +270,32 @@ class AnimalAssetView(AssetMasterView):
animal = f.model_instance animal = f.model_instance
# animal_type # animal_type
f.fields.insert_after("asset_name", "animal_type")
f.set_node("animal_type", AnimalTypeRef(self.request)) f.set_node("animal_type", AnimalTypeRef(self.request))
# birthdate
f.fields.insert_after("animal_type", "birthdate")
# TODO: why must we assign the widget here? pretty sure that
# was not needed when we declared form_fields directly, i.e.
# instead of adding birthdate field in this method
f.set_widget("birthdate", WuttaDateTimeWidget(self.request))
# produces_eggs
f.fields.insert_after("birthdate", "produces_eggs")
# sex # sex
f.fields.insert_after("produces_eggs", "sex")
if not (self.creating or self.editing) and animal.sex is None: if not (self.creating or self.editing) and animal.sex is None:
pass # TODO: dict enum widget does not handle null values well pass # TODO: dict enum widget does not handle null values well
else: else:
f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) # nb. ensure empty option appears like we want
sex_enum = OrderedDict([("", "N/A")] + list(enum.ANIMAL_SEX.items()))
f.set_node("sex", WuttaDictEnum(self.request, sex_enum))
f.set_required("sex", False) f.set_required("sex", False)
# is_sterile
f.fields.insert_after("sex", "is_sterile")
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -23,6 +23,7 @@
Master view for Assets Master view for Assets
""" """
import re
from collections import OrderedDict from collections import OrderedDict
from webhelpers2.html import tags from webhelpers2.html import tags
@ -32,7 +33,7 @@ from wuttaweb.db import Session
from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import Asset, Log from wuttafarm.db.model import Asset, Log
from wuttafarm.web.forms.schema import AssetParentRefs, OwnerRefs, AssetRefs from wuttafarm.web.forms.schema import OwnerRefs, AssetRefs
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.util import get_log_type_enum from wuttafarm.util import get_log_type_enum
from wuttafarm.web.util import get_farmos_client_for_user from wuttafarm.web.util import get_farmos_client_for_user
@ -77,6 +78,25 @@ class AssetMasterView(WuttaFarmMasterView):
"archived": {"active": True, "verb": "is_false"}, "archived": {"active": True, "verb": "is_false"},
} }
form_fields = [
"asset_name",
"parents",
"notes",
"asset_type",
"owners",
"is_location",
"is_fixed",
"locations",
"groups",
"archived",
"drupal_id",
"farmos_uuid",
"thumbnail_url",
"image_url",
"thumbnail",
"image",
]
has_rows = True has_rows = True
row_model_class = Log row_model_class = Log
rows_viewable = True rows_viewable = True
@ -261,11 +281,11 @@ class AssetMasterView(WuttaFarmMasterView):
f.set_default("groups", asset_handler.get_groups(asset)) f.set_default("groups", asset_handler.get_groups(asset))
# parents # parents
if self.creating or self.editing: f.set_node("parents", AssetRefs(self.request, for_asset=asset))
f.remove("parents") # TODO: add support for this f.set_required("parents", False)
else: if not self.creating:
f.set_node("parents", AssetParentRefs(self.request)) # nb. must explicity declare value for non-standard field
f.set_default("parents", [p.uuid for p in asset.parents]) f.set_default("parents", asset.parents)
# notes # notes
f.set_widget("notes", "notes") f.set_widget("notes", "notes")
@ -293,11 +313,29 @@ class AssetMasterView(WuttaFarmMasterView):
f.set_default("image", asset.image_url) f.set_default("image", asset.image_url)
def objectify(self, form): def objectify(self, form):
model = self.app.model
session = self.Session()
asset = super().objectify(form) asset = super().objectify(form)
data = form.validated
if self.creating: if self.creating:
asset.asset_type = self.get_asset_type() asset.asset_type = self.get_asset_type()
current = [p.uuid for p in asset.parents]
desired = data["parents"] or []
for uuid in desired:
if uuid not in current:
parent = session.get(model.Asset, uuid)
assert parent
asset.parents.append(parent)
for uuid in current:
if uuid not in desired:
parent = session.get(model.Asset, uuid)
assert parent
asset.parents.remove(parent)
return asset return asset
def get_asset_type(self): def get_asset_type(self):
@ -322,6 +360,38 @@ class AssetMasterView(WuttaFarmMasterView):
return buttons return buttons
def get_template_context(self, context):
context = super().get_template_context(context)
if self.viewing:
asset = context["instance"]
# add location geometry if applicable
if asset.is_fixed and asset.farmos_uuid and not self.app.is_standalone():
# TODO: eventually sync GIS data, avoid this API call?
client = get_farmos_client_for_user(self.request)
result = client.asset.get_id(asset.asset_type, asset.farmos_uuid)
if geometry := result["data"]["attributes"]["intrinsic_geometry"]:
context["map_center"] = [geometry["lon"], geometry["lat"]]
context["map_bounds"] = [
[geometry["left"], geometry["bottom"]],
[geometry["right"], geometry["top"]],
]
if match := re.match(
r"^POLYGON \(\((?P<points>[^\)]+)\)\)$", geometry["value"]
):
points = match.group("points").split(", ")
points = [
[float(pt) for pt in pair.split(" ")] for pair in points
]
context["map_polygon"] = [points]
return context
def get_version_joins(self): def get_version_joins(self):
""" """
We override this to declare the relationship between the We override this to declare the relationship between the
@ -373,6 +443,23 @@ class AssetMasterView(WuttaFarmMasterView):
g.set_filter("log_type", model.Log.log_type) g.set_filter("log_type", model.Log.log_type)
g.set_enum("log_type", get_log_type_enum(self.config, session=session)) g.set_enum("log_type", get_log_type_enum(self.config, session=session))
# assets
g.set_renderer("assets", self.render_assets_for_grid)
def render_assets_for_grid(self, log, field, value):
assets = getattr(log, field)
if self.farmos_style_grid_links:
links = []
for asset in assets:
url = self.request.route_url(
f"{asset.asset_type}_assets.view", uuid=asset.uuid
)
links.append(tags.link_to(str(asset), url))
return ", ".join(links)
return ", ".join([str(a) for a in assets])
def get_row_action_url_view(self, log, i): def get_row_action_url_view(self, log, i):
return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)

View file

@ -95,6 +95,8 @@ class CommonView(base.CommonView):
"farmos_quantities_standard.view", "farmos_quantities_standard.view",
"farmos_quantity_types.list", "farmos_quantity_types.list",
"farmos_quantity_types.view", "farmos_quantity_types.view",
"farmos_seasons.list",
"farmos_seasons.view",
"farmos_structure_assets.list", "farmos_structure_assets.list",
"farmos_structure_assets.view", "farmos_structure_assets.view",
"farmos_structure_types.list", "farmos_structure_types.list",
@ -132,6 +134,12 @@ class CommonView(base.CommonView):
"logs_observation.view", "logs_observation.view",
"logs_observation.versions", "logs_observation.versions",
"quick.eggs", "quick.eggs",
"plant_types.list",
"plant_types.view",
"plant_types.versions",
"seasons.list",
"seasons.view",
"seasons.versions",
"structure_types.list", "structure_types.list",
"structure_types.view", "structure_types.view",
"structure_types.versions", "structure_types.versions",

View file

@ -0,0 +1,122 @@
# -*- 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 Plants
"""
from wuttafarm.db.model import EquipmentType, EquipmentAsset
from wuttafarm.web.views import TaxonomyMasterView
from wuttafarm.web.views.assets import AssetMasterView
from wuttafarm.web.forms.schema import EquipmentTypeRefs
class EquipmentTypeView(TaxonomyMasterView):
"""
Master view for Equipment Types
"""
model_class = EquipmentType
route_prefix = "equipment_types"
url_prefix = "/equipment-types"
farmos_route_prefix = "farmos_equipment_types"
farmos_entity_type = "taxonomy_term"
farmos_bundle = "equipment_type"
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):
base = globals()
EquipmentTypeView = kwargs.get("EquipmentTypeView", base["EquipmentTypeView"])
EquipmentTypeView.defaults(config)
EquipmentAssetView = kwargs.get("EquipmentAssetView", base["EquipmentAssetView"])
EquipmentAssetView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -28,6 +28,7 @@ from .master import FarmOSMasterView
def includeme(config): def includeme(config):
config.include("wuttafarm.web.views.farmos.users") config.include("wuttafarm.web.views.farmos.users")
config.include("wuttafarm.web.views.farmos.materials")
config.include("wuttafarm.web.views.farmos.quantities") config.include("wuttafarm.web.views.farmos.quantities")
config.include("wuttafarm.web.views.farmos.asset_types") config.include("wuttafarm.web.views.farmos.asset_types")
config.include("wuttafarm.web.views.farmos.units") config.include("wuttafarm.web.views.farmos.units")
@ -35,12 +36,15 @@ def includeme(config):
config.include("wuttafarm.web.views.farmos.land_assets") config.include("wuttafarm.web.views.farmos.land_assets")
config.include("wuttafarm.web.views.farmos.structure_types") config.include("wuttafarm.web.views.farmos.structure_types")
config.include("wuttafarm.web.views.farmos.structures") config.include("wuttafarm.web.views.farmos.structures")
config.include("wuttafarm.web.views.farmos.equipment")
config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animal_types")
config.include("wuttafarm.web.views.farmos.animals") config.include("wuttafarm.web.views.farmos.animals")
config.include("wuttafarm.web.views.farmos.groups") config.include("wuttafarm.web.views.farmos.groups")
config.include("wuttafarm.web.views.farmos.plants") config.include("wuttafarm.web.views.farmos.plants")
config.include("wuttafarm.web.views.farmos.water")
config.include("wuttafarm.web.views.farmos.log_types") config.include("wuttafarm.web.views.farmos.log_types")
config.include("wuttafarm.web.views.farmos.logs_activity") config.include("wuttafarm.web.views.farmos.logs_activity")
config.include("wuttafarm.web.views.farmos.logs_harvest") config.include("wuttafarm.web.views.farmos.logs_harvest")
config.include("wuttafarm.web.views.farmos.logs_medical") config.include("wuttafarm.web.views.farmos.logs_medical")
config.include("wuttafarm.web.views.farmos.logs_observation") config.include("wuttafarm.web.views.farmos.logs_observation")
config.include("wuttafarm.web.views.farmos.logs_seeding")

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

@ -24,10 +24,11 @@ Base class for Asset master views
""" """
import colander import colander
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,
@ -75,6 +76,23 @@ class AssetMasterView(FarmOSMasterView):
"archived": {"active": True, "verb": "is_false"}, "archived": {"active": True, "verb": "is_false"},
} }
form_fields = [
"name",
"notes",
"asset_type_name",
"owners",
"is_location",
"is_fixed",
"locations",
"groups",
"archived",
"drupal_id",
"thumbnail_url",
"image_url",
"thumbnail",
"image",
]
def get_grid_data(self, **kwargs): def get_grid_data(self, **kwargs):
return ResourceData( return ResourceData(
self.config, self.config,
@ -110,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"))
@ -120,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:
@ -139,14 +175,19 @@ 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):
result = self.farmos_client.asset.get_id( try:
self.farmos_asset_type, result = self.farmos_client.asset.get_id(
self.request.matchdict["uuid"], self.farmos_asset_type,
params={"include": ",".join(self.get_farmos_api_includes())}, self.request.matchdict["uuid"],
) params={"include": ",".join(self.get_farmos_api_includes())},
)
except requests.HTTPError as exc:
if exc.response.status_code == 404:
raise self.notfound()
raise
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_asset(result["data"], included) return self.normalize_asset(result["data"], included)
@ -170,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"):
@ -203,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"]):
@ -217,11 +269,14 @@ class AssetMasterView(FarmOSMasterView):
"name": asset["attributes"]["name"], "name": asset["attributes"]["name"],
"asset_type": asset_type_object, "asset_type": asset_type_object,
"asset_type_name": asset_type_name, "asset_type_name": asset_type_name,
"is_location": asset["attributes"]["is_location"],
"is_fixed": asset["attributes"]["is_fixed"],
"notes": notes or colander.null, "notes": notes or colander.null,
"owners": owner_objects, "owners": owner_objects,
"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,
@ -243,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
@ -253,6 +314,16 @@ class AssetMasterView(FarmOSMasterView):
f.set_widget("notes", "notes") f.set_widget("notes", "notes")
f.set_required("notes", False) f.set_required("notes", False)
# is_location
f.set_node("is_location", colander.Boolean())
# is_fixed
f.set_node("is_fixed", colander.Boolean())
# groups
if self.creating or self.editing:
f.remove("groups") # TODO: add support for this?
# archived # archived
f.set_node("archived", colander.Boolean()) f.set_node("archived", colander.Boolean())

View file

@ -0,0 +1,211 @@
# -*- 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 farmOS Equipment
"""
from webhelpers2.html import tags
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):
"""
Master view for Equipment Types in farmOS.
"""
model_name = "farmos_equipment_type"
model_title = "farmOS Equipment Type"
model_title_plural = "farmOS Equipment Types"
route_prefix = "farmos_equipment_types"
url_prefix = "/farmOS/equipment-types"
farmos_taxonomy_type = "equipment_type"
farmos_refurl_path = "/admin/structure/taxonomy/manage/equipment_type/overview"
def get_xref_buttons(self, equipment_type):
buttons = super().get_xref_buttons(equipment_type)
model = self.app.model
session = self.Session()
if wf_equipment_type := (
session.query(model.EquipmentType)
.filter(model.EquipmentType.farmos_uuid == equipment_type["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url(
"equipment_types.view", uuid=wf_equipment_type.uuid
),
icon_left="eye",
)
)
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):
base = globals()
EquipmentTypeView = kwargs.get("EquipmentTypeView", base["EquipmentTypeView"])
EquipmentTypeView.defaults(config)
EquipmentAssetView = kwargs.get("EquipmentAssetView", base["EquipmentAssetView"])
EquipmentAssetView.defaults(config)
def includeme(config):
defaults(config)

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

@ -0,0 +1,105 @@
# -*- 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 Seeding Logs
"""
from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos.logs import LogMasterView
from wuttafarm.web.grids import SimpleSorter, StringFilter
class SeedingLogView(LogMasterView):
"""
View for farmOS seeding logs
"""
model_name = "farmos_seeding_log"
model_title = "farmOS Seeding Log"
model_title_plural = "farmOS Seeding Logs"
route_prefix = "farmos_logs_seeding"
url_prefix = "/farmOS/logs/seeding"
farmos_log_type = "seeding"
farmos_refurl_path = "/logs/seeding"
grid_columns = [
"status",
"drupal_id",
"timestamp",
"name",
"assets",
"locations",
"purchase_date",
"source",
"is_group_assignment",
"owners",
]
def normalize_log(self, log, included):
data = super().normalize_log(log, included)
data.update(
{
"source": log["attributes"]["source"],
"purchase_date": self.normal.normalize_datetime(
log["attributes"]["purchase_date"]
),
"lot_number": log["attributes"]["lot_number"],
}
)
return data
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# purchase_date
g.set_renderer("purchase_date", "date")
def configure_form(self, form):
f = form
super().configure_form(f)
# source
f.fields.insert_after("timestamp", "source")
# purchase_date
f.fields.insert_after("source", "purchase_date")
f.set_node("purchase_date", WuttaDateTime())
f.set_widget("purchase_date", WuttaDateTimeWidget(self.request))
# lot_number
f.fields.insert_after("purchase_date", "lot_number")
def defaults(config, **kwargs):
base = globals()
SeedingLogView = kwargs.get("SeedingLogView", base["SeedingLogView"])
SeedingLogView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -0,0 +1,76 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
View for farmOS materials
"""
from wuttafarm.web.views.farmos.master import TaxonomyMasterView
class MaterialTypeView(TaxonomyMasterView):
"""
Master view for Material Types in farmOS.
"""
model_name = "farmos_material_type"
model_title = "farmOS Material Type"
model_title_plural = "farmOS Material Types"
route_prefix = "farmos_material_types"
url_prefix = "/farmOS/material-types"
farmos_taxonomy_type = "material_type"
farmos_refurl_path = "/admin/structure/taxonomy/manage/material_type/overview"
def get_xref_buttons(self, material_type):
buttons = super().get_xref_buttons(material_type)
model = self.app.model
session = self.Session()
if wf_material_type := (
session.query(model.MaterialType)
.filter(model.MaterialType.farmos_uuid == material_type["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url(
"material_types.view", uuid=wf_material_type.uuid
),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()
MaterialTypeView = kwargs.get("MaterialTypeView", base["MaterialTypeView"])
MaterialTypeView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -32,7 +32,12 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos.master import TaxonomyMasterView from wuttafarm.web.views.farmos.master import TaxonomyMasterView
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.schema import UsersType, StructureType, FarmOSPlantTypes from wuttafarm.web.forms.schema import (
UsersType,
StructureType,
FarmOSPlantTypes,
FarmOSRefs,
)
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
@ -75,6 +80,43 @@ class PlantTypeView(TaxonomyMasterView):
return buttons return buttons
class SeasonView(TaxonomyMasterView):
"""
Master view for Seasons in farmOS.
"""
model_name = "farmos_season"
model_title = "farmOS Season"
model_title_plural = "farmOS Seasons"
route_prefix = "farmos_seasons"
url_prefix = "/farmOS/seasons"
farmos_taxonomy_type = "season"
farmos_refurl_path = "/admin/structure/taxonomy/manage/season/overview"
def get_xref_buttons(self, season):
buttons = super().get_xref_buttons(season)
model = self.app.model
session = self.Session()
if wf_season := (
session.query(model.Season)
.filter(model.Season.farmos_uuid == season["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url("seasons.view", uuid=wf_season.uuid),
icon_left="eye",
)
)
return buttons
class PlantAssetView(FarmOSMasterView): class PlantAssetView(FarmOSMasterView):
""" """
Master view for farmOS Plant Assets Master view for farmOS Plant Assets
@ -89,6 +131,10 @@ class PlantAssetView(FarmOSMasterView):
farmos_refurl_path = "/assets/plant" farmos_refurl_path = "/assets/plant"
labels = {
"seasons": "Season",
}
grid_columns = [ grid_columns = [
"name", "name",
"archived", "archived",
@ -99,6 +145,7 @@ class PlantAssetView(FarmOSMasterView):
form_fields = [ form_fields = [
"name", "name",
"plant_types", "plant_types",
"seasons",
"archived", "archived",
"owners", "owners",
"location", "location",
@ -151,6 +198,21 @@ class PlantAssetView(FarmOSMasterView):
} }
) )
# add seasons
if seasons := relationships.get("season"):
if seasons["data"]:
data["seasons"] = []
for season in seasons["data"]:
season = self.farmos_client.resource.get_id(
"taxonomy_term", "season", season["id"]
)
data["seasons"].append(
{
"uuid": season["data"]["id"],
"name": season["data"]["attributes"]["name"],
}
)
# add location # add location
if location := relationships.get("location"): if location := relationships.get("location"):
if location["data"]: if location["data"]:
@ -199,22 +261,14 @@ class PlantAssetView(FarmOSMasterView):
return plant["name"] return plant["name"]
def normalize_plant(self, plant): def normalize_plant(self, plant):
normal = self.normal.normalize_farmos_asset(plant)
if notes := plant["attributes"]["notes"]:
notes = notes["value"]
if self.farmos_4x:
archived = plant["attributes"]["archived"]
else:
archived = plant["attributes"]["status"] == "archived"
return { return {
"uuid": plant["id"], "uuid": normal["uuid"],
"drupal_id": plant["attributes"]["drupal_internal__id"], "drupal_id": normal["drupal_id"],
"name": plant["attributes"]["name"], "name": normal["asset_name"],
"location": colander.null, # TODO "location": colander.null, # TODO
"archived": archived, "archived": normal["archived"],
"notes": notes or colander.null, "notes": normal["notes"] or colander.null,
} }
def configure_form(self, form): def configure_form(self, form):
@ -225,6 +279,9 @@ class PlantAssetView(FarmOSMasterView):
# plant_types # plant_types
f.set_node("plant_types", FarmOSPlantTypes(self.request)) f.set_node("plant_types", FarmOSPlantTypes(self.request))
# seasons
f.set_node("seasons", FarmOSRefs(self.request, "farmos_seasons"))
# location # location
f.set_node("location", StructureType(self.request)) f.set_node("location", StructureType(self.request))
@ -279,6 +336,9 @@ def defaults(config, **kwargs):
PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"])
PlantTypeView.defaults(config) PlantTypeView.defaults(config)
SeasonView = kwargs.get("SeasonView", base["SeasonView"])
SeasonView.defaults(config)
PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"])
PlantAssetView.defaults(config) PlantAssetView.defaults(config)

View file

@ -26,12 +26,13 @@ View for farmOS Quantity Types
import datetime import datetime
import colander import colander
import requests
from wuttaweb.forms.schema import WuttaDateTime 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
@ -142,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",
@ -171,6 +173,7 @@ class QuantityMasterView(FarmOSMasterView):
# as_text # as_text
g.set_renderer("as_text", self.render_as_text_for_grid) g.set_renderer("as_text", self.render_as_text_for_grid)
g.set_link("as_text")
# measure # measure
g.set_renderer("measure", self.render_measure_for_grid) g.set_renderer("measure", self.render_measure_for_grid)
@ -203,14 +206,26 @@ class QuantityMasterView(FarmOSMasterView):
return qty["value"]["decimal"] return qty["value"]["decimal"]
def get_instance(self): def get_instance(self):
quantity = self.farmos_client.resource.get_id( # TODO: this pattern should be repeated for other views
"quantity", self.farmos_quantity_type, self.request.matchdict["uuid"] try:
) result = self.farmos_client.resource.get_id(
self.raw_json = quantity "quantity",
self.farmos_quantity_type,
self.request.matchdict["uuid"],
params={"include": self.get_farmos_api_includes()},
)
except requests.HTTPError as exc:
if exc.response.status_code == 404:
raise self.notfound()
raise
data = self.normalize_quantity(quantity["data"]) self.raw_json = result
if relationships := quantity["data"].get("relationships"): included = {obj["id"]: obj for obj in result.get("included", [])}
assert included
data = self.normalize_quantity(result["data"], included)
if relationships := result["data"].get("relationships"):
# add units # add units
if units := relationships.get("units"): if units := relationships.get("units"):
@ -278,6 +293,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))
@ -303,6 +323,7 @@ class StandardQuantityView(QuantityMasterView):
url_prefix = "/farmOS/quantities/standard" url_prefix = "/farmOS/quantities/standard"
farmos_quantity_type = "standard" farmos_quantity_type = "standard"
farmos_refurl_path = "/log-quantities/standard"
def get_xref_buttons(self, standard_quantity): def get_xref_buttons(self, standard_quantity):
model = self.app.model model = self.app.model
@ -329,6 +350,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()
@ -340,6 +445,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

@ -0,0 +1,81 @@
# -*- 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 Water Assets in farmOS
"""
from wuttafarm.web.views.farmos.assets import AssetMasterView
class WaterAssetView(AssetMasterView):
"""
Master view for farmOS Water Assets
"""
model_name = "farmos_water_assets"
model_title = "farmOS Water Asset"
model_title_plural = "farmOS Water Assets"
route_prefix = "farmos_water_assets"
url_prefix = "/farmOS/assets/water"
farmos_asset_type = "water"
farmos_refurl_path = "/assets/water"
grid_columns = [
"thumbnail",
"drupal_id",
"name",
"archived",
]
def get_xref_buttons(self, water):
model = self.app.model
session = self.Session()
buttons = super().get_xref_buttons(water)
if wf_water := (
session.query(model.Asset)
.filter(model.Asset.farmos_uuid == water["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url("water_assets.view", uuid=wf_water.uuid),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()
WaterAssetView = kwargs.get("WaterAssetView", base["WaterAssetView"])
WaterAssetView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -47,15 +47,11 @@ class GroupView(AssetMasterView):
"archived", "archived",
] ]
form_fields = [ def configure_form(self, f):
"asset_name", super().configure_form(f)
"notes",
"asset_type", # produces_eggs
"produces_eggs", f.fields.insert_after("asset_type", "produces_eggs")
"archived",
"drupal_id",
"farmos_uuid",
]
def defaults(config, **kwargs): def defaults(config, **kwargs):

View file

@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import LogType, Log from wuttafarm.db.model import LogType, Log
from wuttafarm.web.forms.schema import AssetRefs, LogQuantityRefs, OwnerRefs from wuttafarm.web.forms.schema import AssetRefs, QuantityRefs, OwnerRefs
from wuttafarm.util import get_log_type_enum from wuttafarm.util import get_log_type_enum
@ -256,26 +256,21 @@ class LogMasterView(WuttaFarmMasterView):
f.set_default("timestamp", self.app.make_utc()) f.set_default("timestamp", self.app.make_utc())
# assets # assets
if self.creating or self.editing: f.set_node("assets", AssetRefs(self.request))
f.remove("assets") # TODO: need to support this f.set_required("assets", False)
else: if not self.creating:
f.set_node("assets", AssetRefs(self.request))
# nb. must explicity declare value for non-standard field # nb. must explicity declare value for non-standard field
f.set_default("assets", log.assets) f.set_default("assets", log.assets)
# groups # groups
if self.creating or self.editing: f.set_node("groups", AssetRefs(self.request, is_group=True))
f.remove("groups") # TODO: need to support this if not self.creating:
else:
f.set_node("groups", AssetRefs(self.request))
# nb. must explicity declare value for non-standard field # nb. must explicity declare value for non-standard field
f.set_default("groups", log.groups) f.set_default("groups", log.groups)
# locations # locations
if self.creating or self.editing: f.set_node("locations", AssetRefs(self.request, is_location=True))
f.remove("locations") # TODO: need to support this if not self.creating:
else:
f.set_node("locations", AssetRefs(self.request))
# nb. must explicity declare value for non-standard field # nb. must explicity declare value for non-standard field
f.set_default("locations", log.locations) f.set_default("locations", log.locations)
@ -292,12 +287,12 @@ class LogMasterView(WuttaFarmMasterView):
f.set_readonly("log_type") f.set_readonly("log_type")
# quantities # quantities
if self.creating or self.editing: f.set_node("quantities", QuantityRefs(self.request))
f.remove("quantities") # TODO: need to support this if not self.creating:
else:
f.set_node("quantities", LogQuantityRefs(self.request))
# 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")
@ -324,13 +319,141 @@ class LogMasterView(WuttaFarmMasterView):
def objectify(self, form): def objectify(self, form):
log = super().objectify(form) log = super().objectify(form)
data = form.validated
if self.creating: if self.creating:
model_class = self.get_model_class()
# log_type
log.log_type = self.get_farmos_log_type() log.log_type = self.get_farmos_log_type()
# owner
log.owners = [self.request.user]
self.set_assets(log, data["assets"])
self.set_locations(log, data["locations"])
self.set_groups(log, data["groups"])
self.set_quantities(log, data["quantities"])
return log return log
def set_assets(self, log, desired):
model = self.app.model
session = self.Session()
current = [a.uuid for a in log.assets]
for uuid in desired:
if uuid not in current:
asset = session.get(model.Asset, uuid)
assert asset
log.assets.append(asset)
for uuid in current:
if uuid not in desired:
asset = session.get(model.Asset, uuid)
assert asset
log.assets.remove(asset)
def set_locations(self, log, desired):
model = self.app.model
session = self.Session()
current = [l.uuid for l in log.locations]
for uuid in desired:
if uuid not in current:
location = session.get(model.Asset, uuid)
assert location
log.locations.append(location)
for uuid in current:
if uuid not in desired:
location = session.get(model.Asset, uuid)
assert location
log.locations.remove(location)
def set_groups(self, log, desired):
model = self.app.model
session = self.Session()
current = [g.uuid for g in log.groups]
for uuid in desired:
if uuid not in current:
group = session.get(model.Asset, uuid)
assert group
log.groups.append(group)
for uuid in current:
if uuid not in desired:
group = session.get(model.Asset, uuid)
assert group
log.groups.remove(group)
def set_quantities(self, log, desired):
model = self.app.model
session = self.Session()
current = {
qty.uuid.hex: self.app.get_true_quantity(qty) for qty in log.quantities
}
for new_qty in desired:
units = session.get(model.Unit, new_qty["units"]["uuid"])
assert units
if new_qty["uuid"].startswith("new_"):
qty = self.app.make_true_quantity(
new_qty["quantity_type"]["drupal_id"],
measure_id=new_qty["measure"],
value_numerator=int(new_qty["value"]),
value_denominator=1,
units=units,
)
# nb. must ensure "typed" quantity record persists!
session.add(qty)
# but must add "generic" quantity record to log
log.quantities.append(qty.quantity)
else:
old_qty = current[new_qty["uuid"]]
old_qty.measure_id = new_qty["measure"]
old_qty.value_numerator = int(new_qty["value"])
old_qty.value_denominator = 1
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]
for old_qty in list(log.quantities):
# nb. "old_qty" may be newly-created, w/ no uuid yet
# (this logic may break if session gets flushed early!)
if old_qty.uuid and old_qty.uuid.hex not in desired:
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):
model = self.app.model
session = self.Session()
# nb. ensure quantities have uuid keys
session.flush()
for qty in log.quantities:
qty = self.app.get_true_quantity(qty)
self.app.auto_sync_to_farmos(qty, client=client)
self.app.auto_sync_to_farmos(log, client=client)
def get_farmos_url(self, log): def get_farmos_url(self, log):
return self.app.get_farmos_url(f"/log/{log.drupal_id}") return self.app.get_farmos_url(f"/log/{log.drupal_id}")
@ -368,6 +491,7 @@ class LogMasterView(WuttaFarmMasterView):
return super().get_version_joins() + [ return super().get_version_joins() + [
model.Log, model.Log,
(model.LogAsset, "log_uuid", "uuid"), (model.LogAsset, "log_uuid", "uuid"),
(model.LogQuantity, "log_uuid", "uuid"),
] ]

View file

@ -0,0 +1,80 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Seeding Logs
"""
from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.logs import LogMasterView
from wuttafarm.db.model import SeedingLog
class SeedingLogView(LogMasterView):
"""
Master view for Seeding Logs
"""
model_class = SeedingLog
route_prefix = "logs_seeding"
url_prefix = "/logs/seeding"
farmos_bundle = "seeding"
farmos_refurl_path = "/logs/seeding"
grid_columns = [
"status",
"drupal_id",
"timestamp",
"message",
"assets",
"locations",
"purchase_date",
"source",
"is_group_assignment",
"owners",
]
def configure_form(self, form):
f = form
super().configure_form(f)
# source
f.fields.insert_after("timestamp", "source")
# purchase_date
f.fields.insert_after("source", "purchase_date")
f.set_widget("purchase_date", WuttaDateTimeWidget(self.request))
# lot_number
f.fields.insert_after("purchase_date", "lot_number")
def defaults(config, **kwargs):
base = globals()
SeedingLogView = kwargs.get("SeedingLogView", base["SeedingLogView"])
SeedingLogView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -23,9 +23,14 @@
Base class for WuttaFarm master views Base class for WuttaFarm master views
""" """
import threading
import time
import requests
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
@ -106,13 +111,35 @@ class WuttaFarmMasterView(MasterView):
f.set_readonly("drupal_id") f.set_readonly("drupal_id")
def persist(self, obj, session=None): def persist(self, obj, session=None):
session = session or self.Session()
# save per usual # save per usual
super().persist(obj, session) super().persist(obj, session)
# maybe also sync change to farmOS # maybe also sync change to farmOS
if self.app.is_farmos_mirror(): if self.app.is_farmos_mirror():
if self.creating:
session.flush() # need the new uuid
client = get_farmos_client_for_user(self.request) client = get_farmos_client_for_user(self.request)
thread = threading.Thread(
target=self.auto_sync_to_farmos, args=(client, obj.uuid)
)
thread.start()
def auto_sync_to_farmos(self, client, uuid):
model = self.app.model
model_class = self.get_model_class()
with self.app.short_session(commit=True) as session:
if user := session.query(model.User).filter_by(username="farmos").first():
session.info["continuum_user_id"] = user.uuid
obj = None
while not obj:
obj = session.get(model_class, uuid)
if not obj:
time.sleep(0.1)
self.app.auto_sync_to_farmos(obj, client=client, require=False) self.app.auto_sync_to_farmos(obj, client=client, require=False)
def get_farmos_entity_type(self): def get_farmos_entity_type(self):
@ -141,7 +168,130 @@ class WuttaFarmMasterView(MasterView):
# maybe delete from farmOS also # maybe delete from farmOS also
if farmos_uuid: if farmos_uuid:
entity_type = self.get_farmos_entity_type()
bundle = self.get_farmos_bundle()
client = get_farmos_client_for_user(self.request) client = get_farmos_client_for_user(self.request)
# nb. must use separate thread to avoid some kind of race
# condition (?) - seems as though maybe a "boomerang"
# effect is happening; this seems to help anyway
thread = threading.Thread(
target=self.delete_from_farmos, args=(client, farmos_uuid)
)
thread.start()
def delete_from_farmos(self, client, farmos_uuid):
entity_type = self.get_farmos_entity_type()
bundle = self.get_farmos_bundle()
try:
client.resource.delete(entity_type, bundle, farmos_uuid) client.resource.delete(entity_type, bundle, farmos_uuid)
except requests.HTTPError as exc:
# ignore if record not found in farmOS
if exc.response.status_code != 404:
raise
class TaxonomyMasterView(WuttaFarmMasterView):
"""
Base class for master views serving taxonomy terms.
"""
farmos_entity_type = "taxonomy_term"
grid_columns = [
"name",
"description",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"description",
"drupal_id",
"farmos_uuid",
]
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# name
g.set_link("name")
def configure_form(self, form):
f = form
super().configure_form(f)
# description
f.set_widget("description", "notes")
def get_farmos_url(self, obj):
return self.app.get_farmos_url(f"/taxonomy/term/{obj.drupal_id}")
def get_xref_buttons(self, term):
buttons = super().get_xref_buttons(term)
if term.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
f"{self.farmos_route_prefix}.view", uuid=term.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def ajax_create(self):
"""
AJAX view to create a new taxonomy term.
"""
model = self.app.model
session = self.Session()
data = get_form_data(self.request)
name = data.get("name")
if not name:
return {"error": "Name is required"}
term = self.model_class(name=name)
session.add(term)
session.flush()
if self.app.is_farmos_mirror():
client = get_farmos_client_for_user(self.request)
self.app.auto_sync_to_farmos(term, client=client)
return {
"uuid": term.uuid.hex,
"name": term.name,
"farmos_uuid": term.farmos_uuid.hex,
"drupal_id": term.drupal_id,
}
@classmethod
def defaults(cls, config):
""" """
cls._defaults(config)
cls._taxonomy_defaults(config)
@classmethod
def _taxonomy_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_prefix()
# ajax_create
config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new")
config.add_view(
cls,
attr="ajax_create",
route_name=f"{route_prefix}.ajax_create",
permission=f"{permission_prefix}.create",
renderer="json",
)

View file

@ -0,0 +1,52 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Material Types
"""
from wuttafarm.web.views import TaxonomyMasterView
from wuttafarm.db.model import MaterialType
class MaterialTypeView(TaxonomyMasterView):
"""
Master view for Material Types
"""
model_class = MaterialType
route_prefix = "material_types"
url_prefix = "/material-types"
farmos_route_prefix = "farmos_material_types"
farmos_bundle = "material_type"
farmos_refurl_path = "/admin/structure/taxonomy/manage/material_type/overview"
def defaults(config, **kwargs):
base = globals()
MaterialTypeView = kwargs.get("MaterialTypeView", base["MaterialTypeView"])
MaterialTypeView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -28,9 +28,9 @@ from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.util import get_form_data from wuttaweb.util import get_form_data
from wuttafarm.db.model import PlantType, PlantAsset from wuttafarm.db.model import PlantType, Season, PlantAsset
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.web.forms.schema import PlantTypeRefs from wuttafarm.web.forms.schema import PlantTypeRefs, SeasonRefs
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.util import get_farmos_client_for_user from wuttafarm.web.util import get_farmos_client_for_user
@ -195,6 +195,166 @@ class PlantTypeView(AssetTypeMasterView):
) )
class SeasonView(AssetTypeMasterView):
"""
Master view for Seasons
"""
model_class = Season
route_prefix = "seasons"
url_prefix = "/seasons"
farmos_entity_type = "taxonomy_term"
farmos_bundle = "season"
farmos_refurl_path = "/admin/structure/taxonomy/manage/season/overview"
grid_columns = [
"name",
"description",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"description",
"drupal_id",
"farmos_uuid",
]
has_rows = True
row_model_class = PlantAsset
rows_viewable = True
row_grid_columns = [
"asset_name",
"archived",
]
rows_sort_defaults = "asset_name"
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# name
g.set_link("name")
def get_farmos_url(self, season):
return self.app.get_farmos_url(f"/taxonomy/term/{season.drupal_id}")
def get_xref_buttons(self, season):
buttons = super().get_xref_buttons(season)
if season.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_seasons.view", uuid=season.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def delete(self):
season = self.get_instance()
if season._plant_assets:
self.request.session.flash(
"Cannot delete season which is still referenced by plant assets.",
"warning",
)
url = self.get_action_url("view", season)
return self.redirect(self.request.get_referrer(default=url))
return super().delete()
def get_row_grid_data(self, season):
model = self.app.model
session = self.Session()
return (
session.query(model.PlantAsset)
.join(model.Asset)
.outerjoin(model.PlantAssetSeason)
.filter(model.PlantAssetSeason.season == season)
)
def configure_row_grid(self, grid):
g = grid
super().configure_row_grid(g)
model = self.app.model
# asset_name
g.set_link("asset_name")
g.set_sorter("asset_name", model.Asset.asset_name)
g.set_filter("asset_name", model.Asset.asset_name)
# archived
g.set_renderer("archived", "boolean")
g.set_sorter("archived", model.Asset.archived)
g.set_filter("archived", model.Asset.archived)
def get_row_action_url_view(self, plant, i):
return self.request.route_url("plant_assets.view", uuid=plant.uuid)
def ajax_create(self):
"""
AJAX view to create a new season.
"""
model = self.app.model
session = self.Session()
data = get_form_data(self.request)
name = data.get("name")
if not name:
return {"error": "Name is required"}
season = model.Season(name=name)
session.add(season)
session.flush()
if self.app.is_farmos_mirror():
client = get_farmos_client_for_user(self.request)
self.app.auto_sync_to_farmos(season, client=client)
return {
"uuid": season.uuid.hex,
"name": season.name,
"farmos_uuid": season.farmos_uuid.hex,
"drupal_id": season.drupal_id,
}
@classmethod
def defaults(cls, config):
""" """
cls._defaults(config)
cls._season_defaults(config)
@classmethod
def _season_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_prefix()
# ajax_create
config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new")
config.add_view(
cls,
attr="ajax_create",
route_name=f"{route_prefix}.ajax_create",
permission=f"{permission_prefix}.create",
renderer="json",
)
class PlantAssetView(AssetMasterView): class PlantAssetView(AssetMasterView):
""" """
Master view for Plant Assets Master view for Plant Assets
@ -209,6 +369,7 @@ class PlantAssetView(AssetMasterView):
labels = { labels = {
"plant_types": "Crop/Variety", "plant_types": "Crop/Variety",
"seasons": "Season",
} }
grid_columns = [ grid_columns = [
@ -220,21 +381,6 @@ class PlantAssetView(AssetMasterView):
"archived", "archived",
] ]
form_fields = [
"asset_name",
"plant_types",
"season",
"notes",
"asset_type",
"archived",
"drupal_id",
"farmos_uuid",
"thumbnail_url",
"image_url",
"thumbnail",
"image",
]
def configure_grid(self, grid): def configure_grid(self, grid):
g = grid g = grid
super().configure_grid(g) super().configure_grid(g)
@ -262,23 +408,33 @@ class PlantAssetView(AssetMasterView):
plant = f.model_instance plant = f.model_instance
# plant_types # plant_types
f.fields.insert_after("asset_name", "plant_types")
f.set_node("plant_types", PlantTypeRefs(self.request)) f.set_node("plant_types", PlantTypeRefs(self.request))
if not self.creating: if not self.creating:
# nb. must explcitly declare value for non-standard field # nb. must explcitly declare value for non-standard field
f.set_default("plant_types", [pt.uuid for pt in plant.plant_types]) f.set_default("plant_types", [pt.uuid for pt in plant.plant_types])
# season # season
if self.creating or self.editing: f.fields.insert_after("plant_types", "seasons")
f.remove("season") # TODO: add support for this f.set_node("seasons", SeasonRefs(self.request))
f.set_required("seasons", False)
if not self.creating:
# nb. must explcitly declare value for non-standard field
f.set_default("seasons", plant.seasons)
def objectify(self, form): def objectify(self, form):
model = self.app.model
session = self.Session()
plant = super().objectify(form) plant = super().objectify(form)
data = form.validated data = form.validated
self.set_plant_types(plant, data["plant_types"])
self.set_seasons(plant, data["seasons"])
return plant
def set_plant_types(self, plant, desired):
model = self.app.model
session = self.Session()
current = [pt.uuid for pt in plant.plant_types] current = [pt.uuid for pt in plant.plant_types]
desired = data["plant_types"]
for uuid in desired: for uuid in desired:
if uuid not in current: if uuid not in current:
@ -292,7 +448,22 @@ class PlantAssetView(AssetMasterView):
assert plant_type assert plant_type
plant.plant_types.remove(plant_type) plant.plant_types.remove(plant_type)
return plant def set_seasons(self, plant, desired):
model = self.app.model
session = self.Session()
current = [s.uuid for s in plant.seasons]
for uuid in desired:
if uuid not in current:
season = session.get(model.Season, uuid)
assert season
plant.seasons.append(season)
for uuid in current:
if uuid not in desired:
season = session.get(model.Season, uuid)
assert season
plant.seasons.remove(season)
def defaults(config, **kwargs): def defaults(config, **kwargs):
@ -301,6 +472,9 @@ def defaults(config, **kwargs):
PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"])
PlantTypeView.defaults(config) PlantTypeView.defaults(config)
SeasonView = kwargs.get("SeasonView", base["SeasonView"])
SeasonView.defaults(config)
PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"])
PlantAssetView.defaults(config) PlantAssetView.defaults(config)

View file

@ -25,11 +25,19 @@ Master view for Quantities
from collections import OrderedDict from collections import OrderedDict
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
def get_quantity_type_enum(config): def get_quantity_type_enum(config):
@ -100,17 +108,28 @@ class QuantityMasterView(WuttaFarmMasterView):
Base class for Quantity master views Base class for Quantity master views
""" """
farmos_entity_type = "quantity"
labels = {
"log_id": "Log ID",
}
grid_columns = [ grid_columns = [
"drupal_id", "drupal_id",
"as_text", "log_id",
"quantity_type", "log_status",
"log_timestamp",
"log_type",
"log_name",
"log_assets",
"measure", "measure",
"value", "value",
"units", "units",
"label", "label",
"quantity_type",
] ]
sort_defaults = ("drupal_id", "desc") sort_defaults = ("log_timestamp", "desc")
form_fields = [ form_fields = [
"quantity_type", "quantity_type",
@ -129,10 +148,15 @@ class QuantityMasterView(WuttaFarmMasterView):
model = self.app.model model = self.app.model
model_class = self.get_model_class() model_class = self.get_model_class()
session = session or self.Session() session = session or self.Session()
query = session.query(model_class) query = session.query(model_class)
if model_class is not model.Quantity: if model_class is not model.Quantity:
query = query.join(model.Quantity) query = query.join(model.Quantity)
query = query.join(model.Measure).join(model.Unit) query = query.join(model.Measure).join(model.Unit)
query = query.outerjoin(model.LogQuantity).outerjoin(model.Log)
return query return query
def configure_grid(self, grid): def configure_grid(self, grid):
@ -140,14 +164,39 @@ class QuantityMasterView(WuttaFarmMasterView):
super().configure_grid(g) super().configure_grid(g)
model = self.app.model model = self.app.model
model_class = self.get_model_class() model_class = self.get_model_class()
session = self.Session()
# drupal_id # drupal_id
g.set_label("drupal_id", "ID", column_only=True) g.set_label("drupal_id", "ID", column_only=True)
g.set_sorter("drupal_id", model.Quantity.drupal_id) g.set_sorter("drupal_id", model.Quantity.drupal_id)
# as_text # log_id
g.set_renderer("as_text", self.render_as_text_for_grid) g.set_renderer("log_id", self.render_log_id_for_grid)
g.set_link("as_text") g.set_sorter("log_id", model.Log.drupal_id)
# log_status
g.set_renderer("log_status", self.render_log_status_for_grid)
g.set_sorter("log_status", model.Log.status)
# log_timestamp
g.set_renderer("log_timestamp", self.render_log_timestamp_for_grid)
g.set_sorter("log_timestamp", model.Log.timestamp)
# log_type
self.log_type_enum = get_log_type_enum(self.config, session)
g.set_renderer("log_type", self.render_log_type_for_grid)
g.set_sorter("log_type", model.Log.log_type)
# log_name
g.set_renderer("log_name", self.render_log_name_for_grid)
g.set_sorter("log_name", model.Log.message)
if not self.farmos_style_grid_links:
g.set_link("log_name")
# log_assets
g.set_renderer("log_assets", self.render_log_assets_for_grid)
if not self.farmos_style_grid_links:
g.set_link("log_assets")
# quantity_type # quantity_type
if model_class is not model.Quantity: if model_class is not model.Quantity:
@ -177,8 +226,47 @@ class QuantityMasterView(WuttaFarmMasterView):
g.add_action("view", icon="eye", url=quantity_url) g.add_action("view", icon="eye", url=quantity_url)
def render_as_text_for_grid(self, quantity, field, value): def render_log_id_for_grid(self, quantity, field, value):
return quantity.render_as_text(self.config) if log := quantity.log:
return log.drupal_id
return None
def render_log_status_for_grid(self, quantity, field, value):
enum = self.app.enum
if log := quantity.log:
return enum.LOG_STATUS.get(log.status, log.status)
return None
def render_log_timestamp_for_grid(self, quantity, field, value):
if log := quantity.log:
return self.app.render_date(log.timestamp)
return None
def render_log_type_for_grid(self, quantity, field, value):
if log := quantity.log:
return self.log_type_enum.get(log.log_type, log.log_type)
return None
def render_log_name_for_grid(self, quantity, field, value):
if log := quantity.log:
if self.farmos_style_grid_links:
url = self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
return tags.link_to(log.message, url)
return log.message
return None
def render_log_assets_for_grid(self, quantity, field, value):
if log := quantity.log:
if self.farmos_style_grid_links:
links = []
for asset in log.assets:
url = self.request.route_url(
f"{asset.asset_type}_assets.view", uuid=asset.uuid
)
links.append(tags.link_to(str(asset), url))
return ", ".join(links)
return [str(a) for a in log.assets]
return None
def render_value_for_grid(self, quantity, field, value): def render_value_for_grid(self, quantity, field, value):
value = quantity.value_numerator / quantity.value_denominator value = quantity.value_numerator / quantity.value_denominator
@ -271,6 +359,8 @@ class AllQuantityView(QuantityMasterView):
deletable = False deletable = False
model_is_versioned = False model_is_versioned = False
farmos_refurl_path = "/log-quantities"
class StandardQuantityView(QuantityMasterView): class StandardQuantityView(QuantityMasterView):
""" """
@ -281,6 +371,77 @@ class StandardQuantityView(QuantityMasterView):
route_prefix = "quantities_standard" route_prefix = "quantities_standard"
url_prefix = "/quantities/standard" url_prefix = "/quantities/standard"
farmos_bundle = "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()
@ -296,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)

View file

@ -24,6 +24,8 @@ Quick Form for "Eggs"
""" """
import json import json
import threading
import time
import colander import colander
from deform.widget import SelectWidget from deform.widget import SelectWidget
@ -331,13 +333,43 @@ class EggsQuickForm(QuickFormView):
session.flush() session.flush()
if self.app.is_farmos_mirror(): if self.app.is_farmos_mirror():
if new_unit: thread = threading.Thread(
self.app.auto_sync_to_farmos(unit, client=self.farmos_client) target=self.auto_sync_to_farmos,
self.app.auto_sync_to_farmos(quantity, client=self.farmos_client) args=(log.uuid, quantity.uuid, new_unit.uuid if new_unit else None),
self.app.auto_sync_to_farmos(log, client=self.farmos_client) )
thread.start()
return log return log
def auto_sync_to_farmos(self, log_uuid, quantity_uuid, new_unit_uuid):
model = self.app.model
with self.app.short_session(commit=True) as session:
if user := session.query(model.User).filter_by(username="farmos").first():
session.info["continuum_user_id"] = user.uuid
if new_unit_uuid:
new_unit = None
while not new_unit:
new_unit = session.get(model.Unit, new_unit_uuid)
if not new_unit:
time.sleep(0.1)
self.app.auto_sync_to_farmos(unit, client=self.farmos_client)
quantity = None
while not quantity:
quantity = session.get(model.StandardQuantity, quantity_uuid)
if not quantity:
time.sleep(0.1)
self.app.auto_sync_to_farmos(quantity, client=self.farmos_client)
log = None
while not log:
log = session.get(model.HarvestLog, log_uuid)
if not log:
time.sleep(0.1)
self.app.auto_sync_to_farmos(log, client=self.farmos_client)
def redirect_after_save(self, log): def redirect_after_save(self, log):
model = self.app.model model = self.app.model

View file

@ -37,17 +37,19 @@ class MeasureView(WuttaFarmMasterView):
url_prefix = "/measures" url_prefix = "/measures"
grid_columns = [ grid_columns = [
"ordinal",
"name", "name",
"drupal_id", "drupal_id",
] ]
sort_defaults = "name" sort_defaults = "ordinal"
filter_defaults = { filter_defaults = {
"name": {"active": True, "verb": "contains"}, "name": {"active": True, "verb": "contains"},
} }
form_fields = [ form_fields = [
"ordinal",
"name", "name",
"drupal_id", "drupal_id",
] ]

View file

@ -0,0 +1,59 @@
# -*- 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 Water Assets
"""
from wuttafarm.db.model import WaterAsset
from wuttafarm.web.views.assets import AssetMasterView
class WaterAssetView(AssetMasterView):
"""
Master view for Plant Assets
"""
model_class = WaterAsset
route_prefix = "water_assets"
url_prefix = "/assets/water"
farmos_bundle = "water"
farmos_refurl_path = "/assets/water"
grid_columns = [
"thumbnail",
"drupal_id",
"asset_name",
"parents",
"archived",
]
def defaults(config, **kwargs):
base = globals()
WaterAssetView = kwargs.get("WaterAssetView", base["WaterAssetView"])
WaterAssetView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -0,0 +1,96 @@
# -*- 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/>.
#
################################################################################
"""
Views for use as webhooks
"""
import logging
from uuid import UUID
from wuttaweb.views import View
from wuttaweb.db import Session
log = logging.getLogger(__name__)
class WebhookView(View):
"""
Webhook views
"""
def farmos_webhook(self):
model = self.app.model
session = Session()
try:
data = self.request.json
log.debug("got webhook payload: %s", data)
_, entity_type, event_type = data["event"].split(":")
uuid = data["entity"]["uuid"][0]["value"]
if entity_type == "taxonomy_term":
bundle = data["entity"]["vid"][0]["target_id"]
else:
bundle = data["entity"]["type"][0]["target_id"]
change = model.WebhookChange(
entity_type=entity_type,
bundle=bundle,
farmos_uuid=UUID(uuid),
deleted=event_type == "delete",
)
session.add(change)
except:
log.exception("failed to process webhook request")
return {}
@classmethod
def defaults(cls, config):
cls._webhook_defaults(config)
@classmethod
def _webhook_defaults(cls, config):
# farmos webhook
config.add_route("webhooks.farmos", "/farmos/webhook", request_method="POST")
config.add_view(
cls,
attr="farmos_webhook",
route_name="webhooks.farmos",
require_csrf=False,
renderer="json",
)
def defaults(config, **kwargs):
base = globals()
WebhookView = kwargs.get("WebhookView", base["WebhookView"])
WebhookView.defaults(config)
def includeme(config):
defaults(config)