diff --git a/src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py b/src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py new file mode 100644 index 0000000..0b82da9 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py @@ -0,0 +1,206 @@ +"""add generic log base + +Revision ID: dd6351e69233 +Revises: b8cd4a8f981f +Create Date: 2026-02-18 12:09:05.200134 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "dd6351e69233" +down_revision: Union[str, None] = "b8cd4a8f981f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log + op.create_table( + "log", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_type", sa.String(length=100), nullable=False), + sa.Column("message", sa.String(length=255), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["log_type"], ["log_type.drupal_id"], name=op.f("fk_log_log_type_log_type") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_log_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_farmos_uuid")), + ) + op.create_table( + "log_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_type", sa.String(length=100), autoincrement=False, nullable=True + ), + sa.Column("message", sa.String(length=255), autoincrement=False, nullable=True), + sa.Column("timestamp", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("status", sa.String(length=20), autoincrement=False, nullable=True), + sa.Column("notes", sa.Text(), 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_log_version")), + ) + op.create_index( + op.f("ix_log_version_end_transaction_id"), + "log_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_version_operation_type"), + "log_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_version_pk_transaction_id", + "log_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_version_pk_validity", + "log_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_version_transaction_id"), + "log_version", + ["transaction_id"], + unique=False, + ) + + # log_activity + op.drop_column("log_activity_version", "status") + op.drop_column("log_activity_version", "farmos_uuid") + op.drop_column("log_activity_version", "timestamp") + op.drop_column("log_activity_version", "message") + op.drop_column("log_activity_version", "drupal_id") + op.drop_column("log_activity_version", "notes") + op.drop_constraint( + op.f("uq_log_activity_drupal_id"), "log_activity", type_="unique" + ) + op.drop_constraint( + op.f("uq_log_activity_farmos_uuid"), "log_activity", type_="unique" + ) + op.create_foreign_key( + op.f("fk_log_activity_uuid_log"), "log_activity", "log", ["uuid"], ["uuid"] + ) + op.drop_column("log_activity", "status") + op.drop_column("log_activity", "farmos_uuid") + op.drop_column("log_activity", "timestamp") + op.drop_column("log_activity", "message") + op.drop_column("log_activity", "drupal_id") + op.drop_column("log_activity", "notes") + + +def downgrade() -> None: + + # log_activity + op.add_column( + "log_activity", + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity", + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity", + sa.Column( + "message", sa.VARCHAR(length=255), autoincrement=False, nullable=False + ), + ) + op.add_column( + "log_activity", + sa.Column( + "timestamp", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + ) + op.add_column( + "log_activity", + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity", + sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=False), + ) + op.drop_constraint( + op.f("fk_log_activity_uuid_log"), "log_activity", type_="foreignkey" + ) + op.create_unique_constraint( + op.f("uq_log_activity_farmos_uuid"), + "log_activity", + ["farmos_uuid"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("uq_log_activity_drupal_id"), + "log_activity", + ["drupal_id"], + postgresql_nulls_not_distinct=False, + ) + op.add_column( + "log_activity_version", + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity_version", + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity_version", + sa.Column( + "message", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + ) + op.add_column( + "log_activity_version", + sa.Column( + "timestamp", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "log_activity_version", + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity_version", + sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=True), + ) + + # log + op.drop_index(op.f("ix_log_version_transaction_id"), table_name="log_version") + op.drop_index("ix_log_version_pk_validity", table_name="log_version") + op.drop_index("ix_log_version_pk_transaction_id", table_name="log_version") + op.drop_index(op.f("ix_log_version_operation_type"), table_name="log_version") + op.drop_index(op.f("ix_log_version_end_transaction_id"), table_name="log_version") + op.drop_table("log_version") + op.drop_table("log") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 277a92c..978ed5d 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -35,4 +35,5 @@ from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset from .asset_animal import AnimalType, AnimalAsset from .asset_group import GroupAsset -from .logs import LogType, ActivityLog +from .log import LogType, Log +from .log_activity import ActivityLog diff --git a/src/wuttafarm/db/model/logs.py b/src/wuttafarm/db/model/log.py similarity index 76% rename from src/wuttafarm/db/model/logs.py rename to src/wuttafarm/db/model/log.py index 76f7715..14afe3e 100644 --- a/src/wuttafarm/db/model/logs.py +++ b/src/wuttafarm/db/model/log.py @@ -20,11 +20,12 @@ # ################################################################################ """ -Model definition for Log Types +Model definition for Logs """ import sqlalchemy as sa from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr from wuttjamaican.db import model @@ -82,20 +83,26 @@ class LogType(model.Base): return self.name or "" -class ActivityLog(model.Base): +class Log(model.Base): """ - Represents an activity log from farmOS + Represents a base log record from farmOS """ - __tablename__ = "log_activity" + __tablename__ = "log" __versioned__ = {} __wutta_hint__ = { - "model_title": "Activity Log", - "model_title_plural": "Activity Logs", + "model_title": "Log", + "model_title_plural": "Logs", } uuid = model.uuid_column() + log_type = sa.Column( + sa.String(length=100), + sa.ForeignKey("log_type.drupal_id"), + nullable=False, + ) + message = sa.Column( sa.String(length=255), nullable=False, @@ -148,3 +155,25 @@ class ActivityLog(model.Base): def __str__(self): return self.message or "" + + +class LogMixin: + + uuid = model.uuid_fk_column("log.uuid", nullable=False, primary_key=True) + + @declared_attr + def log(cls): + return orm.relationship(Log) + + def __str__(self): + return self.message or "" + + +def add_log_proxies(subclass): + Log.make_proxy(subclass, "log", "farmos_uuid") + Log.make_proxy(subclass, "log", "drupal_id") + Log.make_proxy(subclass, "log", "log_type") + Log.make_proxy(subclass, "log", "message") + Log.make_proxy(subclass, "log", "timestamp") + Log.make_proxy(subclass, "log", "status") + Log.make_proxy(subclass, "log", "notes") diff --git a/src/wuttafarm/db/model/log_activity.py b/src/wuttafarm/db/model/log_activity.py new file mode 100644 index 0000000..bbf8154 --- /dev/null +++ b/src/wuttafarm/db/model/log_activity.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 Activity Logs +""" + +from wuttjamaican.db import model + +from wuttafarm.db.model.log import LogMixin, add_log_proxies + + +class ActivityLog(LogMixin, model.Base): + """ + Represents an activity log from farmOS + """ + + __tablename__ = "log_activity" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Activity Log", + "model_title_plural": "Activity Logs", + "farmos_log_type": "activity", + } + + +add_log_proxies(ActivityLog) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index b07d06d..e421f26 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -139,43 +139,6 @@ class FromFarmOS(Importer): return self.app.make_utc(dt) -class ActivityLogImporter(FromFarmOS, ToWutta): - """ - farmOS API → WuttaFarm importer for Activity Logs - """ - - model_class = model.ActivityLog - - supported_fields = [ - "farmos_uuid", - "drupal_id", - "message", - "timestamp", - "notes", - "status", - ] - - def get_source_objects(self): - """ """ - logs = self.farmos_client.log.get("activity") - return logs["data"] - - def normalize_source_object(self, log): - """ """ - - if notes := log["attributes"]["notes"]: - notes = notes["value"] - - return { - "farmos_uuid": UUID(log["id"]), - "drupal_id": log["attributes"]["drupal_internal__id"], - "message": log["attributes"]["name"], - "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), - "notes": notes, - "status": log["attributes"]["status"], - } - - class AssetImporterBase(FromFarmOS, ToWutta): """ Base class for farmOS API → WuttaFarm asset importers @@ -320,6 +283,71 @@ class AssetImporterBase(FromFarmOS, ToWutta): return asset +class LogImporterBase(FromFarmOS, ToWutta): + """ + Base class for farmOS API → WuttaFarm log importers + """ + + def get_farmos_log_type(self): + return self.model_class.__wutta_hint__["farmos_log_type"] + + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare proxy fields + fields.extend( + [ + "farmos_uuid", + "drupal_id", + "log_type", + "message", + "timestamp", + "notes", + "status", + ] + ) + return fields + + def get_source_objects(self): + """ """ + log_type = self.get_farmos_log_type() + result = self.farmos_client.log.get(log_type) + return result["data"] + + def normalize_source_object(self, log): + """ """ + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + return { + "farmos_uuid": UUID(log["id"]), + "drupal_id": log["attributes"]["drupal_internal__id"], + "log_type": self.get_farmos_log_type(), + "message": log["attributes"]["name"], + "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), + "notes": notes, + "status": log["attributes"]["status"], + } + + +class ActivityLogImporter(LogImporterBase): + """ + farmOS API → WuttaFarm importer for Activity Logs + """ + + model_class = model.ActivityLog + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "log_type", + "message", + "timestamp", + "notes", + "status", + ] + + class AnimalAssetImporter(AssetImporterBase): """ farmOS API → WuttaFarm importer for Animals diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index e44c16e..fe42703 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -47,7 +47,7 @@ def includeme(config): config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.groups") - config.include("wuttafarm.web.views.log_types") + config.include("wuttafarm.web.views.logs") config.include("wuttafarm.web.views.logs_activity") # views for farmOS diff --git a/src/wuttafarm/web/views/log_types.py b/src/wuttafarm/web/views/log_types.py deleted file mode 100644 index 13ea35f..0000000 --- a/src/wuttafarm/web/views/log_types.py +++ /dev/null @@ -1,90 +0,0 @@ -# -*- 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 Log Types -""" - -from wuttafarm.db.model.logs import LogType -from wuttafarm.web.views import WuttaFarmMasterView - - -class LogTypeView(WuttaFarmMasterView): - """ - Master view for Log Types - """ - - model_class = LogType - route_prefix = "log_types" - url_prefix = "/log-types" - - grid_columns = [ - "name", - "description", - ] - - sort_defaults = "name" - - filter_defaults = { - "name": {"active": True, "verb": "contains"}, - } - - form_fields = [ - "name", - "description", - "farmos_uuid", - "drupal_id", - ] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - - def get_xref_buttons(self, log_type): - buttons = super().get_xref_buttons(log_type) - - if log_type.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_log_types.view", uuid=log_type.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - - -def defaults(config, **kwargs): - base = globals() - - LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) - LogTypeView.defaults(config) - - -def includeme(config): - defaults(config) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py new file mode 100644 index 0000000..fc05613 --- /dev/null +++ b/src/wuttafarm/web/views/logs.py @@ -0,0 +1,223 @@ +# -*- 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 views for Logs +""" + +from collections import OrderedDict + +from wuttaweb.forms.schema import WuttaDictEnum +from wuttaweb.db import Session +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.db.model import LogType + + +def get_log_type_enum(config): + app = config.get_app() + model = app.model + session = Session() + log_types = OrderedDict() + query = session.query(model.LogType).order_by(model.LogType.name) + for log_type in query: + log_types[log_type.drupal_id] = log_type.name + return log_types + + +class LogTypeView(WuttaFarmMasterView): + """ + Master view for Log Types + """ + + model_class = LogType + route_prefix = "log_types" + url_prefix = "/log-types" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, log_type): + buttons = super().get_xref_buttons(log_type) + + if log_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_log_types.view", uuid=log_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +class LogMasterView(WuttaFarmMasterView): + """ + Base class for Asset master views + """ + + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "assets", + "location", + "quantity", + "is_group_assignment", + ] + + sort_defaults = ("timestamp", "desc") + + filter_defaults = { + "message": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "message", + "timestamp", + "assets", + "location", + "quantity", + "notes", + "status", + "log_type", + "owners", + "is_group_assignment", + "farmos_uuid", + "drupal_id", + ] + + def get_query(self, session=None): + """ """ + model = self.app.model + model_class = self.get_model_class() + session = session or self.Session() + return session.query(model_class).join(model.Log) + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + + # status + g.set_sorter("status", model.Log.status) + g.set_filter("status", model.Log.status) + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", model.Log.drupal_id) + g.set_filter("drupal_id", model.Log.drupal_id) + + # timestamp + g.set_renderer("timestamp", "date") + g.set_link("timestamp") + g.set_sorter("timestamp", model.Log.timestamp) + g.set_filter("timestamp", model.Log.timestamp) + + # message + g.set_link("message") + g.set_sorter("message", model.Log.message) + g.set_filter("message", model.Log.message) + + def configure_form(self, form): + f = form + super().configure_form(f) + + # timestamp + # TODO: the widget should be automatic (assn proxy field) + f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) + + # log_type + if self.creating: + f.remove("log_type") + else: + f.set_node( + "log_type", + WuttaDictEnum(self.request, get_log_type_enum(self.config)), + ) + f.set_readonly("log_type") + + # notes + f.set_widget("notes", "notes") + + def get_farmos_url(self, log): + return self.app.get_farmos_url(f"/log/{log.drupal_id}") + + def get_xref_buttons(self, log): + buttons = super().get_xref_buttons(log) + + if log.farmos_uuid: + + # TODO + route = None + if log.log_type == "activity": + route = "farmos_logs_activity.view" + + if route: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url(route, uuid=log.farmos_uuid), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) + LogTypeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/logs_activity.py b/src/wuttafarm/web/views/logs_activity.py index a2b2154..d4333f5 100644 --- a/src/wuttafarm/web/views/logs_activity.py +++ b/src/wuttafarm/web/views/logs_activity.py @@ -23,11 +23,11 @@ Master view for Activity Logs """ -from wuttafarm.db.model.logs import ActivityLog -from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.views.logs import LogMasterView +from wuttafarm.db.model import ActivityLog -class ActivityLogView(WuttaFarmMasterView): +class ActivityLogView(LogMasterView): """ Master view for Activity Logs """ @@ -38,61 +38,6 @@ class ActivityLogView(WuttaFarmMasterView): farmos_refurl_path = "/logs/activity" - grid_columns = [ - "message", - "timestamp", - "status", - ] - - sort_defaults = ("timestamp", "desc") - - filter_defaults = { - "message": {"active": True, "verb": "contains"}, - } - - form_fields = [ - "message", - "timestamp", - "status", - "notes", - "farmos_uuid", - "drupal_id", - ] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # message - g.set_link("message") - - def configure_form(self, form): - f = form - super().configure_form(f) - - # notes - f.set_widget("notes", "notes") - - def get_farmos_url(self, log): - return self.app.get_farmos_url(f"/log/{log.drupal_id}") - - def get_xref_buttons(self, log): - buttons = super().get_xref_buttons(log) - - if log.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_logs_activity.view", uuid=log.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - def defaults(config, **kwargs): base = globals()