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/model/__init__.py b/src/wuttafarm/db/model/__init__.py index df4115a..d90272b 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -58,3 +58,4 @@ 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 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/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index bb1b41c..011a170 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -955,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 bd42e86..746f761 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -115,6 +115,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter importers["ObservationLog"] = ObservationLogImporter + importers["SeedingLog"] = SeedingLogImporter return importers @@ -630,3 +631,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 93e8d30..c739bad 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -126,6 +126,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter importers["ObservationLog"] = ObservationLogImporter + importers["SeedingLog"] = SeedingLogImporter return importers @@ -155,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) @@ -1417,6 +1420,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 diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index d03a3ec..c47fcc3 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -84,6 +84,12 @@ 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={}): """ """ @@ -149,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"] diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index a44e5a9..2756738 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -191,6 +191,11 @@ 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", @@ -305,6 +310,11 @@ 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", @@ -490,6 +500,11 @@ 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", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 8e57f34..e66f479 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -64,6 +64,7 @@ def includeme(config): 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) diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index c92a04c..708b553 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -47,3 +47,4 @@ def includeme(config): 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/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/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)