feat: refactor log models, views to use generic/common base

This commit is contained in:
Lance Edgar 2026-02-18 13:21:38 -06:00
parent 982da89861
commit b061959b18
9 changed files with 580 additions and 193 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,45 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model definition for 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)

View file

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

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

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