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)
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):
def __init__(self, request, route_prefix, *args, **kwargs):
@ -114,6 +142,20 @@ class FarmOSRefs(WuttaSet):
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):
def serialize(self, node, appstruct):
@ -128,6 +170,20 @@ class FarmOSLocationRefs(WuttaSet):
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):
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.db import Session
from wuttafarm.web.util import render_quantity_objects
class ImageWidget(Widget):
"""
@ -54,6 +56,26 @@ class ImageWidget(Widget):
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):
"""
Generic widget to display "any reference field" - as a link to
@ -111,6 +133,33 @@ class FarmOSRefsWidget(Widget):
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):
"""
Widget to display a "Locations" field for an asset.
@ -139,6 +188,40 @@ class FarmOSLocationRefsWidget(Widget):
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):
"""
Widget to display an "animal type" field.

View file

@ -35,29 +35,48 @@ class WuttaFarmMenuHandler(base.MenuHandler):
enum = self.app.enum
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:
return [
quick_menu,
self.make_farmos_asset_menu(request),
self.make_farmos_log_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:
return [
quick_menu,
self.make_asset_menu(request),
self.make_log_menu(request),
self.make_farmos_full_menu(request),
self.make_admin_menu(request, include_people=True),
admin_menu,
]
else: # FARMOS_INTEGRATION_MODE_NONE
return [
quick_menu,
self.make_asset_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):
return {
"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
"""
from webhelpers2.html import HTML
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):
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_observation")
# quick form views
# (nb. these work with all integration modes)
config.include("wuttafarm.web.views.quick")
# views for farmOS
if mode != enum.FARMOS_INTEGRATION_MODE_NONE:
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 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):
locations = []
for location in value:
@ -151,15 +141,14 @@ class AssetMasterView(FarmOSMasterView):
return {"asset_type", "location", "owner", "image"}
def get_instance(self):
asset = self.farmos_client.resource.get_id(
"asset",
result = self.farmos_client.asset.get_id(
self.farmos_asset_type,
self.request.matchdict["uuid"],
params={"include": ",".join(self.get_farmos_api_includes())},
)
self.raw_json = asset
included = {obj["id"]: obj for obj in asset.get("included", [])}
return self.normalize_asset(asset["data"], included)
self.raw_json = result
included = {obj["id"]: obj for obj in result.get("included", [])}
return self.normalize_asset(result["data"], included)
def get_instance_title(self, asset):
return asset["name"]

View file

@ -26,11 +26,27 @@ View for farmOS Harvest Logs
import datetime
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 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):
@ -39,48 +55,183 @@ class LogMasterView(FarmOSMasterView):
"""
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 = [
"name",
"timestamp",
"status",
"drupal_id",
"timestamp",
"name",
"assets",
"quantities",
"is_group_assignment",
"owners",
]
sort_defaults = ("timestamp", "desc")
filter_defaults = {
"name": {"active": True, "verb": "contains"},
"status": {"active": True, "verb": "not_equal", "value": "abandoned"},
}
form_fields = [
"name",
"timestamp",
"status",
"assets",
"quantities",
"notes",
"status",
"log_type_name",
"owners",
"quick",
"drupal_id",
]
def get_grid_data(self, columns=None, session=None):
result = self.farmos_client.log.get(self.farmos_log_type)
return [self.normalize_log(l) for l in result["data"]]
def get_farmos_api_includes(self):
return {"log_type", "quantity", "asset", "owner"}
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):
g = grid
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
g.set_link("name")
g.set_searchable("name")
g.set_sorter("name", SimpleSorter("name"))
g.set_filter("name", StringFilter)
# timestamp
g.set_renderer("timestamp", "datetime")
# assets
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):
log = self.farmos_client.log.get_id(
self.farmos_log_type, self.request.matchdict["uuid"]
result = self.farmos_client.log.get_id(
self.farmos_log_type,
self.request.matchdict["uuid"],
params={"include": ",".join(self.get_farmos_api_includes())},
)
self.raw_json = log
return self.normalize_log(log["data"])
self.raw_json = result
included = {obj["id"]: obj for obj in result.get("included", [])}
return self.normalize_log(result["data"], included)
def get_instance_title(self, log):
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"]:
timestamp = datetime.datetime.fromisoformat(timestamp)
@ -89,26 +240,126 @@ class LogMasterView(FarmOSMasterView):
if notes := log["attributes"]["notes"]:
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 {
"uuid": log["id"],
"drupal_id": log["attributes"]["drupal_internal__id"],
"log_type": log_type_object,
"log_type_name": log_type_name,
"name": log["attributes"]["name"],
"timestamp": timestamp,
"assets": asset_objects,
"quantities": quantity_objects,
"is_group_assignment": log["attributes"]["is_group_assignment"],
"quick": log["attributes"]["quick"],
"status": log["attributes"]["status"],
"notes": notes or colander.null,
"owners": owner_objects,
}
def configure_form(self, form):
f = form
super().configure_form(f)
enum = self.app.enum
log = f.model_instance
# timestamp
f.set_node("timestamp", WuttaDateTime())
f.set_widget("timestamp", WuttaDateTimeWidget(self.request))
# assets
f.set_node("assets", FarmOSAssetRefs(self.request))
# quantities
f.set_node("quantities", FarmOSQuantityRefs(self.request))
# 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):
model = self.app.model
session = self.Session()

View file

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

View file

@ -28,6 +28,7 @@ import json
import colander
import markdown
from webhelpers2.html import tags
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import WuttaDateTime
@ -99,6 +100,16 @@ class FarmOSMasterView(MasterView):
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):
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 wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.schema import FarmOSRef
from wuttafarm.web.forms.schema import FarmOSUnitRef
class QuantityTypeView(FarmOSMasterView):
@ -220,7 +220,7 @@ class QuantityMasterView(FarmOSMasterView):
f.set_widget("changed", WuttaDateTimeWidget(self.request))
# units
f.set_node("units", FarmOSRef(self.request, "farmos_units"))
f.set_node("units", FarmOSUnitRef())
class StandardQuantityView(QuantityMasterView):

View file

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