diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 469a466..a5c396b 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -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): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index dfbaefe..5fc9d55 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -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. diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index be59006..6ce4a8d 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -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", diff --git a/src/wuttafarm/web/templates/quick/form.mako b/src/wuttafarm/web/templates/quick/form.mako new file mode 100644 index 0000000..4a4f75c --- /dev/null +++ b/src/wuttafarm/web/templates/quick/form.mako @@ -0,0 +1,14 @@ +<%inherit file="/form.mako" /> + +<%def name="title()">${index_title} » ${form_title} + +<%def name="content_title()">${form_title} + +<%def name="render_form_tag()"> + +

+ ${help_text} +

+ + ${parent.render_form_tag()} + diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py index cd4ec0d..2d51851 100644 --- a/src/wuttafarm/web/util.py +++ b/src/wuttafarm/web/util.py @@ -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}" diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 6f77e57..0d58a72 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -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") diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index f985c6b..d1ae226 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -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"] diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index a3e804f..4c704ce 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -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() diff --git a/src/wuttafarm/web/views/farmos/logs_harvest.py b/src/wuttafarm/web/views/farmos/logs_harvest.py index 0f39a5a..08b2629 100644 --- a/src/wuttafarm/web/views/farmos/logs_harvest.py +++ b/src/wuttafarm/web/views/farmos/logs_harvest.py @@ -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() diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 5c4c635..36d1778 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -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: diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py index 414474b..8aafeea 100644 --- a/src/wuttafarm/web/views/farmos/quantities.py +++ b/src/wuttafarm/web/views/farmos/quantities.py @@ -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): diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index cf77967..eeef49e 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -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") diff --git a/src/wuttafarm/web/views/quick/__init__.py b/src/wuttafarm/web/views/quick/__init__.py new file mode 100644 index 0000000..92595e1 --- /dev/null +++ b/src/wuttafarm/web/views/quick/__init__.py @@ -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 . +# +################################################################################ +""" +Quick Form views for farmOS +""" + +from .base import QuickFormView + + +def includeme(config): + config.include("wuttafarm.web.views.quick.eggs") diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py new file mode 100644 index 0000000..29ba7ef --- /dev/null +++ b/src/wuttafarm/web/views/quick/base.py @@ -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 . +# +################################################################################ +""" +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}") diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py new file mode 100644 index 0000000..c505381 --- /dev/null +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -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 . +# +################################################################################ +""" +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)