diff --git a/CHANGELOG.md b/CHANGELOG.md index f1eedfc..579fc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/) 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) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 1bb1dda..b702d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.8.0" +version = "0.11.2" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ @@ -34,7 +34,7 @@ dependencies = [ "pyramid_exclog", "uvicorn[standard]", "WuttaSync", - "WuttaWeb[continuum]>=0.29.0", + "WuttaWeb[continuum]>=0.30.1", ] diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index cb9aed3..c5a581b 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -151,6 +151,74 @@ class WuttaFarmAppHandler(base.AppHandler): factory = self.load_object(spec) 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): """ 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 there is no such importer. """ + model = self.app.model handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos") if model_name not in handler.importers: @@ -212,6 +281,10 @@ class WuttaFarmAppHandler(base.AppHandler): # nb. begin txn to establish the API client handler.begin_source_transaction(client) 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 importer = handler.get_importer(model_name, caches_target=False) normal = importer.normalize_source_object(obj) diff --git a/src/wuttafarm/cli/__init__.py b/src/wuttafarm/cli/__init__.py index cd06344..71d4994 100644 --- a/src/wuttafarm/cli/__init__.py +++ b/src/wuttafarm/cli/__init__.py @@ -29,3 +29,4 @@ from .base import wuttafarm_typer from . import export_farmos from . import import_farmos from . import install +from . import process_webhooks diff --git a/src/wuttafarm/cli/process_webhooks.py b/src/wuttafarm/cli/process_webhooks.py new file mode 100644 index 0000000..9d66a70 --- /dev/null +++ b/src/wuttafarm/cli/process_webhooks.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/db/alembic/versions/1c89f3fbb521_add_materialtype.py b/src/wuttafarm/db/alembic/versions/1c89f3fbb521_add_materialtype.py new file mode 100644 index 0000000..0d4c0f5 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/1c89f3fbb521_add_materialtype.py @@ -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") diff --git a/src/wuttafarm/db/alembic/versions/82a497e30a97_add_measure_ordinal.py b/src/wuttafarm/db/alembic/versions/82a497e30a97_add_measure_ordinal.py new file mode 100644 index 0000000..2cd8057 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/82a497e30a97_add_measure_ordinal.py @@ -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") diff --git a/src/wuttafarm/db/alembic/versions/9c53513f8862_add_materialquantity.py b/src/wuttafarm/db/alembic/versions/9c53513f8862_add_materialquantity.py new file mode 100644 index 0000000..6f28989 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/9c53513f8862_add_materialquantity.py @@ -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") diff --git a/src/wuttafarm/db/alembic/versions/c5183b781d34_add_plant_seasons.py b/src/wuttafarm/db/alembic/versions/c5183b781d34_add_plant_seasons.py new file mode 100644 index 0000000..406ad64 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/c5183b781d34_add_plant_seasons.py @@ -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") diff --git a/src/wuttafarm/db/alembic/versions/dca5b48a5562_add_seedinglog.py b/src/wuttafarm/db/alembic/versions/dca5b48a5562_add_seedinglog.py new file mode 100644 index 0000000..6a374b4 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/dca5b48a5562_add_seedinglog.py @@ -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") diff --git a/src/wuttafarm/db/alembic/versions/dd4d4142b96d_add_webhookchange.py b/src/wuttafarm/db/alembic/versions/dd4d4142b96d_add_webhookchange.py new file mode 100644 index 0000000..5b566b2 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/dd4d4142b96d_add_webhookchange.py @@ -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") diff --git a/src/wuttafarm/db/alembic/versions/de1197d24485_add_waterasset.py b/src/wuttafarm/db/alembic/versions/de1197d24485_add_waterasset.py new file mode 100644 index 0000000..e123fc3 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/de1197d24485_add_waterasset.py @@ -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") diff --git a/src/wuttafarm/db/alembic/versions/e5b27eac471c_add_equipmenttype.py b/src/wuttafarm/db/alembic/versions/e5b27eac471c_add_equipmenttype.py new file mode 100644 index 0000000..a436725 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/e5b27eac471c_add_equipmenttype.py @@ -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") diff --git a/src/wuttafarm/db/alembic/versions/e9b8664e1f39_add_equipmentasset.py b/src/wuttafarm/db/alembic/versions/e9b8664e1f39_add_equipmentasset.py new file mode 100644 index 0000000..2a8ed15 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/e9b8664e1f39_add_equipmentasset.py @@ -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") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 15514fb..716fd1c 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -31,15 +31,34 @@ from .users import WuttaFarmUser # wuttafarm proper models 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_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset +from .asset_equipment import EquipmentType, EquipmentAsset, EquipmentAssetEquipmentType from .asset_animal import AnimalType, AnimalAsset 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_activity import ActivityLog from .log_harvest import HarvestLog from .log_medical import MedicalLog from .log_observation import ObservationLog +from .log_seeding import SeedingLog + +# misc. +from .webhook import WebhookChange diff --git a/src/wuttafarm/db/model/asset_equipment.py b/src/wuttafarm/db/model/asset_equipment.py new file mode 100644 index 0000000..51af9ee --- /dev/null +++ b/src/wuttafarm/db/model/asset_equipment.py @@ -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 . +# +################################################################################ +""" +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", + ) diff --git a/src/wuttafarm/db/model/asset_plant.py b/src/wuttafarm/db/model/asset_plant.py index 62f7e9b..fa1be03 100644 --- a/src/wuttafarm/db/model/asset_plant.py +++ b/src/wuttafarm/db/model/asset_plant.py @@ -91,6 +91,65 @@ class PlantType(model.Base): 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): """ Represents a plant asset from farmOS @@ -117,6 +176,19 @@ class PlantAsset(AssetMixin, model.Base): 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) @@ -146,3 +218,30 @@ class PlantAssetPlantType(model.Base): """, 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", + ) diff --git a/src/wuttafarm/db/model/asset_water.py b/src/wuttafarm/db/model/asset_water.py new file mode 100644 index 0000000..046c899 --- /dev/null +++ b/src/wuttafarm/db/model/asset_water.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/db/model/log_seeding.py b/src/wuttafarm/db/model/log_seeding.py new file mode 100644 index 0000000..7f68923 --- /dev/null +++ b/src/wuttafarm/db/model/log_seeding.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/db/model/material_type.py b/src/wuttafarm/db/model/material_type.py new file mode 100644 index 0000000..a124451 --- /dev/null +++ b/src/wuttafarm/db/model/material_type.py @@ -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 . +# +################################################################################ +""" +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 "" diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py index 4bed6a0..4fa92af 100644 --- a/src/wuttafarm/db/model/quantities.py +++ b/src/wuttafarm/db/model/quantities.py @@ -181,9 +181,13 @@ class Quantity(model.Base): 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): measure = str(self.measure or self.measure_id or "") - value = self.value_numerator / self.value_denominator + value = self.get_value_decimal() if config: app = config.get_app() value = app.render_quantity(value) @@ -200,7 +204,15 @@ class QuantityMixin: @declared_attr 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): return self.quantity.render_as_text(config) @@ -240,3 +252,64 @@ class StandardQuantity(QuantityMixin, model.Base): 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", + ) diff --git a/src/wuttafarm/db/model/taxonomy.py b/src/wuttafarm/db/model/taxonomy.py new file mode 100644 index 0000000..3d84197 --- /dev/null +++ b/src/wuttafarm/db/model/taxonomy.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaFarm --Web app to integrate with and extend farmOS +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaFarm. +# +# WuttaFarm is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaFarm. If not, see . +# +################################################################################ +""" +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 "" diff --git a/src/wuttafarm/db/model/unit.py b/src/wuttafarm/db/model/unit.py index e9c6e70..a376e2c 100644 --- a/src/wuttafarm/db/model/unit.py +++ b/src/wuttafarm/db/model/unit.py @@ -42,6 +42,14 @@ class Measure(model.Base): uuid = model.uuid_column() + ordinal = sa.Column( + sa.Integer(), + nullable=True, + doc=""" + Ordinal (sequence number) for the measure. + """, + ) + name = sa.Column( sa.String(length=100), nullable=False, diff --git a/src/wuttafarm/db/model/webhook.py b/src/wuttafarm/db/model/webhook.py new file mode 100644 index 0000000..b96a572 --- /dev/null +++ b/src/wuttafarm/db/model/webhook.py @@ -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 . +# +################################################################################ +""" +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}" diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index ad1cb38..011a170 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -71,13 +71,15 @@ class ToFarmOSTaxonomy(ToFarmOS): supported_fields = [ "uuid", "name", + "description", ] def get_target_objects(self, **kwargs): - result = self.farmos_client.resource.get( - "taxonomy_term", self.farmos_taxonomy_type + return list( + self.farmos_client.resource.iterate( + "taxonomy_term", self.farmos_taxonomy_type + ) ) - return result["data"] def get_target_object(self, key): @@ -101,17 +103,24 @@ class ToFarmOSTaxonomy(ToFarmOS): return result["data"] def normalize_target_object(self, obj): + if description := obj["attributes"]["description"]: + description = description["value"] return { "uuid": UUID(obj["id"]), "name": obj["attributes"]["name"], + "description": description, } def get_term_payload(self, source_data): - return { - "attributes": { - "name": source_data["name"], - } - } + + attrs = {} + 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): if source_data.get("__ignoreme__"): @@ -127,9 +136,9 @@ class ToFarmOSTaxonomy(ToFarmOS): normal["_new_object"] = result["data"] 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: - return asset + return term payload = self.get_term_payload(source_data) payload["id"] = str(source_data["uuid"]) @@ -146,9 +155,12 @@ class ToFarmOSAsset(ToFarmOS): 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): - assets = self.farmos_client.asset.get(self.farmos_asset_type) - return assets["data"] + return list(self.farmos_client.asset.iterate(self.farmos_asset_type)) def get_target_object(self, key): @@ -191,18 +203,17 @@ class ToFarmOSAsset(ToFarmOS): return self.normalize_target_object(result["data"]) def normalize_target_object(self, asset): - - if notes := asset["attributes"]["notes"]: - notes = notes["value"] - + normal = self.normal.normalize_farmos_asset(asset) return { - "uuid": UUID(asset["id"]), - "asset_name": asset["attributes"]["name"], - "is_location": asset["attributes"]["is_location"], - "is_fixed": asset["attributes"]["is_fixed"], - "produces_eggs": asset["attributes"].get("produces_eggs"), - "notes": notes, - "archived": asset["attributes"]["archived"], + "uuid": UUID(normal["uuid"]), + "asset_name": normal["asset_name"], + "is_location": normal["is_location"], + "is_fixed": normal["is_fixed"], + # nb. this is only used for certain asset types + "produces_eggs": normal["produces_eggs"], + "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): @@ -221,8 +232,18 @@ class ToFarmOSAsset(ToFarmOS): if "archived" in self.fields: 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 @@ -245,6 +266,8 @@ class AnimalAssetImporter(ToFarmOSAsset): "is_sterile", "produces_eggs", "birthdate", + "is_location", + "is_fixed", "notes", "archived", ] @@ -296,6 +319,80 @@ class AnimalTypeImporter(ToFarmOSTaxonomy): 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): model_title = "GroupAsset" @@ -353,6 +450,12 @@ class PlantTypeImporter(ToFarmOSTaxonomy): farmos_taxonomy_type = "plant_type" +class SeasonImporter(ToFarmOSTaxonomy): + + model_title = "Season" + farmos_taxonomy_type = "season" + + class PlantAssetImporter(ToFarmOSAsset): model_title = "PlantAsset" @@ -362,6 +465,7 @@ class PlantAssetImporter(ToFarmOSAsset): "uuid", "asset_name", "plant_type_uuids", + "season_uuids", "notes", "archived", ] @@ -373,6 +477,9 @@ class PlantAssetImporter(ToFarmOSAsset): "plant_type_uuids": [ 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 @@ -398,6 +505,15 @@ class PlantAssetImporter(ToFarmOSAsset): "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) if rels: @@ -443,6 +559,21 @@ class StructureAssetImporter(ToFarmOSAsset): 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 ############################## @@ -569,6 +700,49 @@ class ToFarmOSQuantity(ToFarmOS): 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): model_title = "StandardQuantity" @@ -597,6 +771,8 @@ class ToFarmOSLog(ToFarmOS): "notes", "quick", "assets", + "locations", + "groups", "quantities", ] @@ -605,8 +781,7 @@ class ToFarmOSLog(ToFarmOS): self.normal = self.app.get_normalizer(self.farmos_client) def get_target_objects(self, **kwargs): - result = self.farmos_client.log.get(self.farmos_log_type) - return result["data"] + return list(self.farmos_client.log.iterate(self.farmos_log_type)) def get_target_object(self, key): @@ -660,6 +835,10 @@ class ToFarmOSLog(ToFarmOS): "notes": normal["notes"], "quick": normal["quick"], "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"]], } @@ -692,6 +871,26 @@ class ToFarmOSLog(ToFarmOS): } ) 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: quantities = [] for uuid in source_data["quantities"]: @@ -756,3 +955,48 @@ class ObservationLogImporter(ToFarmOSLog): model_title = "ObservationLog" 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 diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 8394e4c..d60a96f 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -98,17 +98,24 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers = super().define_importers() importers["LandAsset"] = LandAssetImporter importers["StructureAsset"] = StructureAssetImporter + importers["WaterAsset"] = WaterAssetImporter + importers["EquipmentType"] = EquipmentTypeImporter + importers["EquipmentAsset"] = EquipmentAssetImporter importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter importers["PlantType"] = PlantTypeImporter + importers["Season"] = SeasonImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter + importers["MaterialType"] = MaterialTypeImporter + importers["MaterialQuantity"] = MaterialQuantityImporter importers["StandardQuantity"] = StandardQuantityImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter importers["ObservationLog"] = ObservationLogImporter + importers["SeedingLog"] = SeedingLogImporter return importers @@ -134,60 +141,156 @@ class FromWuttaFarm(FromWutta): 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 """ source_model_class = model.AnimalAsset - supported_fields = [ - "uuid", - "asset_name", - "animal_type_uuid", - "sex", - "is_sterile", - "produces_eggs", - "birthdate", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "animal_type_uuid", + "sex", + "is_sterile", + "produces_eggs", + "birthdate", + ] + ) + return fields 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 { - "uuid": animal.farmos_uuid or self.app.make_true_uuid(), - "asset_name": animal.asset_name, - "animal_type_uuid": animal.animal_type.farmos_uuid, - "sex": animal.sex, - "is_sterile": animal.is_sterile, - "produces_eggs": animal.produces_eggs, - "birthdate": animal.birthdate, - "notes": animal.notes, - "archived": animal.archived, - "_src_object": animal, + "uuid": term.farmos_uuid or self.app.make_true_uuid(), + "name": term.name, + "description": term.description, + "_src_object": term, } -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 """ 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): - return { - "uuid": animal_type.farmos_uuid or self.app.make_true_uuid(), - "name": animal_type.name, - "_src_object": animal_type, - } + source_model_class = model.MaterialType 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 """ source_model_class = model.GroupAsset - supported_fields = [ - "uuid", - "asset_name", - "produces_eggs", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "produces_eggs", + ] + ) + return fields def normalize_source_object(self, group): - return { - "uuid": group.farmos_uuid or self.app.make_true_uuid(), - "asset_name": group.asset_name, - "produces_eggs": group.produces_eggs, - "notes": group.notes, - "archived": group.archived, - "_src_object": group, - } + data = super().normalize_source_object(group) + data.update( + { + "produces_eggs": group.produces_eggs, + } + ) + return data -class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter): +class LandAssetImporter(FromWuttaFarmAsset, farmos_importing.model.LandAssetImporter): """ WuttaFarm → farmOS API exporter for Land Assets """ source_model_class = model.LandAsset - supported_fields = [ - "uuid", - "asset_name", - "land_type_id", - "is_location", - "is_fixed", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "land_type_id", + ] + ) + return fields def normalize_source_object(self, land): - return { - "uuid": land.farmos_uuid or self.app.make_true_uuid(), - "asset_name": land.asset_name, - "land_type_id": land.land_type.drupal_id, - "is_location": land.is_location, - "is_fixed": land.is_fixed, - "notes": land.notes, - "archived": land.archived, - "_src_object": land, - } + data = super().normalize_source_object(land) + data.update( + { + "land_type_id": land.land_type.drupal_id, + } + ) + return data 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 """ source_model_class = model.PlantAsset - supported_fields = [ - "uuid", - "asset_name", - "plant_type_uuids", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "plant_type_uuids", + "season_uuids", + ] + ) + return fields def normalize_source_object(self, plant): - return { - "uuid": plant.farmos_uuid or self.app.make_true_uuid(), - "asset_name": plant.asset_name, - "plant_type_uuids": [t.plant_type.farmos_uuid for t in plant._plant_types], - "notes": plant.notes, - "archived": plant.archived, - "_src_object": plant, - } + data = super().normalize_source_object(plant) + data.update( + { + "plant_type_uuids": [pt.farmos_uuid for pt in plant.plant_types], + "season_uuids": [s.farmos_uuid for s in plant.seasons], + } + ) + return data class StructureAssetImporter( - FromWuttaFarm, farmos_importing.model.StructureAssetImporter + FromWuttaFarmAsset, farmos_importing.model.StructureAssetImporter ): """ WuttaFarm → farmOS API exporter for Structure Assets @@ -325,27 +448,31 @@ class StructureAssetImporter( source_model_class = model.StructureAsset - supported_fields = [ - "uuid", - "asset_name", - "structure_type_id", - "is_location", - "is_fixed", - "notes", - "archived", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "structure_type_id", + ] + ) + return fields def normalize_source_object(self, structure): - return { - "uuid": structure.farmos_uuid or self.app.make_true_uuid(), - "asset_name": structure.asset_name, - "structure_type_id": structure.structure_type.drupal_id, - "is_location": structure.is_location, - "is_fixed": structure.is_fixed, - "notes": structure.notes, - "archived": structure.archived, - "_src_object": structure, - } + data = super().normalize_source_object(structure) + data.update( + { + "structure_type_id": structure.structure_type.drupal_id, + } + ) + return data + + +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( FromWuttaFarmQuantity, farmos_importing.model.StandardQuantityImporter ): @@ -411,6 +556,8 @@ class FromWuttaFarmLog(FromWuttaFarm): "notes", "quick", "assets", + "locations", + "groups", "quantities", ] @@ -425,6 +572,8 @@ class FromWuttaFarmLog(FromWuttaFarm): "notes": log.notes, "quick": self.config.parse_list(log.quick) if log.quick else [], "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], "_src_object": log, } @@ -480,3 +629,22 @@ class ObservationLogImporter( """ 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 diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 6b21090..4f5a47a 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -106,20 +106,27 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["LandAsset"] = LandAssetImporter importers["StructureType"] = StructureTypeImporter importers["StructureAsset"] = StructureAssetImporter + importers["WaterAsset"] = WaterAssetImporter + importers["EquipmentType"] = EquipmentTypeImporter + importers["EquipmentAsset"] = EquipmentAssetImporter importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter importers["PlantType"] = PlantTypeImporter + importers["Season"] = SeasonImporter importers["PlantAsset"] = PlantAssetImporter importers["Measure"] = MeasureImporter importers["Unit"] = UnitImporter + importers["MaterialType"] = MaterialTypeImporter importers["QuantityType"] = QuantityTypeImporter importers["StandardQuantity"] = StandardQuantityImporter + importers["MaterialQuantity"] = MaterialQuantityImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter importers["ObservationLog"] = ObservationLogImporter + importers["SeedingLog"] = SeedingLogImporter return importers @@ -149,6 +156,8 @@ class FromFarmOS(Importer): :returns: Equivalent naive UTC ``datetime`` """ + if not dt: + return None dt = datetime.datetime.fromisoformat(dt) return self.app.make_utc(dt) @@ -330,21 +339,20 @@ class AnimalAssetImporter(AssetImporterBase): model_class = model.AnimalAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "animal_type_uuid", - "sex", - "is_sterile", - "produces_eggs", - "birthdate", - "notes", - "archived", - "image_url", - "thumbnail_url", - ] + animal_types_by_farmos_uuid = None + + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "animal_type_uuid", + "sex", + "is_sterile", + "produces_eggs", + "birthdate", + ] + ) + return fields def setup(self): super().setup() @@ -355,6 +363,17 @@ class AnimalAssetImporter(AssetImporterBase): if animal_type.farmos_uuid: 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): """ """ animal_type_uuid = None @@ -362,7 +381,7 @@ class AnimalAssetImporter(AssetImporterBase): if animal_type := relationships.get("animal_type"): 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"]) ): animal_type_uuid = wf_animal_type.uuid @@ -399,12 +418,12 @@ class AnimalAssetImporter(AssetImporterBase): 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 = [ "farmos_uuid", @@ -415,19 +434,50 @@ class AnimalTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type") - return animal_types["data"] + return list( + 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 { - "farmos_uuid": UUID(animal_type["id"]), - "drupal_id": animal_type["attributes"]["drupal_internal__tid"], - "name": animal_type["attributes"]["name"], - "description": animal_type["attributes"]["description"], + "farmos_uuid": UUID(term["id"]), + "drupal_id": term["attributes"]["drupal_internal__tid"], + "name": term["attributes"]["name"], + "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): """ farmOS API → WuttaFarm importer for Asset Types @@ -444,8 +494,7 @@ class AssetTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - asset_types = self.farmos_client.resource.get("asset_type") - return asset_types["data"] + return list(self.farmos_client.resource.iterate("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): """ farmOS API → WuttaFarm importer for Group Assets @@ -464,20 +631,14 @@ class GroupAssetImporter(AssetImporterBase): model_class = model.GroupAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "is_location", - "is_fixed", - "produces_eggs", - "notes", - "archived", - "image_url", - "thumbnail_url", - "parents", - ] + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "produces_eggs", + ] + ) + return fields def normalize_source_object(self, group): """ """ @@ -497,18 +658,16 @@ class LandAssetImporter(AssetImporterBase): model_class = model.LandAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "land_type_uuid", - "is_location", - "is_fixed", - "notes", - "archived", - "parents", - ] + land_types_by_id = None + + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "land_type_uuid", + ] + ) + return fields def setup(self): """ """ @@ -519,10 +678,21 @@ class LandAssetImporter(AssetImporterBase): for land_type in self.target_session.query(model.LandType): 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): """ """ 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: log.warning( "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): """ """ - land_types = self.farmos_client.resource.get("land_type") - return land_types["data"] + return list(self.farmos_client.resource.iterate("land_type")) def normalize_source_object(self, land_type): """ """ @@ -581,8 +750,7 @@ class PlantTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - result = self.farmos_client.resource.get("taxonomy_term", "plant_type") - return result["data"] + return list(self.farmos_client.resource.iterate("taxonomy_term", "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): """ farmOS API → WuttaFarm importer for Plant Assets @@ -601,17 +797,18 @@ class PlantAssetImporter(AssetImporterBase): model_class = model.PlantAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "plant_types", - "notes", - "archived", - "image_url", - "thumbnail_url", - ] + plant_types_by_farmos_uuid = None + seasons_by_farmos_uuid = None + + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "plant_types", + "seasons", + ] + ) + return fields def setup(self): super().setup() @@ -622,25 +819,61 @@ class PlantAssetImporter(AssetImporterBase): if plant_type.farmos_uuid: 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): """ """ + data = super().normalize_source_object(plant) + plant_types = [] + seasons = [] if relationships := plant.get("relationships"): if plant_type := relationships.get("plant_type"): plant_types = [] 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"]) ): plant_types.append(wf_plant_type.uuid) else: 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( { "plant_types": set(plant_types), + "seasons": set(seasons), } ) return data @@ -651,6 +884,9 @@ class PlantAssetImporter(AssetImporterBase): if "plant_types" in self.fields: 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 def update_target_object(self, plant, source_data, target_data=None): @@ -683,6 +919,25 @@ class PlantAssetImporter(AssetImporterBase): ) 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 @@ -693,20 +948,16 @@ class StructureAssetImporter(AssetImporterBase): model_class = model.StructureAsset - supported_fields = [ - "farmos_uuid", - "drupal_id", - "asset_type", - "asset_name", - "structure_type_uuid", - "is_location", - "is_fixed", - "notes", - "archived", - "image_url", - "thumbnail_url", - "parents", - ] + structure_types_by_id = None + + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "structure_type_uuid", + ] + ) + return fields def setup(self): super().setup() @@ -716,10 +967,21 @@ class StructureAssetImporter(AssetImporterBase): for structure_type in self.target_session.query(model.StructureType): 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): """ """ 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: log.warning( "invalid structure_type '%s' for farmOS Structure Asset: %s", @@ -752,8 +1014,7 @@ class StructureTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - structure_types = self.farmos_client.resource.get("structure_type") - return structure_types["data"] + return list(self.farmos_client.resource.iterate("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): """ farmOS API → WuttaFarm importer for Users @@ -791,8 +1060,7 @@ class UserImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - users = self.farmos_client.resource.get("user") - return users["data"] + return list(self.farmos_client.resource.iterate("user")) def normalize_source_object(self, user): """ """ @@ -833,6 +1101,7 @@ class MeasureImporter(FromFarmOS, ToWutta): supported_fields = [ "drupal_id", + "ordinal", "name", ] @@ -843,12 +1112,15 @@ class MeasureImporter(FromFarmOS, ToWutta): ) response.raise_for_status() data = response.json() + self.ordinal = 0 return data["definitions"]["attributes"]["properties"]["measure"]["oneOf"] def normalize_source_object(self, measure): """ """ + self.ordinal += 1 return { "drupal_id": measure["const"], + "ordinal": self.ordinal, "name": measure["title"], } @@ -869,8 +1141,7 @@ class UnitImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - result = self.farmos_client.resource.get("taxonomy_term", "unit") - return result["data"] + return list(self.farmos_client.resource.iterate("taxonomy_term", "unit")) def normalize_source_object(self, unit): """ """ @@ -898,8 +1169,7 @@ class QuantityTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - result = self.farmos_client.resource.get("quantity_type") - return result["data"] + return list(self.farmos_client.resource.iterate("quantity_type")) def normalize_source_object(self, quantity_type): """ """ @@ -927,8 +1197,7 @@ class LogTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - log_types = self.farmos_client.resource.get("log_type") - return log_types["data"] + return list(self.farmos_client.resource.iterate("log_type")) def normalize_source_object(self, log_type): """ """ @@ -1226,6 +1495,41 @@ class ObservationLogImporter(LogImporterBase): 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): """ Base class for farmOS API → WuttaFarm quantity importers @@ -1271,8 +1575,7 @@ class QuantityImporterBase(FromFarmOS, ToWutta): def get_source_objects(self): """ """ quantity_type = self.get_farmos_quantity_type() - result = self.farmos_client.resource.get("quantity", quantity_type) - return result["data"] + return list(self.farmos_client.resource.iterate("quantity", quantity_type)) def get_quantity_type_by_farmos_uuid(self, uuid): if hasattr(self, "quantity_types_by_farmos_uuid"): @@ -1355,3 +1658,76 @@ class StandardQuantityImporter(QuantityImporterBase): "units_uuid", "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 diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index 4fc8796..c47fcc3 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -84,16 +84,41 @@ class Normalizer(GenericHandler): self._farmos_units = 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={}): """ """ if notes := asset["attributes"]["notes"]: notes = notes["value"] + parent_objects = [] + parent_uuids = [] owner_objects = [] owner_uuids = [] 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"): for user in owners["data"]: 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 { "uuid": asset["id"], "drupal_id": asset["attributes"]["drupal_internal__id"], @@ -114,6 +144,10 @@ class Normalizer(GenericHandler): "is_fixed": asset["attributes"]["is_fixed"], "archived": asset["attributes"]["archived"], "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, "owner_uuids": owner_uuids, } @@ -121,8 +155,7 @@ class Normalizer(GenericHandler): def normalize_farmos_log(self, log, included={}): if timestamp := log["attributes"]["timestamp"]: - timestamp = datetime.datetime.fromisoformat(timestamp) - timestamp = self.app.localtime(timestamp) + timestamp = self.normalize_datetime(timestamp) if notes := log["attributes"]["notes"]: notes = notes["value"] @@ -232,27 +265,29 @@ class Normalizer(GenericHandler): measure_id = attrs["measure"] - quantity_objects.append( - { - "uuid": quantity["id"], - "drupal_id": attrs["drupal_internal__id"], - "quantity_type_uuid": rels["quantity_type"]["data"][ - "id" - ], - "quantity_type_id": rels["quantity_type"]["data"][ - "meta" - ]["drupal_internal__target_id"], - "measure_id": measure_id, - "measure_name": self.get_farmos_measure_name( - measure_id - ), - "value_numerator": value["numerator"], - "value_decimal": value["decimal"], - "value_denominator": value["denominator"], - "unit_uuid": unit_uuid, - "unit_name": unit["attributes"]["name"], - } - ) + quantity_object = { + "uuid": quantity["id"], + "drupal_id": attrs["drupal_internal__id"], + "quantity_type_uuid": rels["quantity_type"]["data"]["id"], + "quantity_type_id": rels["quantity_type"]["data"]["meta"][ + "drupal_internal__target_id" + ], + "measure_id": measure_id, + "measure_name": self.get_farmos_measure_name(measure_id), + "value_numerator": value["numerator"], + "value_decimal": value["decimal"], + "value_denominator": value["denominator"], + "unit_uuid": unit_uuid, + "unit_name": unit["attributes"]["name"], + } + if quantity_object["quantity_type_id"] == "material": + quantity_object["material_types"] = [ + {"uuid": mtype["id"]} + for mtype in quantity["relationships"]["material_type"][ + "data" + ] + ] + quantity_objects.append(quantity_object) if owners := relationships.get("owner"): for user in owners["data"]: diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 6bf434e..e24f0cf 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -28,7 +28,7 @@ import json import colander 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 @@ -164,10 +164,9 @@ class FarmOSRefs(WuttaSet): self.route_prefix = route_prefix def serialize(self, node, appstruct): - if appstruct is colander.null: + if not appstruct: return colander.null - - return json.dumps(appstruct) + return appstruct def widget_maker(self, **kwargs): from wuttafarm.web.forms.widgets import FarmOSRefsWidget @@ -217,6 +216,35 @@ class FarmOSQuantityRefs(WuttaSet): 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): 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) +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): """ Schema type for Plant Types field (on a Plant Asset). @@ -288,6 +345,62 @@ class PlantTypeRefs(WuttaSet): 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): def __init__(self, request, *args, **kwargs): @@ -372,55 +485,100 @@ class UsersType(colander.SchemaType): 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): """ 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): if not appstruct: return colander.null - return {asset.uuid for asset in appstruct} + return {asset.uuid.hex for asset in appstruct} def widget_maker(self, **kwargs): 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) -class LogQuantityRefs(WuttaSet): +class QuantityRefs(colander.List): """ 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): if not appstruct: 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): - 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): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 0a14638..db79eae 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -33,6 +33,7 @@ from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget from wuttaweb.db import Session from wuttafarm.web.util import render_quantity_objects +from wuttafarm.db.model import EquipmentType class ImageWidget(Widget): @@ -124,7 +125,7 @@ class FarmOSRefsWidget(Widget): return HTML.tag("span") links = [] - for obj in json.loads(cstruct): + for obj in cstruct: url = self.request.route_url( f"{self.route_prefix}.view", uuid=obj["uuid"] ) @@ -228,6 +229,38 @@ class FarmOSUnitRefWidget(Widget): 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): """ Widget to display a farmOS "plant types" field. @@ -258,6 +291,88 @@ class FarmOSPlantTypesWidget(Widget): 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): """ Widget for Plant Types field (on a Plant Asset). @@ -332,6 +447,144 @@ class PlantTypeRefsWidget(Widget): 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): """ Widget to display a "structure" field. @@ -393,42 +646,20 @@ class UsersWidget(Widget): ############################## -class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): - """ - 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): +class AssetRefsWidget(Widget): """ 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): """ """ model = self.app.model @@ -452,14 +683,43 @@ class AssetRefsWidget(WuttaCheckboxChoiceWidget): ) 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) """ + 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): """ """ model = self.app.model @@ -467,24 +727,78 @@ class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): readonly = kw.get("readonly", self.readonly) if readonly: + if not cstruct: + return "" + quantities = [] - for uuid in cstruct or []: - qty = session.get(model.Quantity, uuid) - quantities.append( - HTML.tag( - "li", - c=tags.link_to( - qty.render_as_text(self.config), - # TODO - self.request.route_url( - "quantities_standard.view", uuid=qty.uuid - ), - ), - ) + for qty in cstruct: + url = self.request.route_url( + f"quantities_{qty['quantity_type']['drupal_id']}.view", + uuid=qty["uuid"], ) + quantities.append(HTML.tag("li", c=tags.link_to(qty["as_text"], url))) + 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): diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index fe7719e..2756738 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -92,6 +92,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "animal_assets", "perm": "animal_assets.list", }, + { + "title": "Equipment", + "route": "equipment_assets", + "perm": "equipment_assets.list", + }, { "title": "Group", "route": "group_assets", @@ -112,12 +117,22 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "structure_assets", "perm": "structure_assets.list", }, + { + "title": "Water", + "route": "water_assets", + "perm": "water_assets.list", + }, {"type": "sep"}, { "title": "Animal Types", "route": "animal_types", "perm": "animal_types.list", }, + { + "title": "Equipment Types", + "route": "equipment_types", + "perm": "equipment_types.list", + }, { "title": "Land Types", "route": "land_types", @@ -128,6 +143,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "plant_types", "perm": "plant_types.list", }, + { + "title": "Seasons", + "route": "seasons", + "perm": "seasons.list", + }, { "title": "Structure Types", "route": "structure_types", @@ -171,12 +191,22 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "logs_observation", "perm": "logs_observation.list", }, + { + "title": "Seeding", + "route": "logs_seeding", + "perm": "logs_seeding.list", + }, {"type": "sep"}, { "title": "All Quantities", "route": "quantities", "perm": "quantities.list", }, + { + "title": "Material Quantities", + "route": "quantities_material", + "perm": "quantities_material.list", + }, { "title": "Standard Quantities", "route": "quantities_standard", @@ -188,6 +218,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "log_types", "perm": "log_types.list", }, + { + "title": "Material Types", + "route": "material_types", + "perm": "material_types.list", + }, { "title": "Measures", "route": "measures", @@ -224,6 +259,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animal_assets", "perm": "farmos_animal_assets.list", }, + { + "title": "Equipment Assets", + "route": "farmos_equipment_assets", + "perm": "farmos_equipment_assets.list", + }, { "title": "Group Assets", "route": "farmos_group_assets", @@ -244,6 +284,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_structure_assets", "perm": "farmos_structure_assets.list", }, + { + "title": "Water Assets", + "route": "farmos_water_assets", + "perm": "farmos_water_assets.list", + }, {"type": "sep"}, { "title": "Activity Logs", @@ -265,12 +310,22 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_logs_observation", "perm": "farmos_logs_observation.list", }, + { + "title": "Seeding Logs", + "route": "farmos_logs_seeding", + "perm": "farmos_logs_seeding.list", + }, {"type": "sep"}, { "title": "Animal Types", "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Equipment Types", + "route": "farmos_equipment_types", + "perm": "farmos_equipment_types.list", + }, { "title": "Land Types", "route": "farmos_land_types", @@ -281,6 +336,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_plant_types", "perm": "farmos_plant_types.list", }, + { + "title": "Seasons", + "route": "farmos_seasons", + "perm": "farmos_seasons.list", + }, { "title": "Structure Types", "route": "farmos_structure_types", @@ -297,11 +357,21 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_log_types", "perm": "farmos_log_types.list", }, + { + "title": "Material Types", + "route": "farmos_material_types", + "perm": "farmos_material_types.list", + }, { "title": "Quantity Types", "route": "farmos_quantity_types", "perm": "farmos_quantity_types.list", }, + { + "title": "Material Quantities", + "route": "farmos_quantities_material", + "perm": "farmos_quantities_material.list", + }, { "title": "Standard Quantities", "route": "farmos_quantities_standard", @@ -333,6 +403,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animal_assets", "perm": "farmos_animal_assets.list", }, + { + "title": "Equipment", + "route": "farmos_equipment_assets", + "perm": "farmos_equipment_assets.list", + }, { "title": "Group", "route": "farmos_group_assets", @@ -353,12 +428,22 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_structure_assets", "perm": "farmos_structure_assets.list", }, + { + "title": "Water", + "route": "farmos_water_assets", + "perm": "farmos_water_assets.list", + }, {"type": "sep"}, { "title": "Animal Types", "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Equipment Types", + "route": "farmos_equipment_types", + "perm": "farmos_equipment_types.list", + }, { "title": "Land Types", "route": "farmos_land_types", @@ -369,6 +454,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_plant_types", "perm": "farmos_plant_types.list", }, + { + "title": "Seasons", + "route": "farmos_seasons", + "perm": "farmos_seasons.list", + }, { "title": "Structure Types", "route": "farmos_structure_types", @@ -410,17 +500,32 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_logs_observation", "perm": "farmos_logs_observation.list", }, + { + "title": "Seeding", + "route": "farmos_logs_seeding", + "perm": "farmos_logs_seeding.list", + }, {"type": "sep"}, { "title": "Log Types", "route": "farmos_log_types", "perm": "farmos_log_types.list", }, + { + "title": "Material Types", + "route": "farmos_material_types", + "perm": "farmos_material_types.list", + }, { "title": "Quantity Types", "route": "farmos_quantity_types", "perm": "farmos_quantity_types.list", }, + { + "title": "Material Quantities", + "route": "farmos_quantities_material", + "perm": "farmos_quantities_material.list", + }, { "title": "Standard Quantities", "route": "farmos_quantities_standard", diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako index 912eef0..26d6a54 100644 --- a/src/wuttafarm/web/templates/appinfo/configure.mako +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -57,6 +57,11 @@ + + + + % endif - ${parent.page_content()} +
+ + ## main form +
+ ${parent.page_content()} +
+ + ## location map + % if map_polygon: +
+ % endif + +
+ + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if map_polygon: + + % endif diff --git a/src/wuttafarm/web/templates/base.mako b/src/wuttafarm/web/templates/base.mako index b28b52f..caa5c67 100644 --- a/src/wuttafarm/web/templates/base.mako +++ b/src/wuttafarm/web/templates/base.mako @@ -1,6 +1,16 @@ <%inherit file="wuttaweb:templates/base.mako" /> <%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. + + + + + <%def name="index_title_controls()"> ${parent.index_title_controls()} diff --git a/src/wuttafarm/web/templates/deform/assetrefs.pt b/src/wuttafarm/web/templates/deform/assetrefs.pt new file mode 100644 index 0000000..b2e9660 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/assetrefs.pt @@ -0,0 +1,11 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/deform/equipmenttyperefs.pt b/src/wuttafarm/web/templates/deform/equipmenttyperefs.pt new file mode 100644 index 0000000..4d48fd7 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/equipmenttyperefs.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/deform/materialtyperefs.pt b/src/wuttafarm/web/templates/deform/materialtyperefs.pt new file mode 100644 index 0000000..44ac6e8 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/materialtyperefs.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/deform/quantityrefs.pt b/src/wuttafarm/web/templates/deform/quantityrefs.pt new file mode 100644 index 0000000..cc65f77 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/quantityrefs.pt @@ -0,0 +1,14 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/deform/seasonrefs.pt b/src/wuttafarm/web/templates/deform/seasonrefs.pt new file mode 100644 index 0000000..955241a --- /dev/null +++ b/src/wuttafarm/web/templates/deform/seasonrefs.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako index 37b176e..890568f 100644 --- a/src/wuttafarm/web/templates/wuttafarm-components.mako +++ b/src/wuttafarm/web/templates/wuttafarm-components.mako @@ -1,7 +1,330 @@ <%def name="make_wuttafarm_components()"> + ${self.make_taxonomy_terms_picker_component()} + ${self.make_equipment_types_picker_component()} + ${self.make_assets_picker_component()} ${self.make_animal_type_picker_component()} + ${self.make_material_types_picker_component()} + ${self.make_quantity_editor_component()} + ${self.make_quantities_editor_component()} ${self.make_plant_types_picker_component()} + ${self.make_seasons_picker_component()} + + +<%def name="make_taxonomy_terms_picker_component()"> + + + + +<%def name="make_equipment_types_picker_component()"> + + + + +<%def name="make_assets_picker_component()"> + + <%def name="make_animal_type_picker_component()"> @@ -108,7 +431,10 @@ createSave() { this.createSaving = true + ## TODO + % if not app.is_farmos_wrapper(): const url = "${url('animal_types.ajax_create')}" + % endif const params = {name: this.createName} this.wuttaPOST(url, params, response => { this.internalAnimalTypes.push([response.data.uuid, response.data.name]) @@ -128,6 +454,503 @@ +<%def name="make_material_types_picker_component()"> + + + + +<%def name="make_quantity_editor_component()"> + + + + +<%def name="make_quantities_editor_component()"> + + + + <%def name="make_plant_types_picker_component()"> + +<%def name="make_seasons_picker_component()"> + + + diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py index 977550a..ec88525 100644 --- a/src/wuttafarm/web/util.py +++ b/src/wuttafarm/web/util.py @@ -78,4 +78,12 @@ def render_quantity_object(quantity): measure = quantity["measure_name"] value = quantity["value_decimal"] 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 diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 0d58a72..b663cf5 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -25,7 +25,7 @@ WuttaFarm Views from wuttaweb.views import essential -from .master import WuttaFarmMasterView +from .master import WuttaFarmMasterView, TaxonomyMasterView def includeme(config): @@ -48,19 +48,23 @@ def includeme(config): # native table views if mode != enum.FARMOS_INTEGRATION_MODE_WRAPPER: config.include("wuttafarm.web.views.units") + config.include("wuttafarm.web.views.material_types") config.include("wuttafarm.web.views.quantities") config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.assets") config.include("wuttafarm.web.views.land") config.include("wuttafarm.web.views.structures") + config.include("wuttafarm.web.views.equipment") config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.groups") config.include("wuttafarm.web.views.plants") + config.include("wuttafarm.web.views.water") config.include("wuttafarm.web.views.logs") config.include("wuttafarm.web.views.logs_activity") config.include("wuttafarm.web.views.logs_harvest") config.include("wuttafarm.web.views.logs_medical") config.include("wuttafarm.web.views.logs_observation") + config.include("wuttafarm.web.views.logs_seeding") # quick form views # (nb. these work with all integration modes) @@ -69,3 +73,7 @@ def includeme(config): # views for farmOS if mode != enum.FARMOS_INTEGRATION_MODE_NONE: 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") diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index f4c97e2..ad9f060 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -23,9 +23,12 @@ Master view for Animals """ +from collections import OrderedDict + from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum +from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttaweb.util import get_form_data from wuttafarm.db.model import AnimalType, AnimalAsset @@ -234,27 +237,6 @@ class AnimalAssetView(AssetMasterView): "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): g = grid super().configure_grid(g) @@ -288,15 +270,32 @@ class AnimalAssetView(AssetMasterView): animal = f.model_instance # animal_type + f.fields.insert_after("asset_name", "animal_type") 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 + f.fields.insert_after("produces_eggs", "sex") if not (self.creating or self.editing) and animal.sex is None: pass # TODO: dict enum widget does not handle null values well 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) + # is_sterile + f.fields.insert_after("sex", "is_sterile") + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b4e4d31..1ada778 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -23,6 +23,7 @@ Master view for Assets """ +import re from collections import OrderedDict from webhelpers2.html import tags @@ -32,7 +33,7 @@ from wuttaweb.db import Session from wuttafarm.web.views import WuttaFarmMasterView 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.util import get_log_type_enum from wuttafarm.web.util import get_farmos_client_for_user @@ -77,6 +78,25 @@ class AssetMasterView(WuttaFarmMasterView): "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 row_model_class = Log rows_viewable = True @@ -261,11 +281,11 @@ class AssetMasterView(WuttaFarmMasterView): f.set_default("groups", asset_handler.get_groups(asset)) # parents - if self.creating or self.editing: - f.remove("parents") # TODO: add support for this - else: - f.set_node("parents", AssetParentRefs(self.request)) - f.set_default("parents", [p.uuid for p in asset.parents]) + f.set_node("parents", AssetRefs(self.request, for_asset=asset)) + f.set_required("parents", False) + if not self.creating: + # nb. must explicity declare value for non-standard field + f.set_default("parents", asset.parents) # notes f.set_widget("notes", "notes") @@ -293,11 +313,29 @@ class AssetMasterView(WuttaFarmMasterView): f.set_default("image", asset.image_url) def objectify(self, form): + model = self.app.model + session = self.Session() asset = super().objectify(form) + data = form.validated if self.creating: 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 def get_asset_type(self): @@ -322,6 +360,38 @@ class AssetMasterView(WuttaFarmMasterView): 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[^\)]+)\)\)$", 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): """ 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_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): return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 674d76e..445f810 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -95,6 +95,8 @@ class CommonView(base.CommonView): "farmos_quantities_standard.view", "farmos_quantity_types.list", "farmos_quantity_types.view", + "farmos_seasons.list", + "farmos_seasons.view", "farmos_structure_assets.list", "farmos_structure_assets.view", "farmos_structure_types.list", @@ -132,6 +134,12 @@ class CommonView(base.CommonView): "logs_observation.view", "logs_observation.versions", "quick.eggs", + "plant_types.list", + "plant_types.view", + "plant_types.versions", + "seasons.list", + "seasons.view", + "seasons.versions", "structure_types.list", "structure_types.view", "structure_types.versions", diff --git a/src/wuttafarm/web/views/equipment.py b/src/wuttafarm/web/views/equipment.py new file mode 100644 index 0000000..8076556 --- /dev/null +++ b/src/wuttafarm/web/views/equipment.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index e59ac1f..708b553 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,6 +28,7 @@ from .master import FarmOSMasterView def includeme(config): 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.asset_types") 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.structure_types") 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.animals") config.include("wuttafarm.web.views.farmos.groups") 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.logs_activity") config.include("wuttafarm.web.views.farmos.logs_harvest") config.include("wuttafarm.web.views.farmos.logs_medical") config.include("wuttafarm.web.views.farmos.logs_observation") + config.include("wuttafarm.web.views.farmos.logs_seeding") diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index c99cc5a..6a6bc92 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -39,7 +39,7 @@ from wuttafarm.web.grids import ( NullableBooleanFilter, DateTimeFilter, ) -from wuttafarm.web.forms.schema import FarmOSRef, FarmOSAssetRefs +from wuttafarm.web.forms.schema import FarmOSRef class AnimalView(AssetMasterView): @@ -99,8 +99,7 @@ class AnimalView(AssetMasterView): def get_farmos_api_includes(self): includes = super().get_farmos_api_includes() - includes.add("animal_type") - includes.add("group") + includes.update(["animal_type"]) return includes def configure_grid(self, grid): @@ -131,10 +130,6 @@ class AnimalView(AssetMasterView): g.set_sorter("sex", SimpleSorter("sex")) g.set_filter("sex", StringFilter) - # groups - g.set_label("groups", "Group Membership") - g.set_renderer("groups", self.render_groups_for_grid) - # is_sterile g.set_renderer("is_sterile", "boolean") g.set_sorter("is_sterile", SimpleSorter("is_sterile")) @@ -145,18 +140,6 @@ class AnimalView(AssetMasterView): url = self.request.route_url("farmos_animal_types.view", uuid=uuid) return tags.link_to(value, url) - def render_groups_for_grid(self, animal, field, value): - groups = [] - for group in animal["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): data = super().get_instance() @@ -192,8 +175,6 @@ class AnimalView(AssetMasterView): sterile = animal["attributes"]["is_castrated"] animal_type_object = None - group_objects = [] - group_names = [] if relationships := animal.get("relationships"): if animal_type := relationships.get("animal_type"): @@ -203,24 +184,11 @@ class AnimalView(AssetMasterView): "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( { "animal_type": animal_type_object, "animal_type_uuid": animal_type_object["uuid"], "animal_type_name": animal_type_object["name"], - "groups": group_objects, - "group_names": group_names, "birthdate": birthdate, "sex": animal["attributes"]["sex"] or colander.null, "is_sterile": sterile, @@ -271,12 +239,6 @@ class AnimalView(AssetMasterView): # is_sterile 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): payload = super().get_api_payload(animal) diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 11f744b..24dd145 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -24,10 +24,11 @@ Base class for Asset master views """ import colander +import requests from webhelpers2.html import tags 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.grids import ( ResourceData, @@ -75,6 +76,23 @@ class AssetMasterView(FarmOSMasterView): "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): return ResourceData( self.config, @@ -110,6 +128,10 @@ class AssetMasterView(FarmOSMasterView): # locations 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 g.set_renderer("archived", "boolean") 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 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): locations = [] for location in value: @@ -139,14 +175,19 @@ class AssetMasterView(FarmOSMasterView): return None def get_farmos_api_includes(self): - return {"asset_type", "location", "owner", "image"} + return {"asset_type", "location", "group", "owner", "image"} def get_instance(self): - result = self.farmos_client.asset.get_id( - self.farmos_asset_type, - self.request.matchdict["uuid"], - params={"include": ",".join(self.get_farmos_api_includes())}, - ) + try: + result = self.farmos_client.asset.get_id( + self.farmos_asset_type, + 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 included = {obj["id"]: obj for obj in result.get("included", [])} return self.normalize_asset(result["data"], included) @@ -170,6 +211,7 @@ class AssetMasterView(FarmOSMasterView): owner_names = [] location_objects = [] location_names = [] + group_objects = [] thumbnail_url = None image_url = None if relationships := asset.get("relationships"): @@ -203,6 +245,16 @@ class AssetMasterView(FarmOSMasterView): location_objects.append(location) 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"): for image in images["data"]: if image := included.get(image["id"]): @@ -217,11 +269,14 @@ class AssetMasterView(FarmOSMasterView): "name": asset["attributes"]["name"], "asset_type": asset_type_object, "asset_type_name": asset_type_name, + "is_location": asset["attributes"]["is_location"], + "is_fixed": asset["attributes"]["is_fixed"], "notes": notes or colander.null, "owners": owner_objects, "owner_names": owner_names, "locations": location_objects, "location_names": location_names, + "groups": group_objects, "archived": archived, "thumbnail_url": thumbnail_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_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 if self.creating or self.editing: f.remove("owners") # TODO @@ -253,6 +314,16 @@ class AssetMasterView(FarmOSMasterView): f.set_widget("notes", "notes") 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 f.set_node("archived", colander.Boolean()) diff --git a/src/wuttafarm/web/views/farmos/equipment.py b/src/wuttafarm/web/views/farmos/equipment.py new file mode 100644 index 0000000..6855747 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/equipment.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index cb7a87b..74fb941 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -45,7 +45,7 @@ from wuttafarm.web.forms.schema import ( LogQuick, Notes, ) -from wuttafarm.web.util import render_quantity_objects +from wuttafarm.web.util import render_quantity_objects, render_quantity_object class LogMasterView(FarmOSMasterView): @@ -199,7 +199,20 @@ class LogMasterView(FarmOSMasterView): ) self.raw_json = result 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): return log["name"] diff --git a/src/wuttafarm/web/views/farmos/logs_seeding.py b/src/wuttafarm/web/views/farmos/logs_seeding.py new file mode 100644 index 0000000..ed967cc --- /dev/null +++ b/src/wuttafarm/web/views/farmos/logs_seeding.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/web/views/farmos/materials.py b/src/wuttafarm/web/views/farmos/materials.py new file mode 100644 index 0000000..e56557d --- /dev/null +++ b/src/wuttafarm/web/views/farmos/materials.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py index 57bf2d4..40768c4 100644 --- a/src/wuttafarm/web/views/farmos/plants.py +++ b/src/wuttafarm/web/views/farmos/plants.py @@ -32,7 +32,12 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos.master import TaxonomyMasterView from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType, StructureType, FarmOSPlantTypes +from wuttafarm.web.forms.schema import ( + UsersType, + StructureType, + FarmOSPlantTypes, + FarmOSRefs, +) from wuttafarm.web.forms.widgets import ImageWidget @@ -75,6 +80,43 @@ class PlantTypeView(TaxonomyMasterView): 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): """ Master view for farmOS Plant Assets @@ -89,6 +131,10 @@ class PlantAssetView(FarmOSMasterView): farmos_refurl_path = "/assets/plant" + labels = { + "seasons": "Season", + } + grid_columns = [ "name", "archived", @@ -99,6 +145,7 @@ class PlantAssetView(FarmOSMasterView): form_fields = [ "name", "plant_types", + "seasons", "archived", "owners", "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 if location := relationships.get("location"): if location["data"]: @@ -199,22 +261,14 @@ class PlantAssetView(FarmOSMasterView): return plant["name"] def normalize_plant(self, plant): - - if notes := plant["attributes"]["notes"]: - notes = notes["value"] - - if self.farmos_4x: - archived = plant["attributes"]["archived"] - else: - archived = plant["attributes"]["status"] == "archived" - + normal = self.normal.normalize_farmos_asset(plant) return { - "uuid": plant["id"], - "drupal_id": plant["attributes"]["drupal_internal__id"], - "name": plant["attributes"]["name"], + "uuid": normal["uuid"], + "drupal_id": normal["drupal_id"], + "name": normal["asset_name"], "location": colander.null, # TODO - "archived": archived, - "notes": notes or colander.null, + "archived": normal["archived"], + "notes": normal["notes"] or colander.null, } def configure_form(self, form): @@ -225,6 +279,9 @@ class PlantAssetView(FarmOSMasterView): # plant_types f.set_node("plant_types", FarmOSPlantTypes(self.request)) + # seasons + f.set_node("seasons", FarmOSRefs(self.request, "farmos_seasons")) + # location f.set_node("location", StructureType(self.request)) @@ -279,6 +336,9 @@ def defaults(config, **kwargs): PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) PlantTypeView.defaults(config) + SeasonView = kwargs.get("SeasonView", base["SeasonView"]) + SeasonView.defaults(config) + PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) PlantAssetView.defaults(config) diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py index a388559..bd2e519 100644 --- a/src/wuttafarm/web/views/farmos/quantities.py +++ b/src/wuttafarm/web/views/farmos/quantities.py @@ -26,12 +26,13 @@ View for farmOS Quantity Types import datetime import colander +import requests from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import FarmOSUnitRef +from wuttafarm.web.forms.schema import FarmOSUnitRef, FarmOSRefs from wuttafarm.web.grids import ResourceData @@ -142,6 +143,7 @@ class QuantityMasterView(FarmOSMasterView): sort_defaults = ("drupal_id", "desc") form_fields = [ + "quantity_type_name", "measure", "value", "units", @@ -171,6 +173,7 @@ class QuantityMasterView(FarmOSMasterView): # as_text g.set_renderer("as_text", self.render_as_text_for_grid) + g.set_link("as_text") # measure g.set_renderer("measure", self.render_measure_for_grid) @@ -203,14 +206,26 @@ class QuantityMasterView(FarmOSMasterView): return qty["value"]["decimal"] def get_instance(self): - quantity = self.farmos_client.resource.get_id( - "quantity", self.farmos_quantity_type, self.request.matchdict["uuid"] - ) - self.raw_json = quantity + # TODO: this pattern should be repeated for other views + try: + result = self.farmos_client.resource.get_id( + "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 if units := relationships.get("units"): @@ -278,6 +293,11 @@ class QuantityMasterView(FarmOSMasterView): f = form 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 f.set_node("created", WuttaDateTime(self.request)) f.set_widget("created", WuttaDateTimeWidget(self.request)) @@ -303,6 +323,7 @@ class StandardQuantityView(QuantityMasterView): url_prefix = "/farmOS/quantities/standard" farmos_quantity_type = "standard" + farmos_refurl_path = "/log-quantities/standard" def get_xref_buttons(self, standard_quantity): model = self.app.model @@ -329,6 +350,90 @@ class StandardQuantityView(QuantityMasterView): 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): base = globals() @@ -340,6 +445,11 @@ def defaults(config, **kwargs): ) StandardQuantityView.defaults(config) + MaterialQuantityView = kwargs.get( + "MaterialQuantityView", base["MaterialQuantityView"] + ) + MaterialQuantityView.defaults(config) + def includeme(config): defaults(config) diff --git a/src/wuttafarm/web/views/farmos/water.py b/src/wuttafarm/web/views/farmos/water.py new file mode 100644 index 0000000..129f22e --- /dev/null +++ b/src/wuttafarm/web/views/farmos/water.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 4331280..c8cc7f7 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -47,15 +47,11 @@ class GroupView(AssetMasterView): "archived", ] - form_fields = [ - "asset_name", - "notes", - "asset_type", - "produces_eggs", - "archived", - "drupal_id", - "farmos_uuid", - ] + def configure_form(self, f): + super().configure_form(f) + + # produces_eggs + f.fields.insert_after("asset_type", "produces_eggs") def defaults(config, **kwargs): diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 9c983b7..2a4e6e0 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView 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 @@ -256,26 +256,21 @@ class LogMasterView(WuttaFarmMasterView): f.set_default("timestamp", self.app.make_utc()) # assets - if self.creating or self.editing: - f.remove("assets") # TODO: need to support this - else: - f.set_node("assets", AssetRefs(self.request)) + f.set_node("assets", AssetRefs(self.request)) + f.set_required("assets", False) + if not self.creating: # nb. must explicity declare value for non-standard field f.set_default("assets", log.assets) # groups - if self.creating or self.editing: - f.remove("groups") # TODO: need to support this - else: - f.set_node("groups", AssetRefs(self.request)) + f.set_node("groups", AssetRefs(self.request, is_group=True)) + if not self.creating: # nb. must explicity declare value for non-standard field f.set_default("groups", log.groups) # locations - if self.creating or self.editing: - f.remove("locations") # TODO: need to support this - else: - f.set_node("locations", AssetRefs(self.request)) + f.set_node("locations", AssetRefs(self.request, is_location=True)) + if not self.creating: # nb. must explicity declare value for non-standard field f.set_default("locations", log.locations) @@ -292,12 +287,12 @@ class LogMasterView(WuttaFarmMasterView): f.set_readonly("log_type") # quantities - if self.creating or self.editing: - f.remove("quantities") # TODO: need to support this - else: - f.set_node("quantities", LogQuantityRefs(self.request)) + f.set_node("quantities", QuantityRefs(self.request)) + if not self.creating: # 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 f.set_widget("notes", "notes") @@ -324,13 +319,141 @@ class LogMasterView(WuttaFarmMasterView): def objectify(self, form): log = super().objectify(form) + data = form.validated if self.creating: - model_class = self.get_model_class() + + # 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 + 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): return self.app.get_farmos_url(f"/log/{log.drupal_id}") @@ -368,6 +491,7 @@ class LogMasterView(WuttaFarmMasterView): return super().get_version_joins() + [ model.Log, (model.LogAsset, "log_uuid", "uuid"), + (model.LogQuantity, "log_uuid", "uuid"), ] diff --git a/src/wuttafarm/web/views/logs_seeding.py b/src/wuttafarm/web/views/logs_seeding.py new file mode 100644 index 0000000..8946aff --- /dev/null +++ b/src/wuttafarm/web/views/logs_seeding.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 747cdc5..c828b96 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -23,9 +23,14 @@ Base class for WuttaFarm master views """ +import threading +import time + +import requests from webhelpers2.html import tags 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 @@ -106,13 +111,35 @@ class WuttaFarmMasterView(MasterView): f.set_readonly("drupal_id") def persist(self, obj, session=None): + session = session or self.Session() # save per usual super().persist(obj, session) # maybe also sync change to farmOS if self.app.is_farmos_mirror(): + if self.creating: + session.flush() # need the new uuid 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) def get_farmos_entity_type(self): @@ -141,7 +168,130 @@ class WuttaFarmMasterView(MasterView): # maybe delete from farmOS also if farmos_uuid: - entity_type = self.get_farmos_entity_type() - bundle = self.get_farmos_bundle() 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) + 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", + ) diff --git a/src/wuttafarm/web/views/material_types.py b/src/wuttafarm/web/views/material_types.py new file mode 100644 index 0000000..d2118a7 --- /dev/null +++ b/src/wuttafarm/web/views/material_types.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index a114e07..16bd3c0 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -28,9 +28,9 @@ from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum 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.forms.schema import PlantTypeRefs +from wuttafarm.web.forms.schema import PlantTypeRefs, SeasonRefs from wuttafarm.web.forms.widgets import ImageWidget 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): """ Master view for Plant Assets @@ -209,6 +369,7 @@ class PlantAssetView(AssetMasterView): labels = { "plant_types": "Crop/Variety", + "seasons": "Season", } grid_columns = [ @@ -220,21 +381,6 @@ class PlantAssetView(AssetMasterView): "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): g = grid super().configure_grid(g) @@ -262,23 +408,33 @@ class PlantAssetView(AssetMasterView): plant = f.model_instance # plant_types + f.fields.insert_after("asset_name", "plant_types") f.set_node("plant_types", PlantTypeRefs(self.request)) if not self.creating: # nb. must explcitly declare value for non-standard field f.set_default("plant_types", [pt.uuid for pt in plant.plant_types]) # season - if self.creating or self.editing: - f.remove("season") # TODO: add support for this + f.fields.insert_after("plant_types", "seasons") + 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): - model = self.app.model - session = self.Session() plant = super().objectify(form) 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] - desired = data["plant_types"] for uuid in desired: if uuid not in current: @@ -292,7 +448,22 @@ class PlantAssetView(AssetMasterView): assert 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): @@ -301,6 +472,9 @@ def defaults(config, **kwargs): PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) PlantTypeView.defaults(config) + SeasonView = kwargs.get("SeasonView", base["SeasonView"]) + SeasonView.defaults(config) + PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) PlantAssetView.defaults(config) diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py index d4112cf..9a91941 100644 --- a/src/wuttafarm/web/views/quantities.py +++ b/src/wuttafarm/web/views/quantities.py @@ -25,11 +25,19 @@ Master view for Quantities from collections import OrderedDict +from webhelpers2.html import tags + from wuttaweb.db import Session from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.db.model import QuantityType, Quantity, StandardQuantity -from wuttafarm.web.forms.schema import UnitRef, LogRef +from wuttafarm.db.model import ( + 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): @@ -100,17 +108,28 @@ class QuantityMasterView(WuttaFarmMasterView): Base class for Quantity master views """ + farmos_entity_type = "quantity" + + labels = { + "log_id": "Log ID", + } + grid_columns = [ "drupal_id", - "as_text", - "quantity_type", + "log_id", + "log_status", + "log_timestamp", + "log_type", + "log_name", + "log_assets", "measure", "value", "units", "label", + "quantity_type", ] - sort_defaults = ("drupal_id", "desc") + sort_defaults = ("log_timestamp", "desc") form_fields = [ "quantity_type", @@ -129,10 +148,15 @@ class QuantityMasterView(WuttaFarmMasterView): model = self.app.model model_class = self.get_model_class() session = session or self.Session() + query = session.query(model_class) if model_class is not model.Quantity: query = query.join(model.Quantity) + query = query.join(model.Measure).join(model.Unit) + + query = query.outerjoin(model.LogQuantity).outerjoin(model.Log) + return query def configure_grid(self, grid): @@ -140,14 +164,39 @@ class QuantityMasterView(WuttaFarmMasterView): super().configure_grid(g) model = self.app.model model_class = self.get_model_class() + session = self.Session() # drupal_id g.set_label("drupal_id", "ID", column_only=True) g.set_sorter("drupal_id", model.Quantity.drupal_id) - # as_text - g.set_renderer("as_text", self.render_as_text_for_grid) - g.set_link("as_text") + # log_id + g.set_renderer("log_id", self.render_log_id_for_grid) + 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 if model_class is not model.Quantity: @@ -177,8 +226,47 @@ class QuantityMasterView(WuttaFarmMasterView): g.add_action("view", icon="eye", url=quantity_url) - def render_as_text_for_grid(self, quantity, field, value): - return quantity.render_as_text(self.config) + def render_log_id_for_grid(self, quantity, field, value): + 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): value = quantity.value_numerator / quantity.value_denominator @@ -271,6 +359,8 @@ class AllQuantityView(QuantityMasterView): deletable = False model_is_versioned = False + farmos_refurl_path = "/log-quantities" + class StandardQuantityView(QuantityMasterView): """ @@ -281,6 +371,77 @@ class StandardQuantityView(QuantityMasterView): route_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): base = globals() @@ -296,6 +457,11 @@ def defaults(config, **kwargs): ) StandardQuantityView.defaults(config) + MaterialQuantityView = kwargs.get( + "MaterialQuantityView", base["MaterialQuantityView"] + ) + MaterialQuantityView.defaults(config) + def includeme(config): defaults(config) diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index 8aae46e..fded73c 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -24,6 +24,8 @@ Quick Form for "Eggs" """ import json +import threading +import time import colander from deform.widget import SelectWidget @@ -331,13 +333,43 @@ class EggsQuickForm(QuickFormView): session.flush() if self.app.is_farmos_mirror(): - if new_unit: - self.app.auto_sync_to_farmos(unit, client=self.farmos_client) - self.app.auto_sync_to_farmos(quantity, client=self.farmos_client) - self.app.auto_sync_to_farmos(log, client=self.farmos_client) + thread = threading.Thread( + target=self.auto_sync_to_farmos, + args=(log.uuid, quantity.uuid, new_unit.uuid if new_unit else None), + ) + thread.start() 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): model = self.app.model diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index fe8dafe..13cff36 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -37,17 +37,19 @@ class MeasureView(WuttaFarmMasterView): url_prefix = "/measures" grid_columns = [ + "ordinal", "name", "drupal_id", ] - sort_defaults = "name" + sort_defaults = "ordinal" filter_defaults = { "name": {"active": True, "verb": "contains"}, } form_fields = [ + "ordinal", "name", "drupal_id", ] diff --git a/src/wuttafarm/web/views/water.py b/src/wuttafarm/web/views/water.py new file mode 100644 index 0000000..c0d551e --- /dev/null +++ b/src/wuttafarm/web/views/water.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttafarm/web/views/webhooks.py b/src/wuttafarm/web/views/webhooks.py new file mode 100644 index 0000000..f5a5db5 --- /dev/null +++ b/src/wuttafarm/web/views/webhooks.py @@ -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 . +# +################################################################################ +""" +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)