wuttafarm/src/wuttafarm/web/views/logs.py
Lance Edgar 759eb906b9 feat: expose "current location" for assets
based on most recent movement log, as in farmOS
2026-03-04 12:59:55 -06:00

431 lines
12 KiB
Python

# -*- 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
import colander
from webhelpers2.html import tags, HTML
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, Log
from wuttafarm.web.forms.schema import AssetRefs, LogQuantityRefs, OwnerRefs
from wuttafarm.util import get_log_type_enum
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",
"drupal_id",
"farmos_uuid",
]
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
"""
farmos_entity_type = "log"
labels = {
"message": "Log Name",
"locations": "Location",
"quantities": "Quantity",
}
grid_columns = [
"status",
"drupal_id",
"timestamp",
"message",
"assets",
"locations",
"quantities",
"is_group_assignment",
"owners",
]
sort_defaults = ("timestamp", "desc")
filter_defaults = {
"message": {"active": True, "verb": "contains"},
"status": {"active": True, "verb": "not_equal", "value": "abandoned"},
}
form_fields = [
"message",
"timestamp",
"assets",
"groups",
"locations",
"quantities",
"notes",
"status",
"log_type",
"owners",
"is_movement",
"is_group_assignment",
"quick",
"drupal_id",
"farmos_uuid",
]
def get_query(self, session=None):
""" """
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.Log:
query = query.join(model.Log)
return query
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
model = self.app.model
enum = self.app.enum
# status
g.set_enum("status", enum.LOG_STATUS)
g.set_sorter("status", model.Log.status)
g.set_filter(
"status",
model.Log.status,
verbs=["equal", "not_equal"],
choices=enum.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)
# assets
g.set_renderer("assets", self.render_assets_for_grid)
# groups
g.set_renderer("groups", self.render_assets_for_grid)
# locations
g.set_renderer("locations", self.render_assets_for_grid)
# quantities
g.set_renderer("quantities", self.render_quantities_for_grid)
# is_group_assignment
g.set_renderer("is_group_assignment", "boolean")
g.set_sorter("is_group_assignment", model.Log.is_group_assignment)
g.set_filter("is_group_assignment", model.Log.is_group_assignment)
# owners
g.set_label("owners", "Owner")
g.set_renderer("owners", self.render_owners_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 render_quantities_for_grid(self, log, field, value):
quantities = getattr(log, field) or []
items = []
for qty in quantities:
items.append(HTML.tag("li", c=qty.render_as_text(self.config)))
return HTML.tag("ul", c=items)
def render_owners_for_grid(self, log, field, value):
if self.farmos_style_grid_links:
links = []
for user in log.owners:
url = self.request.route_url("users.view", uuid=user.uuid)
links.append(tags.link_to(user.username, url))
return ", ".join(links)
return ", ".join([user.username for user in log.owners])
def grid_row_class(self, log, data, i):
if log.status == "pending":
return "has-background-warning"
if log.status == "abandoned":
return "has-background-danger"
return None
def configure_form(self, form):
f = form
super().configure_form(f)
enum = self.app.enum
session = self.Session()
log = f.model_instance
# timestamp
# TODO: the widget should be automatic (assn proxy field)
f.set_widget("timestamp", WuttaDateTimeWidget(self.request))
if self.creating:
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))
# 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))
# 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))
# nb. must explicity declare value for non-standard field
f.set_default("locations", log.locations)
# log_type
if self.creating:
f.remove("log_type")
else:
f.set_node(
"log_type",
WuttaDictEnum(
self.request, get_log_type_enum(self.config, session=session)
),
)
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))
# nb. must explicity declare value for non-standard field
f.set_default("quantities", log.quantities)
# notes
f.set_widget("notes", "notes")
# owners
if self.creating or self.editing:
f.remove("owners") # TODO: need to support this
else:
f.set_node("owners", OwnerRefs(self.request))
# nb. must explicity declare value for non-standard field
f.set_default("owners", log.owners)
# status
f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS))
# is_movement
f.set_node("is_movement", colander.Boolean())
# is_group_assignment
f.set_node("is_group_assignment", colander.Boolean())
# quick
f.set_readonly("quick") # TODO
def objectify(self, form):
log = super().objectify(form)
if self.creating:
model_class = self.get_model_class()
log.log_type = self.get_farmos_log_type()
return log
def get_farmos_url(self, log):
return self.app.get_farmos_url(f"/log/{log.drupal_id}")
def get_farmos_log_type(self):
return self.model_class.__wutta_hint__["farmos_log_type"]
def get_xref_buttons(self, log):
buttons = super().get_xref_buttons(log)
if log.farmos_uuid:
log_type = self.get_farmos_log_type()
route = f"farmos_logs_{log_type}.view"
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 get_version_joins(self):
"""
We override this to declare the relationship between the
view's data model (which is some type of log table) and the
canonical ``Log`` model, so the revision history views include
transactions which reference either version table.
See also parent method,
:meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_version_joins()`
"""
model = self.app.model
return super().get_version_joins() + [
model.Log,
(model.LogAsset, "log_uuid", "uuid"),
]
class AllLogView(LogMasterView):
"""
Master view for All Logs
"""
model_class = Log
route_prefix = "log"
url_prefix = "/logs"
farmos_refurl_path = "/logs"
viewable = False
creatable = False
editable = False
deletable = False
model_is_versioned = False
grid_columns = [
"status",
"drupal_id",
"timestamp",
"message",
"log_type",
"assets",
"locations",
"quantities",
"groups",
"is_group_assignment",
"owners",
]
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
session = self.Session()
# log_type
g.set_enum("log_type", get_log_type_enum(self.config, session=session))
# view action links to final log record
def log_url(log, i):
return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
g.add_action("view", icon="eye", url=log_url)
def defaults(config, **kwargs):
base = globals()
LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"])
LogTypeView.defaults(config)
AllLogView = kwargs.get("AllLogView", base["AllLogView"])
AllLogView.defaults(config)
def includeme(config):
defaults(config)