feat: overhaul farmOS log views; add Eggs quick form

probably a few other changes...i'm tired and need a savepoint
This commit is contained in:
Lance Edgar 2026-02-22 14:51:15 -06:00
parent ad6ac13d50
commit 1a6870b8fe
15 changed files with 914 additions and 36 deletions

View file

@ -55,6 +55,34 @@ class AnimalTypeRef(ObjectRef):
return self.request.route_url("animal_types.view", uuid=animal_type.uuid) return self.request.route_url("animal_types.view", uuid=animal_type.uuid)
class LogQuick(WuttaSet):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import LogQuickWidget
return LogQuickWidget(**kwargs)
class FarmOSUnitRef(colander.SchemaType):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSUnitRefWidget
return FarmOSUnitRefWidget(**kwargs)
class FarmOSRef(colander.SchemaType): class FarmOSRef(colander.SchemaType):
def __init__(self, request, route_prefix, *args, **kwargs): def __init__(self, request, route_prefix, *args, **kwargs):
@ -114,6 +142,20 @@ class FarmOSRefs(WuttaSet):
return FarmOSRefsWidget(self.request, self.route_prefix, **kwargs) return FarmOSRefsWidget(self.request, self.route_prefix, **kwargs)
class FarmOSAssetRefs(WuttaSet):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSAssetRefsWidget
return FarmOSAssetRefsWidget(self.request, **kwargs)
class FarmOSLocationRefs(WuttaSet): class FarmOSLocationRefs(WuttaSet):
def serialize(self, node, appstruct): def serialize(self, node, appstruct):
@ -128,6 +170,20 @@ class FarmOSLocationRefs(WuttaSet):
return FarmOSLocationRefsWidget(self.request, **kwargs) return FarmOSLocationRefsWidget(self.request, **kwargs)
class FarmOSQuantityRefs(WuttaSet):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import FarmOSQuantityRefsWidget
return FarmOSQuantityRefsWidget(**kwargs)
class AnimalTypeType(colander.SchemaType): class AnimalTypeType(colander.SchemaType):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):

View file

@ -32,6 +32,8 @@ from webhelpers2.html import HTML, tags
from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttafarm.web.util import render_quantity_objects
class ImageWidget(Widget): class ImageWidget(Widget):
""" """
@ -54,6 +56,26 @@ class ImageWidget(Widget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
class LogQuickWidget(Widget):
"""
Widget to display an image URL for a record.
"""
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
items = []
for quick in json.loads(cstruct):
items.append(HTML.tag("li", c=quick))
return HTML.tag("ul", c=items)
return super().serialize(field, cstruct, **kw)
class FarmOSRefWidget(SelectWidget): class FarmOSRefWidget(SelectWidget):
""" """
Generic widget to display "any reference field" - as a link to Generic widget to display "any reference field" - as a link to
@ -111,6 +133,33 @@ class FarmOSRefsWidget(Widget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
class FarmOSAssetRefsWidget(Widget):
"""
Widget to display a "Assets" field for an asset.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, field, cstruct, **kw):
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
assets = []
for asset in json.loads(cstruct):
url = self.request.route_url(
f"farmos_{asset['asset_type']}_assets.view", uuid=asset["uuid"]
)
assets.append(HTML.tag("li", c=tags.link_to(asset["name"], url)))
return HTML.tag("ul", c=assets)
return super().serialize(field, cstruct, **kw)
class FarmOSLocationRefsWidget(Widget): class FarmOSLocationRefsWidget(Widget):
""" """
Widget to display a "Locations" field for an asset. Widget to display a "Locations" field for an asset.
@ -139,6 +188,40 @@ class FarmOSLocationRefsWidget(Widget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
class FarmOSQuantityRefsWidget(Widget):
"""
Widget to display a "Quantities" field for a log.
"""
def serialize(self, field, cstruct, **kw):
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
quantities = json.loads(cstruct)
return render_quantity_objects(quantities)
return super().serialize(field, cstruct, **kw)
class FarmOSUnitRefWidget(Widget):
"""
Widget to display a "Units" field for a quantity.
"""
def serialize(self, field, cstruct, **kw):
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
unit = json.loads(cstruct)
return unit["name"]
return super().serialize(field, cstruct, **kw)
class AnimalTypeWidget(Widget): class AnimalTypeWidget(Widget):
""" """
Widget to display an "animal type" field. Widget to display an "animal type" field.

View file

@ -35,29 +35,48 @@ class WuttaFarmMenuHandler(base.MenuHandler):
enum = self.app.enum enum = self.app.enum
mode = self.app.get_farmos_integration_mode() mode = self.app.get_farmos_integration_mode()
quick_menu = self.make_quick_menu(request)
admin_menu = self.make_admin_menu(request, include_people=True)
if mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER: if mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER:
return [ return [
quick_menu,
self.make_farmos_asset_menu(request), self.make_farmos_asset_menu(request),
self.make_farmos_log_menu(request), self.make_farmos_log_menu(request),
self.make_farmos_other_menu(request), self.make_farmos_other_menu(request),
self.make_admin_menu(request, include_people=True), admin_menu,
] ]
elif mode == enum.FARMOS_INTEGRATION_MODE_MIRROR: elif mode == enum.FARMOS_INTEGRATION_MODE_MIRROR:
return [ return [
quick_menu,
self.make_asset_menu(request), self.make_asset_menu(request),
self.make_log_menu(request), self.make_log_menu(request),
self.make_farmos_full_menu(request), self.make_farmos_full_menu(request),
self.make_admin_menu(request, include_people=True), admin_menu,
] ]
else: # FARMOS_INTEGRATION_MODE_NONE else: # FARMOS_INTEGRATION_MODE_NONE
return [ return [
quick_menu,
self.make_asset_menu(request), self.make_asset_menu(request),
self.make_log_menu(request), self.make_log_menu(request),
self.make_admin_menu(request, include_people=True), admin_menu,
] ]
def make_quick_menu(self, request):
return {
"title": "Quick",
"type": "menu",
"items": [
{
"title": "Eggs",
"route": "quick.eggs",
# "perm": "assets.list",
},
],
}
def make_asset_menu(self, request): def make_asset_menu(self, request):
return { return {
"title": "Assets", "title": "Assets",

View file

@ -0,0 +1,14 @@
<%inherit file="/form.mako" />
<%def name="title()">${index_title} &raquo; ${form_title}</%def>
<%def name="content_title()">${form_title}</%def>
<%def name="render_form_tag()">
<p class="block">
${help_text}
</p>
${parent.render_form_tag()}
</%def>

View file

@ -23,6 +23,8 @@
Misc. utilities for web app Misc. utilities for web app
""" """
from webhelpers2.html import HTML
def save_farmos_oauth2_token(request, token): def save_farmos_oauth2_token(request, token):
""" """
@ -42,3 +44,18 @@ def save_farmos_oauth2_token(request, token):
def use_farmos_style_grid_links(config): def use_farmos_style_grid_links(config):
return config.get_bool(f"{config.appname}.farmos_style_grid_links", default=True) return config.get_bool(f"{config.appname}.farmos_style_grid_links", default=True)
def render_quantity_objects(quantities):
items = []
for quantity in quantities:
text = render_quantity_object(quantity)
items.append(HTML.tag("li", c=text))
return HTML.tag("ul", c=items)
def render_quantity_object(quantity):
measure = quantity["measure_name"]
value = quantity["value_decimal"]
unit = quantity["unit_name"]
return f"( {measure} ) {value} {unit}"

View file

@ -62,6 +62,10 @@ def includeme(config):
config.include("wuttafarm.web.views.logs_medical") config.include("wuttafarm.web.views.logs_medical")
config.include("wuttafarm.web.views.logs_observation") config.include("wuttafarm.web.views.logs_observation")
# quick form views
# (nb. these work with all integration modes)
config.include("wuttafarm.web.views.quick")
# views for farmOS # views for farmOS
if mode != enum.FARMOS_INTEGRATION_MODE_NONE: if mode != enum.FARMOS_INTEGRATION_MODE_NONE:
config.include("wuttafarm.web.views.farmos") config.include("wuttafarm.web.views.farmos")

View file

@ -119,16 +119,6 @@ class AssetMasterView(FarmOSMasterView):
return tags.image(url, f"thumbnail for {self.get_model_title()}") return tags.image(url, f"thumbnail for {self.get_model_title()}")
return None return None
def render_owners_for_grid(self, asset, field, value):
owners = []
for user in value:
if self.farmos_style_grid_links:
url = self.request.route_url("farmos_users.view", uuid=user["uuid"])
owners.append(tags.link_to(user["name"], url))
else:
owners.append(user["name"])
return ", ".join(owners)
def render_locations_for_grid(self, asset, field, value): def render_locations_for_grid(self, asset, field, value):
locations = [] locations = []
for location in value: for location in value:
@ -151,15 +141,14 @@ class AssetMasterView(FarmOSMasterView):
return {"asset_type", "location", "owner", "image"} return {"asset_type", "location", "owner", "image"}
def get_instance(self): def get_instance(self):
asset = self.farmos_client.resource.get_id( result = self.farmos_client.asset.get_id(
"asset",
self.farmos_asset_type, self.farmos_asset_type,
self.request.matchdict["uuid"], self.request.matchdict["uuid"],
params={"include": ",".join(self.get_farmos_api_includes())}, params={"include": ",".join(self.get_farmos_api_includes())},
) )
self.raw_json = asset self.raw_json = result
included = {obj["id"]: obj for obj in asset.get("included", [])} included = {obj["id"]: obj for obj in result.get("included", [])}
return self.normalize_asset(asset["data"], included) return self.normalize_asset(result["data"], included)
def get_instance_title(self, asset): def get_instance_title(self, asset):
return asset["name"] return asset["name"]

View file

@ -26,11 +26,27 @@ View for farmOS Harvest Logs
import datetime import datetime
import colander import colander
from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum
from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.grids import (
ResourceData,
SimpleSorter,
StringFilter,
IntegerFilter,
DateTimeFilter,
NullableBooleanFilter,
)
from wuttafarm.web.forms.schema import (
FarmOSQuantityRefs,
FarmOSAssetRefs,
FarmOSRefs,
LogQuick,
)
from wuttafarm.web.util import render_quantity_objects
class LogMasterView(FarmOSMasterView): class LogMasterView(FarmOSMasterView):
@ -39,48 +55,183 @@ class LogMasterView(FarmOSMasterView):
""" """
farmos_log_type = None farmos_log_type = None
filterable = True
sort_on_backend = True
_farmos_units = None
_farmos_measures = None
labels = {
"name": "Log Name",
"log_type_name": "Log Type",
"quantities": "Quantity",
}
grid_columns = [ grid_columns = [
"name",
"timestamp",
"status", "status",
"drupal_id",
"timestamp",
"name",
"assets",
"quantities",
"is_group_assignment",
"owners",
] ]
sort_defaults = ("timestamp", "desc") sort_defaults = ("timestamp", "desc")
filter_defaults = {
"name": {"active": True, "verb": "contains"},
"status": {"active": True, "verb": "not_equal", "value": "abandoned"},
}
form_fields = [ form_fields = [
"name", "name",
"timestamp", "timestamp",
"status", "assets",
"quantities",
"notes", "notes",
"status",
"log_type_name",
"owners",
"quick",
"drupal_id",
] ]
def get_grid_data(self, columns=None, session=None): def get_farmos_api_includes(self):
result = self.farmos_client.log.get(self.farmos_log_type) return {"log_type", "quantity", "asset", "owner"}
return [self.normalize_log(l) for l in result["data"]]
def get_grid_data(self, **kwargs):
return ResourceData(
self.config,
self.farmos_client,
f"log--{self.farmos_log_type}",
include=",".join(self.get_farmos_api_includes()),
normalizer=self.normalize_log,
)
def configure_grid(self, grid): def configure_grid(self, grid):
g = grid g = grid
super().configure_grid(g) super().configure_grid(g)
enum = self.app.enum
# status
g.set_enum("status", enum.LOG_STATUS)
g.set_sorter("status", SimpleSorter("status"))
g.set_filter(
"status",
StringFilter,
choices=enum.LOG_STATUS,
verbs=["equal", "not_equal"],
)
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
g.set_sorter("drupal_id", SimpleSorter("drupal_internal__id"))
g.set_filter("drupal_id", IntegerFilter, path="drupal_internal__id")
# timestamp
g.set_renderer("timestamp", "date")
g.set_link("timestamp")
g.set_sorter("timestamp", SimpleSorter("timestamp"))
g.set_filter("timestamp", DateTimeFilter)
# name # name
g.set_link("name") g.set_link("name")
g.set_searchable("name") g.set_sorter("name", SimpleSorter("name"))
g.set_filter("name", StringFilter)
# timestamp # assets
g.set_renderer("timestamp", "datetime") g.set_renderer("assets", 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", SimpleSorter("is_group_assignment"))
g.set_filter("is_group_assignment", NullableBooleanFilter)
# 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 = []
for asset in value:
if self.farmos_style_grid_links:
url = self.request.route_url(
f"farmos_{asset['asset_type']}_assets.view", uuid=asset["uuid"]
)
assets.append(tags.link_to(asset["name"], url))
else:
assets.append(asset["name"])
return ", ".join(assets)
def render_quantities_for_grid(self, log, field, value):
if not value:
return None
return render_quantity_objects(value)
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 get_instance(self): def get_instance(self):
log = self.farmos_client.log.get_id( result = self.farmos_client.log.get_id(
self.farmos_log_type, self.request.matchdict["uuid"] self.farmos_log_type,
self.request.matchdict["uuid"],
params={"include": ",".join(self.get_farmos_api_includes())},
) )
self.raw_json = log self.raw_json = result
return self.normalize_log(log["data"]) included = {obj["id"]: obj for obj in result.get("included", [])}
return self.normalize_log(result["data"], included)
def get_instance_title(self, log): def get_instance_title(self, log):
return log["name"] return log["name"]
def normalize_log(self, log): def get_farmos_units(self):
if self._farmos_units:
return self._farmos_units
units = {}
result = self.farmos_client.resource.get("taxonomy_term", "unit")
for unit in result["data"]:
units[unit["id"]] = unit
self._farmos_units = units
return self._farmos_units
def get_farmos_unit(self, uuid):
units = self.get_farmos_units()
return units[uuid]
def get_farmos_measures(self):
if self._farmos_measures:
return self._farmos_measures
measures = {}
response = self.farmos_client.session.get(
self.app.get_farmos_url("/api/quantity/standard/resource/schema")
)
response.raise_for_status()
data = response.json()
for measure in data["definitions"]["attributes"]["properties"]["measure"][
"oneOf"
]:
measures[measure["const"]] = measure["title"]
self._farmos_measures = measures
return self._farmos_measures
def get_farmos_measure_name(self, measure_id):
measures = self.get_farmos_measures()
return measures[measure_id]
def normalize_log(self, log, included):
if timestamp := log["attributes"]["timestamp"]: if timestamp := log["attributes"]["timestamp"]:
timestamp = datetime.datetime.fromisoformat(timestamp) timestamp = datetime.datetime.fromisoformat(timestamp)
@ -89,26 +240,126 @@ class LogMasterView(FarmOSMasterView):
if notes := log["attributes"]["notes"]: if notes := log["attributes"]["notes"]:
notes = notes["value"] notes = notes["value"]
log_type_object = {}
log_type_name = None
asset_objects = []
quantity_objects = []
owner_objects = []
if relationships := log.get("relationships"):
if log_type := relationships.get("log_type"):
log_type = included[log_type["data"]["id"]]
log_type_object = {
"uuid": log_type["id"],
"name": log_type["attributes"]["label"],
}
log_type_name = log_type_object["name"]
if assets := relationships.get("asset"):
for asset in assets["data"]:
asset = included[asset["id"]]
attrs = asset["attributes"]
rels = asset["relationships"]
asset_objects.append(
{
"uuid": asset["id"],
"drupal_id": attrs["drupal_internal__id"],
"name": attrs["name"],
"is_location": attrs["is_location"],
"is_fixed": attrs["is_fixed"],
"archived": attrs["archived"],
"notes": attrs["notes"],
"asset_type": asset["type"].split("--")[1],
}
)
if quantities := relationships.get("quantity"):
for quantity in quantities["data"]:
quantity = included[quantity["id"]]
attrs = quantity["attributes"]
rels = quantity["relationships"]
value = attrs["value"]
unit_uuid = rels["units"]["data"]["id"]
unit = self.get_farmos_unit(unit_uuid)
measure_id = attrs["measure"]
quantity_objects.append(
{
"uuid": quantity["id"],
"drupal_id": attrs["drupal_internal__id"],
"quantity_type_uuid": rels["quantity_type"]["data"]["id"],
"quantity_type_id": rels["quantity_type"]["data"]["meta"][
"drupal_internal__target_id"
],
"measure_id": measure_id,
"measure_name": self.get_farmos_measure_name(measure_id),
"value_numerator": value["numerator"],
"value_decimal": value["decimal"],
"value_denominator": value["denominator"],
"unit_uuid": unit_uuid,
"unit_name": unit["attributes"]["name"],
}
)
if owners := relationships.get("owner"):
for user in owners["data"]:
user = included[user["id"]]
owner_objects.append(
{
"uuid": user["id"],
"name": user["attributes"]["name"],
}
)
return { return {
"uuid": log["id"], "uuid": log["id"],
"drupal_id": log["attributes"]["drupal_internal__id"], "drupal_id": log["attributes"]["drupal_internal__id"],
"log_type": log_type_object,
"log_type_name": log_type_name,
"name": log["attributes"]["name"], "name": log["attributes"]["name"],
"timestamp": timestamp, "timestamp": timestamp,
"assets": asset_objects,
"quantities": quantity_objects,
"is_group_assignment": log["attributes"]["is_group_assignment"],
"quick": log["attributes"]["quick"],
"status": log["attributes"]["status"], "status": log["attributes"]["status"],
"notes": notes or colander.null, "notes": notes or colander.null,
"owners": owner_objects,
} }
def configure_form(self, form): def configure_form(self, form):
f = form f = form
super().configure_form(f) super().configure_form(f)
enum = self.app.enum
log = f.model_instance
# timestamp # timestamp
f.set_node("timestamp", WuttaDateTime()) f.set_node("timestamp", WuttaDateTime())
f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) f.set_widget("timestamp", WuttaDateTimeWidget(self.request))
# assets
f.set_node("assets", FarmOSAssetRefs(self.request))
# quantities
f.set_node("quantities", FarmOSQuantityRefs(self.request))
# notes # notes
f.set_widget("notes", "notes") f.set_widget("notes", "notes")
# status
f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS))
# owners
if self.creating or self.editing:
f.remove("owners") # TODO
else:
f.set_node("owners", FarmOSRefs(self.request, "farmos_users"))
# quick
f.set_node("quick", LogQuick(self.request))
def get_xref_buttons(self, log): def get_xref_buttons(self, log):
model = self.app.model model = self.app.model
session = self.Session() session = self.Session()

View file

@ -41,6 +41,17 @@ class HarvestLogView(LogMasterView):
farmos_log_type = "harvest" farmos_log_type = "harvest"
farmos_refurl_path = "/logs/harvest" farmos_refurl_path = "/logs/harvest"
grid_columns = [
"status",
"drupal_id",
"timestamp",
"name",
"assets",
"quantities",
"is_group_assignment",
"owners",
]
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -28,6 +28,7 @@ import json
import colander import colander
import markdown import markdown
from webhelpers2.html import tags
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.schema import WuttaDateTime
@ -99,6 +100,16 @@ class FarmOSMasterView(MasterView):
return templates return templates
def render_owners_for_grid(self, obj, field, value):
owners = []
for user in value:
if self.farmos_style_grid_links:
url = self.request.route_url("farmos_users.view", uuid=user["uuid"])
owners.append(tags.link_to(user["name"], url))
else:
owners.append(user["name"])
return ", ".join(owners)
def get_template_context(self, context): def get_template_context(self, context):
if self.listing and self.farmos_refurl_path: if self.listing and self.farmos_refurl_path:

View file

@ -31,7 +31,7 @@ from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.schema import FarmOSRef from wuttafarm.web.forms.schema import FarmOSUnitRef
class QuantityTypeView(FarmOSMasterView): class QuantityTypeView(FarmOSMasterView):
@ -220,7 +220,7 @@ class QuantityMasterView(FarmOSMasterView):
f.set_widget("changed", WuttaDateTimeWidget(self.request)) f.set_widget("changed", WuttaDateTimeWidget(self.request))
# units # units
f.set_node("units", FarmOSRef(self.request, "farmos_units")) f.set_node("units", FarmOSUnitRef())
class StandardQuantityView(QuantityMasterView): class StandardQuantityView(QuantityMasterView):

View file

@ -175,15 +175,21 @@ class LogMasterView(WuttaFarmMasterView):
Base class for Asset master views Base class for Asset master views
""" """
labels = {
"message": "Log Name",
"owners": "Owner",
}
grid_columns = [ grid_columns = [
"status", "status",
"drupal_id", "drupal_id",
"timestamp", "timestamp",
"message", "message",
"assets", "assets",
"location", # "location",
"quantity", "quantity",
"is_group_assignment", "is_group_assignment",
"owners",
] ]
sort_defaults = ("timestamp", "desc") sort_defaults = ("timestamp", "desc")

View file

@ -0,0 +1,30 @@
# -*- 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/>.
#
################################################################################
"""
Quick Form views for farmOS
"""
from .base import QuickFormView
def includeme(config):
config.include("wuttafarm.web.views.quick.eggs")

View file

@ -0,0 +1,155 @@
# -*- 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 class for Quick Form views
"""
import logging
from pyramid.renderers import render_to_response
from wuttaweb.views import View
from wuttafarm.web.util import save_farmos_oauth2_token
log = logging.getLogger(__name__)
class QuickFormView(View):
"""
Base class for quick form views.
"""
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.farmos_client = self.get_farmos_client()
self.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
@classmethod
def get_route_slug(cls):
return cls.route_slug
@classmethod
def get_url_slug(cls):
return cls.url_slug
@classmethod
def get_form_title(cls):
return cls.form_title
def __call__(self):
form = self.make_quick_form()
if form.validate():
try:
result = self.save_quick_form(form)
except Exception as err:
log.warning("failed to save 'edit' form", exc_info=True)
self.request.session.flash(
f"Save failed: {self.app.render_error(err)}", "error"
)
else:
return self.redirect_after_save(result)
return self.render_to_response({"form": form})
def make_quick_form(self):
raise NotImplementedError
def save_quick_form(self, form):
raise NotImplementedError
def redirect_after_save(self, result):
return self.redirect(self.request.current_route_url())
def render_to_response(self, context):
defaults = {
"index_title": "Quick Form",
"form_title": self.get_form_title(),
"help_text": self.__doc__.strip(),
}
defaults.update(context)
context = defaults
# supplement context further if needed
context = self.get_template_context(context)
page_templates = self.get_page_templates()
mako_path = page_templates[0]
try:
render_to_response(mako_path, context, request=self.request)
except IOError:
# try one or more fallback templates
for fallback in page_templates[1:]:
try:
return render_to_response(fallback, context, request=self.request)
except IOError:
pass
# if we made it all the way here, then we found no
# templates at all, in which case re-attempt the first and
# let that error raise on up
return render_to_response(mako_path, context, request=self.request)
def get_page_templates(self):
route_slug = self.get_route_slug()
page_templates = [f"/quick/{route_slug}.mako"]
page_templates.extend(self.get_fallback_templates())
return page_templates
def get_fallback_templates(self):
return ["/quick/form.mako"]
def get_template_context(self, context):
return context
def get_farmos_client(self):
token = self.request.session.get("farmos.oauth2.token")
if not token:
raise self.forbidden()
# nb. must give a *copy* of the token to farmOS client, since
# it will mutate it in-place and we don't want that to happen
# for our original copy in the user session. (otherwise the
# auto-refresh will not work correctly for subsequent calls.)
token = dict(token)
def token_updater(token):
save_farmos_oauth2_token(self.request, token)
return self.app.get_farmos_client(token=token, token_updater=token_updater)
@classmethod
def defaults(cls, config):
cls._defaults(config)
@classmethod
def _defaults(cls, config):
route_slug = cls.get_route_slug()
url_slug = cls.get_url_slug()
config.add_route(f"quick.{route_slug}", f"/quick/{url_slug}")
config.add_view(cls, route_name=f"quick.{route_slug}")

View file

@ -0,0 +1,232 @@
# -*- 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/>.
#
################################################################################
"""
Quick Form for "Eggs"
"""
import json
import colander
from deform.widget import SelectWidget
from farmOS.subrequests import Action, Subrequest, SubrequestsBlueprint, Format
from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.quick import QuickFormView
class EggsQuickForm(QuickFormView):
"""
Use this form to record an egg harvest. A harvest log will be
created with standard details filled in.
"""
form_title = "Eggs"
route_slug = "eggs"
url_slug = "eggs"
_layer_assets = None
def make_quick_form(self):
f = self.make_form(
fields=[
"timestamp",
"count",
"asset",
"notes",
],
labels={
"timestamp": "Date",
"count": "Quantity",
"asset": "Layer Asset",
},
)
# timestamp
f.set_node("timestamp", WuttaDateTime())
f.set_widget("timestamp", WuttaDateTimeWidget(self.request))
f.set_default("timestamp", self.app.make_utc())
# count
f.set_node("count", colander.Integer())
# asset
assets = self.get_layer_assets()
values = [(a["uuid"], a["name"]) for a in assets]
f.set_widget("asset", SelectWidget(values=values))
if len(assets) == 1:
f.set_default("asset", assets[0]["uuid"])
# notes
f.set_widget("notes", "notes")
f.set_required("notes", False)
return f
def get_layer_assets(self):
if self._layer_assets is not None:
return self._layer_assets
assets = []
params = {
"filter[produces_eggs]": 1,
"sort": "name",
}
def normalize(asset):
return {
"uuid": asset["id"],
"name": asset["attributes"]["name"],
"type": asset["type"],
}
result = self.farmos_client.asset.get("animal", params=params)
assets.extend([normalize(a) for a in result["data"]])
result = self.farmos_client.asset.get("group", params=params)
assets.extend([normalize(a) for a in result["data"]])
assets.sort(key=lambda a: a["name"])
self._layer_assets = assets
return assets
def save_quick_form(self, form):
data = form.validated
assets = self.get_layer_assets()
assets = {a["uuid"]: a for a in assets}
asset = assets[data["asset"]]
# TODO: make this configurable?
unit_name = "egg(s)"
unit = {"data": {"type": "taxonomy_term--unit"}}
new_unit = None
result = self.farmos_client.resource.get(
"taxonomy_term",
"unit",
params={
"filter[name]": unit_name,
},
)
if result["data"]:
unit["data"]["id"] = result["data"][0]["id"]
else:
payload = dict(unit)
payload["data"]["attributes"] = {"name": unit_name}
new_unit = Subrequest(
action=Action.create,
requestId="create-unit",
endpoint="api/taxonomy_term/unit",
body=payload,
)
unit["data"]["id"] = "{{create-unit.body@$.data.id}}"
quantity = {
"data": {
"type": "quantity--standard",
"attributes": {
"measure": "count",
"value": {
"numerator": data["count"],
"denominator": 1,
},
},
"relationships": {
"units": unit,
},
},
}
kw = {}
if new_unit:
kw["waitFor"] = ["create-unit"]
new_quantity = Subrequest(
action=Action.create,
requestId="create-quantity",
endpoint="api/quantity/standard",
body=quantity,
**kw,
)
notes = None
if data["notes"]:
notes = {"value": data["notes"]}
log = {
"data": {
"type": "log--harvest",
"attributes": {
"name": f"Collected {data['count']} {unit_name}",
"notes": notes,
"quick": ["eggs"],
},
"relationships": {
"asset": {
"data": [
{
"id": asset["uuid"],
"type": asset["type"],
},
],
},
"quantity": {
"data": [
{
"id": "{{create-quantity.body@$.data.id}}",
"type": "quantity--standard",
},
],
},
},
},
}
new_log = Subrequest(
action=Action.create,
requestId="create-log",
waitFor=["create-quantity"],
endpoint="api/log/harvest",
body=log,
)
blueprints = [new_quantity, new_log]
if new_unit:
blueprints.insert(0, new_unit)
blueprint = SubrequestsBlueprint.parse_obj(blueprints)
response = self.farmos_client.subrequests.send(blueprint, format=Format.json)
result = json.loads(response["create-log#body{0}"]["body"])
return result
def redirect_after_save(self, result):
return self.redirect(
self.request.route_url(
"farmos_logs_harvest.view", uuid=result["data"]["id"]
)
)
def includeme(config):
EggsQuickForm.defaults(config)