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)