From 8ef59bc53f785786f88327c461e6a00ae284c6ce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 Feb 2026 19:37:55 -0600 Subject: [PATCH 001/166] build: no tests to run yet --- tasks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index ce0b62e..fead300 100644 --- a/tasks.py +++ b/tasks.py @@ -15,7 +15,9 @@ def release(c, skip_tests=False): Release a new version of WuttaFarm """ if not skip_tests: - c.run("pytest") + # TODO + # c.run("pytest") + pass if os.path.exists("dist"): shutil.rmtree("dist") From 07b6fa7c22a309a00ff7857ab23b2d53abcca47a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Feb 2026 08:52:10 -0600 Subject: [PATCH 002/166] fix: require minimum version for wuttaweb --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c159419..2f94e1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ license = {text = "GNU General Public License v3"} dependencies = [ "farmOS", "psycopg2", - "WuttaWeb[continuum]", + "WuttaWeb[continuum]>=0.27.2", ] From bd972e30db69c49bd0e2e5f5b4162218bd0598f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Feb 2026 08:53:22 -0600 Subject: [PATCH 003/166] fix: add some more info when viewing animal --- src/wuttafarm/web/views/farmos/animals.py | 58 ++++++++++++++++++++--- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index d765cd4..ab5167c 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -25,6 +25,8 @@ Master view for Farm Animals import datetime +import colander + from farmOS import farmOS from wuttafarm.web.views.farmos import FarmOSMasterView @@ -46,7 +48,8 @@ class AnimalView(FarmOSMasterView): farmos_refurl_path = "/assets/animal" labels = { - "species_breed": "Species / Breed", + "is_castrated": "Castrated", + "location_name": "Current Location", "raw_image_url": "Raw Image URL", "large_image_url": "Large Image URL", "thumbnail_image_url": "Thumbnail Image URL", @@ -54,20 +57,24 @@ class AnimalView(FarmOSMasterView): grid_columns = [ "name", - "species_breed", "birthdate", "sex", - "location", + "is_castrated", + "status", ] sort_defaults = "name" form_fields = [ "name", - "species_breed", + "animal_type_name", "birthdate", "sex", - "location", + "is_castrated", + "status", + "owners", + "location_name", + "notes", "raw_image_url", "large_image_url", "thumbnail_image_url", @@ -98,8 +105,38 @@ class AnimalView(FarmOSMasterView): # instance data data = self.normalize_animal(animal["data"]) - # add image_url if relationships := animal["data"].get("relationships"): + + # add animal type + if animal_type := relationships.get("animal_type"): + if animal_type["data"]: + animal_type = self.farmos_client.resource.get_id( + "taxonomy_term", "animal_type", animal_type["data"]["id"] + ) + data["animal_type_name"] = animal_type["data"]["attributes"]["name"] + + # add location + if location := relationships.get("location"): + if location["data"]: + location = self.farmos_client.resource.get_id( + "asset", "structure", location["data"][0]["id"] + ) + data["location_name"] = location["data"]["attributes"]["name"] + + # add owners + if owner := relationships.get("owner"): + owners = [] + for owner_data in owner["data"]: + owners.append( + self.farmos_client.resource.get_id( + "user", "user", owner_data["id"] + ) + ) + data["owners"] = ", ".join( + [o["data"]["attributes"]["display_name"] for o in owners] + ) + + # add image urls if image := relationships.get("image"): if image["data"]: image = self.farmos_client.resource.get_id( @@ -135,7 +172,10 @@ class AnimalView(FarmOSMasterView): "species_breed": "", # TODO "birthdate": birthdate, "sex": animal["attributes"]["sex"], + "is_castrated": animal["attributes"]["is_castrated"], "location": "", # TODO + "status": animal["attributes"]["status"], + "notes": animal["attributes"]["notes"]["value"], } def configure_form(self, form): @@ -143,6 +183,12 @@ class AnimalView(FarmOSMasterView): super().configure_form(f) animal = f.model_instance + # is_castrated + f.set_node("is_castrated", colander.Boolean()) + + # notes + f.set_widget("notes", "notes") + # image if url := animal.get("large_image_url"): f.set_widget("image", AnimalImage()) From 45bb985465fe842f02a4d72296ff8feb0166e466 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Feb 2026 09:18:44 -0600 Subject: [PATCH 004/166] fix: customize app installer to configure farmos_url --- src/wuttafarm/app.py | 1 + src/wuttafarm/install.py | 41 +++++ .../templates/install/upgrade.sh.mako | 29 +++ src/wuttafarm/templates/install/web.conf.mako | 90 +++++++++ .../templates/install/wutta.conf.mako | 171 ++++++++++++++++++ 5 files changed, 332 insertions(+) create mode 100644 src/wuttafarm/install.py create mode 100755 src/wuttafarm/templates/install/upgrade.sh.mako create mode 100644 src/wuttafarm/templates/install/web.conf.mako create mode 100644 src/wuttafarm/templates/install/wutta.conf.mako diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index bafbdad..7d63a40 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -32,6 +32,7 @@ class WuttaFarmAppHandler(base.AppHandler): """ default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler" + default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler" def get_farmos_handler(self): """ diff --git a/src/wuttafarm/install.py b/src/wuttafarm/install.py new file mode 100644 index 0000000..b67b6e2 --- /dev/null +++ b/src/wuttafarm/install.py @@ -0,0 +1,41 @@ +# -*- 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 . +# +################################################################################ +""" +Install handler for WuttaFarm +""" + +from wuttjamaican import install as base + + +class WuttaFarmInstallHandler(base.InstallHandler): + """ + Custom install handler for WuttaFarm + """ + + template_paths = ["wuttafarm:installer-templates"] + + def prompt_user_for_context(self): + context = super().prompt_user_for_context() + + context["farmos_url"] = self.prompt_generic("farmos_url", required=True) + + return context diff --git a/src/wuttafarm/templates/install/upgrade.sh.mako b/src/wuttafarm/templates/install/upgrade.sh.mako new file mode 100755 index 0000000..aadc31a --- /dev/null +++ b/src/wuttafarm/templates/install/upgrade.sh.mako @@ -0,0 +1,29 @@ +#!/bin/sh -e +<%text>################################################## +# +# ${app_title} - upgrade script +# +<%text>################################################## + +if [ "$1" = "--verbose" ]; then + VERBOSE='--verbose' + QUIET= +else + VERBOSE= + QUIET='--quiet' +fi + +cd ${envdir} + +PIP='bin/pip' +ALEMBIC='bin/alembic' + +# upgrade pip and friends +$PIP install $QUIET --disable-pip-version-check --upgrade pip +$PIP install $QUIET --upgrade setuptools wheel + +# upgrade app proper +$PIP install $QUIET --upgrade --upgrade-strategy eager '${pypi_name}' + +# migrate schema +$ALEMBIC -c app/wutta.conf upgrade heads diff --git a/src/wuttafarm/templates/install/web.conf.mako b/src/wuttafarm/templates/install/web.conf.mako new file mode 100644 index 0000000..4d2b3c7 --- /dev/null +++ b/src/wuttafarm/templates/install/web.conf.mako @@ -0,0 +1,90 @@ +## -*- mode: conf; -*- + +<%text>############################################################ +# +# ${app_title} - web app config +# +<%text>############################################################ + + +<%text>############################## +# wutta +<%text>############################## + +${self.section_wutta_config()} + + +<%text>############################## +# pyramid +<%text>############################## + +${self.section_app_main()} + +${self.section_server_main()} + + +<%text>############################## +# logging +<%text>############################## + +${self.sectiongroup_logging()} + + +###################################################################### +## section templates below +###################################################################### + +<%def name="section_wutta_config()"> +[wutta.config] +require = %(here)s/wutta.conf + + +<%def name="section_app_main()"> +[app:main] +#use = egg:wuttaweb +use = egg:${egg_name} + +pyramid.reload_templates = true +pyramid.debug_all = true +pyramid.default_locale_name = en +#pyramid.includes = pyramid_debugtoolbar + +beaker.session.type = file +beaker.session.data_dir = %(here)s/cache/sessions/data +beaker.session.lock_dir = %(here)s/cache/sessions/lock +beaker.session.secret = ${beaker_secret} +beaker.session.key = ${beaker_key} + +exclog.extra_info = true + +# required for wuttaweb +wutta.config = %(__file__)s + + +<%def name="section_server_main()"> +[server:main] +use = egg:waitress#main +host = ${pyramid_host} +port = ${pyramid_port} + +# NOTE: this is needed for local reverse proxy stuff to work with HTTPS +# https://docs.pylonsproject.org/projects/waitress/en/latest/reverse-proxy.html +# https://docs.pylonsproject.org/projects/waitress/en/latest/arguments.html +trusted_proxy = 127.0.0.1 +trusted_proxy_headers = x-forwarded-for x-forwarded-host x-forwarded-proto x-forwarded-port +clear_untrusted_proxy_headers = True + +# TODO: leave this empty if proxy serves as root site, e.g. https://wutta.example.com/ +# url_prefix = + +# TODO: or, if proxy serves as subpath of root site, e.g. https://wutta.example.com/backend/ +# url_prefix = /backend + + +<%def name="sectiongroup_logging()"> +[handler_console] +level = INFO + +[handler_file] +args = (${repr(os.path.join(appdir, 'log', 'web.log'))}, 'a', 1000000, 100, 'utf_8') + diff --git a/src/wuttafarm/templates/install/wutta.conf.mako b/src/wuttafarm/templates/install/wutta.conf.mako new file mode 100644 index 0000000..5049808 --- /dev/null +++ b/src/wuttafarm/templates/install/wutta.conf.mako @@ -0,0 +1,171 @@ +## -*- mode: conf; -*- + +<%text>############################################################ +# +# ${app_title} - base config +# +<%text>############################################################ + + +[farmos] +url.base = ${farmos_url} + + +<%text>############################## +# wutta +<%text>############################## + +${self.section_wutta()} + +${self.section_wutta_config()} + +${self.section_wutta_db()} + +${self.section_wutta_mail()} + +${self.section_wutta_upgrades()} + + +<%text>############################## +# alembic +<%text>############################## + +${self.section_alembic()} + + +<%text>############################## +# logging +<%text>############################## + +${self.sectiongroup_logging()} + + +###################################################################### +## section templates below +###################################################################### + +<%def name="section_wutta()"> +[wutta] +#app_title = ${app_title} + + +<%def name="section_wutta_config()"> +[wutta.config] +#require = /etc/wutta/wutta.conf +configure_logging = true +usedb = true +preferdb = true + + +<%def name="section_wutta_db()"> +[wutta.db] +default.url = ${db_url} + +% if wants_continuum: +[wutta_continuum] +enable_versioning = true +% endif + + +<%def name="section_wutta_mail()"> +[wutta.mail] + +# this is the global email shutoff switch +#send_emails = false + +# recommended setup is to always talk to postfix on localhost and then +# it can handle any need complexities, e.g. sending to relay +smtp.server = localhost + +# by default only email templates from wuttjamaican are used +templates = wuttjamaican:templates/mail + +## TODO +## # this is the "default" email profile, from which all others initially +## # inherit, but most/all profiles will override these values +## default.prefix = [${app_title}] +## default.from = wutta@localhost +## default.to = root@localhost +# nb. in test environment it can be useful to disable by default, and +# then selectively enable certain (e.g. feedback, upgrade) emails +#default.enabled = false + + +<%def name="section_wutta_upgrades()"> +## TODO +## [wutta.upgrades] +## command = ${os.path.join(appdir, 'upgrade.sh')} --verbose +## files = ${os.path.join(appdir, 'data', 'upgrades')} + + +<%def name="section_alembic()"> +[alembic] +script_location = wuttjamaican.db:alembic +% if wants_continuum: +version_locations = ${pkg_name}.db:alembic/versions wutta_continuum.db:alembic/versions wuttjamaican.db:alembic/versions +% else: +version_locations = ${pkg_name}.db:alembic/versions wuttjamaican.db:alembic/versions +% endif + + +<%def name="sectiongroup_logging()"> +[loggers] +keys = root, beaker, exc_logger, sqlalchemy, txn + +[handlers] +keys = file, console, email + +[formatters] +keys = generic, console + +[logger_root] +handlers = file, console +level = DEBUG + +[logger_beaker] +qualname = beaker +handlers = +level = INFO + +[logger_exc_logger] +qualname = exc_logger +handlers = email +level = ERROR + +[logger_sqlalchemy] +qualname = sqlalchemy.engine +handlers = +# handlers = file +# level = INFO + +[logger_txn] +qualname = txn +handlers = +level = INFO + +[handler_file] +class = handlers.RotatingFileHandler +args = (${repr(os.path.join(appdir, 'log', 'wutta.log'))}, 'a', 1000000, 100, 'utf_8') +formatter = generic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +formatter = console +# formatter = generic +# level = INFO +# level = WARNING + +[handler_email] +class = handlers.SMTPHandler +args = ('localhost', 'wutta@localhost', ['root@localhost'], "[${app_title}] Logging") +formatter = generic +level = ERROR + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(funcName)s: %(message)s +datefmt = %Y-%m-%d %H:%M:%S + +[formatter_console] +format = %(levelname)-5.5s [%(name)s][%(threadName)s] %(funcName)s: %(message)s + From 19dcc2d24b0402d8704f9043c4e42609c6a4ba3e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 5 Feb 2026 20:06:55 -0600 Subject: [PATCH 005/166] fix: preserve oauth2 token so auto-refresh works correctly also add `get_farmos_client()` handler methods --- src/wuttafarm/app.py | 12 ++++++++++-- src/wuttafarm/auth.py | 4 +--- src/wuttafarm/farmos/__init__.py | 0 src/wuttafarm/{farmos.py => farmos/handler.py} | 10 ++++++++++ src/wuttafarm/web/views/farmos/animals.py | 2 -- src/wuttafarm/web/views/farmos/master.py | 14 ++++++++++---- 6 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 src/wuttafarm/farmos/__init__.py rename src/wuttafarm/{farmos.py => farmos/handler.py} (87%) diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 7d63a40..26c6ef8 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -43,7 +43,7 @@ class WuttaFarmAppHandler(base.AppHandler): if "farmos" not in self.handlers: spec = self.config.get( f"{self.appname}.farmos_handler", - default="wuttafarm.farmos:FarmOSHandler", + default="wuttafarm.farmos.handler:FarmOSHandler", ) factory = self.load_object(spec) self.handlers["farmos"] = factory(self.config) @@ -52,7 +52,15 @@ class WuttaFarmAppHandler(base.AppHandler): def get_farmos_url(self, *args, **kwargs): """ Get a farmOS URL. This is a convenience wrapper around - :meth:`~wuttafarm.farmos.FarmOSHandler.get_farmos_url()`. + :meth:`~wuttafarm.farmos.handler.FarmOSHandler.get_farmos_url()`. """ handler = self.get_farmos_handler() return handler.get_farmos_url(*args, **kwargs) + + def get_farmos_client(self, *args, **kwargs): + """ + Get a farmOS client. This is a convenience wrapper around + :meth:`~wuttafarm.farmos.handler.FarmOSHandler.get_farmos_client()`. + """ + handler = self.get_farmos_handler() + return handler.get_farmos_client(*args, **kwargs) diff --git a/src/wuttafarm/auth.py b/src/wuttafarm/auth.py index ab3f67a..1155046 100644 --- a/src/wuttafarm/auth.py +++ b/src/wuttafarm/auth.py @@ -25,7 +25,6 @@ Auth handler for use with farmOS from uuid import UUID -from farmOS import farmOS from oauthlib.oauth2.rfc6749.errors import InvalidGrantError from sqlalchemy import orm @@ -89,8 +88,7 @@ class WuttaFarmAuthHandler(AuthHandler): return None def get_farmos_oauth2_token(self, username, password): - url = self.app.get_farmos_url() - client = farmOS(url) + client = self.app.get_farmos_client() try: return client.authorize(username=username, password=password) except InvalidGrantError: diff --git a/src/wuttafarm/farmos/__init__.py b/src/wuttafarm/farmos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wuttafarm/farmos.py b/src/wuttafarm/farmos/handler.py similarity index 87% rename from src/wuttafarm/farmos.py rename to src/wuttafarm/farmos/handler.py index 16ec1a6..78e76b6 100644 --- a/src/wuttafarm/farmos.py +++ b/src/wuttafarm/farmos/handler.py @@ -23,6 +23,8 @@ farmOS integration handler """ +from farmOS import farmOS + from wuttjamaican.app import GenericHandler @@ -32,6 +34,14 @@ class FarmOSHandler(GenericHandler): :term:`handler`. """ + def get_farmos_client(self, hostname=None, **kwargs): + """ + Returns a new farmOS API client. + """ + if not hostname: + hostname = self.get_farmos_url() + return farmOS(hostname, **kwargs) + def get_farmos_url(self, path=None, require=True): """ Returns the base URL for farmOS, or one with ``path`` appended. diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index ab5167c..aa00412 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -27,8 +27,6 @@ import datetime import colander -from farmOS import farmOS - from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.widgets import AnimalImage diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 40f7bfc..d653418 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -23,8 +23,6 @@ Base class for farmOS master views """ -from farmOS import farmOS - from wuttaweb.views import MasterView @@ -54,8 +52,16 @@ class FarmOSMasterView(MasterView): if not token: raise self.forbidden() - url = self.app.get_farmos_url() - return farmOS(url, token=token) + def token_updater(token): + self.request.session["farmos.oauth2.token"] = token + + # 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) + + return self.app.get_farmos_client(token=token, token_updater=token_updater) def get_template_context(self, context): From 039aa600380b609ec591c22b6b8047ff5b2d33b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 5 Feb 2026 21:17:11 -0600 Subject: [PATCH 006/166] =?UTF-8?q?bump:=20version=200.1.0=20=E2=86=92=200?= =?UTF-8?q?.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c24d5c8..60daa76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.1.1 (2026-02-05) + +### Fix + +- preserve oauth2 token so auto-refresh works correctly +- customize app installer to configure farmos_url +- add some more info when viewing animal +- require minimum version for wuttaweb + ## v0.1.0 (2026-02-03) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 2f94e1b..22e8ba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.1.0" +version = "0.1.1" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 5b96fcfc2acd21771669c62027aeca693c4da161 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Feb 2026 14:24:41 -0600 Subject: [PATCH 007/166] fix: add support for farmOS/OAuth2 Authorization Code grant/workflow --- src/wuttafarm/web/templates/auth/login.mako | 33 ++++++++ src/wuttafarm/web/views/auth.py | 86 +++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/wuttafarm/web/templates/auth/login.mako diff --git a/src/wuttafarm/web/templates/auth/login.mako b/src/wuttafarm/web/templates/auth/login.mako new file mode 100644 index 0000000..2c71694 --- /dev/null +++ b/src/wuttafarm/web/templates/auth/login.mako @@ -0,0 +1,33 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/auth/login.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> + +<%def name="page_content()"> +
+ +
+ +
+
+ ${form.render_vue_tag()} +
+
+ +
+ - OR - +
+ +
+
+ +
+
+ +
+
+ diff --git a/src/wuttafarm/web/views/auth.py b/src/wuttafarm/web/views/auth.py index ea3207f..77e9f20 100644 --- a/src/wuttafarm/web/views/auth.py +++ b/src/wuttafarm/web/views/auth.py @@ -23,7 +23,12 @@ Auth views """ +from oauthlib.oauth2 import AccessDeniedError +from requests_oauthlib import OAuth2Session + from wuttaweb.views import auth as base +from wuttaweb.auth import login_user +from wuttaweb.db import Session class AuthView(base.AuthView): @@ -47,6 +52,87 @@ class AuthView(base.AuthView): return None + def get_farmos_oauth2_session(self): + return OAuth2Session( + client_id="farm", + scope="farm_manager", + redirect_uri=self.request.route_url("farmos_oauth_callback"), + ) + + def farmos_oauth_login(self): + """ + View to initiate OAuth2 workflow. + """ + oauth = self.get_farmos_oauth2_session() + auth_url, state = oauth.authorization_url( + self.app.get_farmos_url("/oauth/authorize") + ) + return self.redirect(auth_url) + + def farmos_oauth_callback(self): + """ + View for OAuth2 workflow, provided as redirect URL when + authorizing. + """ + session = Session() + auth = self.app.get_auth_handler() + oauth = self.get_farmos_oauth2_session() + + try: + # get oauth token from farmOS + token = oauth.fetch_token( + self.app.get_farmos_url("/oauth/token"), + authorization_response=self.request.current_route_url(), + include_client_id=True, + ) + + except AccessDeniedError: + self.request.session.flash("Access to farmOS was denied.", "error") + return self.redirect(self.request.route_url("login")) + + # save token in user session + self.request.session["farmos.oauth2.token"] = token + + # get (or create) native app user + farmos_client = self.app.get_farmos_client(token=token) + info = farmos_client.info() + farmos_user = farmos_client.resource.get_id( + "user", "user", info["meta"]["links"]["me"]["meta"]["id"] + ) + user = auth.get_or_make_farmos_user( + session, farmos_user["data"]["attributes"]["name"] + ) + if not user: + self.request.session.flash( + "farmOS authentication was successful, but user is " + "not allowed login to {self.app.get_node_title()}.", + "error", + ) + return self.redirect(self.request.route_url("login")) + + # delare user is logged in + headers = login_user(self.request, user) + referrer = self.request.get_referrer() + return self.redirect(referrer, headers=headers) + + @classmethod + def defaults(cls, config): + cls._auth_defaults(config) + cls._wuttafarm_defaults(config) + + @classmethod + def _wuttafarm_defaults(cls, config): + + # farmos oauth login + config.add_route("farmos_oauth_login", "/farmos/oauth/login") + config.add_view(cls, attr="farmos_oauth_login", route_name="farmos_oauth_login") + + # farmos oauth callback + config.add_route("farmos_oauth_callback", "/farmos/oauth/callback") + config.add_view( + cls, attr="farmos_oauth_callback", route_name="farmos_oauth_callback" + ) + def defaults(config, **kwargs): local = globals() From b546c9e97d79f9e0475b833947dd67bcedcf3f35 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Feb 2026 14:36:03 -0600 Subject: [PATCH 008/166] =?UTF-8?q?bump:=20version=200.1.1=20=E2=86=92=200?= =?UTF-8?q?.1.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60daa76..cfc633a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.1.2 (2026-02-06) + +### Fix + +- add support for farmOS/OAuth2 Authorization Code grant/workflow + ## v0.1.1 (2026-02-05) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 22e8ba1..9c8de06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.1.1" +version = "0.1.2" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From b161109d6574687f05822a66bc3d4ae42a0b94de Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Feb 2026 17:42:47 -0600 Subject: [PATCH 009/166] fix: fix a couple more edge cases around oauth2 token refresh --- src/wuttafarm/web/util.py | 40 ++++++++++++++++++++++++ src/wuttafarm/web/views/auth.py | 10 +++++- src/wuttafarm/web/views/farmos/master.py | 8 +++-- 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 src/wuttafarm/web/util.py diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py new file mode 100644 index 0000000..65d637d --- /dev/null +++ b/src/wuttafarm/web/util.py @@ -0,0 +1,40 @@ +# -*- 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 . +# +################################################################################ +""" +Misc. utilities for web app +""" + + +def save_farmos_oauth2_token(request, token): + """ + Common logic for saving the given OAuth2 token within the user + session. This function is called from 2 places: + + * :meth:`wuttafarm.web.views.auth.farmos_oauth_callback()` + * :meth:`wuttafarm.web.views.farmos.master.FarmOSMasterView.get_farmos_client()` + """ + # nb. we pretend the token expires 1 minute early, to avoid edge + # cases around token refresh + token["expires_at"] -= 60 + + # save token to user session + request.session["farmos.oauth2.token"] = token diff --git a/src/wuttafarm/web/views/auth.py b/src/wuttafarm/web/views/auth.py index 77e9f20..db757cc 100644 --- a/src/wuttafarm/web/views/auth.py +++ b/src/wuttafarm/web/views/auth.py @@ -30,6 +30,8 @@ from wuttaweb.views import auth as base from wuttaweb.auth import login_user from wuttaweb.db import Session +from wuttafarm.web.util import save_farmos_oauth2_token + class AuthView(base.AuthView): """ @@ -91,7 +93,13 @@ class AuthView(base.AuthView): return self.redirect(self.request.route_url("login")) # save token in user session - self.request.session["farmos.oauth2.token"] = token + save_farmos_oauth2_token(self.request, token) + + # 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) # get (or create) native app user farmos_client = self.app.get_farmos_client(token=token) diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index d653418..eed04d1 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -25,6 +25,8 @@ Base class for farmOS master views from wuttaweb.views import MasterView +from wuttafarm.web.util import save_farmos_oauth2_token + class FarmOSMasterView(MasterView): """ @@ -52,15 +54,15 @@ class FarmOSMasterView(MasterView): if not token: raise self.forbidden() - def token_updater(token): - self.request.session["farmos.oauth2.token"] = token - # 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) def get_template_context(self, context): From 9bd1c07193bb296d44cb6e803d8b38fa31b74977 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Feb 2026 17:43:06 -0600 Subject: [PATCH 010/166] =?UTF-8?q?bump:=20version=200.1.2=20=E2=86=92=200?= =?UTF-8?q?.1.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc633a..b98f5a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.1.3 (2026-02-06) + +### Fix + +- fix a couple more edge cases around oauth2 token refresh + ## v0.1.2 (2026-02-06) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 9c8de06..3178ab3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.1.2" +version = "0.1.3" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 87b97f53b80942d60a2426ab072810ca6c0d1b68 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Feb 2026 19:18:33 -0600 Subject: [PATCH 011/166] build: explicitly ignore emacs backup files, for sake of sdist --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index baecfaa..d65955d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*~ *.pyc dist/ docs/_build/ From 768859b6b92340ef2d07889871da089472ccaa2e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 09:48:26 -0600 Subject: [PATCH 012/166] fix: add custom style to better match farmOS color scheme --- .gitignore | 3 + pyproject.toml | 11 +- src/wuttafarm/web/app.py | 11 + style/package-lock.json | 2358 ++++++++++++++++++++++++++++++++ style/package.json | 14 + style/src/index.js | 1 + style/src/wuttafarm-buefy.scss | 85 ++ style/webpack.config.js | 13 + tasks.py | 16 + 9 files changed, 2511 insertions(+), 1 deletion(-) create mode 100644 style/package-lock.json create mode 100644 style/package.json create mode 100644 style/src/index.js create mode 100644 style/src/wuttafarm-buefy.scss create mode 100644 style/webpack.config.js diff --git a/.gitignore b/.gitignore index d65955d..8178940 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ *.pyc dist/ docs/_build/ +src/wuttafarm/web/static/css/wuttafarm-buefy.css +style/dist/ +style/node_modules/ diff --git a/pyproject.toml b/pyproject.toml index 3178ab3..16e63bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ license = {text = "GNU General Public License v3"} dependencies = [ "farmOS", "psycopg2", - "WuttaWeb[continuum]>=0.27.2", + "WuttaWeb[continuum]>=0.27.3", ] @@ -62,3 +62,12 @@ update_changelog_on_bump = true [tool.hatch.build.targets.wheel] packages = ["src/wuttafarm"] + +[tool.hatch.build.targets.sdist] +exclude = [ + "style/node_modules/", +] + +[tool.hatch.build.targets.sdist.force-include] +# nb. this is necessary due to git ignoring this file (iiuc) +"src/wuttafarm/web/static/css/wuttafarm-buefy.css" = "src/wuttafarm/web/static/css/wuttafarm-buefy.css" diff --git a/src/wuttafarm/web/app.py b/src/wuttafarm/web/app.py index b21bd5c..83c9817 100644 --- a/src/wuttafarm/web/app.py +++ b/src/wuttafarm/web/app.py @@ -23,6 +23,8 @@ WuttaFarm web app """ +import os + from wuttaweb import app as base @@ -43,6 +45,15 @@ def main(global_config, **settings): wutta_config = base.make_wutta_config(settings) pyramid_config = base.make_pyramid_config(settings) + # custom buefy css + app = wutta_config.get_app() + path = app.resource_path("wuttafarm.web.static:css/wuttafarm-buefy.css") + if os.path.exists(path): + # TODO: this is not robust enough, probably..but works for me/now + wutta_config.setdefault( + "wuttaweb.liburl.buefy_css", "/wuttafarm/css/wuttafarm-buefy.css" + ) + # bring in the rest of WuttaFarm pyramid_config.include("wuttafarm.web.static") pyramid_config.include("wuttafarm.web.subscribers") diff --git a/style/package-lock.json b/style/package-lock.json new file mode 100644 index 0000000..94eacb1 --- /dev/null +++ b/style/package-lock.json @@ -0,0 +1,2358 @@ +{ + "name": "style", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "buefy": "^0.9.29", + "css-loader": "^7.1.3", + "mini-css-extract-plugin": "^2.10.0", + "sass": "^1.97.3", + "sass-loader": "^16.0.7", + "webpack-cli": "^6.0.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", + "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", + "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz", + "integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.23.5", + "postcss": "^8.4.14", + "source-map": "^0.6.1" + }, + "optionalDependencies": { + "prettier": "^1.18.2 || ^2.0.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", + "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz", + "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz", + "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buefy": { + "version": "0.9.29", + "resolved": "https://registry.npmjs.org/buefy/-/buefy-0.9.29.tgz", + "integrity": "sha512-iJutB/ezTQcxqcKvKz129DzomMjNRGdy23lJBtoFQBG//2NveTcPkN/VRUF70z2WH3JB/5nmVeoxr8QOPcs42w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bulma": "0.9.4" + }, + "engines": { + "node": ">= 4.0.0", + "npm": ">= 3.0.0" + }, + "peerDependencies": { + "vue": "^2.6.11" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bulma": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.4.tgz", + "integrity": "sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.3.tgz", + "integrity": "sha512-frbERmjT0UC5lMheWpJmMilnt9GEhbZJN/heUb7/zaJYeIzj5St9HvDcfshzzOqbsS+rYpMk++2SD3vGETDSyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz", + "integrity": "sha512-540P2c5dYnJlyJxTaSloliZexv8rji6rY8FhQN+WF/82iHQfA23j/xtJx97L+mXOML27EqksSek/g4eK7jaL3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.7.tgz", + "integrity": "sha512-w6q+fRHourZ+e+xA1kcsF27iGM6jdB8teexYCfdUw0sYgcDNeZESnDNT9sUmmPm3ooziwUJXGwZJSTF3kOdBfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz", + "integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==", + "deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-sfc": "2.7.16", + "csstype": "^3.1.0" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", + "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.6.1", + "@webpack-cli/configtest": "^3.0.1", + "@webpack-cli/info": "^3.0.1", + "@webpack-cli/serve": "^3.0.1", + "colorette": "^2.0.14", + "commander": "^12.1.0", + "cross-spawn": "^7.0.3", + "envinfo": "^7.14.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.82.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/style/package.json b/style/package.json new file mode 100644 index 0000000..85250e1 --- /dev/null +++ b/style/package.json @@ -0,0 +1,14 @@ +{ + "main": "webpack.config.js", + "scripts": { + "build": "webpack --mode production" + }, + "devDependencies": { + "buefy": "^0.9.29", + "css-loader": "^7.1.3", + "mini-css-extract-plugin": "^2.10.0", + "sass": "^1.97.3", + "sass-loader": "^16.0.7", + "webpack-cli": "^6.0.1" + } +} diff --git a/style/src/index.js b/style/src/index.js new file mode 100644 index 0000000..2ab2edf --- /dev/null +++ b/style/src/index.js @@ -0,0 +1 @@ +require("./wuttafarm-buefy.scss"); diff --git a/style/src/wuttafarm-buefy.scss b/style/src/wuttafarm-buefy.scss new file mode 100644 index 0000000..6bbc5e3 --- /dev/null +++ b/style/src/wuttafarm-buefy.scss @@ -0,0 +1,85 @@ +// cf. https://v2.buefy.org/documentation/customization + +// Import Bulma's core +@import "~bulma/sass/utilities/_all"; + +// Set your colors +// nb. primary color was stolen from gin theme in farmOS +$primary: rgb(0, 135, 95); +$primary-light: findLightColor($primary); +$primary-dark: findDarkColor($primary); +$primary-invert: findColorInvert($primary); +$twitter: #4099FF; +$twitter-invert: findColorInvert($twitter); + +// Lists and maps +$custom-colors: null !default; +$custom-shades: null !default; + +// Setup $colors to use as bulma classes (e.g. 'is-twitter') +$colors: mergeColorMaps( + ( + "white": ( + $white, + $black, + ), + "black": ( + $black, + $white, + ), + "light": ( + $light, + $light-invert, + ), + "dark": ( + $dark, + $dark-invert, + ), + "primary": ( + $primary, + $primary-invert, + $primary-light, + $primary-dark, + ), + "link": ( + $link, + $link-invert, + $link-light, + $link-dark, + ), + "info": ( + $info, + $info-invert, + $info-light, + $info-dark, + ), + "success": ( + $success, + $success-invert, + $success-light, + $success-dark, + ), + "warning": ( + $warning, + $warning-invert, + $warning-light, + $warning-dark, + ), + "danger": ( + $danger, + $danger-invert, + $danger-light, + $danger-dark, + ), + ), + $custom-colors +); + +// Links +$link: $primary; +$link-invert: $primary-invert; +$link-focus-border: $primary; + +// Import Bulma and Buefy styles +@import "~bulma"; +@import "~buefy/src/scss/buefy"; diff --git a/style/webpack.config.js b/style/webpack.config.js new file mode 100644 index 0000000..5c8b8ef --- /dev/null +++ b/style/webpack.config.js @@ -0,0 +1,13 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + plugins: [new MiniCssExtractPlugin({ + filename: "css/wuttafarm-buefy.css", + })], + module: { + rules: [{ + test: /\.scss$/, + use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"], + }] + }, +}; diff --git a/tasks.py b/tasks.py index fead300..0643aab 100644 --- a/tasks.py +++ b/tasks.py @@ -22,5 +22,21 @@ def release(c, skip_tests=False): if os.path.exists("dist"): shutil.rmtree("dist") + # custom styles for buefy + update_style(c) + c.run("python -m build --sdist") c.run("twine upload dist/*") + + +@task +def update_style(c): + """ + Build/update the `wuttafarm-buefy.css` file + """ + os.chdir("style") + # c.run("nvm use lts/krypton") + c.run("npm install") + c.run("npm run build") + os.chdir(os.pardir) + shutil.copy("style/dist/css/wuttafarm-buefy.css", "src/wuttafarm/web/static/css/") From f42761f359b2d8ecaaed382374cddfacaa8617b7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 09:48:52 -0600 Subject: [PATCH 013/166] =?UTF-8?q?bump:=20version=200.1.3=20=E2=86=92=200?= =?UTF-8?q?.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b98f5a0..b5f0805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.1.4 (2026-02-07) + +### Fix + +- add custom style to better match farmOS color scheme + ## v0.1.3 (2026-02-06) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 16e63bc..41296af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.1.3" +version = "0.1.4" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From d070caae05d4fc689afc1ade39461af435201851 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 12:30:22 -0600 Subject: [PATCH 014/166] fix: fix built wheel to include custom buefy css --- pyproject.toml | 4 ++++ tasks.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 41296af..084bb13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,3 +71,7 @@ exclude = [ [tool.hatch.build.targets.sdist.force-include] # nb. this is necessary due to git ignoring this file (iiuc) "src/wuttafarm/web/static/css/wuttafarm-buefy.css" = "src/wuttafarm/web/static/css/wuttafarm-buefy.css" + +[tool.hatch.build.targets.wheel.force-include] +# nb. this is necessary due to git ignoring this file (iiuc) +"src/wuttafarm/web/static/css/wuttafarm-buefy.css" = "src/wuttafarm/web/static/css/wuttafarm-buefy.css" diff --git a/tasks.py b/tasks.py index 0643aab..23e4323 100644 --- a/tasks.py +++ b/tasks.py @@ -25,7 +25,7 @@ def release(c, skip_tests=False): # custom styles for buefy update_style(c) - c.run("python -m build --sdist") + c.run("python -m build") c.run("twine upload dist/*") From f0a2308bd93911b91a81ccb6f096a83d1a784267 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 12:31:00 -0600 Subject: [PATCH 015/166] =?UTF-8?q?bump:=20version=200.1.4=20=E2=86=92=200?= =?UTF-8?q?.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f0805..5d6142c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.1.5 (2026-02-07) + +### Fix + +- fix built wheel to include custom buefy css + ## v0.1.4 (2026-02-07) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 084bb13..d2c6ef1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.1.4" +version = "0.1.5" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 74155049268d1bd454fe9689f5878548cdad6655 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 14:26:49 -0600 Subject: [PATCH 016/166] feat: add view for farmOS users --- src/wuttafarm/web/forms/schema.py | 47 +++++++ src/wuttafarm/web/forms/widgets.py | 30 +++++ src/wuttafarm/web/menus.py | 6 + src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + src/wuttafarm/web/views/farmos/animals.py | 22 ++-- src/wuttafarm/web/views/farmos/users.py | 139 +++++++++++++++++++++ 7 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 src/wuttafarm/web/forms/schema.py create mode 100644 src/wuttafarm/web/views/farmos/users.py diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py new file mode 100644 index 0000000..132c7e3 --- /dev/null +++ b/src/wuttafarm/web/forms/schema.py @@ -0,0 +1,47 @@ +# -*- 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 . +# +################################################################################ +""" +Custom form widgets for WuttaFarm +""" + +import json + +import colander + + +class UsersType(colander.SchemaType): + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): # pylint: disable=empty-docstring + """ """ + from wuttafarm.web.forms.widgets import UsersWidget + + return UsersWidget(self.request, **kwargs) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 047a8cd..933e8a4 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -23,6 +23,8 @@ Custom form widgets for WuttaFarm """ +import json + import colander from deform.widget import Widget from webhelpers2.html import HTML, tags @@ -45,3 +47,31 @@ class AnimalImage(Widget): return tags.image(cstruct, "animal image", **kw) return super().serialize(field, cstruct, **kw) + + +class UsersWidget(Widget): + """ + Widget to display the list of owners for an asset etc. + """ + + 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") + + items = [] + for user in json.loads(cstruct): + link = tags.link_to( + user["display_name"], + self.request.route_url("farmos_users.view", uuid=user["uuid"]), + ) + items.append(HTML.tag("li", c=link)) + + return HTML.tag("ul", c=items) + + return super().serialize(field, cstruct, **kw) diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index e999944..642f427 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -47,5 +47,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animals", "perm": "farmos_animals.list", }, + {"type": "sep"}, + { + "title": "Users", + "route": "farmos_users", + "perm": "farmos_users.list", + }, ], } diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index bb8a9c9..278d669 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -50,6 +50,8 @@ class CommonView(base.CommonView): site_admin_perms = [ "farmos_animals.list", "farmos_animals.view", + "farmos_users.list", + "farmos_users.view", ] for perm in site_admin_perms: auth.grant_permission(site_admin, perm) diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index df789b3..dd03b86 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -27,4 +27,5 @@ from .master import FarmOSMasterView def includeme(config): + config.include("wuttafarm.web.views.farmos.users") config.include("wuttafarm.web.views.farmos.animals") diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index aa00412..b6228c7 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -28,6 +28,8 @@ import datetime import colander from wuttafarm.web.views.farmos import FarmOSMasterView + +from wuttafarm.web.forms.schema import UsersType from wuttafarm.web.forms.widgets import AnimalImage @@ -123,16 +125,17 @@ class AnimalView(FarmOSMasterView): # add owners if owner := relationships.get("owner"): - owners = [] + data["owners"] = [] for owner_data in owner["data"]: - owners.append( - self.farmos_client.resource.get_id( - "user", "user", owner_data["id"] - ) + owner = self.farmos_client.resource.get_id( + "user", "user", owner_data["id"] + ) + data["owners"].append( + { + "uuid": owner["data"]["id"], + "display_name": owner["data"]["attributes"]["display_name"], + } ) - data["owners"] = ", ".join( - [o["data"]["attributes"]["display_name"] for o in owners] - ) # add image urls if image := relationships.get("image"): @@ -184,6 +187,9 @@ class AnimalView(FarmOSMasterView): # is_castrated f.set_node("is_castrated", colander.Boolean()) + # owners + f.set_node("owners", UsersType(self.request)) + # notes f.set_widget("notes", "notes") diff --git a/src/wuttafarm/web/views/farmos/users.py b/src/wuttafarm/web/views/farmos/users.py new file mode 100644 index 0000000..317bfe3 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/users.py @@ -0,0 +1,139 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Users +""" + +import datetime + +import colander + +from wuttaweb.forms.schema import WuttaDateTime + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class UserView(FarmOSMasterView): + """ + Master view for Farm Animals + """ + + model_name = "farmos_user" + model_title = "farmOS User" + model_title_plural = "farmOS Users" + + route_prefix = "farmos_users" + url_prefix = "/farmOS/users" + + farmos_refurl_path = "/people" + + grid_columns = [ + "display_name", + ] + + sort_defaults = "display_name" + + form_fields = [ + "display_name", + "name", + "mail", + "timezone", + "created", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + users = self.farmos_client.resource.get("user", "user") + return [self.normalize_user(u) for u in users["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # display_name + g.set_link("display_name") + g.set_searchable("display_name") + + def get_instance(self): + user = self.farmos_client.resource.get_id( + "user", "user", self.request.matchdict["uuid"] + ) + return self.normalize_user(user["data"]) + + def get_instance_title(self, user): + return user["display_name"] + + def normalize_user(self, user): + + if created := user["attributes"].get("created"): + created = datetime.datetime.fromisoformat(created) + created = self.app.localtime(created) + + if changed := user["attributes"].get("changed"): + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + return { + "uuid": user["id"], + "drupal_internal_id": user["attributes"].get("drupal_internal__uid"), + "display_name": user["attributes"]["display_name"], + "name": user["attributes"].get("name") or colander.null, + "mail": user["attributes"].get("mail") or colander.null, + "timezone": user["attributes"].get("timezone") or colander.null, + "created": created, + "changed": changed, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + user = f.model_instance + + # created + f.set_node("created", WuttaDateTime()) + + # changed + f.set_node("changed", WuttaDateTime()) + + def get_xref_buttons(self, user): + if drupal_id := user["drupal_internal_id"]: + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/user/{drupal_id}"), + target="_blank", + icon_left="external-link-alt", + ), + ] + return None + + +def defaults(config, **kwargs): + base = globals() + + UserView = kwargs.get("UserView", base["UserView"]) + UserView.defaults(config) + + +def includeme(config): + defaults(config) From baacd1c15cd3aaa7362770f928f97c216595c99e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 14:53:06 -0600 Subject: [PATCH 017/166] feat: add view for farmOS animal types --- src/wuttafarm/web/forms/schema.py | 19 +++ src/wuttafarm/web/forms/widgets.py | 26 ++++ src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + .../web/views/farmos/animal_types.py | 136 ++++++++++++++++++ src/wuttafarm/web/views/farmos/animals.py | 14 +- 7 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 src/wuttafarm/web/views/farmos/animal_types.py diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 132c7e3..7a9878e 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -28,6 +28,25 @@ import json import colander +class AnimalTypeType(colander.SchemaType): + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): # pylint: disable=empty-docstring + """ """ + from wuttafarm.web.forms.widgets import AnimalTypeWidget + + return AnimalTypeWidget(self.request, **kwargs) + + class UsersType(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 933e8a4..008c295 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -49,6 +49,32 @@ class AnimalImage(Widget): return super().serialize(field, cstruct, **kw) +class AnimalTypeWidget(Widget): + """ + Widget to display an "animal type" field. + """ + + 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") + + animal_type = json.loads(cstruct) + return tags.link_to( + animal_type["name"], + self.request.route_url( + "farmos_animal_types.view", uuid=animal_type["uuid"] + ), + ) + + return super().serialize(field, cstruct, **kw) + + class UsersWidget(Widget): """ Widget to display the list of owners for an asset etc. diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 642f427..a715019 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -47,6 +47,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animals", "perm": "farmos_animals.list", }, + { + "title": "Animal Types", + "route": "farmos_animal_types", + "perm": "farmos_animal_types.list", + }, {"type": "sep"}, { "title": "Users", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 278d669..26d5be3 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -48,6 +48,8 @@ class CommonView(base.CommonView): site_admin = session.query(model.Role).filter_by(name="Site Admin").first() if site_admin: site_admin_perms = [ + "farmos_animal_types.list", + "farmos_animal_types.view", "farmos_animals.list", "farmos_animals.view", "farmos_users.list", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index dd03b86..2f6e764 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,4 +28,5 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") + config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animals") diff --git a/src/wuttafarm/web/views/farmos/animal_types.py b/src/wuttafarm/web/views/farmos/animal_types.py new file mode 100644 index 0000000..a974242 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/animal_types.py @@ -0,0 +1,136 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS animal types +""" + +import datetime + +import colander + +from wuttaweb.forms.schema import WuttaDateTime + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class AnimalTypeView(FarmOSMasterView): + """ + Master view for Animal Types in farmOS. + """ + + model_name = "farmos_animal_type" + model_title = "farmOS Animal Type" + model_title_plural = "farmOS Animal Types" + + route_prefix = "farmos_animal_types" + url_prefix = "/farmOS/animal-types" + + farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview" + + grid_columns = [ + "name", + "description", + "changed", + ] + + sort_defaults = "name" + + form_fields = [ + "name", + "description", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type") + return [self.normalize_animal_type(t) for t in animal_types["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + animal_type = self.farmos_client.resource.get_id( + "taxonomy_term", "animal_type", self.request.matchdict["uuid"] + ) + return self.normalize_animal_type(animal_type["data"]) + + def get_instance_title(self, animal_type): + return animal_type["name"] + + def normalize_animal_type(self, animal_type): + + if changed := animal_type["attributes"]["changed"]: + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + if description := animal_type["attributes"]["description"]: + description = description["value"] + + return { + "uuid": animal_type["id"], + "drupal_internal_id": animal_type["attributes"]["drupal_internal__tid"], + "name": animal_type["attributes"]["name"], + "description": description or colander.null, + "changed": changed, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + # changed + f.set_node("changed", WuttaDateTime()) + + def get_xref_buttons(self, animal_type): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url( + f"/taxonomy/term/{animal_type['drupal_internal_id']}" + ), + target="_blank", + icon_left="external-link-alt", + ), + ] + + +def defaults(config, **kwargs): + base = globals() + + AnimalTypeView = kwargs.get("AnimalTypeView", base["AnimalTypeView"]) + AnimalTypeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index b6228c7..96ae67d 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -29,7 +29,7 @@ import colander from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType +from wuttafarm.web.forms.schema import UsersType, AnimalTypeType from wuttafarm.web.forms.widgets import AnimalImage @@ -48,6 +48,7 @@ class AnimalView(FarmOSMasterView): farmos_refurl_path = "/assets/animal" labels = { + "animal_type": "Species / Breed", "is_castrated": "Castrated", "location_name": "Current Location", "raw_image_url": "Raw Image URL", @@ -67,7 +68,7 @@ class AnimalView(FarmOSMasterView): form_fields = [ "name", - "animal_type_name", + "animal_type", "birthdate", "sex", "is_castrated", @@ -113,7 +114,10 @@ class AnimalView(FarmOSMasterView): animal_type = self.farmos_client.resource.get_id( "taxonomy_term", "animal_type", animal_type["data"]["id"] ) - data["animal_type_name"] = animal_type["data"]["attributes"]["name"] + data["animal_type"] = { + "uuid": animal_type["data"]["id"], + "name": animal_type["data"]["attributes"]["name"], + } # add location if location := relationships.get("location"): @@ -170,7 +174,6 @@ class AnimalView(FarmOSMasterView): "uuid": animal["id"], "drupal_internal_id": animal["attributes"]["drupal_internal__id"], "name": animal["attributes"]["name"], - "species_breed": "", # TODO "birthdate": birthdate, "sex": animal["attributes"]["sex"], "is_castrated": animal["attributes"]["is_castrated"], @@ -184,6 +187,9 @@ class AnimalView(FarmOSMasterView): super().configure_form(f) animal = f.model_instance + # animal_type + f.set_node("animal_type", AnimalTypeType(self.request)) + # is_castrated f.set_node("is_castrated", colander.Boolean()) From d9ef55010043dbc2228f718b47cde8f3b90b51af Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 16:42:32 -0600 Subject: [PATCH 018/166] feat: add view for farmOS structures --- src/wuttafarm/web/forms/schema.py | 19 ++ src/wuttafarm/web/forms/widgets.py | 39 +++- src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + src/wuttafarm/web/views/farmos/animals.py | 21 +- src/wuttafarm/web/views/farmos/master.py | 6 + src/wuttafarm/web/views/farmos/structures.py | 209 +++++++++++++++++++ 8 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 src/wuttafarm/web/views/farmos/structures.py diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 7a9878e..a38588a 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -47,6 +47,25 @@ class AnimalTypeType(colander.SchemaType): return AnimalTypeWidget(self.request, **kwargs) +class StructureType(colander.SchemaType): + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): # pylint: disable=empty-docstring + """ """ + from wuttafarm.web.forms.widgets import StructureWidget + + return StructureWidget(self.request, **kwargs) + + class UsersType(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 008c295..0ffb055 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -30,13 +30,14 @@ from deform.widget import Widget from webhelpers2.html import HTML, tags -class AnimalImage(Widget): +class ImageWidget(Widget): + """ + Widget to display an image URL for a record. """ - Widget to display an image URL for an animal. - TODO: this should be refactored to a more general name, once more - types of images need to be supported. - """ + def __init__(self, alt_text, *args, **kwargs): + super().__init__(*args, **kwargs) + self.alt_text = alt_text def serialize(self, field, cstruct, **kw): readonly = kw.get("readonly", self.readonly) @@ -44,7 +45,7 @@ class AnimalImage(Widget): if cstruct in (colander.null, None): return HTML.tag("span") - return tags.image(cstruct, "animal image", **kw) + return tags.image(cstruct, self.alt_text, **kw) return super().serialize(field, cstruct, **kw) @@ -75,6 +76,32 @@ class AnimalTypeWidget(Widget): return super().serialize(field, cstruct, **kw) +class StructureWidget(Widget): + """ + Widget to display a "structure" field. + """ + + 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") + + structure = json.loads(cstruct) + return tags.link_to( + structure["name"], + self.request.route_url( + "farmos_structures.view", uuid=structure["uuid"] + ), + ) + + return super().serialize(field, cstruct, **kw) + + class UsersWidget(Widget): """ Widget to display the list of owners for an asset etc. diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index a715019..dbeba44 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -52,6 +52,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Structures", + "route": "farmos_structures", + "perm": "farmos_structures.list", + }, {"type": "sep"}, { "title": "Users", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 26d5be3..a238983 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -52,6 +52,8 @@ class CommonView(base.CommonView): "farmos_animal_types.view", "farmos_animals.list", "farmos_animals.view", + "farmos_structures.list", + "farmos_structures.view", "farmos_users.list", "farmos_users.view", ] diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index 2f6e764..52652b8 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,5 +28,6 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") + config.include("wuttafarm.web.views.farmos.structures") config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animals") diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 96ae67d..8eca5af 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -29,8 +29,8 @@ import colander from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType, AnimalTypeType -from wuttafarm.web.forms.widgets import AnimalImage +from wuttafarm.web.forms.schema import UsersType, AnimalTypeType, StructureType +from wuttafarm.web.forms.widgets import ImageWidget class AnimalView(FarmOSMasterView): @@ -50,10 +50,7 @@ class AnimalView(FarmOSMasterView): labels = { "animal_type": "Species / Breed", "is_castrated": "Castrated", - "location_name": "Current Location", - "raw_image_url": "Raw Image URL", - "large_image_url": "Large Image URL", - "thumbnail_image_url": "Thumbnail Image URL", + "location": "Current Location", } grid_columns = [ @@ -74,7 +71,7 @@ class AnimalView(FarmOSMasterView): "is_castrated", "status", "owners", - "location_name", + "location", "notes", "raw_image_url", "large_image_url", @@ -125,7 +122,10 @@ class AnimalView(FarmOSMasterView): location = self.farmos_client.resource.get_id( "asset", "structure", location["data"][0]["id"] ) - data["location_name"] = location["data"]["attributes"]["name"] + data["location"] = { + "uuid": location["data"]["id"], + "name": location["data"]["attributes"]["name"], + } # add owners if owner := relationships.get("owner"): @@ -193,6 +193,9 @@ class AnimalView(FarmOSMasterView): # is_castrated f.set_node("is_castrated", colander.Boolean()) + # location + f.set_node("location", StructureType(self.request)) + # owners f.set_node("owners", UsersType(self.request)) @@ -201,7 +204,7 @@ class AnimalView(FarmOSMasterView): # image if url := animal.get("large_image_url"): - f.set_widget("image", AnimalImage()) + f.set_widget("image", ImageWidget("animal image")) f.set_default("image", url) def get_xref_buttons(self, animal): diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index eed04d1..59003d0 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -45,6 +45,12 @@ class FarmOSMasterView(MasterView): farmos_refurl_path = None + labels = { + "raw_image_url": "Raw Image URL", + "large_image_url": "Large Image URL", + "thumbnail_image_url": "Thumbnail Image URL", + } + def __init__(self, request, context=None): super().__init__(request, context=context) self.farmos_client = self.get_farmos_client() diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py new file mode 100644 index 0000000..bbc4f1f --- /dev/null +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -0,0 +1,209 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Structures +""" + +import datetime + +import colander + +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.forms.widgets import ImageWidget + + +class StructureView(FarmOSMasterView): + """ + View for farmOS Structures + """ + + model_name = "farmos_structure" + model_title = "farmOS Structure" + model_title_plural = "farmOS Structures" + + route_prefix = "farmos_structures" + url_prefix = "/farmOS/structures" + + farmos_refurl_path = "/assets/structure" + + grid_columns = [ + "name", + "status", + "created", + "changed", + ] + + sort_defaults = "name" + + form_fields = [ + "name", + "status", + "structure_type", + "is_location", + "is_fixed", + "notes", + "created", + "changed", + "raw_image_url", + "large_image_url", + "thumbnail_image_url", + "image", + ] + + def get_grid_data(self, columns=None, session=None): + structures = self.farmos_client.resource.get("asset", "structure") + return [self.normalize_structure(s) for s in structures["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # created + g.set_renderer("created", "datetime") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + structure = self.farmos_client.resource.get_id( + "asset", "structure", self.request.matchdict["uuid"] + ) + + data = self.normalize_structure(structure["data"]) + + if relationships := structure["data"].get("relationships"): + + # add owners + if owner := relationships.get("owner"): + data["owners"] = [] + for owner_data in owner["data"]: + owner = self.farmos_client.resource.get_id( + "user", "user", owner_data["id"] + ) + data["owners"].append( + { + "uuid": owner["data"]["id"], + "display_name": owner["data"]["attributes"]["display_name"], + } + ) + + # add image urls + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + data["raw_image_url"] = self.app.get_farmos_url( + image["data"]["attributes"]["uri"]["url"] + ) + # nb. other styles available: medium, wide + data["large_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["large"] + data["thumbnail_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["thumbnail"] + + return data + + def get_instance_title(self, structure): + return structure["name"] + + def normalize_structure(self, structure): + + if created := structure["attributes"].get("created"): + created = datetime.datetime.fromisoformat(created) + created = self.app.localtime(created) + + if changed := structure["attributes"].get("changed"): + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + return { + "uuid": structure["id"], + "drupal_internal_id": structure["attributes"]["drupal_internal__id"], + "name": structure["attributes"]["name"], + "structure_type": structure["attributes"]["structure_type"], + "is_fixed": structure["attributes"]["is_fixed"], + "is_location": structure["attributes"]["is_location"], + "notes": structure["attributes"]["notes"] or colander.null, + "status": structure["attributes"]["status"], + "created": created, + "changed": changed, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + structure = f.model_instance + + # is_fixed + f.set_node("is_fixed", colander.Boolean()) + + # is_location + f.set_node("is_location", colander.Boolean()) + + # notes + f.set_widget("notes", "notes") + + # created + f.set_node("created", WuttaDateTime()) + f.set_widget("created", WuttaDateTimeWidget(self.request)) + + # changed + f.set_node("changed", WuttaDateTime()) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + # image + if url := structure.get("large_image_url"): + f.set_widget("image", ImageWidget("structure image")) + f.set_default("image", url) + + def get_xref_buttons(self, structure): + drupal_id = structure["drupal_internal_id"] + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/asset/{drupal_id}"), + target="_blank", + icon_left="external-link-alt", + ), + ] + + +def defaults(config, **kwargs): + base = globals() + + StructureView = kwargs.get("StructureView", base["StructureView"]) + StructureView.defaults(config) + + +def includeme(config): + defaults(config) From 19b6738e5dfbe2e9ba3e1fd15604d76a969053a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 17:03:35 -0600 Subject: [PATCH 019/166] feat: add view for farmOS asset types of limited value perhaps, but what the heck --- src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + src/wuttafarm/web/views/farmos/asset_types.py | 101 ++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 src/wuttafarm/web/views/farmos/asset_types.py diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index dbeba44..0f46281 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -57,6 +57,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_structures", "perm": "farmos_structures.list", }, + { + "title": "Asset Types", + "route": "farmos_asset_types", + "perm": "farmos_asset_types.list", + }, {"type": "sep"}, { "title": "Users", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index a238983..0a4e72f 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -52,6 +52,8 @@ class CommonView(base.CommonView): "farmos_animal_types.view", "farmos_animals.list", "farmos_animals.view", + "farmos_asset_types.list", + "farmos_asset_types.view", "farmos_structures.list", "farmos_structures.view", "farmos_users.list", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index 52652b8..d7e699b 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,6 +28,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") + config.include("wuttafarm.web.views.farmos.asset_types") config.include("wuttafarm.web.views.farmos.structures") config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animals") diff --git a/src/wuttafarm/web/views/farmos/asset_types.py b/src/wuttafarm/web/views/farmos/asset_types.py new file mode 100644 index 0000000..75eebbe --- /dev/null +++ b/src/wuttafarm/web/views/farmos/asset_types.py @@ -0,0 +1,101 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS asset types +""" + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class AssetTypeView(FarmOSMasterView): + """ + View for farmOS asset types + """ + + model_name = "farmos_asset_type" + model_title = "farmOS Asset Type" + model_title_plural = "farmOS Asset Types" + + route_prefix = "farmos_asset_types" + url_prefix = "/farmOS/asset-types" + + grid_columns = [ + "label", + "description", + ] + + sort_defaults = "label" + + form_fields = [ + "label", + "description", + ] + + def get_grid_data(self, columns=None, session=None): + asset_types = self.farmos_client.resource.get("asset_type", "asset_type") + return [self.normalize_asset_type(t) for t in asset_types["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # label + g.set_link("label") + g.set_searchable("label") + + # description + g.set_searchable("description") + + def get_instance(self): + asset_type = self.farmos_client.resource.get_id( + "asset_type", "asset_type", self.request.matchdict["uuid"] + ) + return self.normalize_asset_type(asset_type["data"]) + + def get_instance_title(self, asset_type): + return asset_type["label"] + + def normalize_asset_type(self, asset_type): + return { + "uuid": asset_type["id"], + "drupal_internal_id": asset_type["attributes"]["drupal_internal__id"], + "label": asset_type["attributes"]["label"], + "description": asset_type["attributes"]["description"], + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + +def defaults(config, **kwargs): + base = globals() + + AssetTypeView = kwargs.get("AssetTypeView", base["AssetTypeView"]) + AssetTypeView.defaults(config) + + +def includeme(config): + defaults(config) From ba926ec2de906af548f47b60a8cf31039f34be80 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 17:04:02 -0600 Subject: [PATCH 020/166] fix: ensure Buefy version matches what we use for custom css this is an alright solution for now, but may need to improve in the future once we look at Vue 3 etc. basically the only reason this solution isn't terrible, is because buefy 0.9.x (for Vue 2) is "stable" --- src/wuttafarm/web/app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wuttafarm/web/app.py b/src/wuttafarm/web/app.py index 83c9817..5c59434 100644 --- a/src/wuttafarm/web/app.py +++ b/src/wuttafarm/web/app.py @@ -49,11 +49,16 @@ def main(global_config, **settings): app = wutta_config.get_app() path = app.resource_path("wuttafarm.web.static:css/wuttafarm-buefy.css") if os.path.exists(path): + # TODO: this is not robust enough, probably..but works for me/now wutta_config.setdefault( "wuttaweb.liburl.buefy_css", "/wuttafarm/css/wuttafarm-buefy.css" ) + # nb. ensure buefy version matches what we use for custom css + wutta_config.setdefault("wuttaweb.libver.buefy", "0.9.29") + wutta_config.setdefault("wuttaweb.libver.buefy_css", "0.9.29") + # bring in the rest of WuttaFarm pyramid_config.include("wuttafarm.web.static") pyramid_config.include("wuttafarm.web.subscribers") From 5005c3c9780523557ded3844aeafaa79c95373ef Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 17:21:42 -0600 Subject: [PATCH 021/166] feat: add view for farmOS groups --- src/wuttafarm/web/menus.py | 12 +- src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + src/wuttafarm/web/views/farmos/groups.py | 164 +++++++++++++++++++++ 4 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 src/wuttafarm/web/views/farmos/groups.py diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 0f46281..60a3a41 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -48,15 +48,21 @@ class WuttaFarmMenuHandler(base.MenuHandler): "perm": "farmos_animals.list", }, { - "title": "Animal Types", - "route": "farmos_animal_types", - "perm": "farmos_animal_types.list", + "title": "Groups", + "route": "farmos_groups", + "perm": "farmos_groups.list", }, { "title": "Structures", "route": "farmos_structures", "perm": "farmos_structures.list", }, + {"type": "sep"}, + { + "title": "Animal Types", + "route": "farmos_animal_types", + "perm": "farmos_animal_types.list", + }, { "title": "Asset Types", "route": "farmos_asset_types", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 0a4e72f..23ed96b 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -54,6 +54,8 @@ class CommonView(base.CommonView): "farmos_animals.view", "farmos_asset_types.list", "farmos_asset_types.view", + "farmos_groups.list", + "farmos_groups.view", "farmos_structures.list", "farmos_structures.view", "farmos_users.list", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index d7e699b..93e1341 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -32,3 +32,4 @@ def includeme(config): config.include("wuttafarm.web.views.farmos.structures") config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animals") + config.include("wuttafarm.web.views.farmos.groups") diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py new file mode 100644 index 0000000..4664a6b --- /dev/null +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -0,0 +1,164 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Groups +""" + +import datetime + +import colander + +from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget + + +class GroupView(FarmOSMasterView): + """ + View for farmOS Groups + """ + + model_name = "farmos_group" + model_title = "farmOS Group" + model_title_plural = "farmOS Groups" + + route_prefix = "farmos_groups" + url_prefix = "/farmOS/groups" + + farmos_refurl_path = "/assets/group" + + grid_columns = [ + "name", + "is_fixed", + "is_location", + "status", + "changed", + ] + + sort_defaults = "name" + + form_fields = [ + "name", + "is_fixed", + "is_location", + "status", + "notes", + "created", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + groups = self.farmos_client.resource.get("asset", "group") + return [self.normalize_group(a) for a in groups["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # is_fixed + g.set_renderer("is_fixed", "boolean") + + # is_location + g.set_renderer("is_location", "boolean") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + + group = self.farmos_client.resource.get_id( + "asset", "group", self.request.matchdict["uuid"] + ) + + return self.normalize_group(group["data"]) + + def get_instance_title(self, group): + return group["name"] + + def normalize_group(self, group): + + if created := group["attributes"].get("created"): + created = datetime.datetime.fromisoformat(created) + created = self.app.localtime(created) + + if changed := group["attributes"].get("changed"): + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + return { + "uuid": group["id"], + "drupal_internal_id": group["attributes"]["drupal_internal__id"], + "name": group["attributes"]["name"], + "created": created, + "changed": changed, + "is_fixed": group["attributes"]["is_fixed"], + "is_location": group["attributes"]["is_location"], + "status": group["attributes"]["status"], + "notes": group["attributes"]["notes"]["value"], + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # is_fixed + f.set_node("is_fixed", colander.Boolean()) + + # is_location + f.set_node("is_location", colander.Boolean()) + + # notes + f.set_widget("notes", "notes") + + # created + f.set_node("created", WuttaDateTime()) + f.set_widget("created", WuttaDateTimeWidget(self.request)) + + # changed + f.set_node("changed", WuttaDateTime()) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + def get_xref_buttons(self, group): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/asset/{group['drupal_internal_id']}"), + target="_blank", + icon_left="external-link-alt", + ), + ] + + +def defaults(config, **kwargs): + base = globals() + + GroupView = kwargs.get("GroupView", base["GroupView"]) + GroupView.defaults(config) + + +def includeme(config): + defaults(config) From 233b2a2dab956062b3c412b74bfb1da2e5a29495 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 18:11:48 -0600 Subject: [PATCH 022/166] feat: add view for farmOS land assets --- src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + src/wuttafarm/web/views/farmos/land_assets.py | 169 ++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/wuttafarm/web/views/farmos/land_assets.py diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 60a3a41..a9e66e9 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -57,6 +57,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_structures", "perm": "farmos_structures.list", }, + { + "title": "Land", + "route": "farmos_land_assets", + "perm": "farmos_land_assets.list", + }, {"type": "sep"}, { "title": "Animal Types", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 23ed96b..b13d43b 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -56,6 +56,8 @@ class CommonView(base.CommonView): "farmos_asset_types.view", "farmos_groups.list", "farmos_groups.view", + "farmos_land_assets.list", + "farmos_land_assets.view", "farmos_structures.list", "farmos_structures.view", "farmos_users.list", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index 93e1341..a194d64 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -29,6 +29,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") config.include("wuttafarm.web.views.farmos.asset_types") + config.include("wuttafarm.web.views.farmos.land_assets") config.include("wuttafarm.web.views.farmos.structures") config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animals") diff --git a/src/wuttafarm/web/views/farmos/land_assets.py b/src/wuttafarm/web/views/farmos/land_assets.py new file mode 100644 index 0000000..a496cc5 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/land_assets.py @@ -0,0 +1,169 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Land Assets +""" + +import datetime + +import colander + +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class LandAssetView(FarmOSMasterView): + """ + View for farmOS Land Assets + """ + + model_name = "farmos_land_asset" + model_title = "farmOS Land Asset" + model_title_plural = "farmOS Land Assets" + + route_prefix = "farmos_land_assets" + url_prefix = "/farmOS/land" + + farmos_refurl_path = "/assets/land" + + grid_columns = [ + "name", + "is_fixed", + "is_location", + "status", + "changed", + ] + + sort_defaults = "name" + + form_fields = [ + "name", + "is_fixed", + "is_location", + "status", + "notes", + "created", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + land_assets = self.farmos_client.resource.get("asset", "land") + return [self.normalize_land_asset(l) for l in land_assets["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # is_fixed + g.set_renderer("is_fixed", "boolean") + + # is_location + g.set_renderer("is_location", "boolean") + + # created + g.set_renderer("created", "datetime") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + land_asset = self.farmos_client.resource.get_id( + "asset", "land", self.request.matchdict["uuid"] + ) + return self.normalize_land_asset(land_asset["data"]) + + def get_instance_title(self, land_asset): + return land_asset["name"] + + def normalize_land_asset(self, land): + + if created := land["attributes"].get("created"): + created = datetime.datetime.fromisoformat(created) + created = self.app.localtime(created) + + if changed := land["attributes"].get("changed"): + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + if notes := land["attributes"]["notes"]: + notes = notes["value"] + + return { + "uuid": land["id"], + "drupal_internal_id": land["attributes"]["drupal_internal__id"], + "name": land["attributes"]["name"], + "created": created, + "changed": changed, + "is_fixed": land["attributes"]["is_fixed"], + "is_location": land["attributes"]["is_location"], + "status": land["attributes"]["status"], + "notes": notes or colander.null, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # is_fixed + f.set_node("is_fixed", colander.Boolean()) + + # is_location + f.set_node("is_location", colander.Boolean()) + + # notes + f.set_widget("notes", "notes") + + # created + f.set_node("created", WuttaDateTime()) + f.set_widget("created", WuttaDateTimeWidget(self.request)) + + # changed + f.set_node("changed", WuttaDateTime()) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + def get_xref_buttons(self, land): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/asset/{land['drupal_internal_id']}"), + target="_blank", + icon_left="external-link-alt", + ), + ] + + +def defaults(config, **kwargs): + base = globals() + + LandAssetView = kwargs.get("LandAssetView", base["LandAssetView"]) + LandAssetView.defaults(config) + + +def includeme(config): + defaults(config) From acba07aa0e601b563a1ad83c94906a27c2a0faeb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 18:19:11 -0600 Subject: [PATCH 023/166] feat: add view for farmOS land types --- src/wuttafarm/web/menus.py | 5 ++ src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + src/wuttafarm/web/views/farmos/land_types.py | 88 ++++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 src/wuttafarm/web/views/farmos/land_types.py diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index a9e66e9..2897038 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -68,6 +68,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Land Types", + "route": "farmos_land_types", + "perm": "farmos_land_types.list", + }, { "title": "Asset Types", "route": "farmos_asset_types", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index b13d43b..1c6e977 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -58,6 +58,8 @@ class CommonView(base.CommonView): "farmos_groups.view", "farmos_land_assets.list", "farmos_land_assets.view", + "farmos_land_types.list", + "farmos_land_types.view", "farmos_structures.list", "farmos_structures.view", "farmos_users.list", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index a194d64..e1dc122 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -29,6 +29,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") config.include("wuttafarm.web.views.farmos.asset_types") + config.include("wuttafarm.web.views.farmos.land_types") config.include("wuttafarm.web.views.farmos.land_assets") config.include("wuttafarm.web.views.farmos.structures") config.include("wuttafarm.web.views.farmos.animal_types") diff --git a/src/wuttafarm/web/views/farmos/land_types.py b/src/wuttafarm/web/views/farmos/land_types.py new file mode 100644 index 0000000..aadece8 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/land_types.py @@ -0,0 +1,88 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS land types +""" + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class LandTypeView(FarmOSMasterView): + """ + Master view for Land Types in farmOS. + """ + + model_name = "farmos_land_type" + model_title = "farmOS Land Type" + model_title_plural = "farmOS Land Types" + + route_prefix = "farmos_land_types" + url_prefix = "/farmOS/land-types" + + grid_columns = [ + "label", + ] + + sort_defaults = "label" + + form_fields = [ + "label", + ] + + def get_grid_data(self, columns=None, session=None): + land_types = self.farmos_client.resource.get("land_type", "land_type") + return [self.normalize_land_type(t) for t in land_types["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # label + g.set_link("label") + g.set_searchable("label") + + def get_instance(self): + land_type = self.farmos_client.resource.get_id( + "land_type", "land_type", self.request.matchdict["uuid"] + ) + return self.normalize_land_type(land_type["data"]) + + def get_instance_title(self, land_type): + return land_type["label"] + + def normalize_land_type(self, land_type): + return { + "uuid": land_type["id"], + "drupal_internal_id": land_type["attributes"]["drupal_internal__id"], + "label": land_type["attributes"]["label"], + } + + +def defaults(config, **kwargs): + base = globals() + + LandTypeView = kwargs.get("LandTypeView", base["LandTypeView"]) + LandTypeView.defaults(config) + + +def includeme(config): + defaults(config) From 7d65d3c5a2eb4f9004316909de4ad8dd58671798 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 18:22:40 -0600 Subject: [PATCH 024/166] feat: add view for farmOS structure types --- src/wuttafarm/web/menus.py | 5 ++ src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + .../web/views/farmos/structure_types.py | 90 +++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 src/wuttafarm/web/views/farmos/structure_types.py diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 2897038..618628e 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -68,6 +68,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Structure Types", + "route": "farmos_structure_types", + "perm": "farmos_structure_types.list", + }, { "title": "Land Types", "route": "farmos_land_types", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 1c6e977..a84e232 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -60,6 +60,8 @@ class CommonView(base.CommonView): "farmos_land_assets.view", "farmos_land_types.list", "farmos_land_types.view", + "farmos_structure_types.list", + "farmos_structure_types.view", "farmos_structures.list", "farmos_structures.view", "farmos_users.list", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index e1dc122..4cc41ed 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -31,6 +31,7 @@ def includeme(config): config.include("wuttafarm.web.views.farmos.asset_types") config.include("wuttafarm.web.views.farmos.land_types") config.include("wuttafarm.web.views.farmos.land_assets") + config.include("wuttafarm.web.views.farmos.structure_types") config.include("wuttafarm.web.views.farmos.structures") config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animals") diff --git a/src/wuttafarm/web/views/farmos/structure_types.py b/src/wuttafarm/web/views/farmos/structure_types.py new file mode 100644 index 0000000..3fe4741 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/structure_types.py @@ -0,0 +1,90 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS structure types +""" + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class StructureTypeView(FarmOSMasterView): + """ + Master view for Structure Types in farmOS. + """ + + model_name = "farmos_structure_type" + model_title = "farmOS Structure Type" + model_title_plural = "farmOS Structure Types" + + route_prefix = "farmos_structure_types" + url_prefix = "/farmOS/structure-types" + + grid_columns = [ + "label", + ] + + sort_defaults = "label" + + form_fields = [ + "label", + ] + + def get_grid_data(self, columns=None, session=None): + structure_types = self.farmos_client.resource.get( + "structure_type", "structure_type" + ) + return [self.normalize_structure_type(t) for t in structure_types["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # label + g.set_link("label") + g.set_searchable("label") + + def get_instance(self): + structure_type = self.farmos_client.resource.get_id( + "structure_type", "structure_type", self.request.matchdict["uuid"] + ) + return self.normalize_structure_type(structure_type["data"]) + + def get_instance_title(self, structure_type): + return structure_type["label"] + + def normalize_structure_type(self, structure_type): + return { + "uuid": structure_type["id"], + "drupal_internal_id": structure_type["attributes"]["drupal_internal__id"], + "label": structure_type["attributes"]["label"], + } + + +def defaults(config, **kwargs): + base = globals() + + StructureTypeView = kwargs.get("StructureTypeView", base["StructureTypeView"]) + StructureTypeView.defaults(config) + + +def includeme(config): + defaults(config) From 33717bb0550308bef60c5d1396d4de5806eeb426 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 18:42:37 -0600 Subject: [PATCH 025/166] fix: add menu option, "Go to farmOS" --- src/wuttafarm/web/menus.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 618628e..d8796f8 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -38,10 +38,18 @@ class WuttaFarmMenuHandler(base.MenuHandler): ] def make_farmos_menu(self, request): + config = request.wutta_config + app = config.get_app() return { "title": "farmOS", "type": "menu", "items": [ + { + "title": "Go to farmOS", + "url": app.get_farmos_url(), + "target": "_blank", + }, + {"type": "sep"}, { "title": "Animals", "route": "farmos_animals", From f7d5d0ab1c70a94f11023f0a5853a1f2c23b7e7e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 19:25:13 -0600 Subject: [PATCH 026/166] feat: add view for farmOS log types --- src/wuttafarm/web/menus.py | 5 ++ src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + src/wuttafarm/web/views/farmos/log_types.py | 98 +++++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 src/wuttafarm/web/views/farmos/log_types.py diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index d8796f8..d4e7715 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -91,6 +91,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_asset_types", "perm": "farmos_asset_types.list", }, + { + "title": "Log Types", + "route": "farmos_log_types", + "perm": "farmos_log_types.list", + }, {"type": "sep"}, { "title": "Users", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index a84e232..9f4100b 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -60,6 +60,8 @@ class CommonView(base.CommonView): "farmos_land_assets.view", "farmos_land_types.list", "farmos_land_types.view", + "farmos_log_types.list", + "farmos_log_types.view", "farmos_structure_types.list", "farmos_structure_types.view", "farmos_structures.list", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index 4cc41ed..cc389db 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -36,3 +36,4 @@ def includeme(config): config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animals") config.include("wuttafarm.web.views.farmos.groups") + config.include("wuttafarm.web.views.farmos.log_types") diff --git a/src/wuttafarm/web/views/farmos/log_types.py b/src/wuttafarm/web/views/farmos/log_types.py new file mode 100644 index 0000000..6e72f8f --- /dev/null +++ b/src/wuttafarm/web/views/farmos/log_types.py @@ -0,0 +1,98 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS log types +""" + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class LogTypeView(FarmOSMasterView): + """ + Master view for Log Types in farmOS. + """ + + model_name = "farmos_log_type" + model_title = "farmOS Log Type" + model_title_plural = "farmOS Log Types" + + route_prefix = "farmos_log_types" + url_prefix = "/farmOS/log-types" + + grid_columns = [ + "label", + "description", + ] + + sort_defaults = "label" + + form_fields = [ + "label", + "description", + ] + + def get_grid_data(self, columns=None, session=None): + log_types = self.farmos_client.resource.get("log_type", "log_type") + return [self.normalize_log_type(t) for t in log_types["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # label + g.set_link("label") + g.set_searchable("label") + + def get_instance(self): + log_type = self.farmos_client.resource.get_id( + "log_type", "log_type", self.request.matchdict["uuid"] + ) + return self.normalize_log_type(log_type["data"]) + + def get_instance_title(self, log_type): + return log_type["label"] + + def normalize_log_type(self, log_type): + return { + "uuid": log_type["id"], + "drupal_internal_id": log_type["attributes"]["drupal_internal__id"], + "label": log_type["attributes"]["label"], + "description": log_type["attributes"]["description"], + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + +def defaults(config, **kwargs): + base = globals() + + LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) + LogTypeView.defaults(config) + + +def includeme(config): + defaults(config) From c778997239e65dd5a3d767ffe8de1df29b49e49a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Feb 2026 19:36:44 -0600 Subject: [PATCH 027/166] feat: add view for farmOS activity logs --- src/wuttafarm/web/menus.py | 6 + src/wuttafarm/web/views/common.py | 2 + src/wuttafarm/web/views/farmos/__init__.py | 1 + .../web/views/farmos/logs_activity.py | 136 ++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 src/wuttafarm/web/views/farmos/logs_activity.py diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index d4e7715..ab6f440 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -71,6 +71,12 @@ class WuttaFarmMenuHandler(base.MenuHandler): "perm": "farmos_land_assets.list", }, {"type": "sep"}, + { + "title": "Activity Logs", + "route": "farmos_logs_activity", + "perm": "farmos_logs_activity.list", + }, + {"type": "sep"}, { "title": "Animal Types", "route": "farmos_animal_types", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 9f4100b..f46c018 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -62,6 +62,8 @@ class CommonView(base.CommonView): "farmos_land_types.view", "farmos_log_types.list", "farmos_log_types.view", + "farmos_logs_activity.list", + "farmos_logs_activity.view", "farmos_structure_types.list", "farmos_structure_types.view", "farmos_structures.list", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index cc389db..deacd7d 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -37,3 +37,4 @@ def includeme(config): config.include("wuttafarm.web.views.farmos.animals") config.include("wuttafarm.web.views.farmos.groups") config.include("wuttafarm.web.views.farmos.log_types") + config.include("wuttafarm.web.views.farmos.logs_activity") diff --git a/src/wuttafarm/web/views/farmos/logs_activity.py b/src/wuttafarm/web/views/farmos/logs_activity.py new file mode 100644 index 0000000..61b4e85 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/logs_activity.py @@ -0,0 +1,136 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS activity logs +""" + +import datetime + +import colander + +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class ActivityLogView(FarmOSMasterView): + """ + View for farmOS activity logs + """ + + model_name = "farmos_activity_log" + model_title = "farmOS Activity Log" + model_title_plural = "farmOS Activity Logs" + + route_prefix = "farmos_logs_activity" + url_prefix = "/farmOS/logs/activity" + + farmos_refurl_path = "/logs/activity" + + grid_columns = [ + "name", + "timestamp", + "status", + ] + + sort_defaults = ("timestamp", "desc") + + form_fields = [ + "name", + "timestamp", + "status", + "notes", + ] + + def get_grid_data(self, columns=None, session=None): + logs = self.farmos_client.log.get("activity") + return [self.normalize_log(t) for t in logs["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # timestamp + g.set_renderer("timestamp", "datetime") + + def get_instance(self): + log = self.farmos_client.log.get_id("activity", self.request.matchdict["uuid"]) + return self.normalize_log(log["data"]) + + def get_instance_title(self, log): + return log["name"] + + def normalize_log(self, log): + + if timestamp := log["attributes"]["timestamp"]: + timestamp = datetime.datetime.fromisoformat(timestamp) + timestamp = self.app.localtime(timestamp) + + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + return { + "uuid": log["id"], + "drupal_internal_id": log["attributes"]["drupal_internal__id"], + "name": log["attributes"]["name"], + "timestamp": timestamp, + "status": log["attributes"]["status"], + "notes": notes or colander.null, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # timestamp + f.set_node("timestamp", WuttaDateTime()) + f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) + + # notes + f.set_widget("notes", "notes") + + def get_xref_buttons(self, log): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/log/{log['drupal_internal_id']}"), + target="_blank", + icon_left="external-link-alt", + ), + ] + + +def defaults(config, **kwargs): + base = globals() + + ActivityLogView = kwargs.get("ActivityLogView", base["ActivityLogView"]) + ActivityLogView.defaults(config) + + +def includeme(config): + defaults(config) From 920811136ef5bccd6504a9f050682bf3bb5b8b92 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 09:23:46 -0600 Subject: [PATCH 028/166] fix: add pyramid_exclog dependency mostly for convenience, since IMHO it is good to use in production --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d2c6ef1..77bc3da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ license = {text = "GNU General Public License v3"} dependencies = [ "farmOS", "psycopg2", + "pyramid_exclog", "WuttaWeb[continuum]>=0.27.3", ] From ccb64c5c4d1eb0a228eecce295088287402b59fe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 09:24:28 -0600 Subject: [PATCH 029/166] =?UTF-8?q?bump:=20version=200.1.5=20=E2=86=92=200?= =?UTF-8?q?.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 21 +++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6142c..eabe9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.2.0 (2026-02-08) + +### Feat + +- add view for farmOS activity logs +- add view for farmOS log types +- add view for farmOS structure types +- add view for farmOS land types +- add view for farmOS land assets +- add view for farmOS groups +- add view for farmOS asset types +- add view for farmOS structures +- add view for farmOS animal types +- add view for farmOS users + +### Fix + +- add pyramid_exclog dependency +- add menu option, "Go to farmOS" +- ensure Buefy version matches what we use for custom css + ## v0.1.5 (2026-02-07) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 77bc3da..48faefd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.1.5" +version = "0.2.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 8cc4af950eb92522f9f83f6b84fe399aab602c99 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 11:35:39 -0600 Subject: [PATCH 030/166] fix: run web app via uvicorn/ASGI by default just seems faster --- pyproject.toml | 3 ++- src/wuttafarm/templates/install/web.conf.mako | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 48faefd..19d7982 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,8 @@ dependencies = [ "farmOS", "psycopg2", "pyramid_exclog", - "WuttaWeb[continuum]>=0.27.3", + "uvicorn[standard]", + "WuttaWeb[continuum]>=0.27.4", ] diff --git a/src/wuttafarm/templates/install/web.conf.mako b/src/wuttafarm/templates/install/web.conf.mako index 4d2b3c7..11824ab 100644 --- a/src/wuttafarm/templates/install/web.conf.mako +++ b/src/wuttafarm/templates/install/web.conf.mako @@ -13,6 +13,8 @@ ${self.section_wutta_config()} +${self.section_wutta_web()} + <%text>############################## # pyramid @@ -39,15 +41,23 @@ ${self.sectiongroup_logging()} require = %(here)s/wutta.conf +<%def name="section_wutta_web()"> +[wutta.web] +app.runner = uvicorn +app.spec = ${pkg_name}.web.app:make_asgi_app +app.factory = true +app.interface = asgi3 +app.port = ${pyramid_port} + + <%def name="section_app_main()"> [app:main] -#use = egg:wuttaweb use = egg:${egg_name} pyramid.reload_templates = true pyramid.debug_all = true pyramid.default_locale_name = en -#pyramid.includes = pyramid_debugtoolbar +#pyramid.includes = pyramid_debugtoolbar pyramid_exclog beaker.session.type = file beaker.session.data_dir = %(here)s/cache/sessions/data From 1327d1f7b2b67b310ae439d89be231a135c64578 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 11:36:38 -0600 Subject: [PATCH 031/166] =?UTF-8?q?bump:=20version=200.2.0=20=E2=86=92=200?= =?UTF-8?q?.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eabe9f0..c971ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.2.1 (2026-02-08) + +### Fix + +- run web app via uvicorn/ASGI by default + ## v0.2.0 (2026-02-08) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 19d7982..e22ef70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.2.0" +version = "0.2.1" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From fa5469958eedf0f706e01f5ebd7b3377c42dbb49 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 11:53:46 -0600 Subject: [PATCH 032/166] fix: update project links for PyPI --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e22ef70..31443d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,10 +51,10 @@ dependencies = [ [project.urls] -Homepage = "https://forgejo.wuttaproject.org/lance/wuttafarm" -Repository = "https://forgejo.wuttaproject.org/lance/wuttafarm" -Issues = "https://forgejo.wuttaproject.org/lance/wuttafarm/issues" -Changelog = "https://forgejo.wuttaproject.org/lance/wuttafarm/src/branch/master/CHANGELOG.md" +Homepage = "https://forgejo.wuttaproject.org/wutta/wuttafarm" +Repository = "https://forgejo.wuttaproject.org/wutta/wuttafarm" +Issues = "https://forgejo.wuttaproject.org/wutta/wuttafarm/issues" +Changelog = "https://forgejo.wuttaproject.org/wutta/wuttafarm/src/branch/master/CHANGELOG.md" [tool.commitizen] From 3290a9936e6e15e2012f32124903560e91ffd235 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 11:54:18 -0600 Subject: [PATCH 033/166] =?UTF-8?q?bump:=20version=200.2.1=20=E2=86=92=200?= =?UTF-8?q?.2.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c971ac0..e873766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.2.2 (2026-02-08) + +### Fix + +- update project links for PyPI + ## v0.2.1 (2026-02-08) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 31443d6..a0fd695 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.2.1" +version = "0.2.2" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 4c0754ee01da5e992a828e75db71767848bdaa01 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 12:51:54 -0600 Subject: [PATCH 034/166] docs: initial/basic project docs --- docs/Makefile | 20 +++++ docs/_static/.keepme | 0 docs/api/wuttafarm.app.rst | 6 ++ docs/api/wuttafarm.auth.rst | 6 ++ docs/api/wuttafarm.cli.rst | 6 ++ docs/api/wuttafarm.config.rst | 6 ++ docs/api/wuttafarm.db.model.rst | 6 ++ docs/api/wuttafarm.db.rst | 6 ++ docs/api/wuttafarm.farmos.handler.rst | 6 ++ docs/api/wuttafarm.farmos.rst | 6 ++ docs/api/wuttafarm.install.rst | 6 ++ docs/api/wuttafarm.rst | 6 ++ docs/api/wuttafarm.web.app.rst | 6 ++ docs/api/wuttafarm.web.forms.rst | 6 ++ docs/api/wuttafarm.web.forms.schema.rst | 6 ++ docs/api/wuttafarm.web.forms.widgets.rst | 6 ++ docs/api/wuttafarm.web.menus.rst | 6 ++ docs/api/wuttafarm.web.rst | 6 ++ docs/api/wuttafarm.web.static.rst | 6 ++ docs/api/wuttafarm.web.subscribers.rst | 6 ++ docs/api/wuttafarm.web.util.rst | 6 ++ docs/api/wuttafarm.web.views.auth.rst | 6 ++ docs/api/wuttafarm.web.views.common.rst | 6 ++ ...uttafarm.web.views.farmos.animal_types.rst | 6 ++ .../wuttafarm.web.views.farmos.animals.rst | 6 ++ ...wuttafarm.web.views.farmos.asset_types.rst | 6 ++ .../api/wuttafarm.web.views.farmos.groups.rst | 6 ++ ...wuttafarm.web.views.farmos.land_assets.rst | 6 ++ .../wuttafarm.web.views.farmos.land_types.rst | 6 ++ .../wuttafarm.web.views.farmos.log_types.rst | 6 ++ ...ttafarm.web.views.farmos.logs_activity.rst | 6 ++ .../api/wuttafarm.web.views.farmos.master.rst | 6 ++ docs/api/wuttafarm.web.views.farmos.rst | 6 ++ ...afarm.web.views.farmos.structure_types.rst | 6 ++ .../wuttafarm.web.views.farmos.structures.rst | 6 ++ docs/api/wuttafarm.web.views.farmos.users.rst | 6 ++ docs/api/wuttafarm.web.views.rst | 6 ++ docs/conf.py | 39 ++++++++++ docs/index.rst | 78 +++++++++++++++++++ docs/make.bat | 35 +++++++++ docs/narr/auth.rst | 52 +++++++++++++ docs/narr/features.rst | 21 +++++ docs/narr/install.rst | 62 +++++++++++++++ pyproject.toml | 4 + src/wuttafarm/web/forms/widgets.py | 4 + tox.ini | 6 ++ 46 files changed, 531 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/_static/.keepme create mode 100644 docs/api/wuttafarm.app.rst create mode 100644 docs/api/wuttafarm.auth.rst create mode 100644 docs/api/wuttafarm.cli.rst create mode 100644 docs/api/wuttafarm.config.rst create mode 100644 docs/api/wuttafarm.db.model.rst create mode 100644 docs/api/wuttafarm.db.rst create mode 100644 docs/api/wuttafarm.farmos.handler.rst create mode 100644 docs/api/wuttafarm.farmos.rst create mode 100644 docs/api/wuttafarm.install.rst create mode 100644 docs/api/wuttafarm.rst create mode 100644 docs/api/wuttafarm.web.app.rst create mode 100644 docs/api/wuttafarm.web.forms.rst create mode 100644 docs/api/wuttafarm.web.forms.schema.rst create mode 100644 docs/api/wuttafarm.web.forms.widgets.rst create mode 100644 docs/api/wuttafarm.web.menus.rst create mode 100644 docs/api/wuttafarm.web.rst create mode 100644 docs/api/wuttafarm.web.static.rst create mode 100644 docs/api/wuttafarm.web.subscribers.rst create mode 100644 docs/api/wuttafarm.web.util.rst create mode 100644 docs/api/wuttafarm.web.views.auth.rst create mode 100644 docs/api/wuttafarm.web.views.common.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.animal_types.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.animals.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.asset_types.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.groups.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.land_assets.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.land_types.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.log_types.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.logs_activity.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.master.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.structure_types.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.structures.rst create mode 100644 docs/api/wuttafarm.web.views.farmos.users.rst create mode 100644 docs/api/wuttafarm.web.views.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/narr/auth.rst create mode 100644 docs/narr/features.rst create mode 100644 docs/narr/install.rst create mode 100644 tox.ini diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/.keepme b/docs/_static/.keepme new file mode 100644 index 0000000..e69de29 diff --git a/docs/api/wuttafarm.app.rst b/docs/api/wuttafarm.app.rst new file mode 100644 index 0000000..6d56e37 --- /dev/null +++ b/docs/api/wuttafarm.app.rst @@ -0,0 +1,6 @@ + +``wuttafarm.app`` +================= + +.. automodule:: wuttafarm.app + :members: diff --git a/docs/api/wuttafarm.auth.rst b/docs/api/wuttafarm.auth.rst new file mode 100644 index 0000000..380c570 --- /dev/null +++ b/docs/api/wuttafarm.auth.rst @@ -0,0 +1,6 @@ + +``wuttafarm.auth`` +================== + +.. automodule:: wuttafarm.auth + :members: diff --git a/docs/api/wuttafarm.cli.rst b/docs/api/wuttafarm.cli.rst new file mode 100644 index 0000000..beb05dd --- /dev/null +++ b/docs/api/wuttafarm.cli.rst @@ -0,0 +1,6 @@ + +``wuttafarm.cli`` +================= + +.. automodule:: wuttafarm.cli + :members: diff --git a/docs/api/wuttafarm.config.rst b/docs/api/wuttafarm.config.rst new file mode 100644 index 0000000..5dc3eb0 --- /dev/null +++ b/docs/api/wuttafarm.config.rst @@ -0,0 +1,6 @@ + +``wuttafarm.config`` +==================== + +.. automodule:: wuttafarm.config + :members: diff --git a/docs/api/wuttafarm.db.model.rst b/docs/api/wuttafarm.db.model.rst new file mode 100644 index 0000000..6de7095 --- /dev/null +++ b/docs/api/wuttafarm.db.model.rst @@ -0,0 +1,6 @@ + +``wuttafarm.db.model`` +====================== + +.. automodule:: wuttafarm.db.model + :members: diff --git a/docs/api/wuttafarm.db.rst b/docs/api/wuttafarm.db.rst new file mode 100644 index 0000000..c184f5b --- /dev/null +++ b/docs/api/wuttafarm.db.rst @@ -0,0 +1,6 @@ + +``wuttafarm.db`` +================ + +.. automodule:: wuttafarm.db + :members: diff --git a/docs/api/wuttafarm.farmos.handler.rst b/docs/api/wuttafarm.farmos.handler.rst new file mode 100644 index 0000000..7d7d918 --- /dev/null +++ b/docs/api/wuttafarm.farmos.handler.rst @@ -0,0 +1,6 @@ + +``wuttafarm.farmos.handler`` +============================ + +.. automodule:: wuttafarm.farmos.handler + :members: diff --git a/docs/api/wuttafarm.farmos.rst b/docs/api/wuttafarm.farmos.rst new file mode 100644 index 0000000..a61f198 --- /dev/null +++ b/docs/api/wuttafarm.farmos.rst @@ -0,0 +1,6 @@ + +``wuttafarm.farmos`` +==================== + +.. automodule:: wuttafarm.farmos + :members: diff --git a/docs/api/wuttafarm.install.rst b/docs/api/wuttafarm.install.rst new file mode 100644 index 0000000..4cb2972 --- /dev/null +++ b/docs/api/wuttafarm.install.rst @@ -0,0 +1,6 @@ + +``wuttafarm.install`` +===================== + +.. automodule:: wuttafarm.install + :members: diff --git a/docs/api/wuttafarm.rst b/docs/api/wuttafarm.rst new file mode 100644 index 0000000..d7c5327 --- /dev/null +++ b/docs/api/wuttafarm.rst @@ -0,0 +1,6 @@ + +``wuttafarm`` +============= + +.. automodule:: wuttafarm + :members: diff --git a/docs/api/wuttafarm.web.app.rst b/docs/api/wuttafarm.web.app.rst new file mode 100644 index 0000000..5610e44 --- /dev/null +++ b/docs/api/wuttafarm.web.app.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.app`` +===================== + +.. automodule:: wuttafarm.web.app + :members: diff --git a/docs/api/wuttafarm.web.forms.rst b/docs/api/wuttafarm.web.forms.rst new file mode 100644 index 0000000..76c23cc --- /dev/null +++ b/docs/api/wuttafarm.web.forms.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.forms`` +======================= + +.. automodule:: wuttafarm.web.forms + :members: diff --git a/docs/api/wuttafarm.web.forms.schema.rst b/docs/api/wuttafarm.web.forms.schema.rst new file mode 100644 index 0000000..4606293 --- /dev/null +++ b/docs/api/wuttafarm.web.forms.schema.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.forms.schema`` +============================== + +.. automodule:: wuttafarm.web.forms.schema + :members: diff --git a/docs/api/wuttafarm.web.forms.widgets.rst b/docs/api/wuttafarm.web.forms.widgets.rst new file mode 100644 index 0000000..86f39f6 --- /dev/null +++ b/docs/api/wuttafarm.web.forms.widgets.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.forms.widgets`` +=============================== + +.. automodule:: wuttafarm.web.forms.widgets + :members: diff --git a/docs/api/wuttafarm.web.menus.rst b/docs/api/wuttafarm.web.menus.rst new file mode 100644 index 0000000..b278be0 --- /dev/null +++ b/docs/api/wuttafarm.web.menus.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.menus`` +======================= + +.. automodule:: wuttafarm.web.menus + :members: diff --git a/docs/api/wuttafarm.web.rst b/docs/api/wuttafarm.web.rst new file mode 100644 index 0000000..2d8295c --- /dev/null +++ b/docs/api/wuttafarm.web.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web`` +================= + +.. automodule:: wuttafarm.web + :members: diff --git a/docs/api/wuttafarm.web.static.rst b/docs/api/wuttafarm.web.static.rst new file mode 100644 index 0000000..512b04d --- /dev/null +++ b/docs/api/wuttafarm.web.static.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.static`` +======================== + +.. automodule:: wuttafarm.web.static + :members: diff --git a/docs/api/wuttafarm.web.subscribers.rst b/docs/api/wuttafarm.web.subscribers.rst new file mode 100644 index 0000000..d71b3e1 --- /dev/null +++ b/docs/api/wuttafarm.web.subscribers.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.subscribers`` +============================= + +.. automodule:: wuttafarm.web.subscribers + :members: diff --git a/docs/api/wuttafarm.web.util.rst b/docs/api/wuttafarm.web.util.rst new file mode 100644 index 0000000..21785e9 --- /dev/null +++ b/docs/api/wuttafarm.web.util.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.util`` +====================== + +.. automodule:: wuttafarm.web.util + :members: diff --git a/docs/api/wuttafarm.web.views.auth.rst b/docs/api/wuttafarm.web.views.auth.rst new file mode 100644 index 0000000..ef9c360 --- /dev/null +++ b/docs/api/wuttafarm.web.views.auth.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.auth`` +============================ + +.. automodule:: wuttafarm.web.views.auth + :members: diff --git a/docs/api/wuttafarm.web.views.common.rst b/docs/api/wuttafarm.web.views.common.rst new file mode 100644 index 0000000..3bf1b09 --- /dev/null +++ b/docs/api/wuttafarm.web.views.common.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.common`` +============================== + +.. automodule:: wuttafarm.web.views.common + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.animal_types.rst b/docs/api/wuttafarm.web.views.farmos.animal_types.rst new file mode 100644 index 0000000..f4a1cbe --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.animal_types.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.animal_types`` +=========================================== + +.. automodule:: wuttafarm.web.views.farmos.animal_types + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.animals.rst b/docs/api/wuttafarm.web.views.farmos.animals.rst new file mode 100644 index 0000000..4e20df6 --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.animals.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.animals`` +====================================== + +.. automodule:: wuttafarm.web.views.farmos.animals + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.asset_types.rst b/docs/api/wuttafarm.web.views.farmos.asset_types.rst new file mode 100644 index 0000000..6066efe --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.asset_types.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.asset_types`` +========================================== + +.. automodule:: wuttafarm.web.views.farmos.asset_types + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.groups.rst b/docs/api/wuttafarm.web.views.farmos.groups.rst new file mode 100644 index 0000000..464b617 --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.groups.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.groups`` +===================================== + +.. automodule:: wuttafarm.web.views.farmos.groups + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.land_assets.rst b/docs/api/wuttafarm.web.views.farmos.land_assets.rst new file mode 100644 index 0000000..2723ceb --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.land_assets.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.land_assets`` +========================================== + +.. automodule:: wuttafarm.web.views.farmos.land_assets + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.land_types.rst b/docs/api/wuttafarm.web.views.farmos.land_types.rst new file mode 100644 index 0000000..29a247d --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.land_types.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.land_types`` +========================================= + +.. automodule:: wuttafarm.web.views.farmos.land_types + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.log_types.rst b/docs/api/wuttafarm.web.views.farmos.log_types.rst new file mode 100644 index 0000000..c9d125a --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.log_types.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.log_types`` +======================================== + +.. automodule:: wuttafarm.web.views.farmos.log_types + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.logs_activity.rst b/docs/api/wuttafarm.web.views.farmos.logs_activity.rst new file mode 100644 index 0000000..fa6ed5b --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.logs_activity.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.logs_activity`` +============================================ + +.. automodule:: wuttafarm.web.views.farmos.logs_activity + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.master.rst b/docs/api/wuttafarm.web.views.farmos.master.rst new file mode 100644 index 0000000..ed212c8 --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.master.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.master`` +===================================== + +.. automodule:: wuttafarm.web.views.farmos.master + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.rst b/docs/api/wuttafarm.web.views.farmos.rst new file mode 100644 index 0000000..372b15a --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos`` +============================== + +.. automodule:: wuttafarm.web.views.farmos + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.structure_types.rst b/docs/api/wuttafarm.web.views.farmos.structure_types.rst new file mode 100644 index 0000000..249b674 --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.structure_types.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.structure_types`` +============================================== + +.. automodule:: wuttafarm.web.views.farmos.structure_types + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.structures.rst b/docs/api/wuttafarm.web.views.farmos.structures.rst new file mode 100644 index 0000000..1171abe --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.structures.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.structures`` +========================================= + +.. automodule:: wuttafarm.web.views.farmos.structures + :members: diff --git a/docs/api/wuttafarm.web.views.farmos.users.rst b/docs/api/wuttafarm.web.views.farmos.users.rst new file mode 100644 index 0000000..917b18e --- /dev/null +++ b/docs/api/wuttafarm.web.views.farmos.users.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views.farmos.users`` +==================================== + +.. automodule:: wuttafarm.web.views.farmos.users + :members: diff --git a/docs/api/wuttafarm.web.views.rst b/docs/api/wuttafarm.web.views.rst new file mode 100644 index 0000000..dbcd81d --- /dev/null +++ b/docs/api/wuttafarm.web.views.rst @@ -0,0 +1,6 @@ + +``wuttafarm.web.views`` +======================= + +.. automodule:: wuttafarm.web.views + :members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..3caa6e3 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,39 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +from importlib.metadata import version as get_version + +project = "WuttaFarm" +copyright = "2026, Lance Edgar" +author = "Lance Edgar" +release = get_version("WuttaFarm") + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", + "sphinx.ext.todo", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +intersphinx_mapping = { + "wuttjamaican": ("https://docs.wuttaproject.org/wuttjamaican/", None), + "wutta-continuum": ("https://docs.wuttaproject.org/wutta-continuum/", None), +} + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "furo" +html_static_path = ["_static"] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..4c7887b --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,78 @@ + +WuttaFarm +========= + +This is a Python web app (built with `WuttaWeb`_), to integrate with +and extend `farmOS`_. + +.. _WuttaWeb: https://wuttaproject.org +.. _farmOS: https://farmos.org + +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/psf/black + +It is just an experiment so far; the ideas I hope to play with +include: + +- display farmOS data directly, via real-time API fetch +- add "mirror" schema and sync data from farmOS to app DB (and display it) +- possibly add more schema / extra features +- possibly sync data back to farmOS + + +.. toctree:: + :maxdepth: 2 + :caption: Documentation: + + narr/install + narr/auth + narr/features + + +.. toctree:: + :maxdepth: 1 + :caption: Package API: + + api/wuttafarm + api/wuttafarm.app + api/wuttafarm.auth + api/wuttafarm.cli + api/wuttafarm.config + api/wuttafarm.db + api/wuttafarm.db.model + api/wuttafarm.farmos + api/wuttafarm.farmos.handler + api/wuttafarm.install + api/wuttafarm.web + api/wuttafarm.web.app + api/wuttafarm.web.forms + api/wuttafarm.web.forms.schema + api/wuttafarm.web.forms.widgets + api/wuttafarm.web.menus + api/wuttafarm.web.static + api/wuttafarm.web.subscribers + api/wuttafarm.web.util + api/wuttafarm.web.views + api/wuttafarm.web.views.auth + api/wuttafarm.web.views.common + api/wuttafarm.web.views.farmos + api/wuttafarm.web.views.farmos.animals + api/wuttafarm.web.views.farmos.animal_types + api/wuttafarm.web.views.farmos.asset_types + api/wuttafarm.web.views.farmos.groups + api/wuttafarm.web.views.farmos.land_assets + api/wuttafarm.web.views.farmos.land_types + api/wuttafarm.web.views.farmos.logs_activity + api/wuttafarm.web.views.farmos.log_types + api/wuttafarm.web.views.farmos.master + api/wuttafarm.web.views.farmos.structures + api/wuttafarm.web.views.farmos.structure_types + api/wuttafarm.web.views.farmos.users + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/narr/auth.rst b/docs/narr/auth.rst new file mode 100644 index 0000000..67a63fa --- /dev/null +++ b/docs/narr/auth.rst @@ -0,0 +1,52 @@ + +============== +Authentication +============== + +At the moment, the expected user login process is as follows: + + +First Launch +------------ + +When you first visit the app, it will not have any user accounts so +you will be shown a form to create one. + +The username should ideally match your main (daily driver) username +within farmOS. The password you give can be anything though, does not +need to (and perhaps should not) match farmOS. + +This account will belong to the Administrator role within WuttaFarm, +which means it can "become root" (same concept as ``sudo`` basically). + +Once the account is created you will be shown the normal login page. +Go ahead and login with this account using the username and password +you gave it. But then you should logout again, for the next step. + + +OAuth2 +------ + +The assumption (for now) is that users will login via farmOS / OAuth2 +for normal operations. Doing so will embed the access token within +the WuttaFarm app user session, which means the user can actually +browse farmOS data within the WuttaFarm views. + +.. note:: + + If you login to WuttaFarm directly with username/password, then + your user session will not have a farmOS access token and so the + farmOS data views in WuttaFarm will not work. + +On the login page, click the "Login via farmOS / OAuth2" button. This +will initiate the OAuth2 workflow, at which point you may be asked to +login to farmOS (if you're not already) and if you wish to grant +access to the 3rd party app (WuttaFarm; if you didn't already). + +If all goes well you should be back in WuttaFarm, logged in as the +same username you have in farmOS. + +Note that your first admin user in WuttaFarm ideally *should* have the +same username as farmOS, but regardless when you login via OAuth2, a +user account will be automatically created if necessary in WuttaFarm, +with that same username. diff --git a/docs/narr/features.rst b/docs/narr/features.rst new file mode 100644 index 0000000..00e435b --- /dev/null +++ b/docs/narr/features.rst @@ -0,0 +1,21 @@ + +======== +Features +======== + +Here is the list of features currently supported: + +* login via farmOS / OAuth2 workflows + * Authorization Code workflow is supported + * (technically, Password Grant workflow is also supported, for now) + +* view some farmOS data directly + * limited data is fetched via farmOS API for several views + * performance isn't bad, but data is not very "complete" + * more data could be fetched, but not sure this is the best way..? + + +Screenshots +----------- + +.. image:: https://wuttaproject.org/images/screenshot.png diff --git a/docs/narr/install.rst b/docs/narr/install.rst new file mode 100644 index 0000000..fdb9958 --- /dev/null +++ b/docs/narr/install.rst @@ -0,0 +1,62 @@ + +============== + Installation +============== + +For now, these instructions mostly reflect my own dev workflow. It +uses a Python virtual environment but no (Docker) containers. + +Eventually it may make sense to add production deployment steps using +Docker etc. - but that will wait for now. + + +Requirements +------------ + +WuttaFarm is designed to run on a (Debian-based) Linux machine; YMMV +with others. + + +farmOS +~~~~~~ + +First you must have a *production* `farmOS`_ instance running +somewhere. For more on that see `Hosting farmOS`_. + +.. _farmOS: https://farmos.org +.. _Hosting farmOS: https://farmos.org/hosting/ + +This must use HTTPS for the OAuth2 workflows to work correctly. (Not +sure but it may also need to be at the root of the domain, i.e. no +subpath.) + + +Database +~~~~~~~~ + +You also must create a PostgreSQL (or MySQL) database for the +WuttaFarm app to use. See also :ref:`wuttjamaican:create-appdb`. + + +App Setup +--------- + +The short version: + +.. code-block:: sh + + python3 -m venv ./venv + ./venv/bin/pip install WuttaFarm + ./venv/bin/wuttafarm install + +The app installer (last command above) will prompt you for DB +credentials, and the farmOS URL. + +One of the questions is about data versioning with +:doc:`wutta-continuum:index`. This feature will be leveraged more in +the future but for the moment doesn't do a whole lot in this app. You +are encouraged to enable it anyway. + +When the installer completes it will output a command you can then use +to run the web app. Do that and you can then view the app in a +browser at http://localhost:9080 diff --git a/pyproject.toml b/pyproject.toml index a0fd695..5d2676e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,10 @@ dependencies = [ ] +[project.optional-dependencies] +docs = ["Sphinx", "furo"] + + [project.scripts] "wuttafarm" = "wuttafarm.cli:wuttafarm_typer" diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 0ffb055..f6a99fc 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -40,6 +40,7 @@ class ImageWidget(Widget): self.alt_text = alt_text def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): @@ -60,6 +61,7 @@ class AnimalTypeWidget(Widget): self.request = request def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): @@ -86,6 +88,7 @@ class StructureWidget(Widget): self.request = request def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): @@ -112,6 +115,7 @@ class UsersWidget(Widget): self.request = request def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..cd87198 --- /dev/null +++ b/tox.ini @@ -0,0 +1,6 @@ + +[testenv:docs] +basepython = python3.11 +extras = docs +changedir = docs +commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From 00fd4846690536bcae3c9d67bc1916f3cac64ee3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 14:07:23 -0600 Subject: [PATCH 035/166] fix: add custom (built) buefy css to repo this makes things simpler, and no real reason not to include it --- .gitignore | 1 - pyproject.toml | 8 -------- src/wuttafarm/web/app.py | 20 ++++++++----------- .../web/static/css/wuttafarm-buefy.css | 1 + tasks.py | 3 --- 5 files changed, 9 insertions(+), 24 deletions(-) create mode 100644 src/wuttafarm/web/static/css/wuttafarm-buefy.css diff --git a/.gitignore b/.gitignore index 8178940..ad957c8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,5 @@ *.pyc dist/ docs/_build/ -src/wuttafarm/web/static/css/wuttafarm-buefy.css style/dist/ style/node_modules/ diff --git a/pyproject.toml b/pyproject.toml index 5d2676e..b7e4ae6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,11 +73,3 @@ packages = ["src/wuttafarm"] exclude = [ "style/node_modules/", ] - -[tool.hatch.build.targets.sdist.force-include] -# nb. this is necessary due to git ignoring this file (iiuc) -"src/wuttafarm/web/static/css/wuttafarm-buefy.css" = "src/wuttafarm/web/static/css/wuttafarm-buefy.css" - -[tool.hatch.build.targets.wheel.force-include] -# nb. this is necessary due to git ignoring this file (iiuc) -"src/wuttafarm/web/static/css/wuttafarm-buefy.css" = "src/wuttafarm/web/static/css/wuttafarm-buefy.css" diff --git a/src/wuttafarm/web/app.py b/src/wuttafarm/web/app.py index 5c59434..2fcb48d 100644 --- a/src/wuttafarm/web/app.py +++ b/src/wuttafarm/web/app.py @@ -44,20 +44,16 @@ def main(global_config, **settings): # make config objects wutta_config = base.make_wutta_config(settings) pyramid_config = base.make_pyramid_config(settings) + app = wutta_config.get_app() # custom buefy css - app = wutta_config.get_app() - path = app.resource_path("wuttafarm.web.static:css/wuttafarm-buefy.css") - if os.path.exists(path): - - # TODO: this is not robust enough, probably..but works for me/now - wutta_config.setdefault( - "wuttaweb.liburl.buefy_css", "/wuttafarm/css/wuttafarm-buefy.css" - ) - - # nb. ensure buefy version matches what we use for custom css - wutta_config.setdefault("wuttaweb.libver.buefy", "0.9.29") - wutta_config.setdefault("wuttaweb.libver.buefy_css", "0.9.29") + # TODO: this is not robust enough, probably..but works for me/now + wutta_config.setdefault( + "wuttaweb.liburl.buefy_css", "/wuttafarm/css/wuttafarm-buefy.css" + ) + # nb. ensure buefy version matches what we use for custom css + wutta_config.setdefault("wuttaweb.libver.buefy", "0.9.29") + wutta_config.setdefault("wuttaweb.libver.buefy_css", "0.9.29") # bring in the rest of WuttaFarm pyramid_config.include("wuttafarm.web.static") diff --git a/src/wuttafarm/web/static/css/wuttafarm-buefy.css b/src/wuttafarm/web/static/css/wuttafarm-buefy.css new file mode 100644 index 0000000..80062ae --- /dev/null +++ b/src/wuttafarm/web/static/css/wuttafarm-buefy.css @@ -0,0 +1 @@ +.pagination-previous,.pagination-next,.pagination-link,.pagination-ellipsis,.file-cta,.file-name,.select select,.taginput .taginput-container.is-focusable,.textarea,.input,.button{-moz-appearance:none;-webkit-appearance:none;align-items:center;border:1px solid rgba(0,0,0,0);border-radius:4px;box-shadow:none;display:inline-flex;font-size:1rem;height:2.5em;justify-content:flex-start;line-height:1.5;padding-bottom:calc(.5em - 1px);padding-left:calc(.75em - 1px);padding-right:calc(.75em - 1px);padding-top:calc(.5em - 1px);position:relative;vertical-align:top}.pagination-previous:focus,.pagination-next:focus,.pagination-link:focus,.pagination-ellipsis:focus,.file-cta:focus,.file-name:focus,.select select:focus,.taginput .taginput-container.is-focusable:focus,.textarea:focus,.input:focus,.button:focus,.is-focused.pagination-previous,.is-focused.pagination-next,.is-focused.pagination-link,.is-focused.pagination-ellipsis,.is-focused.file-cta,.is-focused.file-name,.select select.is-focused,.taginput .is-focused.taginput-container.is-focusable,.is-focused.textarea,.is-focused.input,.is-focused.button,.pagination-previous:active,.pagination-next:active,.pagination-link:active,.pagination-ellipsis:active,.file-cta:active,.file-name:active,.select select:active,.taginput .taginput-container.is-focusable:active,.textarea:active,.input:active,.button:active,.is-active.pagination-previous,.is-active.pagination-next,.is-active.pagination-link,.is-active.pagination-ellipsis,.is-active.file-cta,.is-active.file-name,.select select.is-active,.taginput .is-active.taginput-container.is-focusable,.is-active.textarea,.is-active.input,.is-active.button{outline:none}[disabled].pagination-previous,[disabled].pagination-next,[disabled].pagination-link,[disabled].pagination-ellipsis,[disabled].file-cta,[disabled].file-name,.select select[disabled],.taginput [disabled].taginput-container.is-focusable,[disabled].textarea,[disabled].input,[disabled].button,fieldset[disabled] .pagination-previous,fieldset[disabled] .pagination-next,fieldset[disabled] .pagination-link,fieldset[disabled] .pagination-ellipsis,fieldset[disabled] .file-cta,fieldset[disabled] .file-name,fieldset[disabled] .select select,.select fieldset[disabled] select,fieldset[disabled] .taginput .taginput-container.is-focusable,.taginput fieldset[disabled] .taginput-container.is-focusable,fieldset[disabled] .textarea,fieldset[disabled] .input,fieldset[disabled] .button{cursor:not-allowed}.switch,.b-radio.radio,.b-checkbox.checkbox,.carousel-list,.carousel,.is-unselectable,.tabs,.pagination-previous,.pagination-next,.pagination-link,.pagination-ellipsis,.breadcrumb,.file,.button{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.navbar-link:not(.is-arrowless)::after,.select:not(.is-multiple):not(.is-loading)::after{border:3px solid rgba(0,0,0,0);border-radius:2px;border-right:0;border-top:0;content:" ";display:block;height:.625em;margin-top:-0.4375em;pointer-events:none;position:absolute;top:50%;transform:rotate(-45deg);transform-origin:center;width:.625em}.tabs:not(:last-child),.pagination:not(:last-child),.message:not(:last-child),.level:not(:last-child),.breadcrumb:not(:last-child),.block:not(:last-child),.title:not(:last-child),.subtitle:not(:last-child),.table-container:not(:last-child),.table:not(:last-child),.progress:not(:last-child),.progress-wrapper.is-not-native:not(:last-child),.notification:not(:last-child),.content:not(:last-child),.box:not(:last-child){margin-bottom:1.5rem}.modal-close,.delete{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-moz-appearance:none;-webkit-appearance:none;background-color:hsla(0,0%,4%,.2);border:none;border-radius:9999px;cursor:pointer;pointer-events:auto;display:inline-block;flex-grow:0;flex-shrink:0;font-size:0;height:20px;max-height:20px;max-width:20px;min-height:20px;min-width:20px;outline:none;position:relative;vertical-align:top;width:20px}.modal-close::before,.delete::before,.modal-close::after,.delete::after{background-color:#fff;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.modal-close::before,.delete::before{height:2px;width:50%}.modal-close::after,.delete::after{height:50%;width:2px}.modal-close:hover,.delete:hover,.modal-close:focus,.delete:focus{background-color:hsla(0,0%,4%,.3)}.modal-close:active,.delete:active{background-color:hsla(0,0%,4%,.4)}.is-small.modal-close,.is-small.delete{height:16px;max-height:16px;max-width:16px;min-height:16px;min-width:16px;width:16px}.is-medium.modal-close,.is-medium.delete{height:24px;max-height:24px;max-width:24px;min-height:24px;min-width:24px;width:24px}.is-large.modal-close,.is-large.delete{height:32px;max-height:32px;max-width:32px;min-height:32px;min-width:32px;width:32px}.control.is-loading::after,.select.is-loading::after,.loader,.button.is-loading::after{animation:spinAround 500ms infinite linear;border:2px solid hsl(0,0%,86%);border-radius:9999px;border-right-color:rgba(0,0,0,0);border-top-color:rgba(0,0,0,0);content:"";display:block;height:1em;position:relative;width:1em}.b-image-wrapper>img.has-ratio,.b-image-wrapper>img.placeholder,.hero-video,.is-overlay,.modal-background,.modal,.image.is-square img,.image.is-square .has-ratio,.image.is-1by1 img,.image.is-1by1 .has-ratio,.image.is-5by4 img,.image.is-5by4 .has-ratio,.image.is-4by3 img,.image.is-4by3 .has-ratio,.image.is-3by2 img,.image.is-3by2 .has-ratio,.image.is-5by3 img,.image.is-5by3 .has-ratio,.image.is-16by9 img,.image.is-16by9 .has-ratio,.image.is-2by1 img,.image.is-2by1 .has-ratio,.image.is-3by1 img,.image.is-3by1 .has-ratio,.image.is-4by5 img,.image.is-4by5 .has-ratio,.image.is-3by4 img,.image.is-3by4 .has-ratio,.image.is-2by3 img,.image.is-2by3 .has-ratio,.image.is-3by5 img,.image.is-3by5 .has-ratio,.image.is-9by16 img,.image.is-9by16 .has-ratio,.image.is-1by2 img,.image.is-1by2 .has-ratio,.image.is-1by3 img,.image.is-1by3 .has-ratio{bottom:0;left:0;position:absolute;right:0;top:0}.navbar-burger{-moz-appearance:none;-webkit-appearance:none;appearance:none;background:none;border:none;color:currentColor;font-family:inherit;font-size:1em;margin:0;padding:0}/*! bulma.io v0.9.4 | MIT License | github.com/jgthms/bulma */.pagination-previous,.pagination-next,.pagination-link,.pagination-ellipsis,.file-cta,.file-name,.select select,.taginput .taginput-container.is-focusable,.textarea,.input,.button{-moz-appearance:none;-webkit-appearance:none;align-items:center;border:1px solid rgba(0,0,0,0);border-radius:4px;box-shadow:none;display:inline-flex;font-size:1rem;height:2.5em;justify-content:flex-start;line-height:1.5;padding-bottom:calc(.5em - 1px);padding-left:calc(.75em - 1px);padding-right:calc(.75em - 1px);padding-top:calc(.5em - 1px);position:relative;vertical-align:top}.pagination-previous:focus,.pagination-next:focus,.pagination-link:focus,.pagination-ellipsis:focus,.file-cta:focus,.file-name:focus,.select select:focus,.taginput .taginput-container.is-focusable:focus,.textarea:focus,.input:focus,.button:focus,.is-focused.pagination-previous,.is-focused.pagination-next,.is-focused.pagination-link,.is-focused.pagination-ellipsis,.is-focused.file-cta,.is-focused.file-name,.select select.is-focused,.taginput .is-focused.taginput-container.is-focusable,.is-focused.textarea,.is-focused.input,.is-focused.button,.pagination-previous:active,.pagination-next:active,.pagination-link:active,.pagination-ellipsis:active,.file-cta:active,.file-name:active,.select select:active,.taginput .taginput-container.is-focusable:active,.textarea:active,.input:active,.button:active,.is-active.pagination-previous,.is-active.pagination-next,.is-active.pagination-link,.is-active.pagination-ellipsis,.is-active.file-cta,.is-active.file-name,.select select.is-active,.taginput .is-active.taginput-container.is-focusable,.is-active.textarea,.is-active.input,.is-active.button{outline:none}[disabled].pagination-previous,[disabled].pagination-next,[disabled].pagination-link,[disabled].pagination-ellipsis,[disabled].file-cta,[disabled].file-name,.select select[disabled],.taginput [disabled].taginput-container.is-focusable,[disabled].textarea,[disabled].input,[disabled].button,fieldset[disabled] .pagination-previous,fieldset[disabled] .pagination-next,fieldset[disabled] .pagination-link,fieldset[disabled] .pagination-ellipsis,fieldset[disabled] .file-cta,fieldset[disabled] .file-name,fieldset[disabled] .select select,.select fieldset[disabled] select,fieldset[disabled] .taginput .taginput-container.is-focusable,.taginput fieldset[disabled] .taginput-container.is-focusable,fieldset[disabled] .textarea,fieldset[disabled] .input,fieldset[disabled] .button{cursor:not-allowed}.switch,.b-radio.radio,.b-checkbox.checkbox,.carousel-list,.carousel,.is-unselectable,.tabs,.pagination-previous,.pagination-next,.pagination-link,.pagination-ellipsis,.breadcrumb,.file,.button{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.navbar-link:not(.is-arrowless)::after,.select:not(.is-multiple):not(.is-loading)::after{border:3px solid rgba(0,0,0,0);border-radius:2px;border-right:0;border-top:0;content:" ";display:block;height:.625em;margin-top:-0.4375em;pointer-events:none;position:absolute;top:50%;transform:rotate(-45deg);transform-origin:center;width:.625em}.tabs:not(:last-child),.pagination:not(:last-child),.message:not(:last-child),.level:not(:last-child),.breadcrumb:not(:last-child),.block:not(:last-child),.title:not(:last-child),.subtitle:not(:last-child),.table-container:not(:last-child),.table:not(:last-child),.progress:not(:last-child),.progress-wrapper.is-not-native:not(:last-child),.notification:not(:last-child),.content:not(:last-child),.box:not(:last-child){margin-bottom:1.5rem}.modal-close,.delete{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-moz-appearance:none;-webkit-appearance:none;background-color:hsla(0,0%,4%,.2);border:none;border-radius:9999px;cursor:pointer;pointer-events:auto;display:inline-block;flex-grow:0;flex-shrink:0;font-size:0;height:20px;max-height:20px;max-width:20px;min-height:20px;min-width:20px;outline:none;position:relative;vertical-align:top;width:20px}.modal-close::before,.delete::before,.modal-close::after,.delete::after{background-color:#fff;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.modal-close::before,.delete::before{height:2px;width:50%}.modal-close::after,.delete::after{height:50%;width:2px}.modal-close:hover,.delete:hover,.modal-close:focus,.delete:focus{background-color:hsla(0,0%,4%,.3)}.modal-close:active,.delete:active{background-color:hsla(0,0%,4%,.4)}.is-small.modal-close,.is-small.delete{height:16px;max-height:16px;max-width:16px;min-height:16px;min-width:16px;width:16px}.is-medium.modal-close,.is-medium.delete{height:24px;max-height:24px;max-width:24px;min-height:24px;min-width:24px;width:24px}.is-large.modal-close,.is-large.delete{height:32px;max-height:32px;max-width:32px;min-height:32px;min-width:32px;width:32px}.control.is-loading::after,.select.is-loading::after,.loader,.button.is-loading::after{animation:spinAround 500ms infinite linear;border:2px solid hsl(0,0%,86%);border-radius:9999px;border-right-color:rgba(0,0,0,0);border-top-color:rgba(0,0,0,0);content:"";display:block;height:1em;position:relative;width:1em}.b-image-wrapper>img.has-ratio,.b-image-wrapper>img.placeholder,.hero-video,.is-overlay,.modal-background,.modal,.image.is-square img,.image.is-square .has-ratio,.image.is-1by1 img,.image.is-1by1 .has-ratio,.image.is-5by4 img,.image.is-5by4 .has-ratio,.image.is-4by3 img,.image.is-4by3 .has-ratio,.image.is-3by2 img,.image.is-3by2 .has-ratio,.image.is-5by3 img,.image.is-5by3 .has-ratio,.image.is-16by9 img,.image.is-16by9 .has-ratio,.image.is-2by1 img,.image.is-2by1 .has-ratio,.image.is-3by1 img,.image.is-3by1 .has-ratio,.image.is-4by5 img,.image.is-4by5 .has-ratio,.image.is-3by4 img,.image.is-3by4 .has-ratio,.image.is-2by3 img,.image.is-2by3 .has-ratio,.image.is-3by5 img,.image.is-3by5 .has-ratio,.image.is-9by16 img,.image.is-9by16 .has-ratio,.image.is-1by2 img,.image.is-1by2 .has-ratio,.image.is-1by3 img,.image.is-1by3 .has-ratio{bottom:0;left:0;position:absolute;right:0;top:0}.navbar-burger{-moz-appearance:none;-webkit-appearance:none;appearance:none;background:none;border:none;color:currentColor;font-family:inherit;font-size:1em;margin:0;padding:0}/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */html,body,p,ol,ul,li,dl,dt,dd,blockquote,figure,fieldset,legend,textarea,pre,iframe,hr,h1,h2,h3,h4,h5,h6{margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal}ul{list-style:none}button,input,select,textarea{margin:0}html{box-sizing:border-box}*,*::before,*::after{box-sizing:inherit}img,video{height:auto;max-width:100%}iframe{border:0}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}td:not([align]),th:not([align]){text-align:inherit}html{background-color:#fff;font-size:16px;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:300px;overflow-x:hidden;overflow-y:scroll;text-rendering:optimizeLegibility;text-size-adjust:100%}article,aside,figure,footer,header,hgroup,section{display:block}body,button,input,optgroup,select,textarea{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue","Helvetica","Arial",sans-serif}code,pre{-moz-osx-font-smoothing:auto;-webkit-font-smoothing:auto;font-family:monospace}body{color:hsl(0,0%,29%);font-size:1em;font-weight:400;line-height:1.5}a{color:#00875f;cursor:pointer;text-decoration:none}a strong{color:currentColor}a:hover{color:hsl(0,0%,21%)}code{background-color:hsl(0,0%,96%);color:hsl(348,86%,46%);font-size:.875em;font-weight:normal;padding:.25em .5em .25em}hr{background-color:hsl(0,0%,96%);border:none;display:block;height:2px;margin:1.5rem 0}img{height:auto;max-width:100%}input[type=checkbox],input[type=radio]{vertical-align:baseline}small{font-size:.875em}span{font-style:inherit;font-weight:inherit}strong{color:hsl(0,0%,21%);font-weight:700}fieldset{border:none}pre{-webkit-overflow-scrolling:touch;background-color:hsl(0,0%,96%);color:hsl(0,0%,29%);font-size:.875em;overflow-x:auto;padding:1.25rem 1.5rem;white-space:pre;word-wrap:normal}pre code{background-color:rgba(0,0,0,0);color:currentColor;font-size:1em;padding:0}table td,table th{vertical-align:top}table td:not([align]),table th:not([align]){text-align:inherit}table th{color:hsl(0,0%,21%)}@keyframes spinAround{from{transform:rotate(0deg)}to{transform:rotate(359deg)}}.box{background-color:#fff;border-radius:6px;box-shadow:0 .5em 1em -0.125em hsla(0,0%,4%,.1),0 0px 0 1px hsla(0,0%,4%,.02);color:hsl(0,0%,29%);display:block;padding:1.25rem}a.box:hover,a.box:focus{box-shadow:0 .5em 1em -0.125em hsla(0,0%,4%,.1),0 0 0 1px #00875f}a.box:active{box-shadow:inset 0 1px 2px hsla(0,0%,4%,.2),0 0 0 1px #00875f}.button{background-color:#fff;border-color:hsl(0,0%,86%);border-width:1px;color:hsl(0,0%,21%);cursor:pointer;justify-content:center;padding-bottom:calc(.5em - 1px);padding-left:1em;padding-right:1em;padding-top:calc(.5em - 1px);text-align:center;white-space:nowrap}.button strong{color:inherit}.button .icon,.button .icon.is-small,.button .icon.is-medium,.button .icon.is-large{height:1.5em;width:1.5em}.button .icon:first-child:not(:last-child){margin-left:calc(-0.5em - 1px);margin-right:.25em}.button .icon:last-child:not(:first-child){margin-left:.25em;margin-right:calc(-0.5em - 1px)}.button .icon:first-child:last-child{margin-left:calc(-0.5em - 1px);margin-right:calc(-0.5em - 1px)}.button:hover,.button.is-hovered{border-color:hsl(0,0%,71%);color:hsl(0,0%,21%)}.button:focus,.button.is-focused{border-color:#00875f;color:hsl(0,0%,21%)}.button:focus:not(:active),.button.is-focused:not(:active){box-shadow:0 0 0 .125em rgba(0,135,95,.25)}.button:active,.button.is-active{border-color:hsl(0,0%,29%);color:hsl(0,0%,21%)}.button.is-text{background-color:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:hsl(0,0%,29%);text-decoration:underline}.button.is-text:hover,.button.is-text.is-hovered,.button.is-text:focus,.button.is-text.is-focused{background-color:hsl(0,0%,96%);color:hsl(0,0%,21%)}.button.is-text:active,.button.is-text.is-active{background-color:hsl(0,0%,91%);color:hsl(0,0%,21%)}.button.is-text[disabled],fieldset[disabled] .button.is-text{background-color:rgba(0,0,0,0);border-color:rgba(0,0,0,0);box-shadow:none}.button.is-ghost{background:none;border-color:rgba(0,0,0,0);color:#00875f;text-decoration:none}.button.is-ghost:hover,.button.is-ghost.is-hovered{color:#00875f;text-decoration:underline}.button.is-white{background-color:#fff;border-color:rgba(0,0,0,0);color:hsl(0,0%,4%)}.button.is-white:hover,.button.is-white.is-hovered{background-color:hsl(0,0%,97.5%);border-color:rgba(0,0,0,0);color:hsl(0,0%,4%)}.button.is-white:focus,.button.is-white.is-focused{border-color:rgba(0,0,0,0);color:hsl(0,0%,4%)}.button.is-white:focus:not(:active),.button.is-white.is-focused:not(:active){box-shadow:0 0 0 .125em hsla(0,0%,100%,.25)}.button.is-white:active,.button.is-white.is-active{background-color:hsl(0,0%,95%);border-color:rgba(0,0,0,0);color:hsl(0,0%,4%)}.button.is-white[disabled],fieldset[disabled] .button.is-white{background-color:#fff;border-color:#fff;box-shadow:none}.button.is-white.is-inverted{background-color:hsl(0,0%,4%);color:#fff}.button.is-white.is-inverted:hover,.button.is-white.is-inverted.is-hovered{background-color:#000}.button.is-white.is-inverted[disabled],fieldset[disabled] .button.is-white.is-inverted{background-color:hsl(0,0%,4%);border-color:rgba(0,0,0,0);box-shadow:none;color:#fff}.button.is-white.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(0,0%,4%) hsl(0,0%,4%) !important}.button.is-white.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;color:#fff}.button.is-white.is-outlined:hover,.button.is-white.is-outlined.is-hovered,.button.is-white.is-outlined:focus,.button.is-white.is-outlined.is-focused{background-color:#fff;border-color:#fff;color:hsl(0,0%,4%)}.button.is-white.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-white.is-outlined.is-loading:hover::after,.button.is-white.is-outlined.is-loading.is-hovered::after,.button.is-white.is-outlined.is-loading:focus::after,.button.is-white.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(0,0%,4%) hsl(0,0%,4%) !important}.button.is-white.is-outlined[disabled],fieldset[disabled] .button.is-white.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;box-shadow:none;color:#fff}.button.is-white.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(0,0%,4%);color:hsl(0,0%,4%)}.button.is-white.is-inverted.is-outlined:hover,.button.is-white.is-inverted.is-outlined.is-hovered,.button.is-white.is-inverted.is-outlined:focus,.button.is-white.is-inverted.is-outlined.is-focused{background-color:hsl(0,0%,4%);color:#fff}.button.is-white.is-inverted.is-outlined.is-loading:hover::after,.button.is-white.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-white.is-inverted.is-outlined.is-loading:focus::after,.button.is-white.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-white.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-white.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(0,0%,4%);box-shadow:none;color:hsl(0,0%,4%)}.button.is-black{background-color:hsl(0,0%,4%);border-color:rgba(0,0,0,0);color:#fff}.button.is-black:hover,.button.is-black.is-hovered{background-color:hsl(0,0%,1.5%);border-color:rgba(0,0,0,0);color:#fff}.button.is-black:focus,.button.is-black.is-focused{border-color:rgba(0,0,0,0);color:#fff}.button.is-black:focus:not(:active),.button.is-black.is-focused:not(:active){box-shadow:0 0 0 .125em hsla(0,0%,4%,.25)}.button.is-black:active,.button.is-black.is-active{background-color:#000;border-color:rgba(0,0,0,0);color:#fff}.button.is-black[disabled],fieldset[disabled] .button.is-black{background-color:hsl(0,0%,4%);border-color:hsl(0,0%,4%);box-shadow:none}.button.is-black.is-inverted{background-color:#fff;color:hsl(0,0%,4%)}.button.is-black.is-inverted:hover,.button.is-black.is-inverted.is-hovered{background-color:hsl(0,0%,95%)}.button.is-black.is-inverted[disabled],fieldset[disabled] .button.is-black.is-inverted{background-color:#fff;border-color:rgba(0,0,0,0);box-shadow:none;color:hsl(0,0%,4%)}.button.is-black.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-black.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(0,0%,4%);color:hsl(0,0%,4%)}.button.is-black.is-outlined:hover,.button.is-black.is-outlined.is-hovered,.button.is-black.is-outlined:focus,.button.is-black.is-outlined.is-focused{background-color:hsl(0,0%,4%);border-color:hsl(0,0%,4%);color:#fff}.button.is-black.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(0,0%,4%) hsl(0,0%,4%) !important}.button.is-black.is-outlined.is-loading:hover::after,.button.is-black.is-outlined.is-loading.is-hovered::after,.button.is-black.is-outlined.is-loading:focus::after,.button.is-black.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-black.is-outlined[disabled],fieldset[disabled] .button.is-black.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(0,0%,4%);box-shadow:none;color:hsl(0,0%,4%)}.button.is-black.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;color:#fff}.button.is-black.is-inverted.is-outlined:hover,.button.is-black.is-inverted.is-outlined.is-hovered,.button.is-black.is-inverted.is-outlined:focus,.button.is-black.is-inverted.is-outlined.is-focused{background-color:#fff;color:hsl(0,0%,4%)}.button.is-black.is-inverted.is-outlined.is-loading:hover::after,.button.is-black.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-black.is-inverted.is-outlined.is-loading:focus::after,.button.is-black.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(0,0%,4%) hsl(0,0%,4%) !important}.button.is-black.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-black.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;box-shadow:none;color:#fff}.button.is-light{background-color:hsl(0,0%,96%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.button.is-light:hover,.button.is-light.is-hovered{background-color:hsl(0,0%,93.5%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.button.is-light:focus,.button.is-light.is-focused{border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.button.is-light:focus:not(:active),.button.is-light.is-focused:not(:active){box-shadow:0 0 0 .125em hsla(0,0%,96%,.25)}.button.is-light:active,.button.is-light.is-active{background-color:hsl(0,0%,91%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.button.is-light[disabled],fieldset[disabled] .button.is-light{background-color:hsl(0,0%,96%);border-color:hsl(0,0%,96%);box-shadow:none}.button.is-light.is-inverted{background-color:rgba(0,0,0,.7);color:hsl(0,0%,96%)}.button.is-light.is-inverted:hover,.button.is-light.is-inverted.is-hovered{background-color:rgba(0,0,0,.7)}.button.is-light.is-inverted[disabled],fieldset[disabled] .button.is-light.is-inverted{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,0);box-shadow:none;color:hsl(0,0%,96%)}.button.is-light.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) rgba(0,0,0,.7) rgba(0,0,0,.7) !important}.button.is-light.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(0,0%,96%);color:hsl(0,0%,96%)}.button.is-light.is-outlined:hover,.button.is-light.is-outlined.is-hovered,.button.is-light.is-outlined:focus,.button.is-light.is-outlined.is-focused{background-color:hsl(0,0%,96%);border-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.button.is-light.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(0,0%,96%) hsl(0,0%,96%) !important}.button.is-light.is-outlined.is-loading:hover::after,.button.is-light.is-outlined.is-loading.is-hovered::after,.button.is-light.is-outlined.is-loading:focus::after,.button.is-light.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) rgba(0,0,0,.7) rgba(0,0,0,.7) !important}.button.is-light.is-outlined[disabled],fieldset[disabled] .button.is-light.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(0,0%,96%);box-shadow:none;color:hsl(0,0%,96%)}.button.is-light.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:rgba(0,0,0,.7);color:rgba(0,0,0,.7)}.button.is-light.is-inverted.is-outlined:hover,.button.is-light.is-inverted.is-outlined.is-hovered,.button.is-light.is-inverted.is-outlined:focus,.button.is-light.is-inverted.is-outlined.is-focused{background-color:rgba(0,0,0,.7);color:hsl(0,0%,96%)}.button.is-light.is-inverted.is-outlined.is-loading:hover::after,.button.is-light.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-light.is-inverted.is-outlined.is-loading:focus::after,.button.is-light.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(0,0%,96%) hsl(0,0%,96%) !important}.button.is-light.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-light.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:rgba(0,0,0,.7);box-shadow:none;color:rgba(0,0,0,.7)}.button.is-dark{background-color:hsl(0,0%,21%);border-color:rgba(0,0,0,0);color:#fff}.button.is-dark:hover,.button.is-dark.is-hovered{background-color:hsl(0,0%,18.5%);border-color:rgba(0,0,0,0);color:#fff}.button.is-dark:focus,.button.is-dark.is-focused{border-color:rgba(0,0,0,0);color:#fff}.button.is-dark:focus:not(:active),.button.is-dark.is-focused:not(:active){box-shadow:0 0 0 .125em hsla(0,0%,21%,.25)}.button.is-dark:active,.button.is-dark.is-active{background-color:hsl(0,0%,16%);border-color:rgba(0,0,0,0);color:#fff}.button.is-dark[disabled],fieldset[disabled] .button.is-dark{background-color:hsl(0,0%,21%);border-color:hsl(0,0%,21%);box-shadow:none}.button.is-dark.is-inverted{background-color:#fff;color:hsl(0,0%,21%)}.button.is-dark.is-inverted:hover,.button.is-dark.is-inverted.is-hovered{background-color:hsl(0,0%,95%)}.button.is-dark.is-inverted[disabled],fieldset[disabled] .button.is-dark.is-inverted{background-color:#fff;border-color:rgba(0,0,0,0);box-shadow:none;color:hsl(0,0%,21%)}.button.is-dark.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-dark.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(0,0%,21%);color:hsl(0,0%,21%)}.button.is-dark.is-outlined:hover,.button.is-dark.is-outlined.is-hovered,.button.is-dark.is-outlined:focus,.button.is-dark.is-outlined.is-focused{background-color:hsl(0,0%,21%);border-color:hsl(0,0%,21%);color:#fff}.button.is-dark.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(0,0%,21%) hsl(0,0%,21%) !important}.button.is-dark.is-outlined.is-loading:hover::after,.button.is-dark.is-outlined.is-loading.is-hovered::after,.button.is-dark.is-outlined.is-loading:focus::after,.button.is-dark.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-dark.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(0,0%,21%);box-shadow:none;color:hsl(0,0%,21%)}.button.is-dark.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;color:#fff}.button.is-dark.is-inverted.is-outlined:hover,.button.is-dark.is-inverted.is-outlined.is-hovered,.button.is-dark.is-inverted.is-outlined:focus,.button.is-dark.is-inverted.is-outlined.is-focused{background-color:#fff;color:hsl(0,0%,21%)}.button.is-dark.is-inverted.is-outlined.is-loading:hover::after,.button.is-dark.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-dark.is-inverted.is-outlined.is-loading:focus::after,.button.is-dark.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(0,0%,21%) hsl(0,0%,21%) !important}.button.is-dark.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;box-shadow:none;color:#fff}.button.is-primary{background-color:#00875f;border-color:rgba(0,0,0,0);color:#fff}.button.is-primary:hover,.button.is-primary.is-hovered{background-color:rgb(0,122.25,86.0277777778);border-color:rgba(0,0,0,0);color:#fff}.button.is-primary:focus,.button.is-primary.is-focused{border-color:rgba(0,0,0,0);color:#fff}.button.is-primary:focus:not(:active),.button.is-primary.is-focused:not(:active){box-shadow:0 0 0 .125em rgba(0,135,95,.25)}.button.is-primary:active,.button.is-primary.is-active{background-color:rgb(0,109.5,77.0555555556);border-color:rgba(0,0,0,0);color:#fff}.button.is-primary[disabled],fieldset[disabled] .button.is-primary{background-color:#00875f;border-color:#00875f;box-shadow:none}.button.is-primary.is-inverted{background-color:#fff;color:#00875f}.button.is-primary.is-inverted:hover,.button.is-primary.is-inverted.is-hovered{background-color:hsl(0,0%,95%)}.button.is-primary.is-inverted[disabled],fieldset[disabled] .button.is-primary.is-inverted{background-color:#fff;border-color:rgba(0,0,0,0);box-shadow:none;color:#00875f}.button.is-primary.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-primary.is-outlined{background-color:rgba(0,0,0,0);border-color:#00875f;color:#00875f}.button.is-primary.is-outlined:hover,.button.is-primary.is-outlined.is-hovered,.button.is-primary.is-outlined:focus,.button.is-primary.is-outlined.is-focused{background-color:#00875f;border-color:#00875f;color:#fff}.button.is-primary.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #00875f #00875f !important}.button.is-primary.is-outlined.is-loading:hover::after,.button.is-primary.is-outlined.is-loading.is-hovered::after,.button.is-primary.is-outlined.is-loading:focus::after,.button.is-primary.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-primary.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-outlined{background-color:rgba(0,0,0,0);border-color:#00875f;box-shadow:none;color:#00875f}.button.is-primary.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;color:#fff}.button.is-primary.is-inverted.is-outlined:hover,.button.is-primary.is-inverted.is-outlined.is-hovered,.button.is-primary.is-inverted.is-outlined:focus,.button.is-primary.is-inverted.is-outlined.is-focused{background-color:#fff;color:#00875f}.button.is-primary.is-inverted.is-outlined.is-loading:hover::after,.button.is-primary.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-primary.is-inverted.is-outlined.is-loading:focus::after,.button.is-primary.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #00875f #00875f !important}.button.is-primary.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;box-shadow:none;color:#fff}.button.is-primary.is-light{background-color:hsl(162.2222222222,100%,96%);color:rgb(0,229.5,161.5)}.button.is-primary.is-light:hover,.button.is-primary.is-light.is-hovered{background-color:rgb(221.85,255,245.1777777778);border-color:rgba(0,0,0,0);color:rgb(0,229.5,161.5)}.button.is-primary.is-light:active,.button.is-primary.is-light.is-active{background-color:rgb(209.1,255,241.4);border-color:rgba(0,0,0,0);color:rgb(0,229.5,161.5)}.button.is-link{background-color:hsl(229,53%,53%);border-color:rgba(0,0,0,0);color:#fff}.button.is-link:hover,.button.is-link.is-hovered{background-color:hsl(229,53%,50.5%);border-color:rgba(0,0,0,0);color:#fff}.button.is-link:focus,.button.is-link.is-focused{border-color:rgba(0,0,0,0);color:#fff}.button.is-link:focus:not(:active),.button.is-link.is-focused:not(:active){box-shadow:0 0 0 .125em hsla(229,53%,53%,.25)}.button.is-link:active,.button.is-link.is-active{background-color:hsl(229,53%,48%);border-color:rgba(0,0,0,0);color:#fff}.button.is-link[disabled],fieldset[disabled] .button.is-link{background-color:hsl(229,53%,53%);border-color:hsl(229,53%,53%);box-shadow:none}.button.is-link.is-inverted{background-color:#fff;color:hsl(229,53%,53%)}.button.is-link.is-inverted:hover,.button.is-link.is-inverted.is-hovered{background-color:hsl(0,0%,95%)}.button.is-link.is-inverted[disabled],fieldset[disabled] .button.is-link.is-inverted{background-color:#fff;border-color:rgba(0,0,0,0);box-shadow:none;color:hsl(229,53%,53%)}.button.is-link.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-link.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(229,53%,53%);color:hsl(229,53%,53%)}.button.is-link.is-outlined:hover,.button.is-link.is-outlined.is-hovered,.button.is-link.is-outlined:focus,.button.is-link.is-outlined.is-focused{background-color:hsl(229,53%,53%);border-color:hsl(229,53%,53%);color:#fff}.button.is-link.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(229,53%,53%) hsl(229,53%,53%) !important}.button.is-link.is-outlined.is-loading:hover::after,.button.is-link.is-outlined.is-loading.is-hovered::after,.button.is-link.is-outlined.is-loading:focus::after,.button.is-link.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-link.is-outlined[disabled],fieldset[disabled] .button.is-link.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(229,53%,53%);box-shadow:none;color:hsl(229,53%,53%)}.button.is-link.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;color:#fff}.button.is-link.is-inverted.is-outlined:hover,.button.is-link.is-inverted.is-outlined.is-hovered,.button.is-link.is-inverted.is-outlined:focus,.button.is-link.is-inverted.is-outlined.is-focused{background-color:#fff;color:hsl(229,53%,53%)}.button.is-link.is-inverted.is-outlined.is-loading:hover::after,.button.is-link.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-link.is-inverted.is-outlined.is-loading:focus::after,.button.is-link.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(229,53%,53%) hsl(229,53%,53%) !important}.button.is-link.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-link.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;box-shadow:none;color:#fff}.button.is-link.is-light{background-color:hsl(229,53%,96%);color:hsl(229,53%,47%)}.button.is-link.is-light:hover,.button.is-link.is-light.is-hovered{background-color:hsl(229,53%,93.5%);border-color:rgba(0,0,0,0);color:hsl(229,53%,47%)}.button.is-link.is-light:active,.button.is-link.is-light.is-active{background-color:hsl(229,53%,91%);border-color:rgba(0,0,0,0);color:hsl(229,53%,47%)}.button.is-info{background-color:hsl(207,61%,53%);border-color:rgba(0,0,0,0);color:#fff}.button.is-info:hover,.button.is-info.is-hovered{background-color:hsl(207,61%,50.5%);border-color:rgba(0,0,0,0);color:#fff}.button.is-info:focus,.button.is-info.is-focused{border-color:rgba(0,0,0,0);color:#fff}.button.is-info:focus:not(:active),.button.is-info.is-focused:not(:active){box-shadow:0 0 0 .125em hsla(207,61%,53%,.25)}.button.is-info:active,.button.is-info.is-active{background-color:hsl(207,61%,48%);border-color:rgba(0,0,0,0);color:#fff}.button.is-info[disabled],fieldset[disabled] .button.is-info{background-color:hsl(207,61%,53%);border-color:hsl(207,61%,53%);box-shadow:none}.button.is-info.is-inverted{background-color:#fff;color:hsl(207,61%,53%)}.button.is-info.is-inverted:hover,.button.is-info.is-inverted.is-hovered{background-color:hsl(0,0%,95%)}.button.is-info.is-inverted[disabled],fieldset[disabled] .button.is-info.is-inverted{background-color:#fff;border-color:rgba(0,0,0,0);box-shadow:none;color:hsl(207,61%,53%)}.button.is-info.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-info.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(207,61%,53%);color:hsl(207,61%,53%)}.button.is-info.is-outlined:hover,.button.is-info.is-outlined.is-hovered,.button.is-info.is-outlined:focus,.button.is-info.is-outlined.is-focused{background-color:hsl(207,61%,53%);border-color:hsl(207,61%,53%);color:#fff}.button.is-info.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(207,61%,53%) hsl(207,61%,53%) !important}.button.is-info.is-outlined.is-loading:hover::after,.button.is-info.is-outlined.is-loading.is-hovered::after,.button.is-info.is-outlined.is-loading:focus::after,.button.is-info.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-info.is-outlined[disabled],fieldset[disabled] .button.is-info.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(207,61%,53%);box-shadow:none;color:hsl(207,61%,53%)}.button.is-info.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;color:#fff}.button.is-info.is-inverted.is-outlined:hover,.button.is-info.is-inverted.is-outlined.is-hovered,.button.is-info.is-inverted.is-outlined:focus,.button.is-info.is-inverted.is-outlined.is-focused{background-color:#fff;color:hsl(207,61%,53%)}.button.is-info.is-inverted.is-outlined.is-loading:hover::after,.button.is-info.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-info.is-inverted.is-outlined.is-loading:focus::after,.button.is-info.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(207,61%,53%) hsl(207,61%,53%) !important}.button.is-info.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-info.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;box-shadow:none;color:#fff}.button.is-info.is-light{background-color:hsl(207,61%,96%);color:hsl(207,61%,41%)}.button.is-info.is-light:hover,.button.is-info.is-light.is-hovered{background-color:hsl(207,61%,93.5%);border-color:rgba(0,0,0,0);color:hsl(207,61%,41%)}.button.is-info.is-light:active,.button.is-info.is-light.is-active{background-color:hsl(207,61%,91%);border-color:rgba(0,0,0,0);color:hsl(207,61%,41%)}.button.is-success{background-color:hsl(153,53%,53%);border-color:rgba(0,0,0,0);color:#fff}.button.is-success:hover,.button.is-success.is-hovered{background-color:hsl(153,53%,50.5%);border-color:rgba(0,0,0,0);color:#fff}.button.is-success:focus,.button.is-success.is-focused{border-color:rgba(0,0,0,0);color:#fff}.button.is-success:focus:not(:active),.button.is-success.is-focused:not(:active){box-shadow:0 0 0 .125em hsla(153,53%,53%,.25)}.button.is-success:active,.button.is-success.is-active{background-color:hsl(153,53%,48%);border-color:rgba(0,0,0,0);color:#fff}.button.is-success[disabled],fieldset[disabled] .button.is-success{background-color:hsl(153,53%,53%);border-color:hsl(153,53%,53%);box-shadow:none}.button.is-success.is-inverted{background-color:#fff;color:hsl(153,53%,53%)}.button.is-success.is-inverted:hover,.button.is-success.is-inverted.is-hovered{background-color:hsl(0,0%,95%)}.button.is-success.is-inverted[disabled],fieldset[disabled] .button.is-success.is-inverted{background-color:#fff;border-color:rgba(0,0,0,0);box-shadow:none;color:hsl(153,53%,53%)}.button.is-success.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-success.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(153,53%,53%);color:hsl(153,53%,53%)}.button.is-success.is-outlined:hover,.button.is-success.is-outlined.is-hovered,.button.is-success.is-outlined:focus,.button.is-success.is-outlined.is-focused{background-color:hsl(153,53%,53%);border-color:hsl(153,53%,53%);color:#fff}.button.is-success.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(153,53%,53%) hsl(153,53%,53%) !important}.button.is-success.is-outlined.is-loading:hover::after,.button.is-success.is-outlined.is-loading.is-hovered::after,.button.is-success.is-outlined.is-loading:focus::after,.button.is-success.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-success.is-outlined[disabled],fieldset[disabled] .button.is-success.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(153,53%,53%);box-shadow:none;color:hsl(153,53%,53%)}.button.is-success.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;color:#fff}.button.is-success.is-inverted.is-outlined:hover,.button.is-success.is-inverted.is-outlined.is-hovered,.button.is-success.is-inverted.is-outlined:focus,.button.is-success.is-inverted.is-outlined.is-focused{background-color:#fff;color:hsl(153,53%,53%)}.button.is-success.is-inverted.is-outlined.is-loading:hover::after,.button.is-success.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-success.is-inverted.is-outlined.is-loading:focus::after,.button.is-success.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(153,53%,53%) hsl(153,53%,53%) !important}.button.is-success.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-success.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;box-shadow:none;color:#fff}.button.is-success.is-light{background-color:hsl(153,53%,96%);color:hsl(153,53%,31%)}.button.is-success.is-light:hover,.button.is-success.is-light.is-hovered{background-color:hsl(153,53%,93.5%);border-color:rgba(0,0,0,0);color:hsl(153,53%,31%)}.button.is-success.is-light:active,.button.is-success.is-light.is-active{background-color:hsl(153,53%,91%);border-color:rgba(0,0,0,0);color:hsl(153,53%,31%)}.button.is-warning{background-color:hsl(44,100%,77%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.button.is-warning:hover,.button.is-warning.is-hovered{background-color:hsl(44,100%,74.5%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.button.is-warning:focus,.button.is-warning.is-focused{border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.button.is-warning:focus:not(:active),.button.is-warning.is-focused:not(:active){box-shadow:0 0 0 .125em hsla(44,100%,77%,.25)}.button.is-warning:active,.button.is-warning.is-active{background-color:hsl(44,100%,72%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.button.is-warning[disabled],fieldset[disabled] .button.is-warning{background-color:hsl(44,100%,77%);border-color:hsl(44,100%,77%);box-shadow:none}.button.is-warning.is-inverted{background-color:rgba(0,0,0,.7);color:hsl(44,100%,77%)}.button.is-warning.is-inverted:hover,.button.is-warning.is-inverted.is-hovered{background-color:rgba(0,0,0,.7)}.button.is-warning.is-inverted[disabled],fieldset[disabled] .button.is-warning.is-inverted{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,0);box-shadow:none;color:hsl(44,100%,77%)}.button.is-warning.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) rgba(0,0,0,.7) rgba(0,0,0,.7) !important}.button.is-warning.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(44,100%,77%);color:hsl(44,100%,77%)}.button.is-warning.is-outlined:hover,.button.is-warning.is-outlined.is-hovered,.button.is-warning.is-outlined:focus,.button.is-warning.is-outlined.is-focused{background-color:hsl(44,100%,77%);border-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.button.is-warning.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(44,100%,77%) hsl(44,100%,77%) !important}.button.is-warning.is-outlined.is-loading:hover::after,.button.is-warning.is-outlined.is-loading.is-hovered::after,.button.is-warning.is-outlined.is-loading:focus::after,.button.is-warning.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) rgba(0,0,0,.7) rgba(0,0,0,.7) !important}.button.is-warning.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(44,100%,77%);box-shadow:none;color:hsl(44,100%,77%)}.button.is-warning.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:rgba(0,0,0,.7);color:rgba(0,0,0,.7)}.button.is-warning.is-inverted.is-outlined:hover,.button.is-warning.is-inverted.is-outlined.is-hovered,.button.is-warning.is-inverted.is-outlined:focus,.button.is-warning.is-inverted.is-outlined.is-focused{background-color:rgba(0,0,0,.7);color:hsl(44,100%,77%)}.button.is-warning.is-inverted.is-outlined.is-loading:hover::after,.button.is-warning.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-warning.is-inverted.is-outlined.is-loading:focus::after,.button.is-warning.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(44,100%,77%) hsl(44,100%,77%) !important}.button.is-warning.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:rgba(0,0,0,.7);box-shadow:none;color:rgba(0,0,0,.7)}.button.is-warning.is-light{background-color:hsl(44,100%,96%);color:hsl(44,100%,29%)}.button.is-warning.is-light:hover,.button.is-warning.is-light.is-hovered{background-color:hsl(44,100%,93.5%);border-color:rgba(0,0,0,0);color:hsl(44,100%,29%)}.button.is-warning.is-light:active,.button.is-warning.is-light.is-active{background-color:hsl(44,100%,91%);border-color:rgba(0,0,0,0);color:hsl(44,100%,29%)}.button.is-danger{background-color:hsl(348,86%,61%);border-color:rgba(0,0,0,0);color:#fff}.button.is-danger:hover,.button.is-danger.is-hovered{background-color:hsl(348,86%,58.5%);border-color:rgba(0,0,0,0);color:#fff}.button.is-danger:focus,.button.is-danger.is-focused{border-color:rgba(0,0,0,0);color:#fff}.button.is-danger:focus:not(:active),.button.is-danger.is-focused:not(:active){box-shadow:0 0 0 .125em hsla(348,86%,61%,.25)}.button.is-danger:active,.button.is-danger.is-active{background-color:hsl(348,86%,56%);border-color:rgba(0,0,0,0);color:#fff}.button.is-danger[disabled],fieldset[disabled] .button.is-danger{background-color:hsl(348,86%,61%);border-color:hsl(348,86%,61%);box-shadow:none}.button.is-danger.is-inverted{background-color:#fff;color:hsl(348,86%,61%)}.button.is-danger.is-inverted:hover,.button.is-danger.is-inverted.is-hovered{background-color:hsl(0,0%,95%)}.button.is-danger.is-inverted[disabled],fieldset[disabled] .button.is-danger.is-inverted{background-color:#fff;border-color:rgba(0,0,0,0);box-shadow:none;color:hsl(348,86%,61%)}.button.is-danger.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-danger.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(348,86%,61%);color:hsl(348,86%,61%)}.button.is-danger.is-outlined:hover,.button.is-danger.is-outlined.is-hovered,.button.is-danger.is-outlined:focus,.button.is-danger.is-outlined.is-focused{background-color:hsl(348,86%,61%);border-color:hsl(348,86%,61%);color:#fff}.button.is-danger.is-outlined.is-loading::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(348,86%,61%) hsl(348,86%,61%) !important}.button.is-danger.is-outlined.is-loading:hover::after,.button.is-danger.is-outlined.is-loading.is-hovered::after,.button.is-danger.is-outlined.is-loading:focus::after,.button.is-danger.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) #fff #fff !important}.button.is-danger.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-outlined{background-color:rgba(0,0,0,0);border-color:hsl(348,86%,61%);box-shadow:none;color:hsl(348,86%,61%)}.button.is-danger.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;color:#fff}.button.is-danger.is-inverted.is-outlined:hover,.button.is-danger.is-inverted.is-outlined.is-hovered,.button.is-danger.is-inverted.is-outlined:focus,.button.is-danger.is-inverted.is-outlined.is-focused{background-color:#fff;color:hsl(348,86%,61%)}.button.is-danger.is-inverted.is-outlined.is-loading:hover::after,.button.is-danger.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-danger.is-inverted.is-outlined.is-loading:focus::after,.button.is-danger.is-inverted.is-outlined.is-loading.is-focused::after{border-color:rgba(0,0,0,0) rgba(0,0,0,0) hsl(348,86%,61%) hsl(348,86%,61%) !important}.button.is-danger.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-inverted.is-outlined{background-color:rgba(0,0,0,0);border-color:#fff;box-shadow:none;color:#fff}.button.is-danger.is-light{background-color:hsl(348,86%,96%);color:hsl(348,86%,43%)}.button.is-danger.is-light:hover,.button.is-danger.is-light.is-hovered{background-color:hsl(348,86%,93.5%);border-color:rgba(0,0,0,0);color:hsl(348,86%,43%)}.button.is-danger.is-light:active,.button.is-danger.is-light.is-active{background-color:hsl(348,86%,91%);border-color:rgba(0,0,0,0);color:hsl(348,86%,43%)}.button.is-small:not(.is-rounded){border-radius:2px}.button.is-small{font-size:.75rem}.button.is-normal{font-size:1rem}.button.is-medium{font-size:1.25rem}.button.is-large{font-size:1.5rem}.button[disabled],fieldset[disabled] .button{background-color:#fff;border-color:hsl(0,0%,86%);box-shadow:none;opacity:.5}.button.is-fullwidth{display:flex;width:100%}.button.is-loading{color:rgba(0,0,0,0) !important;pointer-events:none}.button.is-loading::after{position:absolute;left:calc(50% - 1em*.5);top:calc(50% - 1em*.5);position:absolute !important}.button.is-static{background-color:hsl(0,0%,96%);border-color:hsl(0,0%,86%);color:hsl(0,0%,48%);box-shadow:none;pointer-events:none}.button.is-rounded{border-radius:9999px;padding-left:calc(1em + .25em);padding-right:calc(1em + .25em)}.buttons{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-start}.buttons .button{margin-bottom:.5rem}.buttons .button:not(:last-child):not(.is-fullwidth){margin-right:.5rem}.buttons:last-child{margin-bottom:-0.5rem}.buttons:not(:last-child){margin-bottom:1rem}.buttons.are-small .button:not(.is-normal):not(.is-medium):not(.is-large):not(.is-rounded){border-radius:2px}.buttons.are-small .button:not(.is-normal):not(.is-medium):not(.is-large){font-size:.75rem}.buttons.are-medium .button:not(.is-small):not(.is-normal):not(.is-large){font-size:1.25rem}.buttons.are-large .button:not(.is-small):not(.is-normal):not(.is-medium){font-size:1.5rem}.buttons.has-addons .button:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.buttons.has-addons .button:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0;margin-right:-1px}.buttons.has-addons .button:last-child{margin-right:0}.buttons.has-addons .button:hover,.buttons.has-addons .button.is-hovered{z-index:2}.buttons.has-addons .button:focus,.buttons.has-addons .button.is-focused,.buttons.has-addons .button:active,.buttons.has-addons .button.is-active,.buttons.has-addons .button.is-selected{z-index:3}.buttons.has-addons .button:focus:hover,.buttons.has-addons .button.is-focused:hover,.buttons.has-addons .button:active:hover,.buttons.has-addons .button.is-active:hover,.buttons.has-addons .button.is-selected:hover{z-index:4}.buttons.has-addons .button.is-expanded{flex-grow:1;flex-shrink:1}.buttons.is-centered{justify-content:center}.buttons.is-centered:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}.buttons.is-right{justify-content:flex-end}.buttons.is-right:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}@media screen and (max-width: 768px){.button.is-responsive.is-small{font-size:.5625rem}.button.is-responsive,.button.is-responsive.is-normal{font-size:.65625rem}.button.is-responsive.is-medium{font-size:.75rem}.button.is-responsive.is-large{font-size:1rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.button.is-responsive.is-small{font-size:.65625rem}.button.is-responsive,.button.is-responsive.is-normal{font-size:.75rem}.button.is-responsive.is-medium{font-size:1rem}.button.is-responsive.is-large{font-size:1.25rem}}.container{flex-grow:1;margin:0 auto;position:relative;width:auto}.container.is-fluid{max-width:none !important;padding-left:32px;padding-right:32px;width:100%}@media screen and (min-width: 1024px){.container{max-width:960px}}@media screen and (max-width: 1215px){.container.is-widescreen:not(.is-max-desktop){max-width:1152px}}@media screen and (max-width: 1407px){.container.is-fullhd:not(.is-max-desktop):not(.is-max-widescreen){max-width:1344px}}@media screen and (min-width: 1216px){.container:not(.is-max-desktop){max-width:1152px}}@media screen and (min-width: 1408px){.container:not(.is-max-desktop):not(.is-max-widescreen){max-width:1344px}}.content li+li{margin-top:.25em}.content p:not(:last-child),.content dl:not(:last-child),.content ol:not(:last-child),.content ul:not(:last-child),.content blockquote:not(:last-child),.content pre:not(:last-child),.content table:not(:last-child){margin-bottom:1em}.content h1,.content h2,.content h3,.content h4,.content h5,.content h6{color:hsl(0,0%,21%);font-weight:600;line-height:1.125}.content h1{font-size:2em;margin-bottom:.5em}.content h1:not(:first-child){margin-top:1em}.content h2{font-size:1.75em;margin-bottom:.5714em}.content h2:not(:first-child){margin-top:1.1428em}.content h3{font-size:1.5em;margin-bottom:.6666em}.content h3:not(:first-child){margin-top:1.3333em}.content h4{font-size:1.25em;margin-bottom:.8em}.content h5{font-size:1.125em;margin-bottom:.8888em}.content h6{font-size:1em;margin-bottom:1em}.content blockquote{background-color:hsl(0,0%,96%);border-left:5px solid hsl(0,0%,86%);padding:1.25em 1.5em}.content ol{list-style-position:outside;margin-left:2em;margin-top:1em}.content ol:not([type]){list-style-type:decimal}.content ol:not([type]).is-lower-alpha{list-style-type:lower-alpha}.content ol:not([type]).is-lower-roman{list-style-type:lower-roman}.content ol:not([type]).is-upper-alpha{list-style-type:upper-alpha}.content ol:not([type]).is-upper-roman{list-style-type:upper-roman}.content ul{list-style:disc outside;margin-left:2em;margin-top:1em}.content ul ul{list-style-type:circle;margin-top:.5em}.content ul ul ul{list-style-type:square}.content dd{margin-left:2em}.content figure{margin-left:2em;margin-right:2em;text-align:center}.content figure:not(:first-child){margin-top:2em}.content figure:not(:last-child){margin-bottom:2em}.content figure img{display:inline-block}.content figure figcaption{font-style:italic}.content pre{-webkit-overflow-scrolling:touch;overflow-x:auto;padding:1.25em 1.5em;white-space:pre;word-wrap:normal}.content sup,.content sub{font-size:75%}.content table{width:100%}.content table td,.content table th{border:1px solid hsl(0,0%,86%);border-width:0 0 1px;padding:.5em .75em;vertical-align:top}.content table th{color:hsl(0,0%,21%)}.content table th:not([align]){text-align:inherit}.content table thead td,.content table thead th{border-width:0 0 2px;color:hsl(0,0%,21%)}.content table tfoot td,.content table tfoot th{border-width:2px 0 0;color:hsl(0,0%,21%)}.content table tbody tr:last-child td,.content table tbody tr:last-child th{border-bottom-width:0}.content .tabs li+li{margin-top:0}.content.is-small{font-size:.75rem}.content.is-normal{font-size:1rem}.content.is-medium{font-size:1.25rem}.content.is-large{font-size:1.5rem}.icon{align-items:center;display:inline-flex;justify-content:center;height:1.5rem;width:1.5rem}.icon.is-small{height:1rem;width:1rem}.icon.is-medium{height:2rem;width:2rem}.icon.is-large{height:3rem;width:3rem}.icon-text{align-items:flex-start;color:inherit;display:inline-flex;flex-wrap:wrap;line-height:1.5rem;vertical-align:top}.icon-text .icon{flex-grow:0;flex-shrink:0}.icon-text .icon:not(:last-child){margin-right:.25em}.icon-text .icon:not(:first-child){margin-left:.25em}div.icon-text{display:flex}.image{display:block;position:relative}.image img{display:block;height:auto;width:100%}.image img.is-rounded{border-radius:9999px}.image.is-fullwidth{width:100%}.image.is-square img,.image.is-square .has-ratio,.image.is-1by1 img,.image.is-1by1 .has-ratio,.image.is-5by4 img,.image.is-5by4 .has-ratio,.image.is-4by3 img,.image.is-4by3 .has-ratio,.image.is-3by2 img,.image.is-3by2 .has-ratio,.image.is-5by3 img,.image.is-5by3 .has-ratio,.image.is-16by9 img,.image.is-16by9 .has-ratio,.image.is-2by1 img,.image.is-2by1 .has-ratio,.image.is-3by1 img,.image.is-3by1 .has-ratio,.image.is-4by5 img,.image.is-4by5 .has-ratio,.image.is-3by4 img,.image.is-3by4 .has-ratio,.image.is-2by3 img,.image.is-2by3 .has-ratio,.image.is-3by5 img,.image.is-3by5 .has-ratio,.image.is-9by16 img,.image.is-9by16 .has-ratio,.image.is-1by2 img,.image.is-1by2 .has-ratio,.image.is-1by3 img,.image.is-1by3 .has-ratio{height:100%;width:100%}.image.is-square,.image.is-1by1{padding-top:100%}.image.is-5by4{padding-top:80%}.image.is-4by3{padding-top:75%}.image.is-3by2{padding-top:66.6666%}.image.is-5by3{padding-top:60%}.image.is-16by9{padding-top:56.25%}.image.is-2by1{padding-top:50%}.image.is-3by1{padding-top:33.3333%}.image.is-4by5{padding-top:125%}.image.is-3by4{padding-top:133.3333%}.image.is-2by3{padding-top:150%}.image.is-3by5{padding-top:166.6666%}.image.is-9by16{padding-top:177.7777%}.image.is-1by2{padding-top:200%}.image.is-1by3{padding-top:300%}.image.is-16x16{height:16px;width:16px}.image.is-24x24{height:24px;width:24px}.image.is-32x32{height:32px;width:32px}.image.is-48x48{height:48px;width:48px}.image.is-64x64{height:64px;width:64px}.image.is-96x96{height:96px;width:96px}.image.is-128x128{height:128px;width:128px}.notification{background-color:hsl(0,0%,96%);border-radius:4px;position:relative;padding:1.25rem 2.5rem 1.25rem 1.5rem}.notification a:not(.button):not(.dropdown-item){color:currentColor;text-decoration:underline}.notification strong{color:currentColor}.notification code,.notification pre{background:#fff}.notification pre code{background:rgba(0,0,0,0)}.notification>.delete{right:.5rem;position:absolute;top:.5rem}.notification .title,.notification .subtitle,.notification .content{color:currentColor}.notification.is-white{background-color:#fff;color:hsl(0,0%,4%)}.notification.is-black{background-color:hsl(0,0%,4%);color:#fff}.notification.is-light{background-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.notification.is-dark{background-color:hsl(0,0%,21%);color:#fff}.notification.is-primary{background-color:#00875f;color:#fff}.notification.is-primary.is-light{background-color:hsl(162.2222222222,100%,96%);color:rgb(0,229.5,161.5)}.notification.is-link{background-color:hsl(229,53%,53%);color:#fff}.notification.is-link.is-light{background-color:hsl(229,53%,96%);color:hsl(229,53%,47%)}.notification.is-info{background-color:hsl(207,61%,53%);color:#fff}.notification.is-info.is-light{background-color:hsl(207,61%,96%);color:hsl(207,61%,41%)}.notification.is-success{background-color:hsl(153,53%,53%);color:#fff}.notification.is-success.is-light{background-color:hsl(153,53%,96%);color:hsl(153,53%,31%)}.notification.is-warning{background-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.notification.is-warning.is-light{background-color:hsl(44,100%,96%);color:hsl(44,100%,29%)}.notification.is-danger{background-color:hsl(348,86%,61%);color:#fff}.notification.is-danger.is-light{background-color:hsl(348,86%,96%);color:hsl(348,86%,43%)}.progress,.progress-wrapper.is-not-native{-moz-appearance:none;-webkit-appearance:none;border:none;border-radius:9999px;display:block;height:1rem;overflow:hidden;padding:0;width:100%}.progress::-webkit-progress-bar,.progress-wrapper.is-not-native::-webkit-progress-bar{background-color:hsl(0,0%,93%)}.progress::-webkit-progress-value,.progress-wrapper.is-not-native::-webkit-progress-value{background-color:hsl(0,0%,29%)}.progress::-moz-progress-bar,.progress-wrapper.is-not-native::-moz-progress-bar{background-color:hsl(0,0%,29%)}.progress::-ms-fill,.progress-wrapper.is-not-native::-ms-fill{background-color:hsl(0,0%,29%);border:none}.progress.is-white::-webkit-progress-value,.is-white.progress-wrapper.is-not-native::-webkit-progress-value{background-color:#fff}.progress.is-white::-moz-progress-bar,.is-white.progress-wrapper.is-not-native::-moz-progress-bar{background-color:#fff}.progress.is-white::-ms-fill,.is-white.progress-wrapper.is-not-native::-ms-fill{background-color:#fff}.progress.is-white:indeterminate,.is-white.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, hsl(0, 0%, 100%) 30%, hsl(0, 0%, 93%) 30%)}.progress.is-black::-webkit-progress-value,.is-black.progress-wrapper.is-not-native::-webkit-progress-value{background-color:hsl(0,0%,4%)}.progress.is-black::-moz-progress-bar,.is-black.progress-wrapper.is-not-native::-moz-progress-bar{background-color:hsl(0,0%,4%)}.progress.is-black::-ms-fill,.is-black.progress-wrapper.is-not-native::-ms-fill{background-color:hsl(0,0%,4%)}.progress.is-black:indeterminate,.is-black.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, hsl(0, 0%, 4%) 30%, hsl(0, 0%, 93%) 30%)}.progress.is-light::-webkit-progress-value,.is-light.progress-wrapper.is-not-native::-webkit-progress-value{background-color:hsl(0,0%,96%)}.progress.is-light::-moz-progress-bar,.is-light.progress-wrapper.is-not-native::-moz-progress-bar{background-color:hsl(0,0%,96%)}.progress.is-light::-ms-fill,.is-light.progress-wrapper.is-not-native::-ms-fill{background-color:hsl(0,0%,96%)}.progress.is-light:indeterminate,.is-light.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, hsl(0, 0%, 96%) 30%, hsl(0, 0%, 93%) 30%)}.progress.is-dark::-webkit-progress-value,.is-dark.progress-wrapper.is-not-native::-webkit-progress-value{background-color:hsl(0,0%,21%)}.progress.is-dark::-moz-progress-bar,.is-dark.progress-wrapper.is-not-native::-moz-progress-bar{background-color:hsl(0,0%,21%)}.progress.is-dark::-ms-fill,.is-dark.progress-wrapper.is-not-native::-ms-fill{background-color:hsl(0,0%,21%)}.progress.is-dark:indeterminate,.is-dark.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, hsl(0, 0%, 21%) 30%, hsl(0, 0%, 93%) 30%)}.progress.is-primary::-webkit-progress-value,.is-primary.progress-wrapper.is-not-native::-webkit-progress-value{background-color:#00875f}.progress.is-primary::-moz-progress-bar,.is-primary.progress-wrapper.is-not-native::-moz-progress-bar{background-color:#00875f}.progress.is-primary::-ms-fill,.is-primary.progress-wrapper.is-not-native::-ms-fill{background-color:#00875f}.progress.is-primary:indeterminate,.is-primary.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, rgb(0, 135, 95) 30%, hsl(0, 0%, 93%) 30%)}.progress.is-link::-webkit-progress-value,.is-link.progress-wrapper.is-not-native::-webkit-progress-value{background-color:hsl(229,53%,53%)}.progress.is-link::-moz-progress-bar,.is-link.progress-wrapper.is-not-native::-moz-progress-bar{background-color:hsl(229,53%,53%)}.progress.is-link::-ms-fill,.is-link.progress-wrapper.is-not-native::-ms-fill{background-color:hsl(229,53%,53%)}.progress.is-link:indeterminate,.is-link.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, hsl(229, 53%, 53%) 30%, hsl(0, 0%, 93%) 30%)}.progress.is-info::-webkit-progress-value,.is-info.progress-wrapper.is-not-native::-webkit-progress-value{background-color:hsl(207,61%,53%)}.progress.is-info::-moz-progress-bar,.is-info.progress-wrapper.is-not-native::-moz-progress-bar{background-color:hsl(207,61%,53%)}.progress.is-info::-ms-fill,.is-info.progress-wrapper.is-not-native::-ms-fill{background-color:hsl(207,61%,53%)}.progress.is-info:indeterminate,.is-info.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, hsl(207, 61%, 53%) 30%, hsl(0, 0%, 93%) 30%)}.progress.is-success::-webkit-progress-value,.is-success.progress-wrapper.is-not-native::-webkit-progress-value{background-color:hsl(153,53%,53%)}.progress.is-success::-moz-progress-bar,.is-success.progress-wrapper.is-not-native::-moz-progress-bar{background-color:hsl(153,53%,53%)}.progress.is-success::-ms-fill,.is-success.progress-wrapper.is-not-native::-ms-fill{background-color:hsl(153,53%,53%)}.progress.is-success:indeterminate,.is-success.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, hsl(153, 53%, 53%) 30%, hsl(0, 0%, 93%) 30%)}.progress.is-warning::-webkit-progress-value,.is-warning.progress-wrapper.is-not-native::-webkit-progress-value{background-color:hsl(44,100%,77%)}.progress.is-warning::-moz-progress-bar,.is-warning.progress-wrapper.is-not-native::-moz-progress-bar{background-color:hsl(44,100%,77%)}.progress.is-warning::-ms-fill,.is-warning.progress-wrapper.is-not-native::-ms-fill{background-color:hsl(44,100%,77%)}.progress.is-warning:indeterminate,.is-warning.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, hsl(44, 100%, 77%) 30%, hsl(0, 0%, 93%) 30%)}.progress.is-danger::-webkit-progress-value,.is-danger.progress-wrapper.is-not-native::-webkit-progress-value{background-color:hsl(348,86%,61%)}.progress.is-danger::-moz-progress-bar,.is-danger.progress-wrapper.is-not-native::-moz-progress-bar{background-color:hsl(348,86%,61%)}.progress.is-danger::-ms-fill,.is-danger.progress-wrapper.is-not-native::-ms-fill{background-color:hsl(348,86%,61%)}.progress.is-danger:indeterminate,.is-danger.progress-wrapper.is-not-native:indeterminate{background-image:linear-gradient(to right, hsl(348, 86%, 61%) 30%, hsl(0, 0%, 93%) 30%)}.progress:indeterminate,.progress-wrapper.is-not-native:indeterminate{animation-duration:1.5s;animation-iteration-count:infinite;animation-name:moveIndeterminate;animation-timing-function:linear;background-color:hsl(0,0%,93%);background-image:linear-gradient(to right, hsl(0, 0%, 29%) 30%, hsl(0, 0%, 93%) 30%);background-position:top left;background-repeat:no-repeat;background-size:150% 150%}.progress:indeterminate::-webkit-progress-bar,.progress-wrapper.is-not-native:indeterminate::-webkit-progress-bar{background-color:rgba(0,0,0,0)}.progress:indeterminate::-moz-progress-bar,.progress-wrapper.is-not-native:indeterminate::-moz-progress-bar{background-color:rgba(0,0,0,0)}.progress:indeterminate::-ms-fill,.progress-wrapper.is-not-native:indeterminate::-ms-fill{animation-name:none}.progress.is-small,.is-small.progress-wrapper.is-not-native{height:.75rem}.progress.is-medium,.is-medium.progress-wrapper.is-not-native{height:1.25rem}.progress.is-large,.is-large.progress-wrapper.is-not-native{height:1.5rem}@keyframes moveIndeterminate{from{background-position:200% 0}to{background-position:-200% 0}}.table{background-color:#fff;color:hsl(0,0%,21%)}.table td,.table th{border:1px solid hsl(0,0%,86%);border-width:0 0 1px;padding:.5em .75em;vertical-align:top}.table td.is-white,.table th.is-white{background-color:#fff;border-color:#fff;color:hsl(0,0%,4%)}.table td.is-black,.table th.is-black{background-color:hsl(0,0%,4%);border-color:hsl(0,0%,4%);color:#fff}.table td.is-light,.table th.is-light{background-color:hsl(0,0%,96%);border-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.table td.is-dark,.table th.is-dark{background-color:hsl(0,0%,21%);border-color:hsl(0,0%,21%);color:#fff}.table td.is-primary,.table th.is-primary{background-color:#00875f;border-color:#00875f;color:#fff}.table td.is-link,.table th.is-link{background-color:hsl(229,53%,53%);border-color:hsl(229,53%,53%);color:#fff}.table td.is-info,.table th.is-info{background-color:hsl(207,61%,53%);border-color:hsl(207,61%,53%);color:#fff}.table td.is-success,.table th.is-success{background-color:hsl(153,53%,53%);border-color:hsl(153,53%,53%);color:#fff}.table td.is-warning,.table th.is-warning{background-color:hsl(44,100%,77%);border-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.table td.is-danger,.table th.is-danger{background-color:hsl(348,86%,61%);border-color:hsl(348,86%,61%);color:#fff}.table td.is-narrow,.table th.is-narrow{white-space:nowrap;width:1%}.table td.is-selected,.table th.is-selected{background-color:#00875f;color:#fff}.table td.is-selected a,.table td.is-selected strong,.table th.is-selected a,.table th.is-selected strong{color:currentColor}.table td.is-vcentered,.table th.is-vcentered{vertical-align:middle}.table th{color:hsl(0,0%,21%)}.table th:not([align]){text-align:left}.table tr.is-selected{background-color:#00875f;color:#fff}.table tr.is-selected a,.table tr.is-selected strong{color:currentColor}.table tr.is-selected td,.table tr.is-selected th{border-color:#fff;color:currentColor}.table thead{background-color:rgba(0,0,0,0)}.table thead td,.table thead th{border-width:0 0 2px;color:hsl(0,0%,21%)}.table tfoot{background-color:rgba(0,0,0,0)}.table tfoot td,.table tfoot th{border-width:2px 0 0;color:hsl(0,0%,21%)}.table tbody{background-color:rgba(0,0,0,0)}.table tbody tr:last-child td,.table tbody tr:last-child th{border-bottom-width:0}.table.is-bordered td,.table.is-bordered th{border-width:1px}.table.is-bordered tr:last-child td,.table.is-bordered tr:last-child th{border-bottom-width:1px}.table.is-fullwidth{width:100%}.table.is-hoverable tbody tr:not(.is-selected):hover{background-color:hsl(0,0%,98%)}.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover{background-color:hsl(0,0%,98%)}.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover:nth-child(even){background-color:hsl(0,0%,96%)}.table.is-narrow td,.table.is-narrow th{padding:.25em .5em}.table.is-striped tbody tr:not(.is-selected):nth-child(even){background-color:hsl(0,0%,98%)}.table-container{-webkit-overflow-scrolling:touch;overflow:auto;overflow-y:hidden;max-width:100%}.tags{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-start}.tags .tag{margin-bottom:.5rem}.tags .tag:not(:last-child){margin-right:.5rem}.tags:last-child{margin-bottom:-0.5rem}.tags:not(:last-child){margin-bottom:1rem}.tags.are-medium .tag:not(.is-normal):not(.is-large){font-size:1rem}.tags.are-large .tag:not(.is-normal):not(.is-medium){font-size:1.25rem}.tags.is-centered{justify-content:center}.tags.is-centered .tag{margin-right:.25rem;margin-left:.25rem}.tags.is-right{justify-content:flex-end}.tags.is-right .tag:not(:first-child){margin-left:.5rem}.tags.is-right .tag:not(:last-child){margin-right:0}.tags.has-addons .tag{margin-right:0}.tags.has-addons .tag:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}.tags.has-addons .tag:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.tag:not(body){align-items:center;background-color:hsl(0,0%,96%);border-radius:4px;color:hsl(0,0%,29%);display:inline-flex;font-size:.75rem;height:2em;justify-content:center;line-height:1.5;padding-left:.75em;padding-right:.75em;white-space:nowrap}.tag:not(body) .delete{margin-left:.25rem;margin-right:-0.375rem}.tag:not(body).is-white{background-color:#fff;color:hsl(0,0%,4%)}.tag:not(body).is-black{background-color:hsl(0,0%,4%);color:#fff}.tag:not(body).is-light{background-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.tag:not(body).is-dark{background-color:hsl(0,0%,21%);color:#fff}.tag:not(body).is-primary{background-color:#00875f;color:#fff}.tag:not(body).is-primary.is-light{background-color:hsl(162.2222222222,100%,96%);color:rgb(0,229.5,161.5)}.tag:not(body).is-link{background-color:hsl(229,53%,53%);color:#fff}.tag:not(body).is-link.is-light{background-color:hsl(229,53%,96%);color:hsl(229,53%,47%)}.tag:not(body).is-info{background-color:hsl(207,61%,53%);color:#fff}.tag:not(body).is-info.is-light{background-color:hsl(207,61%,96%);color:hsl(207,61%,41%)}.tag:not(body).is-success{background-color:hsl(153,53%,53%);color:#fff}.tag:not(body).is-success.is-light{background-color:hsl(153,53%,96%);color:hsl(153,53%,31%)}.tag:not(body).is-warning{background-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.tag:not(body).is-warning.is-light{background-color:hsl(44,100%,96%);color:hsl(44,100%,29%)}.tag:not(body).is-danger{background-color:hsl(348,86%,61%);color:#fff}.tag:not(body).is-danger.is-light{background-color:hsl(348,86%,96%);color:hsl(348,86%,43%)}.tag:not(body).is-normal{font-size:.75rem}.tag:not(body).is-medium{font-size:1rem}.tag:not(body).is-large{font-size:1.25rem}.tag:not(body) .icon:first-child:not(:last-child){margin-left:-0.375em;margin-right:.1875em}.tag:not(body) .icon:last-child:not(:first-child){margin-left:.1875em;margin-right:-0.375em}.tag:not(body) .icon:first-child:last-child{margin-left:-0.375em;margin-right:-0.375em}.tag:not(body).is-delete{margin-left:1px;padding:0;position:relative;width:2em}.tag:not(body).is-delete::before,.tag:not(body).is-delete::after{background-color:currentColor;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.tag:not(body).is-delete::before{height:1px;width:50%}.tag:not(body).is-delete::after{height:50%;width:1px}.tag:not(body).is-delete:hover,.tag:not(body).is-delete:focus{background-color:hsl(0,0%,91%)}.tag:not(body).is-delete:active{background-color:hsl(0,0%,86%)}.tag:not(body).is-rounded{border-radius:9999px}a.tag:hover{text-decoration:underline}.title,.subtitle{word-break:break-word}.title em,.title span,.subtitle em,.subtitle span{font-weight:inherit}.title sub,.subtitle sub{font-size:.75em}.title sup,.subtitle sup{font-size:.75em}.title .tag,.subtitle .tag{vertical-align:middle}.title{color:hsl(0,0%,21%);font-size:2rem;font-weight:600;line-height:1.125}.title strong{color:inherit;font-weight:inherit}.title:not(.is-spaced)+.subtitle{margin-top:-1.25rem}.title.is-1{font-size:3rem}.title.is-2{font-size:2.5rem}.title.is-3{font-size:2rem}.title.is-4{font-size:1.5rem}.title.is-5{font-size:1.25rem}.title.is-6{font-size:1rem}.title.is-7{font-size:.75rem}.subtitle{color:hsl(0,0%,29%);font-size:1.25rem;font-weight:400;line-height:1.25}.subtitle strong{color:hsl(0,0%,21%);font-weight:600}.subtitle:not(.is-spaced)+.title{margin-top:-1.25rem}.subtitle.is-1{font-size:3rem}.subtitle.is-2{font-size:2.5rem}.subtitle.is-3{font-size:2rem}.subtitle.is-4{font-size:1.5rem}.subtitle.is-5{font-size:1.25rem}.subtitle.is-6{font-size:1rem}.subtitle.is-7{font-size:.75rem}.heading{display:block;font-size:11px;letter-spacing:1px;margin-bottom:5px;text-transform:uppercase}.number{align-items:center;background-color:hsl(0,0%,96%);border-radius:9999px;display:inline-flex;font-size:1.25rem;height:2em;justify-content:center;margin-right:1.5rem;min-width:2.5em;padding:.25rem .5rem;text-align:center;vertical-align:top}.select select,.taginput .taginput-container.is-focusable,.textarea,.input{background-color:#fff;border-color:hsl(0,0%,86%);border-radius:4px;color:hsl(0,0%,21%)}.select select::-moz-placeholder,.taginput .taginput-container.is-focusable::-moz-placeholder,.textarea::-moz-placeholder,.input::-moz-placeholder{color:hsla(0,0%,21%,.3)}.select select::-webkit-input-placeholder,.taginput .taginput-container.is-focusable::-webkit-input-placeholder,.textarea::-webkit-input-placeholder,.input::-webkit-input-placeholder{color:hsla(0,0%,21%,.3)}.select select:-moz-placeholder,.taginput .taginput-container.is-focusable:-moz-placeholder,.textarea:-moz-placeholder,.input:-moz-placeholder{color:hsla(0,0%,21%,.3)}.select select:-ms-input-placeholder,.taginput .taginput-container.is-focusable:-ms-input-placeholder,.textarea:-ms-input-placeholder,.input:-ms-input-placeholder{color:hsla(0,0%,21%,.3)}.select select:hover,.taginput .taginput-container.is-focusable:hover,.textarea:hover,.input:hover,.select select.is-hovered,.taginput .is-hovered.taginput-container.is-focusable,.is-hovered.textarea,.is-hovered.input{border-color:hsl(0,0%,71%)}.select select:focus,.taginput .taginput-container.is-focusable:focus,.textarea:focus,.input:focus,.select select.is-focused,.taginput .is-focused.taginput-container.is-focusable,.is-focused.textarea,.is-focused.input,.select select:active,.taginput .taginput-container.is-focusable:active,.textarea:active,.input:active,.select select.is-active,.taginput .is-active.taginput-container.is-focusable,.is-active.textarea,.is-active.input{border-color:#00875f;box-shadow:0 0 0 .125em rgba(0,135,95,.25)}.select select[disabled],.taginput [disabled].taginput-container.is-focusable,[disabled].textarea,[disabled].input,fieldset[disabled] .select select,.select fieldset[disabled] select,fieldset[disabled] .taginput .taginput-container.is-focusable,.taginput fieldset[disabled] .taginput-container.is-focusable,fieldset[disabled] .textarea,fieldset[disabled] .input{background-color:hsl(0,0%,96%);border-color:hsl(0,0%,96%);box-shadow:none;color:hsl(0,0%,48%)}.select select[disabled]::-moz-placeholder,.taginput [disabled].taginput-container.is-focusable::-moz-placeholder,[disabled].textarea::-moz-placeholder,[disabled].input::-moz-placeholder,fieldset[disabled] .select select::-moz-placeholder,.select fieldset[disabled] select::-moz-placeholder,fieldset[disabled] .taginput .taginput-container.is-focusable::-moz-placeholder,.taginput fieldset[disabled] .taginput-container.is-focusable::-moz-placeholder,fieldset[disabled] .textarea::-moz-placeholder,fieldset[disabled] .input::-moz-placeholder{color:hsla(0,0%,48%,.3)}.select select[disabled]::-webkit-input-placeholder,.taginput [disabled].taginput-container.is-focusable::-webkit-input-placeholder,[disabled].textarea::-webkit-input-placeholder,[disabled].input::-webkit-input-placeholder,fieldset[disabled] .select select::-webkit-input-placeholder,.select fieldset[disabled] select::-webkit-input-placeholder,fieldset[disabled] .taginput .taginput-container.is-focusable::-webkit-input-placeholder,.taginput fieldset[disabled] .taginput-container.is-focusable::-webkit-input-placeholder,fieldset[disabled] .textarea::-webkit-input-placeholder,fieldset[disabled] .input::-webkit-input-placeholder{color:hsla(0,0%,48%,.3)}.select select[disabled]:-moz-placeholder,.taginput [disabled].taginput-container.is-focusable:-moz-placeholder,[disabled].textarea:-moz-placeholder,[disabled].input:-moz-placeholder,fieldset[disabled] .select select:-moz-placeholder,.select fieldset[disabled] select:-moz-placeholder,fieldset[disabled] .taginput .taginput-container.is-focusable:-moz-placeholder,.taginput fieldset[disabled] .taginput-container.is-focusable:-moz-placeholder,fieldset[disabled] .textarea:-moz-placeholder,fieldset[disabled] .input:-moz-placeholder{color:hsla(0,0%,48%,.3)}.select select[disabled]:-ms-input-placeholder,.taginput [disabled].taginput-container.is-focusable:-ms-input-placeholder,[disabled].textarea:-ms-input-placeholder,[disabled].input:-ms-input-placeholder,fieldset[disabled] .select select:-ms-input-placeholder,.select fieldset[disabled] select:-ms-input-placeholder,fieldset[disabled] .taginput .taginput-container.is-focusable:-ms-input-placeholder,.taginput fieldset[disabled] .taginput-container.is-focusable:-ms-input-placeholder,fieldset[disabled] .textarea:-ms-input-placeholder,fieldset[disabled] .input:-ms-input-placeholder{color:hsla(0,0%,48%,.3)}.taginput .taginput-container.is-focusable,.textarea,.input{box-shadow:inset 0 .0625em .125em hsla(0,0%,4%,.05);max-width:100%;width:100%}.taginput [readonly].taginput-container.is-focusable,[readonly].textarea,[readonly].input{box-shadow:none}.taginput .is-white.taginput-container.is-focusable,.is-white.textarea,.is-white.input{border-color:#fff}.taginput .is-white.taginput-container.is-focusable:focus,.is-white.textarea:focus,.is-white.input:focus,.taginput .is-white.is-focused.taginput-container.is-focusable,.is-white.is-focused.textarea,.is-white.is-focused.input,.taginput .is-white.taginput-container.is-focusable:active,.is-white.textarea:active,.is-white.input:active,.taginput .is-white.is-active.taginput-container.is-focusable,.is-white.is-active.textarea,.is-white.is-active.input{box-shadow:0 0 0 .125em hsla(0,0%,100%,.25)}.taginput .is-black.taginput-container.is-focusable,.is-black.textarea,.is-black.input{border-color:hsl(0,0%,4%)}.taginput .is-black.taginput-container.is-focusable:focus,.is-black.textarea:focus,.is-black.input:focus,.taginput .is-black.is-focused.taginput-container.is-focusable,.is-black.is-focused.textarea,.is-black.is-focused.input,.taginput .is-black.taginput-container.is-focusable:active,.is-black.textarea:active,.is-black.input:active,.taginput .is-black.is-active.taginput-container.is-focusable,.is-black.is-active.textarea,.is-black.is-active.input{box-shadow:0 0 0 .125em hsla(0,0%,4%,.25)}.taginput .is-light.taginput-container.is-focusable,.is-light.textarea,.is-light.input{border-color:hsl(0,0%,96%)}.taginput .is-light.taginput-container.is-focusable:focus,.is-light.textarea:focus,.is-light.input:focus,.taginput .is-light.is-focused.taginput-container.is-focusable,.is-light.is-focused.textarea,.is-light.is-focused.input,.taginput .is-light.taginput-container.is-focusable:active,.is-light.textarea:active,.is-light.input:active,.taginput .is-light.is-active.taginput-container.is-focusable,.is-light.is-active.textarea,.is-light.is-active.input{box-shadow:0 0 0 .125em hsla(0,0%,96%,.25)}.taginput .is-dark.taginput-container.is-focusable,.is-dark.textarea,.is-dark.input{border-color:hsl(0,0%,21%)}.taginput .is-dark.taginput-container.is-focusable:focus,.is-dark.textarea:focus,.is-dark.input:focus,.taginput .is-dark.is-focused.taginput-container.is-focusable,.is-dark.is-focused.textarea,.is-dark.is-focused.input,.taginput .is-dark.taginput-container.is-focusable:active,.is-dark.textarea:active,.is-dark.input:active,.taginput .is-dark.is-active.taginput-container.is-focusable,.is-dark.is-active.textarea,.is-dark.is-active.input{box-shadow:0 0 0 .125em hsla(0,0%,21%,.25)}.taginput .is-primary.taginput-container.is-focusable,.is-primary.textarea,.is-primary.input{border-color:#00875f}.taginput .is-primary.taginput-container.is-focusable:focus,.is-primary.textarea:focus,.is-primary.input:focus,.taginput .is-primary.is-focused.taginput-container.is-focusable,.is-primary.is-focused.textarea,.is-primary.is-focused.input,.taginput .is-primary.taginput-container.is-focusable:active,.is-primary.textarea:active,.is-primary.input:active,.taginput .is-primary.is-active.taginput-container.is-focusable,.is-primary.is-active.textarea,.is-primary.is-active.input{box-shadow:0 0 0 .125em rgba(0,135,95,.25)}.taginput .is-link.taginput-container.is-focusable,.is-link.textarea,.is-link.input{border-color:hsl(229,53%,53%)}.taginput .is-link.taginput-container.is-focusable:focus,.is-link.textarea:focus,.is-link.input:focus,.taginput .is-link.is-focused.taginput-container.is-focusable,.is-link.is-focused.textarea,.is-link.is-focused.input,.taginput .is-link.taginput-container.is-focusable:active,.is-link.textarea:active,.is-link.input:active,.taginput .is-link.is-active.taginput-container.is-focusable,.is-link.is-active.textarea,.is-link.is-active.input{box-shadow:0 0 0 .125em hsla(229,53%,53%,.25)}.taginput .is-info.taginput-container.is-focusable,.is-info.textarea,.is-info.input{border-color:hsl(207,61%,53%)}.taginput .is-info.taginput-container.is-focusable:focus,.is-info.textarea:focus,.is-info.input:focus,.taginput .is-info.is-focused.taginput-container.is-focusable,.is-info.is-focused.textarea,.is-info.is-focused.input,.taginput .is-info.taginput-container.is-focusable:active,.is-info.textarea:active,.is-info.input:active,.taginput .is-info.is-active.taginput-container.is-focusable,.is-info.is-active.textarea,.is-info.is-active.input{box-shadow:0 0 0 .125em hsla(207,61%,53%,.25)}.taginput .is-success.taginput-container.is-focusable,.is-success.textarea,.is-success.input{border-color:hsl(153,53%,53%)}.taginput .is-success.taginput-container.is-focusable:focus,.is-success.textarea:focus,.is-success.input:focus,.taginput .is-success.is-focused.taginput-container.is-focusable,.is-success.is-focused.textarea,.is-success.is-focused.input,.taginput .is-success.taginput-container.is-focusable:active,.is-success.textarea:active,.is-success.input:active,.taginput .is-success.is-active.taginput-container.is-focusable,.is-success.is-active.textarea,.is-success.is-active.input{box-shadow:0 0 0 .125em hsla(153,53%,53%,.25)}.taginput .is-warning.taginput-container.is-focusable,.is-warning.textarea,.is-warning.input{border-color:hsl(44,100%,77%)}.taginput .is-warning.taginput-container.is-focusable:focus,.is-warning.textarea:focus,.is-warning.input:focus,.taginput .is-warning.is-focused.taginput-container.is-focusable,.is-warning.is-focused.textarea,.is-warning.is-focused.input,.taginput .is-warning.taginput-container.is-focusable:active,.is-warning.textarea:active,.is-warning.input:active,.taginput .is-warning.is-active.taginput-container.is-focusable,.is-warning.is-active.textarea,.is-warning.is-active.input{box-shadow:0 0 0 .125em hsla(44,100%,77%,.25)}.taginput .is-danger.taginput-container.is-focusable,.is-danger.textarea,.is-danger.input{border-color:hsl(348,86%,61%)}.taginput .is-danger.taginput-container.is-focusable:focus,.is-danger.textarea:focus,.is-danger.input:focus,.taginput .is-danger.is-focused.taginput-container.is-focusable,.is-danger.is-focused.textarea,.is-danger.is-focused.input,.taginput .is-danger.taginput-container.is-focusable:active,.is-danger.textarea:active,.is-danger.input:active,.taginput .is-danger.is-active.taginput-container.is-focusable,.is-danger.is-active.textarea,.is-danger.is-active.input{box-shadow:0 0 0 .125em hsla(348,86%,61%,.25)}.taginput .is-small.taginput-container.is-focusable,.is-small.textarea,.is-small.input{border-radius:2px;font-size:.75rem}.taginput .is-medium.taginput-container.is-focusable,.is-medium.textarea,.is-medium.input{font-size:1.25rem}.taginput .is-large.taginput-container.is-focusable,.is-large.textarea,.is-large.input{font-size:1.5rem}.taginput .is-fullwidth.taginput-container.is-focusable,.is-fullwidth.textarea,.is-fullwidth.input{display:block;width:100%}.taginput .is-inline.taginput-container.is-focusable,.is-inline.textarea,.is-inline.input{display:inline;width:auto}.input.is-rounded{border-radius:9999px;padding-left:calc(calc(0.75em - 1px) + .375em);padding-right:calc(calc(0.75em - 1px) + .375em)}.input.is-static{background-color:rgba(0,0,0,0);border-color:rgba(0,0,0,0);box-shadow:none;padding-left:0;padding-right:0}.textarea{display:block;max-width:100%;min-width:100%;padding:calc(.75em - 1px);resize:vertical}.textarea:not([rows]){max-height:40em;min-height:8em}.textarea[rows]{height:initial}.textarea.has-fixed-size{resize:none}.radio,.checkbox{cursor:pointer;display:inline-block;line-height:1.25;position:relative}.radio input,.checkbox input{cursor:pointer}.radio:hover,.checkbox:hover{color:hsl(0,0%,21%)}[disabled].radio,[disabled].checkbox,fieldset[disabled] .radio,fieldset[disabled] .checkbox,.radio input[disabled],.checkbox input[disabled]{color:hsl(0,0%,48%);cursor:not-allowed}.radio+.radio{margin-left:.5em}.select{display:inline-block;max-width:100%;position:relative;vertical-align:top}.select:not(.is-multiple){height:2.5em}.select:not(.is-multiple):not(.is-loading)::after{border-color:#00875f;right:1.125em;z-index:4}.select.is-rounded select{border-radius:9999px;padding-left:1em}.select select{cursor:pointer;display:block;font-size:1em;max-width:100%;outline:none}.select select::-ms-expand{display:none}.select select[disabled]:hover,fieldset[disabled] .select select:hover{border-color:hsl(0,0%,96%)}.select select:not([multiple]){padding-right:2.5em}.select select[multiple]{height:auto;padding:0}.select select[multiple] option{padding:.5em 1em}.select:not(.is-multiple):not(.is-loading):hover::after{border-color:hsl(0,0%,21%)}.select.is-white:not(:hover)::after{border-color:#fff}.select.is-white select{border-color:#fff}.select.is-white select:hover,.select.is-white select.is-hovered{border-color:hsl(0,0%,95%)}.select.is-white select:focus,.select.is-white select.is-focused,.select.is-white select:active,.select.is-white select.is-active{box-shadow:0 0 0 .125em hsla(0,0%,100%,.25)}.select.is-black:not(:hover)::after{border-color:hsl(0,0%,4%)}.select.is-black select{border-color:hsl(0,0%,4%)}.select.is-black select:hover,.select.is-black select.is-hovered{border-color:#000}.select.is-black select:focus,.select.is-black select.is-focused,.select.is-black select:active,.select.is-black select.is-active{box-shadow:0 0 0 .125em hsla(0,0%,4%,.25)}.select.is-light:not(:hover)::after{border-color:hsl(0,0%,96%)}.select.is-light select{border-color:hsl(0,0%,96%)}.select.is-light select:hover,.select.is-light select.is-hovered{border-color:hsl(0,0%,91%)}.select.is-light select:focus,.select.is-light select.is-focused,.select.is-light select:active,.select.is-light select.is-active{box-shadow:0 0 0 .125em hsla(0,0%,96%,.25)}.select.is-dark:not(:hover)::after{border-color:hsl(0,0%,21%)}.select.is-dark select{border-color:hsl(0,0%,21%)}.select.is-dark select:hover,.select.is-dark select.is-hovered{border-color:hsl(0,0%,16%)}.select.is-dark select:focus,.select.is-dark select.is-focused,.select.is-dark select:active,.select.is-dark select.is-active{box-shadow:0 0 0 .125em hsla(0,0%,21%,.25)}.select.is-primary:not(:hover)::after{border-color:#00875f}.select.is-primary select{border-color:#00875f}.select.is-primary select:hover,.select.is-primary select.is-hovered{border-color:rgb(0,109.5,77.0555555556)}.select.is-primary select:focus,.select.is-primary select.is-focused,.select.is-primary select:active,.select.is-primary select.is-active{box-shadow:0 0 0 .125em rgba(0,135,95,.25)}.select.is-link:not(:hover)::after{border-color:hsl(229,53%,53%)}.select.is-link select{border-color:hsl(229,53%,53%)}.select.is-link select:hover,.select.is-link select.is-hovered{border-color:hsl(229,53%,48%)}.select.is-link select:focus,.select.is-link select.is-focused,.select.is-link select:active,.select.is-link select.is-active{box-shadow:0 0 0 .125em hsla(229,53%,53%,.25)}.select.is-info:not(:hover)::after{border-color:hsl(207,61%,53%)}.select.is-info select{border-color:hsl(207,61%,53%)}.select.is-info select:hover,.select.is-info select.is-hovered{border-color:hsl(207,61%,48%)}.select.is-info select:focus,.select.is-info select.is-focused,.select.is-info select:active,.select.is-info select.is-active{box-shadow:0 0 0 .125em hsla(207,61%,53%,.25)}.select.is-success:not(:hover)::after{border-color:hsl(153,53%,53%)}.select.is-success select{border-color:hsl(153,53%,53%)}.select.is-success select:hover,.select.is-success select.is-hovered{border-color:hsl(153,53%,48%)}.select.is-success select:focus,.select.is-success select.is-focused,.select.is-success select:active,.select.is-success select.is-active{box-shadow:0 0 0 .125em hsla(153,53%,53%,.25)}.select.is-warning:not(:hover)::after{border-color:hsl(44,100%,77%)}.select.is-warning select{border-color:hsl(44,100%,77%)}.select.is-warning select:hover,.select.is-warning select.is-hovered{border-color:hsl(44,100%,72%)}.select.is-warning select:focus,.select.is-warning select.is-focused,.select.is-warning select:active,.select.is-warning select.is-active{box-shadow:0 0 0 .125em hsla(44,100%,77%,.25)}.select.is-danger:not(:hover)::after{border-color:hsl(348,86%,61%)}.select.is-danger select{border-color:hsl(348,86%,61%)}.select.is-danger select:hover,.select.is-danger select.is-hovered{border-color:hsl(348,86%,56%)}.select.is-danger select:focus,.select.is-danger select.is-focused,.select.is-danger select:active,.select.is-danger select.is-active{box-shadow:0 0 0 .125em hsla(348,86%,61%,.25)}.select.is-small{border-radius:2px;font-size:.75rem}.select.is-medium{font-size:1.25rem}.select.is-large{font-size:1.5rem}.select.is-disabled::after{border-color:hsl(0,0%,48%) !important;opacity:.5}.select.is-fullwidth{width:100%}.select.is-fullwidth select{width:100%}.select.is-loading::after{margin-top:0;position:absolute;right:.625em;top:.625em;transform:none}.select.is-loading.is-small:after{font-size:.75rem}.select.is-loading.is-medium:after{font-size:1.25rem}.select.is-loading.is-large:after{font-size:1.5rem}.file{align-items:stretch;display:flex;justify-content:flex-start;position:relative}.file.is-white .file-cta{background-color:#fff;border-color:rgba(0,0,0,0);color:hsl(0,0%,4%)}.file.is-white:hover .file-cta,.file.is-white.is-hovered .file-cta{background-color:hsl(0,0%,97.5%);border-color:rgba(0,0,0,0);color:hsl(0,0%,4%)}.file.is-white:focus .file-cta,.file.is-white.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em hsla(0,0%,100%,.25);color:hsl(0,0%,4%)}.file.is-white:active .file-cta,.file.is-white.is-active .file-cta{background-color:hsl(0,0%,95%);border-color:rgba(0,0,0,0);color:hsl(0,0%,4%)}.file.is-black .file-cta{background-color:hsl(0,0%,4%);border-color:rgba(0,0,0,0);color:#fff}.file.is-black:hover .file-cta,.file.is-black.is-hovered .file-cta{background-color:hsl(0,0%,1.5%);border-color:rgba(0,0,0,0);color:#fff}.file.is-black:focus .file-cta,.file.is-black.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em hsla(0,0%,4%,.25);color:#fff}.file.is-black:active .file-cta,.file.is-black.is-active .file-cta{background-color:#000;border-color:rgba(0,0,0,0);color:#fff}.file.is-light .file-cta{background-color:hsl(0,0%,96%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.file.is-light:hover .file-cta,.file.is-light.is-hovered .file-cta{background-color:hsl(0,0%,93.5%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.file.is-light:focus .file-cta,.file.is-light.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em hsla(0,0%,96%,.25);color:rgba(0,0,0,.7)}.file.is-light:active .file-cta,.file.is-light.is-active .file-cta{background-color:hsl(0,0%,91%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.file.is-dark .file-cta{background-color:hsl(0,0%,21%);border-color:rgba(0,0,0,0);color:#fff}.file.is-dark:hover .file-cta,.file.is-dark.is-hovered .file-cta{background-color:hsl(0,0%,18.5%);border-color:rgba(0,0,0,0);color:#fff}.file.is-dark:focus .file-cta,.file.is-dark.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em hsla(0,0%,21%,.25);color:#fff}.file.is-dark:active .file-cta,.file.is-dark.is-active .file-cta{background-color:hsl(0,0%,16%);border-color:rgba(0,0,0,0);color:#fff}.file.is-primary .file-cta{background-color:#00875f;border-color:rgba(0,0,0,0);color:#fff}.file.is-primary:hover .file-cta,.file.is-primary.is-hovered .file-cta{background-color:rgb(0,122.25,86.0277777778);border-color:rgba(0,0,0,0);color:#fff}.file.is-primary:focus .file-cta,.file.is-primary.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em rgba(0,135,95,.25);color:#fff}.file.is-primary:active .file-cta,.file.is-primary.is-active .file-cta{background-color:rgb(0,109.5,77.0555555556);border-color:rgba(0,0,0,0);color:#fff}.file.is-link .file-cta{background-color:hsl(229,53%,53%);border-color:rgba(0,0,0,0);color:#fff}.file.is-link:hover .file-cta,.file.is-link.is-hovered .file-cta{background-color:hsl(229,53%,50.5%);border-color:rgba(0,0,0,0);color:#fff}.file.is-link:focus .file-cta,.file.is-link.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em hsla(229,53%,53%,.25);color:#fff}.file.is-link:active .file-cta,.file.is-link.is-active .file-cta{background-color:hsl(229,53%,48%);border-color:rgba(0,0,0,0);color:#fff}.file.is-info .file-cta{background-color:hsl(207,61%,53%);border-color:rgba(0,0,0,0);color:#fff}.file.is-info:hover .file-cta,.file.is-info.is-hovered .file-cta{background-color:hsl(207,61%,50.5%);border-color:rgba(0,0,0,0);color:#fff}.file.is-info:focus .file-cta,.file.is-info.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em hsla(207,61%,53%,.25);color:#fff}.file.is-info:active .file-cta,.file.is-info.is-active .file-cta{background-color:hsl(207,61%,48%);border-color:rgba(0,0,0,0);color:#fff}.file.is-success .file-cta{background-color:hsl(153,53%,53%);border-color:rgba(0,0,0,0);color:#fff}.file.is-success:hover .file-cta,.file.is-success.is-hovered .file-cta{background-color:hsl(153,53%,50.5%);border-color:rgba(0,0,0,0);color:#fff}.file.is-success:focus .file-cta,.file.is-success.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em hsla(153,53%,53%,.25);color:#fff}.file.is-success:active .file-cta,.file.is-success.is-active .file-cta{background-color:hsl(153,53%,48%);border-color:rgba(0,0,0,0);color:#fff}.file.is-warning .file-cta{background-color:hsl(44,100%,77%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.file.is-warning:hover .file-cta,.file.is-warning.is-hovered .file-cta{background-color:hsl(44,100%,74.5%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.file.is-warning:focus .file-cta,.file.is-warning.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em hsla(44,100%,77%,.25);color:rgba(0,0,0,.7)}.file.is-warning:active .file-cta,.file.is-warning.is-active .file-cta{background-color:hsl(44,100%,72%);border-color:rgba(0,0,0,0);color:rgba(0,0,0,.7)}.file.is-danger .file-cta{background-color:hsl(348,86%,61%);border-color:rgba(0,0,0,0);color:#fff}.file.is-danger:hover .file-cta,.file.is-danger.is-hovered .file-cta{background-color:hsl(348,86%,58.5%);border-color:rgba(0,0,0,0);color:#fff}.file.is-danger:focus .file-cta,.file.is-danger.is-focused .file-cta{border-color:rgba(0,0,0,0);box-shadow:0 0 .5em hsla(348,86%,61%,.25);color:#fff}.file.is-danger:active .file-cta,.file.is-danger.is-active .file-cta{background-color:hsl(348,86%,56%);border-color:rgba(0,0,0,0);color:#fff}.file.is-small{font-size:.75rem}.file.is-normal{font-size:1rem}.file.is-medium{font-size:1.25rem}.file.is-medium .file-icon .fa{font-size:21px}.file.is-large{font-size:1.5rem}.file.is-large .file-icon .fa{font-size:28px}.file.has-name .file-cta{border-bottom-right-radius:0;border-top-right-radius:0}.file.has-name .file-name{border-bottom-left-radius:0;border-top-left-radius:0}.file.has-name.is-empty .file-cta{border-radius:4px}.file.has-name.is-empty .file-name{display:none}.file.is-boxed .file-label{flex-direction:column}.file.is-boxed .file-cta{flex-direction:column;height:auto;padding:1em 3em}.file.is-boxed .file-name{border-width:0 1px 1px}.file.is-boxed .file-icon{height:1.5em;width:1.5em}.file.is-boxed .file-icon .fa{font-size:21px}.file.is-boxed.is-small .file-icon .fa{font-size:14px}.file.is-boxed.is-medium .file-icon .fa{font-size:28px}.file.is-boxed.is-large .file-icon .fa{font-size:35px}.file.is-boxed.has-name .file-cta{border-radius:4px 4px 0 0}.file.is-boxed.has-name .file-name{border-radius:0 0 4px 4px;border-width:0 1px 1px}.file.is-centered{justify-content:center}.file.is-fullwidth .file-label{width:100%}.file.is-fullwidth .file-name{flex-grow:1;max-width:none}.file.is-right{justify-content:flex-end}.file.is-right .file-cta{border-radius:0 4px 4px 0}.file.is-right .file-name{border-radius:4px 0 0 4px;border-width:1px 0 1px 1px;order:-1}.file-label{align-items:stretch;display:flex;cursor:pointer;justify-content:flex-start;overflow:hidden;position:relative}.file-label:hover .file-cta{background-color:hsl(0,0%,93.5%);color:hsl(0,0%,21%)}.file-label:hover .file-name{border-color:hsl(0,0%,83.5%)}.file-label:active .file-cta{background-color:hsl(0,0%,91%);color:hsl(0,0%,21%)}.file-label:active .file-name{border-color:hsl(0,0%,81%)}.file-input{height:100%;left:0;opacity:0;outline:none;position:absolute;top:0;width:100%}.file-cta,.file-name{border-color:hsl(0,0%,86%);border-radius:4px;font-size:1em;padding-left:1em;padding-right:1em;white-space:nowrap}.file-cta{background-color:hsl(0,0%,96%);color:hsl(0,0%,29%)}.file-name{border-color:hsl(0,0%,86%);border-style:solid;border-width:1px 1px 1px 0;display:block;max-width:16em;overflow:hidden;text-align:inherit;text-overflow:ellipsis}.file-icon{align-items:center;display:flex;height:1em;justify-content:center;margin-right:.5em;width:1em}.file-icon .fa{font-size:14px}.label{color:hsl(0,0%,21%);display:block;font-size:1rem;font-weight:700}.label:not(:last-child){margin-bottom:.5em}.label.is-small{font-size:.75rem}.label.is-medium{font-size:1.25rem}.label.is-large{font-size:1.5rem}.help{display:block;font-size:.75rem;margin-top:.25rem}.help.is-white{color:#fff}.help.is-black{color:hsl(0,0%,4%)}.help.is-light{color:hsl(0,0%,96%)}.help.is-dark{color:hsl(0,0%,21%)}.help.is-primary{color:#00875f}.help.is-link{color:hsl(229,53%,53%)}.help.is-info{color:hsl(207,61%,53%)}.help.is-success{color:hsl(153,53%,53%)}.help.is-warning{color:hsl(44,100%,77%)}.help.is-danger{color:hsl(348,86%,61%)}.field:not(:last-child){margin-bottom:.75rem}.field.has-addons{display:flex;justify-content:flex-start}.field.has-addons .control:not(:last-child){margin-right:-1px}.field.has-addons .control:not(:first-child):not(:last-child) .button,.field.has-addons .control:not(:first-child):not(:last-child) .input,.field.has-addons .control:not(:first-child):not(:last-child) .select select{border-radius:0}.field.has-addons .control:first-child:not(:only-child) .button,.field.has-addons .control:first-child:not(:only-child) .input,.field.has-addons .control:first-child:not(:only-child) .select select{border-bottom-right-radius:0;border-top-right-radius:0}.field.has-addons .control:last-child:not(:only-child) .button,.field.has-addons .control:last-child:not(:only-child) .input,.field.has-addons .control:last-child:not(:only-child) .select select{border-bottom-left-radius:0;border-top-left-radius:0}.field.has-addons .control .button:not([disabled]):hover,.field.has-addons .control .button:not([disabled]).is-hovered,.field.has-addons .control .input:not([disabled]):hover,.field.has-addons .control .input:not([disabled]).is-hovered,.field.has-addons .control .select select:not([disabled]):hover,.field.has-addons .control .select select:not([disabled]).is-hovered{z-index:2}.field.has-addons .control .button:not([disabled]):focus,.field.has-addons .control .button:not([disabled]).is-focused,.field.has-addons .control .button:not([disabled]):active,.field.has-addons .control .button:not([disabled]).is-active,.field.has-addons .control .input:not([disabled]):focus,.field.has-addons .control .input:not([disabled]).is-focused,.field.has-addons .control .input:not([disabled]):active,.field.has-addons .control .input:not([disabled]).is-active,.field.has-addons .control .select select:not([disabled]):focus,.field.has-addons .control .select select:not([disabled]).is-focused,.field.has-addons .control .select select:not([disabled]):active,.field.has-addons .control .select select:not([disabled]).is-active{z-index:3}.field.has-addons .control .button:not([disabled]):focus:hover,.field.has-addons .control .button:not([disabled]).is-focused:hover,.field.has-addons .control .button:not([disabled]):active:hover,.field.has-addons .control .button:not([disabled]).is-active:hover,.field.has-addons .control .input:not([disabled]):focus:hover,.field.has-addons .control .input:not([disabled]).is-focused:hover,.field.has-addons .control .input:not([disabled]):active:hover,.field.has-addons .control .input:not([disabled]).is-active:hover,.field.has-addons .control .select select:not([disabled]):focus:hover,.field.has-addons .control .select select:not([disabled]).is-focused:hover,.field.has-addons .control .select select:not([disabled]):active:hover,.field.has-addons .control .select select:not([disabled]).is-active:hover{z-index:4}.field.has-addons .control.is-expanded{flex-grow:1;flex-shrink:1}.field.has-addons.has-addons-centered{justify-content:center}.field.has-addons.has-addons-right{justify-content:flex-end}.field.has-addons.has-addons-fullwidth .control{flex-grow:1;flex-shrink:0}.field.is-grouped{display:flex;justify-content:flex-start}.field.is-grouped>.control{flex-shrink:0}.field.is-grouped>.control:not(:last-child){margin-bottom:0;margin-right:.75rem}.field.is-grouped>.control.is-expanded{flex-grow:1;flex-shrink:1}.field.is-grouped.is-grouped-centered{justify-content:center}.field.is-grouped.is-grouped-right{justify-content:flex-end}.field.is-grouped.is-grouped-multiline{flex-wrap:wrap}.field.is-grouped.is-grouped-multiline>.control:last-child,.field.is-grouped.is-grouped-multiline>.control:not(:last-child){margin-bottom:.75rem}.field.is-grouped.is-grouped-multiline:last-child{margin-bottom:-0.75rem}.field.is-grouped.is-grouped-multiline:not(:last-child){margin-bottom:0}@media screen and (min-width: 769px),print{.field.is-horizontal{display:flex}}.field-label .label{font-size:inherit}@media screen and (max-width: 768px){.field-label{margin-bottom:.5rem}}@media screen and (min-width: 769px),print{.field-label{flex-basis:0;flex-grow:1;flex-shrink:0;margin-right:1.5rem;text-align:right}.field-label.is-small{font-size:.75rem;padding-top:.375em}.field-label.is-normal{padding-top:.375em}.field-label.is-medium{font-size:1.25rem;padding-top:.375em}.field-label.is-large{font-size:1.5rem;padding-top:.375em}}.field-body .field .field{margin-bottom:0}@media screen and (min-width: 769px),print{.field-body{display:flex;flex-basis:0;flex-grow:5;flex-shrink:1}.field-body .field{margin-bottom:0}.field-body>.field{flex-shrink:1}.field-body>.field:not(.is-narrow){flex-grow:1}.field-body>.field:not(:last-child){margin-right:.75rem}}.control{box-sizing:border-box;clear:both;font-size:1rem;position:relative;text-align:inherit}.control.has-icons-left .input:focus~.icon,.control.has-icons-left .select:focus~.icon,.control.has-icons-right .input:focus~.icon,.control.has-icons-right .select:focus~.icon{color:hsl(0,0%,29%)}.control.has-icons-left .input.is-small~.icon,.control.has-icons-left .select.is-small~.icon,.control.has-icons-right .input.is-small~.icon,.control.has-icons-right .select.is-small~.icon{font-size:.75rem}.control.has-icons-left .input.is-medium~.icon,.control.has-icons-left .select.is-medium~.icon,.control.has-icons-right .input.is-medium~.icon,.control.has-icons-right .select.is-medium~.icon{font-size:1.25rem}.control.has-icons-left .input.is-large~.icon,.control.has-icons-left .select.is-large~.icon,.control.has-icons-right .input.is-large~.icon,.control.has-icons-right .select.is-large~.icon{font-size:1.5rem}.control.has-icons-left .icon,.control.has-icons-right .icon{color:hsl(0,0%,86%);height:2.5em;pointer-events:none;position:absolute;top:0;width:2.5em;z-index:4}.control.has-icons-left .input,.control.has-icons-left .select select{padding-left:2.5em}.control.has-icons-left .icon.is-left{left:0}.control.has-icons-right .input,.control.has-icons-right .select select{padding-right:2.5em}.control.has-icons-right .icon.is-right{right:0}.control.is-loading::after{position:absolute !important;right:.625em;top:.625em;z-index:4}.control.is-loading.is-small:after{font-size:.75rem}.control.is-loading.is-medium:after{font-size:1.25rem}.control.is-loading.is-large:after{font-size:1.5rem}.breadcrumb{font-size:1rem;white-space:nowrap}.breadcrumb a{align-items:center;color:#00875f;display:flex;justify-content:center;padding:0 .75em}.breadcrumb a:hover{color:hsl(0,0%,21%)}.breadcrumb li{align-items:center;display:flex}.breadcrumb li:first-child a{padding-left:0}.breadcrumb li.is-active a{color:hsl(0,0%,21%);cursor:default;pointer-events:none}.breadcrumb li+li::before{color:hsl(0,0%,71%);content:"/"}.breadcrumb ul,.breadcrumb ol{align-items:flex-start;display:flex;flex-wrap:wrap;justify-content:flex-start}.breadcrumb .icon:first-child{margin-right:.5em}.breadcrumb .icon:last-child{margin-left:.5em}.breadcrumb.is-centered ol,.breadcrumb.is-centered ul{justify-content:center}.breadcrumb.is-right ol,.breadcrumb.is-right ul{justify-content:flex-end}.breadcrumb.is-small{font-size:.75rem}.breadcrumb.is-medium{font-size:1.25rem}.breadcrumb.is-large{font-size:1.5rem}.breadcrumb.has-arrow-separator li+li::before{content:"→"}.breadcrumb.has-bullet-separator li+li::before{content:"•"}.breadcrumb.has-dot-separator li+li::before{content:"·"}.breadcrumb.has-succeeds-separator li+li::before{content:"≻"}.card{background-color:#fff;border-radius:.25rem;box-shadow:0 .5em 1em -0.125em hsla(0,0%,4%,.1),0 0px 0 1px hsla(0,0%,4%,.02);color:hsl(0,0%,29%);max-width:100%;position:relative}.card-footer:first-child,.card-content:first-child,.card-header:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card-footer:last-child,.card-content:last-child,.card-header:last-child{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.card-header{background-color:rgba(0,0,0,0);align-items:stretch;box-shadow:0 .125em .25em hsla(0,0%,4%,.1);display:flex}.card-header-title{align-items:center;color:hsl(0,0%,21%);display:flex;flex-grow:1;font-weight:700;padding:.75rem 1rem}.card-header-title.is-centered{justify-content:center}.card-header-icon{-moz-appearance:none;-webkit-appearance:none;appearance:none;background:none;border:none;color:currentColor;font-family:inherit;font-size:1em;margin:0;padding:0;align-items:center;cursor:pointer;display:flex;justify-content:center;padding:.75rem 1rem}.card-image{display:block;position:relative}.card-image:first-child img{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card-image:last-child img{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.card-content{background-color:rgba(0,0,0,0);padding:1.5rem}.card-footer{background-color:rgba(0,0,0,0);border-top:1px solid hsl(0,0%,93%);align-items:stretch;display:flex}.card-footer-item{align-items:center;display:flex;flex-basis:0;flex-grow:1;flex-shrink:0;justify-content:center;padding:.75rem}.card-footer-item:not(:last-child){border-right:1px solid hsl(0,0%,93%)}.card .media:not(:last-child){margin-bottom:1.5rem}.dropdown{display:inline-flex;position:relative;vertical-align:top}.dropdown.is-active .dropdown-menu,.dropdown.is-hoverable:hover .dropdown-menu{display:block}.dropdown.is-right .dropdown-menu{left:auto;right:0}.dropdown.is-up .dropdown-menu{bottom:100%;padding-bottom:4px;padding-top:initial;top:auto}.dropdown-menu{display:none;left:0;min-width:12rem;padding-top:4px;position:absolute;top:100%;z-index:20}.dropdown-content{background-color:#fff;border-radius:4px;box-shadow:0 .5em 1em -0.125em hsla(0,0%,4%,.1),0 0px 0 1px hsla(0,0%,4%,.02);padding-bottom:.5rem;padding-top:.5rem}.dropdown-item,.dropdown .dropdown-menu .has-link a{color:hsl(0,0%,29%);display:block;font-size:.875rem;line-height:1.5;padding:.375rem 1rem;position:relative}a.dropdown-item,.dropdown .dropdown-menu .has-link a,button.dropdown-item{padding-right:3rem;text-align:inherit;white-space:nowrap;width:100%}a.dropdown-item:hover,.dropdown .dropdown-menu .has-link a:hover,button.dropdown-item:hover{background-color:hsl(0,0%,96%);color:hsl(0,0%,4%)}a.dropdown-item.is-active,.dropdown .dropdown-menu .has-link a.is-active,button.dropdown-item.is-active{background-color:#00875f;color:#fff}.dropdown-divider{background-color:hsl(0,0%,93%);border:none;display:block;height:1px;margin:.5rem 0}.level{align-items:center;justify-content:space-between}.level code{border-radius:4px}.level img{display:inline-block;vertical-align:top}.level.is-mobile{display:flex}.level.is-mobile .level-left,.level.is-mobile .level-right{display:flex}.level.is-mobile .level-left+.level-right{margin-top:0}.level.is-mobile .level-item:not(:last-child){margin-bottom:0;margin-right:.75rem}.level.is-mobile .level-item:not(.is-narrow){flex-grow:1}@media screen and (min-width: 769px),print{.level{display:flex}.level>.level-item:not(.is-narrow){flex-grow:1}}.level-item{align-items:center;display:flex;flex-basis:auto;flex-grow:0;flex-shrink:0;justify-content:center}.level-item .title,.level-item .subtitle{margin-bottom:0}@media screen and (max-width: 768px){.level-item:not(:last-child){margin-bottom:.75rem}}.level-left,.level-right{flex-basis:auto;flex-grow:0;flex-shrink:0}.level-left .level-item.is-flexible,.level-right .level-item.is-flexible{flex-grow:1}@media screen and (min-width: 769px),print{.level-left .level-item:not(:last-child),.level-right .level-item:not(:last-child){margin-right:.75rem}}.level-left{align-items:center;justify-content:flex-start}@media screen and (max-width: 768px){.level-left+.level-right{margin-top:1.5rem}}@media screen and (min-width: 769px),print{.level-left{display:flex}}.level-right{align-items:center;justify-content:flex-end}@media screen and (min-width: 769px),print{.level-right{display:flex}}.media{align-items:flex-start;display:flex;text-align:inherit}.media .content:not(:last-child){margin-bottom:.75rem}.media .media{border-top:1px solid hsla(0,0%,86%,.5);display:flex;padding-top:.75rem}.media .media .content:not(:last-child),.media .media .control:not(:last-child){margin-bottom:.5rem}.media .media .media{padding-top:.5rem}.media .media .media+.media{margin-top:.5rem}.media+.media{border-top:1px solid hsla(0,0%,86%,.5);margin-top:1rem;padding-top:1rem}.media.is-large+.media{margin-top:1.5rem;padding-top:1.5rem}.media-left,.media-right{flex-basis:auto;flex-grow:0;flex-shrink:0}.media-left{margin-right:1rem}.media-right{margin-left:1rem}.media-content{flex-basis:auto;flex-grow:1;flex-shrink:1;text-align:inherit}@media screen and (max-width: 768px){.media-content{overflow-x:auto}}.menu{font-size:1rem}.menu.is-small{font-size:.75rem}.menu.is-medium{font-size:1.25rem}.menu.is-large{font-size:1.5rem}.menu-list{line-height:1.25}.menu-list a{border-radius:2px;color:hsl(0,0%,29%);display:block;padding:.5em .75em}.menu-list a:hover{background-color:hsl(0,0%,96%);color:hsl(0,0%,21%)}.menu-list a.is-active{background-color:#00875f;color:#fff}.menu-list li ul{border-left:1px solid hsl(0,0%,86%);margin:.75em;padding-left:.75em}.menu-label{color:hsl(0,0%,48%);font-size:.75em;letter-spacing:.1em;text-transform:uppercase}.menu-label:not(:first-child){margin-top:1em}.menu-label:not(:last-child){margin-bottom:1em}.message{background-color:hsl(0,0%,96%);border-radius:4px;font-size:1rem}.message strong{color:currentColor}.message a:not(.button):not(.tag):not(.dropdown-item){color:currentColor;text-decoration:underline}.message.is-small{font-size:.75rem}.message.is-medium{font-size:1.25rem}.message.is-large{font-size:1.5rem}.message.is-white{background-color:#fff}.message.is-white .message-header{background-color:#fff;color:hsl(0,0%,4%)}.message.is-white .message-body{border-color:#fff}.message.is-black{background-color:hsl(0,0%,98%)}.message.is-black .message-header{background-color:hsl(0,0%,4%);color:#fff}.message.is-black .message-body{border-color:hsl(0,0%,4%)}.message.is-light{background-color:hsl(0,0%,98%)}.message.is-light .message-header{background-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.message.is-light .message-body{border-color:hsl(0,0%,96%)}.message.is-dark{background-color:hsl(0,0%,98%)}.message.is-dark .message-header{background-color:hsl(0,0%,21%);color:#fff}.message.is-dark .message-body{border-color:hsl(0,0%,21%)}.message.is-primary{background-color:hsl(162.2222222222,100%,96%)}.message.is-primary .message-header{background-color:#00875f;color:#fff}.message.is-primary .message-body{border-color:#00875f;color:rgb(0,229.5,161.5)}.message.is-link{background-color:hsl(229,53%,96%)}.message.is-link .message-header{background-color:hsl(229,53%,53%);color:#fff}.message.is-link .message-body{border-color:hsl(229,53%,53%);color:hsl(229,53%,47%)}.message.is-info{background-color:hsl(207,61%,96%)}.message.is-info .message-header{background-color:hsl(207,61%,53%);color:#fff}.message.is-info .message-body{border-color:hsl(207,61%,53%);color:hsl(207,61%,41%)}.message.is-success{background-color:hsl(153,53%,96%)}.message.is-success .message-header{background-color:hsl(153,53%,53%);color:#fff}.message.is-success .message-body{border-color:hsl(153,53%,53%);color:hsl(153,53%,31%)}.message.is-warning{background-color:hsl(44,100%,96%)}.message.is-warning .message-header{background-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.message.is-warning .message-body{border-color:hsl(44,100%,77%);color:hsl(44,100%,29%)}.message.is-danger{background-color:hsl(348,86%,96%)}.message.is-danger .message-header{background-color:hsl(348,86%,61%);color:#fff}.message.is-danger .message-body{border-color:hsl(348,86%,61%);color:hsl(348,86%,43%)}.message-header{align-items:center;background-color:hsl(0,0%,29%);border-radius:4px 4px 0 0;color:#fff;display:flex;font-weight:700;justify-content:space-between;line-height:1.25;padding:.75em 1em;position:relative}.message-header .delete{flex-grow:0;flex-shrink:0;margin-left:.75em}.message-header+.message-body{border-width:0;border-top-left-radius:0;border-top-right-radius:0}.message-body{border-color:hsl(0,0%,86%);border-radius:4px;border-style:solid;border-width:0 0 0 4px;color:hsl(0,0%,29%);padding:1.25em 1.5em}.message-body code,.message-body pre{background-color:#fff}.message-body pre code{background-color:rgba(0,0,0,0)}.modal{align-items:center;display:none;flex-direction:column;justify-content:center;overflow:hidden;position:fixed;z-index:40}.modal.is-active{display:flex}.modal-background{background-color:hsla(0,0%,4%,.86)}.modal-content,.modal-card{margin:0 20px;max-height:calc(100vh - 160px);overflow:auto;position:relative;width:100%}@media screen and (min-width: 769px){.modal-content,.modal-card{margin:0 auto;max-height:calc(100vh - 40px);width:640px}}.modal-close{background:none;height:40px;position:fixed;right:20px;top:20px;width:40px}.modal-card{display:flex;flex-direction:column;max-height:calc(100vh - 40px);overflow:hidden;-ms-overflow-y:visible}.modal-card-head,.modal-card-foot{align-items:center;background-color:hsl(0,0%,96%);display:flex;flex-shrink:0;justify-content:flex-start;padding:20px;position:relative}.modal-card-head{border-bottom:1px solid hsl(0,0%,86%);border-top-left-radius:6px;border-top-right-radius:6px}.modal-card-title{color:hsl(0,0%,21%);flex-grow:1;flex-shrink:0;font-size:1.5rem;line-height:1}.modal-card-foot{border-bottom-left-radius:6px;border-bottom-right-radius:6px;border-top:1px solid hsl(0,0%,86%)}.modal-card-foot .button:not(:last-child){margin-right:.5em}.modal-card-body{-webkit-overflow-scrolling:touch;background-color:#fff;flex-grow:1;flex-shrink:1;overflow:auto;padding:20px}.navbar{background-color:#fff;min-height:3.25rem;position:relative;z-index:30}.navbar.is-white{background-color:#fff;color:hsl(0,0%,4%)}.navbar.is-white .navbar-brand>.navbar-item,.navbar.is-white .navbar-brand .navbar-link{color:hsl(0,0%,4%)}.navbar.is-white .navbar-brand>a.navbar-item:focus,.navbar.is-white .navbar-brand>a.navbar-item:hover,.navbar.is-white .navbar-brand>a.navbar-item.is-active,.navbar.is-white .navbar-brand .navbar-link:focus,.navbar.is-white .navbar-brand .navbar-link:hover,.navbar.is-white .navbar-brand .navbar-link.is-active{background-color:hsl(0,0%,95%);color:hsl(0,0%,4%)}.navbar.is-white .navbar-brand .navbar-link::after{border-color:hsl(0,0%,4%)}.navbar.is-white .navbar-burger{color:hsl(0,0%,4%)}@media screen and (min-width: 1024px){.navbar.is-white .navbar-start>.navbar-item,.navbar.is-white .navbar-start .navbar-link,.navbar.is-white .navbar-end>.navbar-item,.navbar.is-white .navbar-end .navbar-link{color:hsl(0,0%,4%)}.navbar.is-white .navbar-start>a.navbar-item:focus,.navbar.is-white .navbar-start>a.navbar-item:hover,.navbar.is-white .navbar-start>a.navbar-item.is-active,.navbar.is-white .navbar-start .navbar-link:focus,.navbar.is-white .navbar-start .navbar-link:hover,.navbar.is-white .navbar-start .navbar-link.is-active,.navbar.is-white .navbar-end>a.navbar-item:focus,.navbar.is-white .navbar-end>a.navbar-item:hover,.navbar.is-white .navbar-end>a.navbar-item.is-active,.navbar.is-white .navbar-end .navbar-link:focus,.navbar.is-white .navbar-end .navbar-link:hover,.navbar.is-white .navbar-end .navbar-link.is-active{background-color:hsl(0,0%,95%);color:hsl(0,0%,4%)}.navbar.is-white .navbar-start .navbar-link::after,.navbar.is-white .navbar-end .navbar-link::after{border-color:hsl(0,0%,4%)}.navbar.is-white .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-white .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-white .navbar-item.has-dropdown.is-active .navbar-link{background-color:hsl(0,0%,95%);color:hsl(0,0%,4%)}.navbar.is-white .navbar-dropdown a.navbar-item.is-active{background-color:#fff;color:hsl(0,0%,4%)}}.navbar.is-black{background-color:hsl(0,0%,4%);color:#fff}.navbar.is-black .navbar-brand>.navbar-item,.navbar.is-black .navbar-brand .navbar-link{color:#fff}.navbar.is-black .navbar-brand>a.navbar-item:focus,.navbar.is-black .navbar-brand>a.navbar-item:hover,.navbar.is-black .navbar-brand>a.navbar-item.is-active,.navbar.is-black .navbar-brand .navbar-link:focus,.navbar.is-black .navbar-brand .navbar-link:hover,.navbar.is-black .navbar-brand .navbar-link.is-active{background-color:#000;color:#fff}.navbar.is-black .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-black .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-black .navbar-start>.navbar-item,.navbar.is-black .navbar-start .navbar-link,.navbar.is-black .navbar-end>.navbar-item,.navbar.is-black .navbar-end .navbar-link{color:#fff}.navbar.is-black .navbar-start>a.navbar-item:focus,.navbar.is-black .navbar-start>a.navbar-item:hover,.navbar.is-black .navbar-start>a.navbar-item.is-active,.navbar.is-black .navbar-start .navbar-link:focus,.navbar.is-black .navbar-start .navbar-link:hover,.navbar.is-black .navbar-start .navbar-link.is-active,.navbar.is-black .navbar-end>a.navbar-item:focus,.navbar.is-black .navbar-end>a.navbar-item:hover,.navbar.is-black .navbar-end>a.navbar-item.is-active,.navbar.is-black .navbar-end .navbar-link:focus,.navbar.is-black .navbar-end .navbar-link:hover,.navbar.is-black .navbar-end .navbar-link.is-active{background-color:#000;color:#fff}.navbar.is-black .navbar-start .navbar-link::after,.navbar.is-black .navbar-end .navbar-link::after{border-color:#fff}.navbar.is-black .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-black .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-black .navbar-item.has-dropdown.is-active .navbar-link{background-color:#000;color:#fff}.navbar.is-black .navbar-dropdown a.navbar-item.is-active{background-color:hsl(0,0%,4%);color:#fff}}.navbar.is-light{background-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand>.navbar-item,.navbar.is-light .navbar-brand .navbar-link{color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand>a.navbar-item:focus,.navbar.is-light .navbar-brand>a.navbar-item:hover,.navbar.is-light .navbar-brand>a.navbar-item.is-active,.navbar.is-light .navbar-brand .navbar-link:focus,.navbar.is-light .navbar-brand .navbar-link:hover,.navbar.is-light .navbar-brand .navbar-link.is-active{background-color:hsl(0,0%,91%);color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-light .navbar-burger{color:rgba(0,0,0,.7)}@media screen and (min-width: 1024px){.navbar.is-light .navbar-start>.navbar-item,.navbar.is-light .navbar-start .navbar-link,.navbar.is-light .navbar-end>.navbar-item,.navbar.is-light .navbar-end .navbar-link{color:rgba(0,0,0,.7)}.navbar.is-light .navbar-start>a.navbar-item:focus,.navbar.is-light .navbar-start>a.navbar-item:hover,.navbar.is-light .navbar-start>a.navbar-item.is-active,.navbar.is-light .navbar-start .navbar-link:focus,.navbar.is-light .navbar-start .navbar-link:hover,.navbar.is-light .navbar-start .navbar-link.is-active,.navbar.is-light .navbar-end>a.navbar-item:focus,.navbar.is-light .navbar-end>a.navbar-item:hover,.navbar.is-light .navbar-end>a.navbar-item.is-active,.navbar.is-light .navbar-end .navbar-link:focus,.navbar.is-light .navbar-end .navbar-link:hover,.navbar.is-light .navbar-end .navbar-link.is-active{background-color:hsl(0,0%,91%);color:rgba(0,0,0,.7)}.navbar.is-light .navbar-start .navbar-link::after,.navbar.is-light .navbar-end .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-light .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-light .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-light .navbar-item.has-dropdown.is-active .navbar-link{background-color:hsl(0,0%,91%);color:rgba(0,0,0,.7)}.navbar.is-light .navbar-dropdown a.navbar-item.is-active{background-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}}.navbar.is-dark{background-color:hsl(0,0%,21%);color:#fff}.navbar.is-dark .navbar-brand>.navbar-item,.navbar.is-dark .navbar-brand .navbar-link{color:#fff}.navbar.is-dark .navbar-brand>a.navbar-item:focus,.navbar.is-dark .navbar-brand>a.navbar-item:hover,.navbar.is-dark .navbar-brand>a.navbar-item.is-active,.navbar.is-dark .navbar-brand .navbar-link:focus,.navbar.is-dark .navbar-brand .navbar-link:hover,.navbar.is-dark .navbar-brand .navbar-link.is-active{background-color:hsl(0,0%,16%);color:#fff}.navbar.is-dark .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-dark .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-dark .navbar-start>.navbar-item,.navbar.is-dark .navbar-start .navbar-link,.navbar.is-dark .navbar-end>.navbar-item,.navbar.is-dark .navbar-end .navbar-link{color:#fff}.navbar.is-dark .navbar-start>a.navbar-item:focus,.navbar.is-dark .navbar-start>a.navbar-item:hover,.navbar.is-dark .navbar-start>a.navbar-item.is-active,.navbar.is-dark .navbar-start .navbar-link:focus,.navbar.is-dark .navbar-start .navbar-link:hover,.navbar.is-dark .navbar-start .navbar-link.is-active,.navbar.is-dark .navbar-end>a.navbar-item:focus,.navbar.is-dark .navbar-end>a.navbar-item:hover,.navbar.is-dark .navbar-end>a.navbar-item.is-active,.navbar.is-dark .navbar-end .navbar-link:focus,.navbar.is-dark .navbar-end .navbar-link:hover,.navbar.is-dark .navbar-end .navbar-link.is-active{background-color:hsl(0,0%,16%);color:#fff}.navbar.is-dark .navbar-start .navbar-link::after,.navbar.is-dark .navbar-end .navbar-link::after{border-color:#fff}.navbar.is-dark .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-dark .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-dark .navbar-item.has-dropdown.is-active .navbar-link{background-color:hsl(0,0%,16%);color:#fff}.navbar.is-dark .navbar-dropdown a.navbar-item.is-active{background-color:hsl(0,0%,21%);color:#fff}}.navbar.is-primary{background-color:#00875f;color:#fff}.navbar.is-primary .navbar-brand>.navbar-item,.navbar.is-primary .navbar-brand .navbar-link{color:#fff}.navbar.is-primary .navbar-brand>a.navbar-item:focus,.navbar.is-primary .navbar-brand>a.navbar-item:hover,.navbar.is-primary .navbar-brand>a.navbar-item.is-active,.navbar.is-primary .navbar-brand .navbar-link:focus,.navbar.is-primary .navbar-brand .navbar-link:hover,.navbar.is-primary .navbar-brand .navbar-link.is-active{background-color:rgb(0,109.5,77.0555555556);color:#fff}.navbar.is-primary .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-primary .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-primary .navbar-start>.navbar-item,.navbar.is-primary .navbar-start .navbar-link,.navbar.is-primary .navbar-end>.navbar-item,.navbar.is-primary .navbar-end .navbar-link{color:#fff}.navbar.is-primary .navbar-start>a.navbar-item:focus,.navbar.is-primary .navbar-start>a.navbar-item:hover,.navbar.is-primary .navbar-start>a.navbar-item.is-active,.navbar.is-primary .navbar-start .navbar-link:focus,.navbar.is-primary .navbar-start .navbar-link:hover,.navbar.is-primary .navbar-start .navbar-link.is-active,.navbar.is-primary .navbar-end>a.navbar-item:focus,.navbar.is-primary .navbar-end>a.navbar-item:hover,.navbar.is-primary .navbar-end>a.navbar-item.is-active,.navbar.is-primary .navbar-end .navbar-link:focus,.navbar.is-primary .navbar-end .navbar-link:hover,.navbar.is-primary .navbar-end .navbar-link.is-active{background-color:rgb(0,109.5,77.0555555556);color:#fff}.navbar.is-primary .navbar-start .navbar-link::after,.navbar.is-primary .navbar-end .navbar-link::after{border-color:#fff}.navbar.is-primary .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-primary .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-primary .navbar-item.has-dropdown.is-active .navbar-link{background-color:rgb(0,109.5,77.0555555556);color:#fff}.navbar.is-primary .navbar-dropdown a.navbar-item.is-active{background-color:#00875f;color:#fff}}.navbar.is-link{background-color:hsl(229,53%,53%);color:#fff}.navbar.is-link .navbar-brand>.navbar-item,.navbar.is-link .navbar-brand .navbar-link{color:#fff}.navbar.is-link .navbar-brand>a.navbar-item:focus,.navbar.is-link .navbar-brand>a.navbar-item:hover,.navbar.is-link .navbar-brand>a.navbar-item.is-active,.navbar.is-link .navbar-brand .navbar-link:focus,.navbar.is-link .navbar-brand .navbar-link:hover,.navbar.is-link .navbar-brand .navbar-link.is-active{background-color:hsl(229,53%,48%);color:#fff}.navbar.is-link .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-link .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-link .navbar-start>.navbar-item,.navbar.is-link .navbar-start .navbar-link,.navbar.is-link .navbar-end>.navbar-item,.navbar.is-link .navbar-end .navbar-link{color:#fff}.navbar.is-link .navbar-start>a.navbar-item:focus,.navbar.is-link .navbar-start>a.navbar-item:hover,.navbar.is-link .navbar-start>a.navbar-item.is-active,.navbar.is-link .navbar-start .navbar-link:focus,.navbar.is-link .navbar-start .navbar-link:hover,.navbar.is-link .navbar-start .navbar-link.is-active,.navbar.is-link .navbar-end>a.navbar-item:focus,.navbar.is-link .navbar-end>a.navbar-item:hover,.navbar.is-link .navbar-end>a.navbar-item.is-active,.navbar.is-link .navbar-end .navbar-link:focus,.navbar.is-link .navbar-end .navbar-link:hover,.navbar.is-link .navbar-end .navbar-link.is-active{background-color:hsl(229,53%,48%);color:#fff}.navbar.is-link .navbar-start .navbar-link::after,.navbar.is-link .navbar-end .navbar-link::after{border-color:#fff}.navbar.is-link .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-link .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-link .navbar-item.has-dropdown.is-active .navbar-link{background-color:hsl(229,53%,48%);color:#fff}.navbar.is-link .navbar-dropdown a.navbar-item.is-active{background-color:hsl(229,53%,53%);color:#fff}}.navbar.is-info{background-color:hsl(207,61%,53%);color:#fff}.navbar.is-info .navbar-brand>.navbar-item,.navbar.is-info .navbar-brand .navbar-link{color:#fff}.navbar.is-info .navbar-brand>a.navbar-item:focus,.navbar.is-info .navbar-brand>a.navbar-item:hover,.navbar.is-info .navbar-brand>a.navbar-item.is-active,.navbar.is-info .navbar-brand .navbar-link:focus,.navbar.is-info .navbar-brand .navbar-link:hover,.navbar.is-info .navbar-brand .navbar-link.is-active{background-color:hsl(207,61%,48%);color:#fff}.navbar.is-info .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-info .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-info .navbar-start>.navbar-item,.navbar.is-info .navbar-start .navbar-link,.navbar.is-info .navbar-end>.navbar-item,.navbar.is-info .navbar-end .navbar-link{color:#fff}.navbar.is-info .navbar-start>a.navbar-item:focus,.navbar.is-info .navbar-start>a.navbar-item:hover,.navbar.is-info .navbar-start>a.navbar-item.is-active,.navbar.is-info .navbar-start .navbar-link:focus,.navbar.is-info .navbar-start .navbar-link:hover,.navbar.is-info .navbar-start .navbar-link.is-active,.navbar.is-info .navbar-end>a.navbar-item:focus,.navbar.is-info .navbar-end>a.navbar-item:hover,.navbar.is-info .navbar-end>a.navbar-item.is-active,.navbar.is-info .navbar-end .navbar-link:focus,.navbar.is-info .navbar-end .navbar-link:hover,.navbar.is-info .navbar-end .navbar-link.is-active{background-color:hsl(207,61%,48%);color:#fff}.navbar.is-info .navbar-start .navbar-link::after,.navbar.is-info .navbar-end .navbar-link::after{border-color:#fff}.navbar.is-info .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-info .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-info .navbar-item.has-dropdown.is-active .navbar-link{background-color:hsl(207,61%,48%);color:#fff}.navbar.is-info .navbar-dropdown a.navbar-item.is-active{background-color:hsl(207,61%,53%);color:#fff}}.navbar.is-success{background-color:hsl(153,53%,53%);color:#fff}.navbar.is-success .navbar-brand>.navbar-item,.navbar.is-success .navbar-brand .navbar-link{color:#fff}.navbar.is-success .navbar-brand>a.navbar-item:focus,.navbar.is-success .navbar-brand>a.navbar-item:hover,.navbar.is-success .navbar-brand>a.navbar-item.is-active,.navbar.is-success .navbar-brand .navbar-link:focus,.navbar.is-success .navbar-brand .navbar-link:hover,.navbar.is-success .navbar-brand .navbar-link.is-active{background-color:hsl(153,53%,48%);color:#fff}.navbar.is-success .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-success .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-success .navbar-start>.navbar-item,.navbar.is-success .navbar-start .navbar-link,.navbar.is-success .navbar-end>.navbar-item,.navbar.is-success .navbar-end .navbar-link{color:#fff}.navbar.is-success .navbar-start>a.navbar-item:focus,.navbar.is-success .navbar-start>a.navbar-item:hover,.navbar.is-success .navbar-start>a.navbar-item.is-active,.navbar.is-success .navbar-start .navbar-link:focus,.navbar.is-success .navbar-start .navbar-link:hover,.navbar.is-success .navbar-start .navbar-link.is-active,.navbar.is-success .navbar-end>a.navbar-item:focus,.navbar.is-success .navbar-end>a.navbar-item:hover,.navbar.is-success .navbar-end>a.navbar-item.is-active,.navbar.is-success .navbar-end .navbar-link:focus,.navbar.is-success .navbar-end .navbar-link:hover,.navbar.is-success .navbar-end .navbar-link.is-active{background-color:hsl(153,53%,48%);color:#fff}.navbar.is-success .navbar-start .navbar-link::after,.navbar.is-success .navbar-end .navbar-link::after{border-color:#fff}.navbar.is-success .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-success .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-success .navbar-item.has-dropdown.is-active .navbar-link{background-color:hsl(153,53%,48%);color:#fff}.navbar.is-success .navbar-dropdown a.navbar-item.is-active{background-color:hsl(153,53%,53%);color:#fff}}.navbar.is-warning{background-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand>.navbar-item,.navbar.is-warning .navbar-brand .navbar-link{color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand>a.navbar-item:focus,.navbar.is-warning .navbar-brand>a.navbar-item:hover,.navbar.is-warning .navbar-brand>a.navbar-item.is-active,.navbar.is-warning .navbar-brand .navbar-link:focus,.navbar.is-warning .navbar-brand .navbar-link:hover,.navbar.is-warning .navbar-brand .navbar-link.is-active{background-color:hsl(44,100%,72%);color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-burger{color:rgba(0,0,0,.7)}@media screen and (min-width: 1024px){.navbar.is-warning .navbar-start>.navbar-item,.navbar.is-warning .navbar-start .navbar-link,.navbar.is-warning .navbar-end>.navbar-item,.navbar.is-warning .navbar-end .navbar-link{color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-start>a.navbar-item:focus,.navbar.is-warning .navbar-start>a.navbar-item:hover,.navbar.is-warning .navbar-start>a.navbar-item.is-active,.navbar.is-warning .navbar-start .navbar-link:focus,.navbar.is-warning .navbar-start .navbar-link:hover,.navbar.is-warning .navbar-start .navbar-link.is-active,.navbar.is-warning .navbar-end>a.navbar-item:focus,.navbar.is-warning .navbar-end>a.navbar-item:hover,.navbar.is-warning .navbar-end>a.navbar-item.is-active,.navbar.is-warning .navbar-end .navbar-link:focus,.navbar.is-warning .navbar-end .navbar-link:hover,.navbar.is-warning .navbar-end .navbar-link.is-active{background-color:hsl(44,100%,72%);color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-start .navbar-link::after,.navbar.is-warning .navbar-end .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-warning .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-warning .navbar-item.has-dropdown.is-active .navbar-link{background-color:hsl(44,100%,72%);color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-dropdown a.navbar-item.is-active{background-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}}.navbar.is-danger{background-color:hsl(348,86%,61%);color:#fff}.navbar.is-danger .navbar-brand>.navbar-item,.navbar.is-danger .navbar-brand .navbar-link{color:#fff}.navbar.is-danger .navbar-brand>a.navbar-item:focus,.navbar.is-danger .navbar-brand>a.navbar-item:hover,.navbar.is-danger .navbar-brand>a.navbar-item.is-active,.navbar.is-danger .navbar-brand .navbar-link:focus,.navbar.is-danger .navbar-brand .navbar-link:hover,.navbar.is-danger .navbar-brand .navbar-link.is-active{background-color:hsl(348,86%,56%);color:#fff}.navbar.is-danger .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-danger .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-danger .navbar-start>.navbar-item,.navbar.is-danger .navbar-start .navbar-link,.navbar.is-danger .navbar-end>.navbar-item,.navbar.is-danger .navbar-end .navbar-link{color:#fff}.navbar.is-danger .navbar-start>a.navbar-item:focus,.navbar.is-danger .navbar-start>a.navbar-item:hover,.navbar.is-danger .navbar-start>a.navbar-item.is-active,.navbar.is-danger .navbar-start .navbar-link:focus,.navbar.is-danger .navbar-start .navbar-link:hover,.navbar.is-danger .navbar-start .navbar-link.is-active,.navbar.is-danger .navbar-end>a.navbar-item:focus,.navbar.is-danger .navbar-end>a.navbar-item:hover,.navbar.is-danger .navbar-end>a.navbar-item.is-active,.navbar.is-danger .navbar-end .navbar-link:focus,.navbar.is-danger .navbar-end .navbar-link:hover,.navbar.is-danger .navbar-end .navbar-link.is-active{background-color:hsl(348,86%,56%);color:#fff}.navbar.is-danger .navbar-start .navbar-link::after,.navbar.is-danger .navbar-end .navbar-link::after{border-color:#fff}.navbar.is-danger .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-danger .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-danger .navbar-item.has-dropdown.is-active .navbar-link{background-color:hsl(348,86%,56%);color:#fff}.navbar.is-danger .navbar-dropdown a.navbar-item.is-active{background-color:hsl(348,86%,61%);color:#fff}}.navbar>.container{align-items:stretch;display:flex;min-height:3.25rem;width:100%}.navbar.has-shadow{box-shadow:0 2px 0 0 hsl(0,0%,96%)}.navbar.is-fixed-bottom,.navbar.is-fixed-top{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom{bottom:0}.navbar.is-fixed-bottom.has-shadow{box-shadow:0 -2px 0 0 hsl(0,0%,96%)}.navbar.is-fixed-top{top:0}html.has-navbar-fixed-top,body.has-navbar-fixed-top{padding-top:3.25rem}html.has-navbar-fixed-bottom,body.has-navbar-fixed-bottom{padding-bottom:3.25rem}.navbar-brand,.navbar-tabs{align-items:stretch;display:flex;flex-shrink:0;min-height:3.25rem}.navbar-brand a.navbar-item:focus,.navbar-brand a.navbar-item:hover{background-color:rgba(0,0,0,0)}.navbar-tabs{-webkit-overflow-scrolling:touch;max-width:100vw;overflow-x:auto;overflow-y:hidden}.navbar-burger{color:hsl(0,0%,29%);-moz-appearance:none;-webkit-appearance:none;appearance:none;background:none;border:none;cursor:pointer;display:block;height:3.25rem;position:relative;width:3.25rem}.navbar-burger span{background-color:currentColor;display:block;height:1px;left:calc(50% - 8px);position:absolute;transform-origin:center;transition-duration:86ms;transition-property:background-color,opacity,transform;transition-timing-function:ease-out;width:16px}.navbar-burger span:nth-child(1){top:calc(50% - 6px)}.navbar-burger span:nth-child(2){top:calc(50% - 1px)}.navbar-burger span:nth-child(3){top:calc(50% + 4px)}.navbar-burger:hover{background-color:rgba(0,0,0,.05)}.navbar-burger.is-active span:nth-child(1){transform:translateY(5px) rotate(45deg)}.navbar-burger.is-active span:nth-child(2){opacity:0}.navbar-burger.is-active span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.navbar-burger{margin-left:auto}.navbar-menu{display:none}.navbar-item,.navbar-link{color:hsl(0,0%,29%);display:block;line-height:1.5;padding:.5rem .75rem;position:relative}.navbar-item .icon:only-child,.navbar-link .icon:only-child{margin-left:-0.25rem;margin-right:-0.25rem}a.navbar-item,.navbar-link{cursor:pointer}a.navbar-item:focus,a.navbar-item:focus-within,a.navbar-item:hover,a.navbar-item.is-active,.navbar-link:focus,.navbar-link:focus-within,.navbar-link:hover,.navbar-link.is-active{background-color:hsl(0,0%,98%);color:#00875f}.navbar-item{flex-grow:0;flex-shrink:0}.navbar-item img{max-height:1.75rem}.navbar-item.has-dropdown{padding:0}.navbar-item.is-expanded{flex-grow:1;flex-shrink:1}.navbar-item.is-tab{border-bottom:1px solid rgba(0,0,0,0);min-height:3.25rem;padding-bottom:calc(.5rem - 1px)}.navbar-item.is-tab:focus,.navbar-item.is-tab:hover{background-color:rgba(0,0,0,0);border-bottom-color:#00875f}.navbar-item.is-tab.is-active{background-color:rgba(0,0,0,0);border-bottom-color:#00875f;border-bottom-style:solid;border-bottom-width:3px;color:#00875f;padding-bottom:calc(.5rem - 3px)}.navbar-content{flex-grow:1;flex-shrink:1}.navbar-link:not(.is-arrowless){padding-right:2.5em}.navbar-link:not(.is-arrowless)::after{border-color:#00875f;margin-top:-0.375em;right:1.125em}.navbar-dropdown{font-size:.875rem;padding-bottom:.5rem;padding-top:.5rem}.navbar-dropdown .navbar-item{padding-left:1.5rem;padding-right:1.5rem}.navbar-divider{background-color:hsl(0,0%,96%);border:none;display:none;height:2px;margin:.5rem 0}@media screen and (max-width: 1023px){.navbar>.container{display:block}.navbar-brand .navbar-item,.navbar-tabs .navbar-item{align-items:center;display:flex}.navbar-link::after{display:none}.navbar-menu{background-color:#fff;box-shadow:0 8px 16px hsla(0,0%,4%,.1);padding:.5rem 0}.navbar-menu.is-active{display:block}.navbar.is-fixed-bottom-touch,.navbar.is-fixed-top-touch{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-touch{bottom:0}.navbar.is-fixed-bottom-touch.has-shadow{box-shadow:0 -2px 3px hsla(0,0%,4%,.1)}.navbar.is-fixed-top-touch{top:0}.navbar.is-fixed-top .navbar-menu,.navbar.is-fixed-top-touch .navbar-menu{-webkit-overflow-scrolling:touch;max-height:calc(100vh - 3.25rem);overflow:auto}html.has-navbar-fixed-top-touch,body.has-navbar-fixed-top-touch{padding-top:3.25rem}html.has-navbar-fixed-bottom-touch,body.has-navbar-fixed-bottom-touch{padding-bottom:3.25rem}}@media screen and (min-width: 1024px){.navbar,.navbar-menu,.navbar-start,.navbar-end{align-items:stretch;display:flex}.navbar{min-height:3.25rem}.navbar.is-spaced{padding:1rem 2rem}.navbar.is-spaced .navbar-start,.navbar.is-spaced .navbar-end{align-items:center}.navbar.is-spaced a.navbar-item,.navbar.is-spaced .navbar-link{border-radius:4px}.navbar.is-transparent a.navbar-item:focus,.navbar.is-transparent a.navbar-item:hover,.navbar.is-transparent a.navbar-item.is-active,.navbar.is-transparent .navbar-link:focus,.navbar.is-transparent .navbar-link:hover,.navbar.is-transparent .navbar-link.is-active{background-color:rgba(0,0,0,0) !important}.navbar.is-transparent .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus-within .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:hover .navbar-link{background-color:rgba(0,0,0,0) !important}.navbar.is-transparent .navbar-dropdown a.navbar-item:focus,.navbar.is-transparent .navbar-dropdown a.navbar-item:hover{background-color:hsl(0,0%,96%);color:hsl(0,0%,4%)}.navbar.is-transparent .navbar-dropdown a.navbar-item.is-active{background-color:hsl(0,0%,96%);color:#00875f}.navbar-burger{display:none}.navbar-item,.navbar-link{align-items:center;display:flex}.navbar-item.has-dropdown{align-items:stretch}.navbar-item.has-dropdown-up .navbar-link::after{transform:rotate(135deg) translate(0.25em, -0.25em)}.navbar-item.has-dropdown-up .navbar-dropdown{border-bottom:2px solid hsl(0,0%,86%);border-radius:6px 6px 0 0;border-top:none;bottom:100%;box-shadow:0 -8px 8px hsla(0,0%,4%,.1);top:auto}.navbar-item.is-active .navbar-dropdown,.navbar-item.is-hoverable:focus .navbar-dropdown,.navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar-item.is-hoverable:hover .navbar-dropdown{display:block}.navbar.is-spaced .navbar-item.is-active .navbar-dropdown,.navbar-item.is-active .navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-item.is-hoverable:focus .navbar-dropdown,.navbar-item.is-hoverable:focus .navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar-item.is-hoverable:focus-within .navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-item.is-hoverable:hover .navbar-dropdown,.navbar-item.is-hoverable:hover .navbar-dropdown.is-boxed{opacity:1;pointer-events:auto;transform:translateY(0)}.navbar-menu{flex-grow:1;flex-shrink:0}.navbar-start{justify-content:flex-start;margin-right:auto}.navbar-end{justify-content:flex-end;margin-left:auto}.navbar-dropdown{background-color:#fff;border-bottom-left-radius:6px;border-bottom-right-radius:6px;border-top:2px solid hsl(0,0%,86%);box-shadow:0 8px 8px hsla(0,0%,4%,.1);display:none;font-size:.875rem;left:0;min-width:100%;position:absolute;top:100%;z-index:20}.navbar-dropdown .navbar-item{padding:.375rem 1rem;white-space:nowrap}.navbar-dropdown a.navbar-item{padding-right:3rem}.navbar-dropdown a.navbar-item:focus,.navbar-dropdown a.navbar-item:hover{background-color:hsl(0,0%,96%);color:hsl(0,0%,4%)}.navbar-dropdown a.navbar-item.is-active{background-color:hsl(0,0%,96%);color:#00875f}.navbar.is-spaced .navbar-dropdown,.navbar-dropdown.is-boxed{border-radius:6px;border-top:none;box-shadow:0 8px 8px hsla(0,0%,4%,.1),0 0 0 1px hsla(0,0%,4%,.1);display:block;opacity:0;pointer-events:none;top:calc(100% + (-4px));transform:translateY(-5px);transition-duration:86ms;transition-property:opacity,transform}.navbar-dropdown.is-right{left:auto;right:0}.navbar-divider{display:block}.navbar>.container .navbar-brand,.container>.navbar .navbar-brand{margin-left:-0.75rem}.navbar>.container .navbar-menu,.container>.navbar .navbar-menu{margin-right:-0.75rem}.navbar.is-fixed-bottom-desktop,.navbar.is-fixed-top-desktop{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-desktop{bottom:0}.navbar.is-fixed-bottom-desktop.has-shadow{box-shadow:0 -2px 3px hsla(0,0%,4%,.1)}.navbar.is-fixed-top-desktop{top:0}html.has-navbar-fixed-top-desktop,body.has-navbar-fixed-top-desktop{padding-top:3.25rem}html.has-navbar-fixed-bottom-desktop,body.has-navbar-fixed-bottom-desktop{padding-bottom:3.25rem}html.has-spaced-navbar-fixed-top,body.has-spaced-navbar-fixed-top{padding-top:5.25rem}html.has-spaced-navbar-fixed-bottom,body.has-spaced-navbar-fixed-bottom{padding-bottom:5.25rem}a.navbar-item.is-active,.navbar-link.is-active{color:hsl(0,0%,4%)}a.navbar-item.is-active:not(:focus):not(:hover),.navbar-link.is-active:not(:focus):not(:hover){background-color:rgba(0,0,0,0)}.navbar-item.has-dropdown:focus .navbar-link,.navbar-item.has-dropdown:hover .navbar-link,.navbar-item.has-dropdown.is-active .navbar-link{background-color:hsl(0,0%,98%)}}.hero.is-fullheight-with-navbar{min-height:calc(100vh - 3.25rem)}.pagination{font-size:1rem;margin:-0.25rem}.pagination.is-small{font-size:.75rem}.pagination.is-medium{font-size:1.25rem}.pagination.is-large{font-size:1.5rem}.pagination.is-rounded .pagination-previous,.pagination.is-rounded .pagination-next{padding-left:1em;padding-right:1em;border-radius:9999px}.pagination.is-rounded .pagination-link{border-radius:9999px}.pagination,.pagination-list{align-items:center;display:flex;justify-content:center;text-align:center}.pagination-previous,.pagination-next,.pagination-link,.pagination-ellipsis{font-size:1em;justify-content:center;margin:.25rem;padding-left:.5em;padding-right:.5em;text-align:center}.pagination-previous,.pagination-next,.pagination-link{border-color:hsl(0,0%,86%);color:hsl(0,0%,21%);min-width:2.5em}.pagination-previous:hover,.pagination-next:hover,.pagination-link:hover{border-color:hsl(0,0%,71%);color:hsl(0,0%,21%)}.pagination-previous:focus,.pagination-next:focus,.pagination-link:focus{border-color:#00875f}.pagination-previous:active,.pagination-next:active,.pagination-link:active{box-shadow:inset 0 1px 2px hsla(0,0%,4%,.2)}.pagination-previous[disabled],.pagination-previous.is-disabled,.pagination-next[disabled],.pagination-next.is-disabled,.pagination-link[disabled],.pagination-link.is-disabled{background-color:hsl(0,0%,86%);border-color:hsl(0,0%,86%);box-shadow:none;color:hsl(0,0%,48%);opacity:.5}.pagination-previous,.pagination-next{padding-left:.75em;padding-right:.75em;white-space:nowrap}.pagination-link.is-current{background-color:#00875f;border-color:#00875f;color:#fff}.pagination-ellipsis{color:hsl(0,0%,71%);pointer-events:none}.pagination-list{flex-wrap:wrap}.pagination-list li{list-style:none}@media screen and (max-width: 768px){.pagination{flex-wrap:wrap}.pagination-previous,.pagination-next{flex-grow:1;flex-shrink:1}.pagination-list li{flex-grow:1;flex-shrink:1}}@media screen and (min-width: 769px),print{.pagination-list{flex-grow:1;flex-shrink:1;justify-content:flex-start;order:1}.pagination-previous,.pagination-next,.pagination-link,.pagination-ellipsis{margin-bottom:0;margin-top:0}.pagination-previous{order:2}.pagination-next{order:3}.pagination{justify-content:space-between;margin-bottom:0;margin-top:0}.pagination.is-centered .pagination-previous{order:1}.pagination.is-centered .pagination-list{justify-content:center;order:2}.pagination.is-centered .pagination-next{order:3}.pagination.is-right .pagination-previous{order:1}.pagination.is-right .pagination-next{order:2}.pagination.is-right .pagination-list{justify-content:flex-end;order:3}}.panel{border-radius:6px;box-shadow:0 .5em 1em -0.125em hsla(0,0%,4%,.1),0 0px 0 1px hsla(0,0%,4%,.02);font-size:1rem}.panel:not(:last-child){margin-bottom:1.5rem}.panel.is-white .panel-heading{background-color:#fff;color:hsl(0,0%,4%)}.panel.is-white .panel-tabs a.is-active{border-bottom-color:#fff}.panel.is-white .panel-block.is-active .panel-icon{color:#fff}.panel.is-black .panel-heading{background-color:hsl(0,0%,4%);color:#fff}.panel.is-black .panel-tabs a.is-active{border-bottom-color:hsl(0,0%,4%)}.panel.is-black .panel-block.is-active .panel-icon{color:hsl(0,0%,4%)}.panel.is-light .panel-heading{background-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.panel.is-light .panel-tabs a.is-active{border-bottom-color:hsl(0,0%,96%)}.panel.is-light .panel-block.is-active .panel-icon{color:hsl(0,0%,96%)}.panel.is-dark .panel-heading{background-color:hsl(0,0%,21%);color:#fff}.panel.is-dark .panel-tabs a.is-active{border-bottom-color:hsl(0,0%,21%)}.panel.is-dark .panel-block.is-active .panel-icon{color:hsl(0,0%,21%)}.panel.is-primary .panel-heading{background-color:#00875f;color:#fff}.panel.is-primary .panel-tabs a.is-active{border-bottom-color:#00875f}.panel.is-primary .panel-block.is-active .panel-icon{color:#00875f}.panel.is-link .panel-heading{background-color:hsl(229,53%,53%);color:#fff}.panel.is-link .panel-tabs a.is-active{border-bottom-color:hsl(229,53%,53%)}.panel.is-link .panel-block.is-active .panel-icon{color:hsl(229,53%,53%)}.panel.is-info .panel-heading{background-color:hsl(207,61%,53%);color:#fff}.panel.is-info .panel-tabs a.is-active{border-bottom-color:hsl(207,61%,53%)}.panel.is-info .panel-block.is-active .panel-icon{color:hsl(207,61%,53%)}.panel.is-success .panel-heading{background-color:hsl(153,53%,53%);color:#fff}.panel.is-success .panel-tabs a.is-active{border-bottom-color:hsl(153,53%,53%)}.panel.is-success .panel-block.is-active .panel-icon{color:hsl(153,53%,53%)}.panel.is-warning .panel-heading{background-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.panel.is-warning .panel-tabs a.is-active{border-bottom-color:hsl(44,100%,77%)}.panel.is-warning .panel-block.is-active .panel-icon{color:hsl(44,100%,77%)}.panel.is-danger .panel-heading{background-color:hsl(348,86%,61%);color:#fff}.panel.is-danger .panel-tabs a.is-active{border-bottom-color:hsl(348,86%,61%)}.panel.is-danger .panel-block.is-active .panel-icon{color:hsl(348,86%,61%)}.panel-tabs:not(:last-child),.panel-block:not(:last-child){border-bottom:1px solid hsl(0,0%,93%)}.panel-heading{background-color:hsl(0,0%,93%);border-radius:6px 6px 0 0;color:hsl(0,0%,21%);font-size:1.25em;font-weight:700;line-height:1.25;padding:.75em 1em}.panel-tabs{align-items:flex-end;display:flex;font-size:.875em;justify-content:center}.panel-tabs a{border-bottom:1px solid hsl(0,0%,86%);margin-bottom:-1px;padding:.5em}.panel-tabs a.is-active{border-bottom-color:hsl(0,0%,29%);color:hsl(0,0%,21%)}.panel-list a{color:hsl(0,0%,29%)}.panel-list a:hover{color:#00875f}.panel-block{align-items:center;color:hsl(0,0%,21%);display:flex;justify-content:flex-start;padding:.5em .75em}.panel-block input[type=checkbox]{margin-right:.75em}.panel-block>.control{flex-grow:1;flex-shrink:1;width:100%}.panel-block.is-wrapped{flex-wrap:wrap}.panel-block.is-active{border-left-color:#00875f;color:hsl(0,0%,21%)}.panel-block.is-active .panel-icon{color:#00875f}.panel-block:last-child{border-bottom-left-radius:6px;border-bottom-right-radius:6px}a.panel-block,label.panel-block{cursor:pointer}a.panel-block:hover,label.panel-block:hover{background-color:hsl(0,0%,96%)}.panel-icon{display:inline-block;font-size:14px;height:1em;line-height:1em;text-align:center;vertical-align:top;width:1em;color:hsl(0,0%,48%);margin-right:.75em}.panel-icon .fa{font-size:inherit;line-height:inherit}.tabs{-webkit-overflow-scrolling:touch;align-items:stretch;display:flex;font-size:1rem;justify-content:space-between;overflow:hidden;overflow-x:auto;white-space:nowrap}.tabs a{align-items:center;border-bottom-color:hsl(0,0%,86%);border-bottom-style:solid;border-bottom-width:1px;color:hsl(0,0%,29%);display:flex;justify-content:center;margin-bottom:-1px;padding:.5em 1em;vertical-align:top}.tabs a:hover{border-bottom-color:hsl(0,0%,21%);color:hsl(0,0%,21%)}.tabs li{display:block}.tabs li.is-active a{border-bottom-color:#00875f;color:#00875f}.tabs ul{align-items:center;border-bottom-color:hsl(0,0%,86%);border-bottom-style:solid;border-bottom-width:1px;display:flex;flex-grow:1;flex-shrink:0;justify-content:flex-start}.tabs ul.is-left{padding-right:.75em}.tabs ul.is-center{flex:none;justify-content:center;padding-left:.75em;padding-right:.75em}.tabs ul.is-right{justify-content:flex-end;padding-left:.75em}.tabs .icon:first-child{margin-right:.5em}.tabs .icon:last-child{margin-left:.5em}.tabs.is-centered ul{justify-content:center}.tabs.is-right ul{justify-content:flex-end}.tabs.is-boxed a{border:1px solid rgba(0,0,0,0);border-radius:4px 4px 0 0}.tabs.is-boxed a:hover{background-color:hsl(0,0%,96%);border-bottom-color:hsl(0,0%,86%)}.tabs.is-boxed li.is-active a{background-color:#fff;border-color:hsl(0,0%,86%);border-bottom-color:rgba(0,0,0,0) !important}.tabs.is-fullwidth li{flex-grow:1;flex-shrink:0}.tabs.is-toggle a{border-color:hsl(0,0%,86%);border-style:solid;border-width:1px;margin-bottom:0;position:relative}.tabs.is-toggle a:hover{background-color:hsl(0,0%,96%);border-color:hsl(0,0%,71%);z-index:2}.tabs.is-toggle li+li{margin-left:-1px}.tabs.is-toggle li:first-child a{border-top-left-radius:4px;border-bottom-left-radius:4px}.tabs.is-toggle li:last-child a{border-top-right-radius:4px;border-bottom-right-radius:4px}.tabs.is-toggle li.is-active a{background-color:#00875f;border-color:#00875f;color:#fff;z-index:1}.tabs.is-toggle ul{border-bottom:none}.tabs.is-toggle.is-toggle-rounded li:first-child a{border-bottom-left-radius:9999px;border-top-left-radius:9999px;padding-left:1.25em}.tabs.is-toggle.is-toggle-rounded li:last-child a{border-bottom-right-radius:9999px;border-top-right-radius:9999px;padding-right:1.25em}.tabs.is-small{font-size:.75rem}.tabs.is-medium{font-size:1.25rem}.tabs.is-large{font-size:1.5rem}.column{display:block;flex-basis:0;flex-grow:1;flex-shrink:1;padding:.75rem}.columns.is-mobile>.column.is-narrow{flex:none;width:unset}.columns.is-mobile>.column.is-full{flex:none;width:100%}.columns.is-mobile>.column.is-three-quarters{flex:none;width:75%}.columns.is-mobile>.column.is-two-thirds{flex:none;width:66.6666%}.columns.is-mobile>.column.is-half{flex:none;width:50%}.columns.is-mobile>.column.is-one-third{flex:none;width:33.3333%}.columns.is-mobile>.column.is-one-quarter{flex:none;width:25%}.columns.is-mobile>.column.is-one-fifth{flex:none;width:20%}.columns.is-mobile>.column.is-two-fifths{flex:none;width:40%}.columns.is-mobile>.column.is-three-fifths{flex:none;width:60%}.columns.is-mobile>.column.is-four-fifths{flex:none;width:80%}.columns.is-mobile>.column.is-offset-three-quarters{margin-left:75%}.columns.is-mobile>.column.is-offset-two-thirds{margin-left:66.6666%}.columns.is-mobile>.column.is-offset-half{margin-left:50%}.columns.is-mobile>.column.is-offset-one-third{margin-left:33.3333%}.columns.is-mobile>.column.is-offset-one-quarter{margin-left:25%}.columns.is-mobile>.column.is-offset-one-fifth{margin-left:20%}.columns.is-mobile>.column.is-offset-two-fifths{margin-left:40%}.columns.is-mobile>.column.is-offset-three-fifths{margin-left:60%}.columns.is-mobile>.column.is-offset-four-fifths{margin-left:80%}.columns.is-mobile>.column.is-0{flex:none;width:0%}.columns.is-mobile>.column.is-offset-0{margin-left:0%}.columns.is-mobile>.column.is-1{flex:none;width:8.33333337%}.columns.is-mobile>.column.is-offset-1{margin-left:8.33333337%}.columns.is-mobile>.column.is-2{flex:none;width:16.66666674%}.columns.is-mobile>.column.is-offset-2{margin-left:16.66666674%}.columns.is-mobile>.column.is-3{flex:none;width:25%}.columns.is-mobile>.column.is-offset-3{margin-left:25%}.columns.is-mobile>.column.is-4{flex:none;width:33.33333337%}.columns.is-mobile>.column.is-offset-4{margin-left:33.33333337%}.columns.is-mobile>.column.is-5{flex:none;width:41.66666674%}.columns.is-mobile>.column.is-offset-5{margin-left:41.66666674%}.columns.is-mobile>.column.is-6{flex:none;width:50%}.columns.is-mobile>.column.is-offset-6{margin-left:50%}.columns.is-mobile>.column.is-7{flex:none;width:58.33333337%}.columns.is-mobile>.column.is-offset-7{margin-left:58.33333337%}.columns.is-mobile>.column.is-8{flex:none;width:66.66666674%}.columns.is-mobile>.column.is-offset-8{margin-left:66.66666674%}.columns.is-mobile>.column.is-9{flex:none;width:75%}.columns.is-mobile>.column.is-offset-9{margin-left:75%}.columns.is-mobile>.column.is-10{flex:none;width:83.33333337%}.columns.is-mobile>.column.is-offset-10{margin-left:83.33333337%}.columns.is-mobile>.column.is-11{flex:none;width:91.66666674%}.columns.is-mobile>.column.is-offset-11{margin-left:91.66666674%}.columns.is-mobile>.column.is-12{flex:none;width:100%}.columns.is-mobile>.column.is-offset-12{margin-left:100%}@media screen and (max-width: 768px){.column.is-narrow-mobile{flex:none;width:unset}.column.is-full-mobile{flex:none;width:100%}.column.is-three-quarters-mobile{flex:none;width:75%}.column.is-two-thirds-mobile{flex:none;width:66.6666%}.column.is-half-mobile{flex:none;width:50%}.column.is-one-third-mobile{flex:none;width:33.3333%}.column.is-one-quarter-mobile{flex:none;width:25%}.column.is-one-fifth-mobile{flex:none;width:20%}.column.is-two-fifths-mobile{flex:none;width:40%}.column.is-three-fifths-mobile{flex:none;width:60%}.column.is-four-fifths-mobile{flex:none;width:80%}.column.is-offset-three-quarters-mobile{margin-left:75%}.column.is-offset-two-thirds-mobile{margin-left:66.6666%}.column.is-offset-half-mobile{margin-left:50%}.column.is-offset-one-third-mobile{margin-left:33.3333%}.column.is-offset-one-quarter-mobile{margin-left:25%}.column.is-offset-one-fifth-mobile{margin-left:20%}.column.is-offset-two-fifths-mobile{margin-left:40%}.column.is-offset-three-fifths-mobile{margin-left:60%}.column.is-offset-four-fifths-mobile{margin-left:80%}.column.is-0-mobile{flex:none;width:0%}.column.is-offset-0-mobile{margin-left:0%}.column.is-1-mobile{flex:none;width:8.33333337%}.column.is-offset-1-mobile{margin-left:8.33333337%}.column.is-2-mobile{flex:none;width:16.66666674%}.column.is-offset-2-mobile{margin-left:16.66666674%}.column.is-3-mobile{flex:none;width:25%}.column.is-offset-3-mobile{margin-left:25%}.column.is-4-mobile{flex:none;width:33.33333337%}.column.is-offset-4-mobile{margin-left:33.33333337%}.column.is-5-mobile{flex:none;width:41.66666674%}.column.is-offset-5-mobile{margin-left:41.66666674%}.column.is-6-mobile{flex:none;width:50%}.column.is-offset-6-mobile{margin-left:50%}.column.is-7-mobile{flex:none;width:58.33333337%}.column.is-offset-7-mobile{margin-left:58.33333337%}.column.is-8-mobile{flex:none;width:66.66666674%}.column.is-offset-8-mobile{margin-left:66.66666674%}.column.is-9-mobile{flex:none;width:75%}.column.is-offset-9-mobile{margin-left:75%}.column.is-10-mobile{flex:none;width:83.33333337%}.column.is-offset-10-mobile{margin-left:83.33333337%}.column.is-11-mobile{flex:none;width:91.66666674%}.column.is-offset-11-mobile{margin-left:91.66666674%}.column.is-12-mobile{flex:none;width:100%}.column.is-offset-12-mobile{margin-left:100%}}@media screen and (min-width: 769px),print{.column.is-narrow,.column.is-narrow-tablet{flex:none;width:unset}.column.is-full,.column.is-full-tablet{flex:none;width:100%}.column.is-three-quarters,.column.is-three-quarters-tablet{flex:none;width:75%}.column.is-two-thirds,.column.is-two-thirds-tablet{flex:none;width:66.6666%}.column.is-half,.column.is-half-tablet{flex:none;width:50%}.column.is-one-third,.column.is-one-third-tablet{flex:none;width:33.3333%}.column.is-one-quarter,.column.is-one-quarter-tablet{flex:none;width:25%}.column.is-one-fifth,.column.is-one-fifth-tablet{flex:none;width:20%}.column.is-two-fifths,.column.is-two-fifths-tablet{flex:none;width:40%}.column.is-three-fifths,.column.is-three-fifths-tablet{flex:none;width:60%}.column.is-four-fifths,.column.is-four-fifths-tablet{flex:none;width:80%}.column.is-offset-three-quarters,.column.is-offset-three-quarters-tablet{margin-left:75%}.column.is-offset-two-thirds,.column.is-offset-two-thirds-tablet{margin-left:66.6666%}.column.is-offset-half,.column.is-offset-half-tablet{margin-left:50%}.column.is-offset-one-third,.column.is-offset-one-third-tablet{margin-left:33.3333%}.column.is-offset-one-quarter,.column.is-offset-one-quarter-tablet{margin-left:25%}.column.is-offset-one-fifth,.column.is-offset-one-fifth-tablet{margin-left:20%}.column.is-offset-two-fifths,.column.is-offset-two-fifths-tablet{margin-left:40%}.column.is-offset-three-fifths,.column.is-offset-three-fifths-tablet{margin-left:60%}.column.is-offset-four-fifths,.column.is-offset-four-fifths-tablet{margin-left:80%}.column.is-0,.column.is-0-tablet{flex:none;width:0%}.column.is-offset-0,.column.is-offset-0-tablet{margin-left:0%}.column.is-1,.column.is-1-tablet{flex:none;width:8.33333337%}.column.is-offset-1,.column.is-offset-1-tablet{margin-left:8.33333337%}.column.is-2,.column.is-2-tablet{flex:none;width:16.66666674%}.column.is-offset-2,.column.is-offset-2-tablet{margin-left:16.66666674%}.column.is-3,.column.is-3-tablet{flex:none;width:25%}.column.is-offset-3,.column.is-offset-3-tablet{margin-left:25%}.column.is-4,.column.is-4-tablet{flex:none;width:33.33333337%}.column.is-offset-4,.column.is-offset-4-tablet{margin-left:33.33333337%}.column.is-5,.column.is-5-tablet{flex:none;width:41.66666674%}.column.is-offset-5,.column.is-offset-5-tablet{margin-left:41.66666674%}.column.is-6,.column.is-6-tablet{flex:none;width:50%}.column.is-offset-6,.column.is-offset-6-tablet{margin-left:50%}.column.is-7,.column.is-7-tablet{flex:none;width:58.33333337%}.column.is-offset-7,.column.is-offset-7-tablet{margin-left:58.33333337%}.column.is-8,.column.is-8-tablet{flex:none;width:66.66666674%}.column.is-offset-8,.column.is-offset-8-tablet{margin-left:66.66666674%}.column.is-9,.column.is-9-tablet{flex:none;width:75%}.column.is-offset-9,.column.is-offset-9-tablet{margin-left:75%}.column.is-10,.column.is-10-tablet{flex:none;width:83.33333337%}.column.is-offset-10,.column.is-offset-10-tablet{margin-left:83.33333337%}.column.is-11,.column.is-11-tablet{flex:none;width:91.66666674%}.column.is-offset-11,.column.is-offset-11-tablet{margin-left:91.66666674%}.column.is-12,.column.is-12-tablet{flex:none;width:100%}.column.is-offset-12,.column.is-offset-12-tablet{margin-left:100%}}@media screen and (max-width: 1023px){.column.is-narrow-touch{flex:none;width:unset}.column.is-full-touch{flex:none;width:100%}.column.is-three-quarters-touch{flex:none;width:75%}.column.is-two-thirds-touch{flex:none;width:66.6666%}.column.is-half-touch{flex:none;width:50%}.column.is-one-third-touch{flex:none;width:33.3333%}.column.is-one-quarter-touch{flex:none;width:25%}.column.is-one-fifth-touch{flex:none;width:20%}.column.is-two-fifths-touch{flex:none;width:40%}.column.is-three-fifths-touch{flex:none;width:60%}.column.is-four-fifths-touch{flex:none;width:80%}.column.is-offset-three-quarters-touch{margin-left:75%}.column.is-offset-two-thirds-touch{margin-left:66.6666%}.column.is-offset-half-touch{margin-left:50%}.column.is-offset-one-third-touch{margin-left:33.3333%}.column.is-offset-one-quarter-touch{margin-left:25%}.column.is-offset-one-fifth-touch{margin-left:20%}.column.is-offset-two-fifths-touch{margin-left:40%}.column.is-offset-three-fifths-touch{margin-left:60%}.column.is-offset-four-fifths-touch{margin-left:80%}.column.is-0-touch{flex:none;width:0%}.column.is-offset-0-touch{margin-left:0%}.column.is-1-touch{flex:none;width:8.33333337%}.column.is-offset-1-touch{margin-left:8.33333337%}.column.is-2-touch{flex:none;width:16.66666674%}.column.is-offset-2-touch{margin-left:16.66666674%}.column.is-3-touch{flex:none;width:25%}.column.is-offset-3-touch{margin-left:25%}.column.is-4-touch{flex:none;width:33.33333337%}.column.is-offset-4-touch{margin-left:33.33333337%}.column.is-5-touch{flex:none;width:41.66666674%}.column.is-offset-5-touch{margin-left:41.66666674%}.column.is-6-touch{flex:none;width:50%}.column.is-offset-6-touch{margin-left:50%}.column.is-7-touch{flex:none;width:58.33333337%}.column.is-offset-7-touch{margin-left:58.33333337%}.column.is-8-touch{flex:none;width:66.66666674%}.column.is-offset-8-touch{margin-left:66.66666674%}.column.is-9-touch{flex:none;width:75%}.column.is-offset-9-touch{margin-left:75%}.column.is-10-touch{flex:none;width:83.33333337%}.column.is-offset-10-touch{margin-left:83.33333337%}.column.is-11-touch{flex:none;width:91.66666674%}.column.is-offset-11-touch{margin-left:91.66666674%}.column.is-12-touch{flex:none;width:100%}.column.is-offset-12-touch{margin-left:100%}}@media screen and (min-width: 1024px){.column.is-narrow-desktop{flex:none;width:unset}.column.is-full-desktop{flex:none;width:100%}.column.is-three-quarters-desktop{flex:none;width:75%}.column.is-two-thirds-desktop{flex:none;width:66.6666%}.column.is-half-desktop{flex:none;width:50%}.column.is-one-third-desktop{flex:none;width:33.3333%}.column.is-one-quarter-desktop{flex:none;width:25%}.column.is-one-fifth-desktop{flex:none;width:20%}.column.is-two-fifths-desktop{flex:none;width:40%}.column.is-three-fifths-desktop{flex:none;width:60%}.column.is-four-fifths-desktop{flex:none;width:80%}.column.is-offset-three-quarters-desktop{margin-left:75%}.column.is-offset-two-thirds-desktop{margin-left:66.6666%}.column.is-offset-half-desktop{margin-left:50%}.column.is-offset-one-third-desktop{margin-left:33.3333%}.column.is-offset-one-quarter-desktop{margin-left:25%}.column.is-offset-one-fifth-desktop{margin-left:20%}.column.is-offset-two-fifths-desktop{margin-left:40%}.column.is-offset-three-fifths-desktop{margin-left:60%}.column.is-offset-four-fifths-desktop{margin-left:80%}.column.is-0-desktop{flex:none;width:0%}.column.is-offset-0-desktop{margin-left:0%}.column.is-1-desktop{flex:none;width:8.33333337%}.column.is-offset-1-desktop{margin-left:8.33333337%}.column.is-2-desktop{flex:none;width:16.66666674%}.column.is-offset-2-desktop{margin-left:16.66666674%}.column.is-3-desktop{flex:none;width:25%}.column.is-offset-3-desktop{margin-left:25%}.column.is-4-desktop{flex:none;width:33.33333337%}.column.is-offset-4-desktop{margin-left:33.33333337%}.column.is-5-desktop{flex:none;width:41.66666674%}.column.is-offset-5-desktop{margin-left:41.66666674%}.column.is-6-desktop{flex:none;width:50%}.column.is-offset-6-desktop{margin-left:50%}.column.is-7-desktop{flex:none;width:58.33333337%}.column.is-offset-7-desktop{margin-left:58.33333337%}.column.is-8-desktop{flex:none;width:66.66666674%}.column.is-offset-8-desktop{margin-left:66.66666674%}.column.is-9-desktop{flex:none;width:75%}.column.is-offset-9-desktop{margin-left:75%}.column.is-10-desktop{flex:none;width:83.33333337%}.column.is-offset-10-desktop{margin-left:83.33333337%}.column.is-11-desktop{flex:none;width:91.66666674%}.column.is-offset-11-desktop{margin-left:91.66666674%}.column.is-12-desktop{flex:none;width:100%}.column.is-offset-12-desktop{margin-left:100%}}@media screen and (min-width: 1216px){.column.is-narrow-widescreen{flex:none;width:unset}.column.is-full-widescreen{flex:none;width:100%}.column.is-three-quarters-widescreen{flex:none;width:75%}.column.is-two-thirds-widescreen{flex:none;width:66.6666%}.column.is-half-widescreen{flex:none;width:50%}.column.is-one-third-widescreen{flex:none;width:33.3333%}.column.is-one-quarter-widescreen{flex:none;width:25%}.column.is-one-fifth-widescreen{flex:none;width:20%}.column.is-two-fifths-widescreen{flex:none;width:40%}.column.is-three-fifths-widescreen{flex:none;width:60%}.column.is-four-fifths-widescreen{flex:none;width:80%}.column.is-offset-three-quarters-widescreen{margin-left:75%}.column.is-offset-two-thirds-widescreen{margin-left:66.6666%}.column.is-offset-half-widescreen{margin-left:50%}.column.is-offset-one-third-widescreen{margin-left:33.3333%}.column.is-offset-one-quarter-widescreen{margin-left:25%}.column.is-offset-one-fifth-widescreen{margin-left:20%}.column.is-offset-two-fifths-widescreen{margin-left:40%}.column.is-offset-three-fifths-widescreen{margin-left:60%}.column.is-offset-four-fifths-widescreen{margin-left:80%}.column.is-0-widescreen{flex:none;width:0%}.column.is-offset-0-widescreen{margin-left:0%}.column.is-1-widescreen{flex:none;width:8.33333337%}.column.is-offset-1-widescreen{margin-left:8.33333337%}.column.is-2-widescreen{flex:none;width:16.66666674%}.column.is-offset-2-widescreen{margin-left:16.66666674%}.column.is-3-widescreen{flex:none;width:25%}.column.is-offset-3-widescreen{margin-left:25%}.column.is-4-widescreen{flex:none;width:33.33333337%}.column.is-offset-4-widescreen{margin-left:33.33333337%}.column.is-5-widescreen{flex:none;width:41.66666674%}.column.is-offset-5-widescreen{margin-left:41.66666674%}.column.is-6-widescreen{flex:none;width:50%}.column.is-offset-6-widescreen{margin-left:50%}.column.is-7-widescreen{flex:none;width:58.33333337%}.column.is-offset-7-widescreen{margin-left:58.33333337%}.column.is-8-widescreen{flex:none;width:66.66666674%}.column.is-offset-8-widescreen{margin-left:66.66666674%}.column.is-9-widescreen{flex:none;width:75%}.column.is-offset-9-widescreen{margin-left:75%}.column.is-10-widescreen{flex:none;width:83.33333337%}.column.is-offset-10-widescreen{margin-left:83.33333337%}.column.is-11-widescreen{flex:none;width:91.66666674%}.column.is-offset-11-widescreen{margin-left:91.66666674%}.column.is-12-widescreen{flex:none;width:100%}.column.is-offset-12-widescreen{margin-left:100%}}@media screen and (min-width: 1408px){.column.is-narrow-fullhd{flex:none;width:unset}.column.is-full-fullhd{flex:none;width:100%}.column.is-three-quarters-fullhd{flex:none;width:75%}.column.is-two-thirds-fullhd{flex:none;width:66.6666%}.column.is-half-fullhd{flex:none;width:50%}.column.is-one-third-fullhd{flex:none;width:33.3333%}.column.is-one-quarter-fullhd{flex:none;width:25%}.column.is-one-fifth-fullhd{flex:none;width:20%}.column.is-two-fifths-fullhd{flex:none;width:40%}.column.is-three-fifths-fullhd{flex:none;width:60%}.column.is-four-fifths-fullhd{flex:none;width:80%}.column.is-offset-three-quarters-fullhd{margin-left:75%}.column.is-offset-two-thirds-fullhd{margin-left:66.6666%}.column.is-offset-half-fullhd{margin-left:50%}.column.is-offset-one-third-fullhd{margin-left:33.3333%}.column.is-offset-one-quarter-fullhd{margin-left:25%}.column.is-offset-one-fifth-fullhd{margin-left:20%}.column.is-offset-two-fifths-fullhd{margin-left:40%}.column.is-offset-three-fifths-fullhd{margin-left:60%}.column.is-offset-four-fifths-fullhd{margin-left:80%}.column.is-0-fullhd{flex:none;width:0%}.column.is-offset-0-fullhd{margin-left:0%}.column.is-1-fullhd{flex:none;width:8.33333337%}.column.is-offset-1-fullhd{margin-left:8.33333337%}.column.is-2-fullhd{flex:none;width:16.66666674%}.column.is-offset-2-fullhd{margin-left:16.66666674%}.column.is-3-fullhd{flex:none;width:25%}.column.is-offset-3-fullhd{margin-left:25%}.column.is-4-fullhd{flex:none;width:33.33333337%}.column.is-offset-4-fullhd{margin-left:33.33333337%}.column.is-5-fullhd{flex:none;width:41.66666674%}.column.is-offset-5-fullhd{margin-left:41.66666674%}.column.is-6-fullhd{flex:none;width:50%}.column.is-offset-6-fullhd{margin-left:50%}.column.is-7-fullhd{flex:none;width:58.33333337%}.column.is-offset-7-fullhd{margin-left:58.33333337%}.column.is-8-fullhd{flex:none;width:66.66666674%}.column.is-offset-8-fullhd{margin-left:66.66666674%}.column.is-9-fullhd{flex:none;width:75%}.column.is-offset-9-fullhd{margin-left:75%}.column.is-10-fullhd{flex:none;width:83.33333337%}.column.is-offset-10-fullhd{margin-left:83.33333337%}.column.is-11-fullhd{flex:none;width:91.66666674%}.column.is-offset-11-fullhd{margin-left:91.66666674%}.column.is-12-fullhd{flex:none;width:100%}.column.is-offset-12-fullhd{margin-left:100%}}.columns{margin-left:-0.75rem;margin-right:-0.75rem;margin-top:-0.75rem}.columns:last-child{margin-bottom:-0.75rem}.columns:not(:last-child){margin-bottom:calc(1.5rem - 0.75rem)}.columns.is-centered{justify-content:center}.columns.is-gapless{margin-left:0;margin-right:0;margin-top:0}.columns.is-gapless>.column{margin:0;padding:0 !important}.columns.is-gapless:not(:last-child){margin-bottom:1.5rem}.columns.is-gapless:last-child{margin-bottom:0}.columns.is-mobile{display:flex}.columns.is-multiline{flex-wrap:wrap}.columns.is-vcentered{align-items:center}@media screen and (min-width: 769px),print{.columns:not(.is-desktop){display:flex}}@media screen and (min-width: 1024px){.columns.is-desktop{display:flex}}.columns.is-variable{--columnGap: 0.75rem;margin-left:calc(-1*var(--columnGap));margin-right:calc(-1*var(--columnGap))}.columns.is-variable>.column{padding-left:var(--columnGap);padding-right:var(--columnGap)}.columns.is-variable.is-0{--columnGap: 0rem}@media screen and (max-width: 768px){.columns.is-variable.is-0-mobile{--columnGap: 0rem}}@media screen and (min-width: 769px),print{.columns.is-variable.is-0-tablet{--columnGap: 0rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.columns.is-variable.is-0-tablet-only{--columnGap: 0rem}}@media screen and (max-width: 1023px){.columns.is-variable.is-0-touch{--columnGap: 0rem}}@media screen and (min-width: 1024px){.columns.is-variable.is-0-desktop{--columnGap: 0rem}}@media screen and (min-width: 1024px)and (max-width: 1215px){.columns.is-variable.is-0-desktop-only{--columnGap: 0rem}}@media screen and (min-width: 1216px){.columns.is-variable.is-0-widescreen{--columnGap: 0rem}}@media screen and (min-width: 1216px)and (max-width: 1407px){.columns.is-variable.is-0-widescreen-only{--columnGap: 0rem}}@media screen and (min-width: 1408px){.columns.is-variable.is-0-fullhd{--columnGap: 0rem}}.columns.is-variable.is-1{--columnGap: 0.25rem}@media screen and (max-width: 768px){.columns.is-variable.is-1-mobile{--columnGap: 0.25rem}}@media screen and (min-width: 769px),print{.columns.is-variable.is-1-tablet{--columnGap: 0.25rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.columns.is-variable.is-1-tablet-only{--columnGap: 0.25rem}}@media screen and (max-width: 1023px){.columns.is-variable.is-1-touch{--columnGap: 0.25rem}}@media screen and (min-width: 1024px){.columns.is-variable.is-1-desktop{--columnGap: 0.25rem}}@media screen and (min-width: 1024px)and (max-width: 1215px){.columns.is-variable.is-1-desktop-only{--columnGap: 0.25rem}}@media screen and (min-width: 1216px){.columns.is-variable.is-1-widescreen{--columnGap: 0.25rem}}@media screen and (min-width: 1216px)and (max-width: 1407px){.columns.is-variable.is-1-widescreen-only{--columnGap: 0.25rem}}@media screen and (min-width: 1408px){.columns.is-variable.is-1-fullhd{--columnGap: 0.25rem}}.columns.is-variable.is-2{--columnGap: 0.5rem}@media screen and (max-width: 768px){.columns.is-variable.is-2-mobile{--columnGap: 0.5rem}}@media screen and (min-width: 769px),print{.columns.is-variable.is-2-tablet{--columnGap: 0.5rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.columns.is-variable.is-2-tablet-only{--columnGap: 0.5rem}}@media screen and (max-width: 1023px){.columns.is-variable.is-2-touch{--columnGap: 0.5rem}}@media screen and (min-width: 1024px){.columns.is-variable.is-2-desktop{--columnGap: 0.5rem}}@media screen and (min-width: 1024px)and (max-width: 1215px){.columns.is-variable.is-2-desktop-only{--columnGap: 0.5rem}}@media screen and (min-width: 1216px){.columns.is-variable.is-2-widescreen{--columnGap: 0.5rem}}@media screen and (min-width: 1216px)and (max-width: 1407px){.columns.is-variable.is-2-widescreen-only{--columnGap: 0.5rem}}@media screen and (min-width: 1408px){.columns.is-variable.is-2-fullhd{--columnGap: 0.5rem}}.columns.is-variable.is-3{--columnGap: 0.75rem}@media screen and (max-width: 768px){.columns.is-variable.is-3-mobile{--columnGap: 0.75rem}}@media screen and (min-width: 769px),print{.columns.is-variable.is-3-tablet{--columnGap: 0.75rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.columns.is-variable.is-3-tablet-only{--columnGap: 0.75rem}}@media screen and (max-width: 1023px){.columns.is-variable.is-3-touch{--columnGap: 0.75rem}}@media screen and (min-width: 1024px){.columns.is-variable.is-3-desktop{--columnGap: 0.75rem}}@media screen and (min-width: 1024px)and (max-width: 1215px){.columns.is-variable.is-3-desktop-only{--columnGap: 0.75rem}}@media screen and (min-width: 1216px){.columns.is-variable.is-3-widescreen{--columnGap: 0.75rem}}@media screen and (min-width: 1216px)and (max-width: 1407px){.columns.is-variable.is-3-widescreen-only{--columnGap: 0.75rem}}@media screen and (min-width: 1408px){.columns.is-variable.is-3-fullhd{--columnGap: 0.75rem}}.columns.is-variable.is-4{--columnGap: 1rem}@media screen and (max-width: 768px){.columns.is-variable.is-4-mobile{--columnGap: 1rem}}@media screen and (min-width: 769px),print{.columns.is-variable.is-4-tablet{--columnGap: 1rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.columns.is-variable.is-4-tablet-only{--columnGap: 1rem}}@media screen and (max-width: 1023px){.columns.is-variable.is-4-touch{--columnGap: 1rem}}@media screen and (min-width: 1024px){.columns.is-variable.is-4-desktop{--columnGap: 1rem}}@media screen and (min-width: 1024px)and (max-width: 1215px){.columns.is-variable.is-4-desktop-only{--columnGap: 1rem}}@media screen and (min-width: 1216px){.columns.is-variable.is-4-widescreen{--columnGap: 1rem}}@media screen and (min-width: 1216px)and (max-width: 1407px){.columns.is-variable.is-4-widescreen-only{--columnGap: 1rem}}@media screen and (min-width: 1408px){.columns.is-variable.is-4-fullhd{--columnGap: 1rem}}.columns.is-variable.is-5{--columnGap: 1.25rem}@media screen and (max-width: 768px){.columns.is-variable.is-5-mobile{--columnGap: 1.25rem}}@media screen and (min-width: 769px),print{.columns.is-variable.is-5-tablet{--columnGap: 1.25rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.columns.is-variable.is-5-tablet-only{--columnGap: 1.25rem}}@media screen and (max-width: 1023px){.columns.is-variable.is-5-touch{--columnGap: 1.25rem}}@media screen and (min-width: 1024px){.columns.is-variable.is-5-desktop{--columnGap: 1.25rem}}@media screen and (min-width: 1024px)and (max-width: 1215px){.columns.is-variable.is-5-desktop-only{--columnGap: 1.25rem}}@media screen and (min-width: 1216px){.columns.is-variable.is-5-widescreen{--columnGap: 1.25rem}}@media screen and (min-width: 1216px)and (max-width: 1407px){.columns.is-variable.is-5-widescreen-only{--columnGap: 1.25rem}}@media screen and (min-width: 1408px){.columns.is-variable.is-5-fullhd{--columnGap: 1.25rem}}.columns.is-variable.is-6{--columnGap: 1.5rem}@media screen and (max-width: 768px){.columns.is-variable.is-6-mobile{--columnGap: 1.5rem}}@media screen and (min-width: 769px),print{.columns.is-variable.is-6-tablet{--columnGap: 1.5rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.columns.is-variable.is-6-tablet-only{--columnGap: 1.5rem}}@media screen and (max-width: 1023px){.columns.is-variable.is-6-touch{--columnGap: 1.5rem}}@media screen and (min-width: 1024px){.columns.is-variable.is-6-desktop{--columnGap: 1.5rem}}@media screen and (min-width: 1024px)and (max-width: 1215px){.columns.is-variable.is-6-desktop-only{--columnGap: 1.5rem}}@media screen and (min-width: 1216px){.columns.is-variable.is-6-widescreen{--columnGap: 1.5rem}}@media screen and (min-width: 1216px)and (max-width: 1407px){.columns.is-variable.is-6-widescreen-only{--columnGap: 1.5rem}}@media screen and (min-width: 1408px){.columns.is-variable.is-6-fullhd{--columnGap: 1.5rem}}.columns.is-variable.is-7{--columnGap: 1.75rem}@media screen and (max-width: 768px){.columns.is-variable.is-7-mobile{--columnGap: 1.75rem}}@media screen and (min-width: 769px),print{.columns.is-variable.is-7-tablet{--columnGap: 1.75rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.columns.is-variable.is-7-tablet-only{--columnGap: 1.75rem}}@media screen and (max-width: 1023px){.columns.is-variable.is-7-touch{--columnGap: 1.75rem}}@media screen and (min-width: 1024px){.columns.is-variable.is-7-desktop{--columnGap: 1.75rem}}@media screen and (min-width: 1024px)and (max-width: 1215px){.columns.is-variable.is-7-desktop-only{--columnGap: 1.75rem}}@media screen and (min-width: 1216px){.columns.is-variable.is-7-widescreen{--columnGap: 1.75rem}}@media screen and (min-width: 1216px)and (max-width: 1407px){.columns.is-variable.is-7-widescreen-only{--columnGap: 1.75rem}}@media screen and (min-width: 1408px){.columns.is-variable.is-7-fullhd{--columnGap: 1.75rem}}.columns.is-variable.is-8{--columnGap: 2rem}@media screen and (max-width: 768px){.columns.is-variable.is-8-mobile{--columnGap: 2rem}}@media screen and (min-width: 769px),print{.columns.is-variable.is-8-tablet{--columnGap: 2rem}}@media screen and (min-width: 769px)and (max-width: 1023px){.columns.is-variable.is-8-tablet-only{--columnGap: 2rem}}@media screen and (max-width: 1023px){.columns.is-variable.is-8-touch{--columnGap: 2rem}}@media screen and (min-width: 1024px){.columns.is-variable.is-8-desktop{--columnGap: 2rem}}@media screen and (min-width: 1024px)and (max-width: 1215px){.columns.is-variable.is-8-desktop-only{--columnGap: 2rem}}@media screen and (min-width: 1216px){.columns.is-variable.is-8-widescreen{--columnGap: 2rem}}@media screen and (min-width: 1216px)and (max-width: 1407px){.columns.is-variable.is-8-widescreen-only{--columnGap: 2rem}}@media screen and (min-width: 1408px){.columns.is-variable.is-8-fullhd{--columnGap: 2rem}}.tile{align-items:stretch;display:block;flex-basis:0;flex-grow:1;flex-shrink:1;min-height:min-content}.tile.is-ancestor{margin-left:-0.75rem;margin-right:-0.75rem;margin-top:-0.75rem}.tile.is-ancestor:last-child{margin-bottom:-0.75rem}.tile.is-ancestor:not(:last-child){margin-bottom:.75rem}.tile.is-child{margin:0 !important}.tile.is-parent{padding:.75rem}.tile.is-vertical{flex-direction:column}.tile.is-vertical>.tile.is-child:not(:last-child){margin-bottom:1.5rem !important}@media screen and (min-width: 769px),print{.tile:not(.is-child){display:flex}.tile.is-1{flex:none;width:8.33333337%}.tile.is-2{flex:none;width:16.66666674%}.tile.is-3{flex:none;width:25%}.tile.is-4{flex:none;width:33.33333337%}.tile.is-5{flex:none;width:41.66666674%}.tile.is-6{flex:none;width:50%}.tile.is-7{flex:none;width:58.33333337%}.tile.is-8{flex:none;width:66.66666674%}.tile.is-9{flex:none;width:75%}.tile.is-10{flex:none;width:83.33333337%}.tile.is-11{flex:none;width:91.66666674%}.tile.is-12{flex:none;width:100%}}.has-text-white{color:#fff !important}a.has-text-white:hover,a.has-text-white:focus{color:hsl(0,0%,90%) !important}.has-background-white{background-color:#fff !important}.has-text-black{color:hsl(0,0%,4%) !important}a.has-text-black:hover,a.has-text-black:focus{color:#000 !important}.has-background-black{background-color:hsl(0,0%,4%) !important}.has-text-light{color:hsl(0,0%,96%) !important}a.has-text-light:hover,a.has-text-light:focus{color:hsl(0,0%,86%) !important}.has-background-light{background-color:hsl(0,0%,96%) !important}.has-text-dark{color:hsl(0,0%,21%) !important}a.has-text-dark:hover,a.has-text-dark:focus{color:hsl(0,0%,11%) !important}.has-background-dark{background-color:hsl(0,0%,21%) !important}.has-text-primary{color:#00875f !important}a.has-text-primary:hover,a.has-text-primary:focus{color:rgb(0,84,59.1111111111) !important}.has-background-primary{background-color:#00875f !important}.has-text-primary-light{color:hsl(162.2222222222,100%,96%) !important}a.has-text-primary-light:hover,a.has-text-primary-light:focus{color:hsl(162.2222222222,100%,86%) !important}.has-background-primary-light{background-color:hsl(162.2222222222,100%,96%) !important}.has-text-primary-dark{color:rgb(0,229.5,161.5) !important}a.has-text-primary-dark:hover,a.has-text-primary-dark:focus{color:rgb(25.5,255,187) !important}.has-background-primary-dark{background-color:rgb(0,229.5,161.5) !important}.has-text-link{color:hsl(229,53%,53%) !important}a.has-text-link:hover,a.has-text-link:focus{color:hsl(229,53%,43%) !important}.has-background-link{background-color:hsl(229,53%,53%) !important}.has-text-link-light{color:hsl(229,53%,96%) !important}a.has-text-link-light:hover,a.has-text-link-light:focus{color:hsl(229,53%,86%) !important}.has-background-link-light{background-color:hsl(229,53%,96%) !important}.has-text-link-dark{color:hsl(229,53%,47%) !important}a.has-text-link-dark:hover,a.has-text-link-dark:focus{color:hsl(229,53%,57%) !important}.has-background-link-dark{background-color:hsl(229,53%,47%) !important}.has-text-info{color:hsl(207,61%,53%) !important}a.has-text-info:hover,a.has-text-info:focus{color:hsl(207,61%,43%) !important}.has-background-info{background-color:hsl(207,61%,53%) !important}.has-text-info-light{color:hsl(207,61%,96%) !important}a.has-text-info-light:hover,a.has-text-info-light:focus{color:hsl(207,61%,86%) !important}.has-background-info-light{background-color:hsl(207,61%,96%) !important}.has-text-info-dark{color:hsl(207,61%,41%) !important}a.has-text-info-dark:hover,a.has-text-info-dark:focus{color:hsl(207,61%,51%) !important}.has-background-info-dark{background-color:hsl(207,61%,41%) !important}.has-text-success{color:hsl(153,53%,53%) !important}a.has-text-success:hover,a.has-text-success:focus{color:hsl(153,53%,43%) !important}.has-background-success{background-color:hsl(153,53%,53%) !important}.has-text-success-light{color:hsl(153,53%,96%) !important}a.has-text-success-light:hover,a.has-text-success-light:focus{color:hsl(153,53%,86%) !important}.has-background-success-light{background-color:hsl(153,53%,96%) !important}.has-text-success-dark{color:hsl(153,53%,31%) !important}a.has-text-success-dark:hover,a.has-text-success-dark:focus{color:hsl(153,53%,41%) !important}.has-background-success-dark{background-color:hsl(153,53%,31%) !important}.has-text-warning{color:hsl(44,100%,77%) !important}a.has-text-warning:hover,a.has-text-warning:focus{color:hsl(44,100%,67%) !important}.has-background-warning{background-color:hsl(44,100%,77%) !important}.has-text-warning-light{color:hsl(44,100%,96%) !important}a.has-text-warning-light:hover,a.has-text-warning-light:focus{color:hsl(44,100%,86%) !important}.has-background-warning-light{background-color:hsl(44,100%,96%) !important}.has-text-warning-dark{color:hsl(44,100%,29%) !important}a.has-text-warning-dark:hover,a.has-text-warning-dark:focus{color:hsl(44,100%,39%) !important}.has-background-warning-dark{background-color:hsl(44,100%,29%) !important}.has-text-danger{color:hsl(348,86%,61%) !important}a.has-text-danger:hover,a.has-text-danger:focus{color:hsl(348,86%,51%) !important}.has-background-danger{background-color:hsl(348,86%,61%) !important}.has-text-danger-light{color:hsl(348,86%,96%) !important}a.has-text-danger-light:hover,a.has-text-danger-light:focus{color:hsl(348,86%,86%) !important}.has-background-danger-light{background-color:hsl(348,86%,96%) !important}.has-text-danger-dark{color:hsl(348,86%,43%) !important}a.has-text-danger-dark:hover,a.has-text-danger-dark:focus{color:hsl(348,86%,53%) !important}.has-background-danger-dark{background-color:hsl(348,86%,43%) !important}.has-text-black-bis{color:hsl(0,0%,7%) !important}.has-background-black-bis{background-color:hsl(0,0%,7%) !important}.has-text-black-ter{color:hsl(0,0%,14%) !important}.has-background-black-ter{background-color:hsl(0,0%,14%) !important}.has-text-grey-darker{color:hsl(0,0%,21%) !important}.has-background-grey-darker{background-color:hsl(0,0%,21%) !important}.has-text-grey-dark{color:hsl(0,0%,29%) !important}.has-background-grey-dark{background-color:hsl(0,0%,29%) !important}.has-text-grey{color:hsl(0,0%,48%) !important}.has-background-grey{background-color:hsl(0,0%,48%) !important}.has-text-grey-light{color:hsl(0,0%,71%) !important}.has-background-grey-light{background-color:hsl(0,0%,71%) !important}.has-text-grey-lighter{color:hsl(0,0%,86%) !important}.has-background-grey-lighter{background-color:hsl(0,0%,86%) !important}.has-text-white-ter{color:hsl(0,0%,96%) !important}.has-background-white-ter{background-color:hsl(0,0%,96%) !important}.has-text-white-bis{color:hsl(0,0%,98%) !important}.has-background-white-bis{background-color:hsl(0,0%,98%) !important}.is-flex-direction-row{flex-direction:row !important}.is-flex-direction-row-reverse{flex-direction:row-reverse !important}.is-flex-direction-column{flex-direction:column !important}.is-flex-direction-column-reverse{flex-direction:column-reverse !important}.is-flex-wrap-nowrap{flex-wrap:nowrap !important}.is-flex-wrap-wrap{flex-wrap:wrap !important}.is-flex-wrap-wrap-reverse{flex-wrap:wrap-reverse !important}.is-justify-content-flex-start{justify-content:flex-start !important}.is-justify-content-flex-end{justify-content:flex-end !important}.is-justify-content-center{justify-content:center !important}.is-justify-content-space-between{justify-content:space-between !important}.is-justify-content-space-around{justify-content:space-around !important}.is-justify-content-space-evenly{justify-content:space-evenly !important}.is-justify-content-start{justify-content:start !important}.is-justify-content-end{justify-content:end !important}.is-justify-content-left{justify-content:left !important}.is-justify-content-right{justify-content:right !important}.is-align-content-flex-start{align-content:flex-start !important}.is-align-content-flex-end{align-content:flex-end !important}.is-align-content-center{align-content:center !important}.is-align-content-space-between{align-content:space-between !important}.is-align-content-space-around{align-content:space-around !important}.is-align-content-space-evenly{align-content:space-evenly !important}.is-align-content-stretch{align-content:stretch !important}.is-align-content-start{align-content:start !important}.is-align-content-end{align-content:end !important}.is-align-content-baseline{align-content:baseline !important}.is-align-items-stretch{align-items:stretch !important}.is-align-items-flex-start{align-items:flex-start !important}.is-align-items-flex-end{align-items:flex-end !important}.is-align-items-center{align-items:center !important}.is-align-items-baseline{align-items:baseline !important}.is-align-items-start{align-items:start !important}.is-align-items-end{align-items:end !important}.is-align-items-self-start{align-items:self-start !important}.is-align-items-self-end{align-items:self-end !important}.is-align-self-auto{align-self:auto !important}.is-align-self-flex-start{align-self:flex-start !important}.is-align-self-flex-end{align-self:flex-end !important}.is-align-self-center{align-self:center !important}.is-align-self-baseline{align-self:baseline !important}.is-align-self-stretch{align-self:stretch !important}.is-flex-grow-0{flex-grow:0 !important}.is-flex-grow-1{flex-grow:1 !important}.is-flex-grow-2{flex-grow:2 !important}.is-flex-grow-3{flex-grow:3 !important}.is-flex-grow-4{flex-grow:4 !important}.is-flex-grow-5{flex-grow:5 !important}.is-flex-shrink-0{flex-shrink:0 !important}.is-flex-shrink-1{flex-shrink:1 !important}.is-flex-shrink-2{flex-shrink:2 !important}.is-flex-shrink-3{flex-shrink:3 !important}.is-flex-shrink-4{flex-shrink:4 !important}.is-flex-shrink-5{flex-shrink:5 !important}.is-clearfix::after{clear:both;content:" ";display:table}.is-pulled-left{float:left !important}.is-pulled-right{float:right !important}.is-radiusless{border-radius:0 !important}.is-shadowless{box-shadow:none !important}.is-clickable{cursor:pointer !important;pointer-events:all !important}.is-clipped{overflow:hidden !important}.is-relative{position:relative !important}.is-marginless{margin:0 !important}.is-paddingless{padding:0 !important}.m-0{margin:0 !important}.mt-0{margin-top:0 !important}.mr-0{margin-right:0 !important}.mb-0{margin-bottom:0 !important}.ml-0{margin-left:0 !important}.mx-0{margin-left:0 !important;margin-right:0 !important}.my-0{margin-top:0 !important;margin-bottom:0 !important}.m-1{margin:.25rem !important}.mt-1{margin-top:.25rem !important}.mr-1{margin-right:.25rem !important}.mb-1{margin-bottom:.25rem !important}.ml-1{margin-left:.25rem !important}.mx-1{margin-left:.25rem !important;margin-right:.25rem !important}.my-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.m-2{margin:.5rem !important}.mt-2{margin-top:.5rem !important}.mr-2{margin-right:.5rem !important}.mb-2{margin-bottom:.5rem !important}.ml-2{margin-left:.5rem !important}.mx-2{margin-left:.5rem !important;margin-right:.5rem !important}.my-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.m-3{margin:.75rem !important}.mt-3{margin-top:.75rem !important}.mr-3{margin-right:.75rem !important}.mb-3{margin-bottom:.75rem !important}.ml-3{margin-left:.75rem !important}.mx-3{margin-left:.75rem !important;margin-right:.75rem !important}.my-3{margin-top:.75rem !important;margin-bottom:.75rem !important}.m-4{margin:1rem !important}.mt-4{margin-top:1rem !important}.mr-4{margin-right:1rem !important}.mb-4{margin-bottom:1rem !important}.ml-4{margin-left:1rem !important}.mx-4{margin-left:1rem !important;margin-right:1rem !important}.my-4{margin-top:1rem !important;margin-bottom:1rem !important}.m-5{margin:1.5rem !important}.mt-5{margin-top:1.5rem !important}.mr-5{margin-right:1.5rem !important}.mb-5{margin-bottom:1.5rem !important}.ml-5{margin-left:1.5rem !important}.mx-5{margin-left:1.5rem !important;margin-right:1.5rem !important}.my-5{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.m-6{margin:3rem !important}.mt-6{margin-top:3rem !important}.mr-6{margin-right:3rem !important}.mb-6{margin-bottom:3rem !important}.ml-6{margin-left:3rem !important}.mx-6{margin-left:3rem !important;margin-right:3rem !important}.my-6{margin-top:3rem !important;margin-bottom:3rem !important}.m-auto{margin:auto !important}.mt-auto{margin-top:auto !important}.mr-auto{margin-right:auto !important}.mb-auto{margin-bottom:auto !important}.ml-auto{margin-left:auto !important}.mx-auto{margin-left:auto !important;margin-right:auto !important}.my-auto{margin-top:auto !important;margin-bottom:auto !important}.p-0{padding:0 !important}.pt-0{padding-top:0 !important}.pr-0{padding-right:0 !important}.pb-0{padding-bottom:0 !important}.pl-0{padding-left:0 !important}.px-0{padding-left:0 !important;padding-right:0 !important}.py-0{padding-top:0 !important;padding-bottom:0 !important}.p-1{padding:.25rem !important}.pt-1{padding-top:.25rem !important}.pr-1{padding-right:.25rem !important}.pb-1{padding-bottom:.25rem !important}.pl-1{padding-left:.25rem !important}.px-1{padding-left:.25rem !important;padding-right:.25rem !important}.py-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.p-2{padding:.5rem !important}.pt-2{padding-top:.5rem !important}.pr-2{padding-right:.5rem !important}.pb-2{padding-bottom:.5rem !important}.pl-2{padding-left:.5rem !important}.px-2{padding-left:.5rem !important;padding-right:.5rem !important}.py-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.p-3{padding:.75rem !important}.pt-3{padding-top:.75rem !important}.pr-3{padding-right:.75rem !important}.pb-3{padding-bottom:.75rem !important}.pl-3{padding-left:.75rem !important}.px-3{padding-left:.75rem !important;padding-right:.75rem !important}.py-3{padding-top:.75rem !important;padding-bottom:.75rem !important}.p-4{padding:1rem !important}.pt-4{padding-top:1rem !important}.pr-4{padding-right:1rem !important}.pb-4{padding-bottom:1rem !important}.pl-4{padding-left:1rem !important}.px-4{padding-left:1rem !important;padding-right:1rem !important}.py-4{padding-top:1rem !important;padding-bottom:1rem !important}.p-5{padding:1.5rem !important}.pt-5{padding-top:1.5rem !important}.pr-5{padding-right:1.5rem !important}.pb-5{padding-bottom:1.5rem !important}.pl-5{padding-left:1.5rem !important}.px-5{padding-left:1.5rem !important;padding-right:1.5rem !important}.py-5{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.p-6{padding:3rem !important}.pt-6{padding-top:3rem !important}.pr-6{padding-right:3rem !important}.pb-6{padding-bottom:3rem !important}.pl-6{padding-left:3rem !important}.px-6{padding-left:3rem !important;padding-right:3rem !important}.py-6{padding-top:3rem !important;padding-bottom:3rem !important}.p-auto{padding:auto !important}.pt-auto{padding-top:auto !important}.pr-auto{padding-right:auto !important}.pb-auto{padding-bottom:auto !important}.pl-auto{padding-left:auto !important}.px-auto{padding-left:auto !important;padding-right:auto !important}.py-auto{padding-top:auto !important;padding-bottom:auto !important}.is-size-1{font-size:3rem !important}.is-size-2{font-size:2.5rem !important}.is-size-3{font-size:2rem !important}.is-size-4{font-size:1.5rem !important}.is-size-5{font-size:1.25rem !important}.is-size-6{font-size:1rem !important}.is-size-7{font-size:.75rem !important}@media screen and (max-width: 768px){.is-size-1-mobile{font-size:3rem !important}.is-size-2-mobile{font-size:2.5rem !important}.is-size-3-mobile{font-size:2rem !important}.is-size-4-mobile{font-size:1.5rem !important}.is-size-5-mobile{font-size:1.25rem !important}.is-size-6-mobile{font-size:1rem !important}.is-size-7-mobile{font-size:.75rem !important}}@media screen and (min-width: 769px),print{.is-size-1-tablet{font-size:3rem !important}.is-size-2-tablet{font-size:2.5rem !important}.is-size-3-tablet{font-size:2rem !important}.is-size-4-tablet{font-size:1.5rem !important}.is-size-5-tablet{font-size:1.25rem !important}.is-size-6-tablet{font-size:1rem !important}.is-size-7-tablet{font-size:.75rem !important}}@media screen and (max-width: 1023px){.is-size-1-touch{font-size:3rem !important}.is-size-2-touch{font-size:2.5rem !important}.is-size-3-touch{font-size:2rem !important}.is-size-4-touch{font-size:1.5rem !important}.is-size-5-touch{font-size:1.25rem !important}.is-size-6-touch{font-size:1rem !important}.is-size-7-touch{font-size:.75rem !important}}@media screen and (min-width: 1024px){.is-size-1-desktop{font-size:3rem !important}.is-size-2-desktop{font-size:2.5rem !important}.is-size-3-desktop{font-size:2rem !important}.is-size-4-desktop{font-size:1.5rem !important}.is-size-5-desktop{font-size:1.25rem !important}.is-size-6-desktop{font-size:1rem !important}.is-size-7-desktop{font-size:.75rem !important}}@media screen and (min-width: 1216px){.is-size-1-widescreen{font-size:3rem !important}.is-size-2-widescreen{font-size:2.5rem !important}.is-size-3-widescreen{font-size:2rem !important}.is-size-4-widescreen{font-size:1.5rem !important}.is-size-5-widescreen{font-size:1.25rem !important}.is-size-6-widescreen{font-size:1rem !important}.is-size-7-widescreen{font-size:.75rem !important}}@media screen and (min-width: 1408px){.is-size-1-fullhd{font-size:3rem !important}.is-size-2-fullhd{font-size:2.5rem !important}.is-size-3-fullhd{font-size:2rem !important}.is-size-4-fullhd{font-size:1.5rem !important}.is-size-5-fullhd{font-size:1.25rem !important}.is-size-6-fullhd{font-size:1rem !important}.is-size-7-fullhd{font-size:.75rem !important}}.has-text-centered{text-align:center !important}.has-text-justified{text-align:justify !important}.has-text-left{text-align:left !important}.has-text-right{text-align:right !important}@media screen and (max-width: 768px){.has-text-centered-mobile{text-align:center !important}}@media screen and (min-width: 769px),print{.has-text-centered-tablet{text-align:center !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.has-text-centered-tablet-only{text-align:center !important}}@media screen and (max-width: 1023px){.has-text-centered-touch{text-align:center !important}}@media screen and (min-width: 1024px){.has-text-centered-desktop{text-align:center !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.has-text-centered-desktop-only{text-align:center !important}}@media screen and (min-width: 1216px){.has-text-centered-widescreen{text-align:center !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.has-text-centered-widescreen-only{text-align:center !important}}@media screen and (min-width: 1408px){.has-text-centered-fullhd{text-align:center !important}}@media screen and (max-width: 768px){.has-text-justified-mobile{text-align:justify !important}}@media screen and (min-width: 769px),print{.has-text-justified-tablet{text-align:justify !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.has-text-justified-tablet-only{text-align:justify !important}}@media screen and (max-width: 1023px){.has-text-justified-touch{text-align:justify !important}}@media screen and (min-width: 1024px){.has-text-justified-desktop{text-align:justify !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.has-text-justified-desktop-only{text-align:justify !important}}@media screen and (min-width: 1216px){.has-text-justified-widescreen{text-align:justify !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.has-text-justified-widescreen-only{text-align:justify !important}}@media screen and (min-width: 1408px){.has-text-justified-fullhd{text-align:justify !important}}@media screen and (max-width: 768px){.has-text-left-mobile{text-align:left !important}}@media screen and (min-width: 769px),print{.has-text-left-tablet{text-align:left !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.has-text-left-tablet-only{text-align:left !important}}@media screen and (max-width: 1023px){.has-text-left-touch{text-align:left !important}}@media screen and (min-width: 1024px){.has-text-left-desktop{text-align:left !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.has-text-left-desktop-only{text-align:left !important}}@media screen and (min-width: 1216px){.has-text-left-widescreen{text-align:left !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.has-text-left-widescreen-only{text-align:left !important}}@media screen and (min-width: 1408px){.has-text-left-fullhd{text-align:left !important}}@media screen and (max-width: 768px){.has-text-right-mobile{text-align:right !important}}@media screen and (min-width: 769px),print{.has-text-right-tablet{text-align:right !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.has-text-right-tablet-only{text-align:right !important}}@media screen and (max-width: 1023px){.has-text-right-touch{text-align:right !important}}@media screen and (min-width: 1024px){.has-text-right-desktop{text-align:right !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.has-text-right-desktop-only{text-align:right !important}}@media screen and (min-width: 1216px){.has-text-right-widescreen{text-align:right !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.has-text-right-widescreen-only{text-align:right !important}}@media screen and (min-width: 1408px){.has-text-right-fullhd{text-align:right !important}}.is-capitalized{text-transform:capitalize !important}.is-lowercase{text-transform:lowercase !important}.is-uppercase{text-transform:uppercase !important}.is-italic{font-style:italic !important}.is-underlined{text-decoration:underline !important}.has-text-weight-light{font-weight:300 !important}.has-text-weight-normal{font-weight:400 !important}.has-text-weight-medium{font-weight:500 !important}.has-text-weight-semibold{font-weight:600 !important}.has-text-weight-bold{font-weight:700 !important}.is-family-primary{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue","Helvetica","Arial",sans-serif !important}.is-family-secondary{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue","Helvetica","Arial",sans-serif !important}.is-family-sans-serif{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue","Helvetica","Arial",sans-serif !important}.is-family-monospace{font-family:monospace !important}.is-family-code{font-family:monospace !important}.is-block{display:block !important}@media screen and (max-width: 768px){.is-block-mobile{display:block !important}}@media screen and (min-width: 769px),print{.is-block-tablet{display:block !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.is-block-tablet-only{display:block !important}}@media screen and (max-width: 1023px){.is-block-touch{display:block !important}}@media screen and (min-width: 1024px){.is-block-desktop{display:block !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.is-block-desktop-only{display:block !important}}@media screen and (min-width: 1216px){.is-block-widescreen{display:block !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.is-block-widescreen-only{display:block !important}}@media screen and (min-width: 1408px){.is-block-fullhd{display:block !important}}.is-flex{display:flex !important}@media screen and (max-width: 768px){.is-flex-mobile{display:flex !important}}@media screen and (min-width: 769px),print{.is-flex-tablet{display:flex !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.is-flex-tablet-only{display:flex !important}}@media screen and (max-width: 1023px){.is-flex-touch{display:flex !important}}@media screen and (min-width: 1024px){.is-flex-desktop{display:flex !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.is-flex-desktop-only{display:flex !important}}@media screen and (min-width: 1216px){.is-flex-widescreen{display:flex !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.is-flex-widescreen-only{display:flex !important}}@media screen and (min-width: 1408px){.is-flex-fullhd{display:flex !important}}.is-inline{display:inline !important}@media screen and (max-width: 768px){.is-inline-mobile{display:inline !important}}@media screen and (min-width: 769px),print{.is-inline-tablet{display:inline !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.is-inline-tablet-only{display:inline !important}}@media screen and (max-width: 1023px){.is-inline-touch{display:inline !important}}@media screen and (min-width: 1024px){.is-inline-desktop{display:inline !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.is-inline-desktop-only{display:inline !important}}@media screen and (min-width: 1216px){.is-inline-widescreen{display:inline !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.is-inline-widescreen-only{display:inline !important}}@media screen and (min-width: 1408px){.is-inline-fullhd{display:inline !important}}.is-inline-block{display:inline-block !important}@media screen and (max-width: 768px){.is-inline-block-mobile{display:inline-block !important}}@media screen and (min-width: 769px),print{.is-inline-block-tablet{display:inline-block !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.is-inline-block-tablet-only{display:inline-block !important}}@media screen and (max-width: 1023px){.is-inline-block-touch{display:inline-block !important}}@media screen and (min-width: 1024px){.is-inline-block-desktop{display:inline-block !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.is-inline-block-desktop-only{display:inline-block !important}}@media screen and (min-width: 1216px){.is-inline-block-widescreen{display:inline-block !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.is-inline-block-widescreen-only{display:inline-block !important}}@media screen and (min-width: 1408px){.is-inline-block-fullhd{display:inline-block !important}}.is-inline-flex{display:inline-flex !important}@media screen and (max-width: 768px){.is-inline-flex-mobile{display:inline-flex !important}}@media screen and (min-width: 769px),print{.is-inline-flex-tablet{display:inline-flex !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.is-inline-flex-tablet-only{display:inline-flex !important}}@media screen and (max-width: 1023px){.is-inline-flex-touch{display:inline-flex !important}}@media screen and (min-width: 1024px){.is-inline-flex-desktop{display:inline-flex !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.is-inline-flex-desktop-only{display:inline-flex !important}}@media screen and (min-width: 1216px){.is-inline-flex-widescreen{display:inline-flex !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.is-inline-flex-widescreen-only{display:inline-flex !important}}@media screen and (min-width: 1408px){.is-inline-flex-fullhd{display:inline-flex !important}}.is-hidden{display:none !important}.is-sr-only{border:none !important;clip:rect(0, 0, 0, 0) !important;height:.01em !important;overflow:hidden !important;padding:0 !important;position:absolute !important;white-space:nowrap !important;width:.01em !important}@media screen and (max-width: 768px){.is-hidden-mobile{display:none !important}}@media screen and (min-width: 769px),print{.is-hidden-tablet{display:none !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.is-hidden-tablet-only{display:none !important}}@media screen and (max-width: 1023px){.is-hidden-touch{display:none !important}}@media screen and (min-width: 1024px){.is-hidden-desktop{display:none !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.is-hidden-desktop-only{display:none !important}}@media screen and (min-width: 1216px){.is-hidden-widescreen{display:none !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.is-hidden-widescreen-only{display:none !important}}@media screen and (min-width: 1408px){.is-hidden-fullhd{display:none !important}}.is-invisible{visibility:hidden !important}@media screen and (max-width: 768px){.is-invisible-mobile{visibility:hidden !important}}@media screen and (min-width: 769px),print{.is-invisible-tablet{visibility:hidden !important}}@media screen and (min-width: 769px)and (max-width: 1023px){.is-invisible-tablet-only{visibility:hidden !important}}@media screen and (max-width: 1023px){.is-invisible-touch{visibility:hidden !important}}@media screen and (min-width: 1024px){.is-invisible-desktop{visibility:hidden !important}}@media screen and (min-width: 1024px)and (max-width: 1215px){.is-invisible-desktop-only{visibility:hidden !important}}@media screen and (min-width: 1216px){.is-invisible-widescreen{visibility:hidden !important}}@media screen and (min-width: 1216px)and (max-width: 1407px){.is-invisible-widescreen-only{visibility:hidden !important}}@media screen and (min-width: 1408px){.is-invisible-fullhd{visibility:hidden !important}}.hero{align-items:stretch;display:flex;flex-direction:column;justify-content:space-between}.hero .navbar{background:none}.hero .tabs ul{border-bottom:none}.hero.is-white{background-color:#fff;color:hsl(0,0%,4%)}.hero.is-white a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-white strong{color:inherit}.hero.is-white .title{color:hsl(0,0%,4%)}.hero.is-white .subtitle{color:hsla(0,0%,4%,.9)}.hero.is-white .subtitle a:not(.button),.hero.is-white .subtitle strong{color:hsl(0,0%,4%)}@media screen and (max-width: 1023px){.hero.is-white .navbar-menu{background-color:#fff}}.hero.is-white .navbar-item,.hero.is-white .navbar-link{color:hsla(0,0%,4%,.7)}.hero.is-white a.navbar-item:hover,.hero.is-white a.navbar-item.is-active,.hero.is-white .navbar-link:hover,.hero.is-white .navbar-link.is-active{background-color:hsl(0,0%,95%);color:hsl(0,0%,4%)}.hero.is-white .tabs a{color:hsl(0,0%,4%);opacity:.9}.hero.is-white .tabs a:hover{opacity:1}.hero.is-white .tabs li.is-active a{color:#fff !important;opacity:1}.hero.is-white .tabs.is-boxed a,.hero.is-white .tabs.is-toggle a{color:hsl(0,0%,4%)}.hero.is-white .tabs.is-boxed a:hover,.hero.is-white .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-white .tabs.is-boxed li.is-active a,.hero.is-white .tabs.is-boxed li.is-active a:hover,.hero.is-white .tabs.is-toggle li.is-active a,.hero.is-white .tabs.is-toggle li.is-active a:hover{background-color:hsl(0,0%,4%);border-color:hsl(0,0%,4%);color:#fff}.hero.is-white.is-bold{background-image:linear-gradient(141deg, hsl(350, 10%, 90%) 0%, hsl(0, 0%, 100%) 71%, hsl(10, 5%, 100%) 100%)}@media screen and (max-width: 768px){.hero.is-white.is-bold .navbar-menu{background-image:linear-gradient(141deg, hsl(350, 10%, 90%) 0%, hsl(0, 0%, 100%) 71%, hsl(10, 5%, 100%) 100%)}}.hero.is-black{background-color:hsl(0,0%,4%);color:#fff}.hero.is-black a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-black strong{color:inherit}.hero.is-black .title{color:#fff}.hero.is-black .subtitle{color:hsla(0,0%,100%,.9)}.hero.is-black .subtitle a:not(.button),.hero.is-black .subtitle strong{color:#fff}@media screen and (max-width: 1023px){.hero.is-black .navbar-menu{background-color:hsl(0,0%,4%)}}.hero.is-black .navbar-item,.hero.is-black .navbar-link{color:hsla(0,0%,100%,.7)}.hero.is-black a.navbar-item:hover,.hero.is-black a.navbar-item.is-active,.hero.is-black .navbar-link:hover,.hero.is-black .navbar-link.is-active{background-color:#000;color:#fff}.hero.is-black .tabs a{color:#fff;opacity:.9}.hero.is-black .tabs a:hover{opacity:1}.hero.is-black .tabs li.is-active a{color:hsl(0,0%,4%) !important;opacity:1}.hero.is-black .tabs.is-boxed a,.hero.is-black .tabs.is-toggle a{color:#fff}.hero.is-black .tabs.is-boxed a:hover,.hero.is-black .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-black .tabs.is-boxed li.is-active a,.hero.is-black .tabs.is-boxed li.is-active a:hover,.hero.is-black .tabs.is-toggle li.is-active a,.hero.is-black .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:hsl(0,0%,4%)}.hero.is-black.is-bold{background-image:linear-gradient(141deg, hsl(350, 10%, 0%) 0%, hsl(0, 0%, 4%) 71%, hsl(10, 5%, 9%) 100%)}@media screen and (max-width: 768px){.hero.is-black.is-bold .navbar-menu{background-image:linear-gradient(141deg, hsl(350, 10%, 0%) 0%, hsl(0, 0%, 4%) 71%, hsl(10, 5%, 9%) 100%)}}.hero.is-light{background-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.hero.is-light a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-light strong{color:inherit}.hero.is-light .title{color:rgba(0,0,0,.7)}.hero.is-light .subtitle{color:rgba(0,0,0,.9)}.hero.is-light .subtitle a:not(.button),.hero.is-light .subtitle strong{color:rgba(0,0,0,.7)}@media screen and (max-width: 1023px){.hero.is-light .navbar-menu{background-color:hsl(0,0%,96%)}}.hero.is-light .navbar-item,.hero.is-light .navbar-link{color:rgba(0,0,0,.7)}.hero.is-light a.navbar-item:hover,.hero.is-light a.navbar-item.is-active,.hero.is-light .navbar-link:hover,.hero.is-light .navbar-link.is-active{background-color:hsl(0,0%,91%);color:rgba(0,0,0,.7)}.hero.is-light .tabs a{color:rgba(0,0,0,.7);opacity:.9}.hero.is-light .tabs a:hover{opacity:1}.hero.is-light .tabs li.is-active a{color:hsl(0,0%,96%) !important;opacity:1}.hero.is-light .tabs.is-boxed a,.hero.is-light .tabs.is-toggle a{color:rgba(0,0,0,.7)}.hero.is-light .tabs.is-boxed a:hover,.hero.is-light .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-light .tabs.is-boxed li.is-active a,.hero.is-light .tabs.is-boxed li.is-active a:hover,.hero.is-light .tabs.is-toggle li.is-active a,.hero.is-light .tabs.is-toggle li.is-active a:hover{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,.7);color:hsl(0,0%,96%)}.hero.is-light.is-bold{background-image:linear-gradient(141deg, hsl(350, 10%, 86%) 0%, hsl(0, 0%, 96%) 71%, hsl(10, 5%, 100%) 100%)}@media screen and (max-width: 768px){.hero.is-light.is-bold .navbar-menu{background-image:linear-gradient(141deg, hsl(350, 10%, 86%) 0%, hsl(0, 0%, 96%) 71%, hsl(10, 5%, 100%) 100%)}}.hero.is-dark{background-color:hsl(0,0%,21%);color:#fff}.hero.is-dark a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-dark strong{color:inherit}.hero.is-dark .title{color:#fff}.hero.is-dark .subtitle{color:hsla(0,0%,100%,.9)}.hero.is-dark .subtitle a:not(.button),.hero.is-dark .subtitle strong{color:#fff}@media screen and (max-width: 1023px){.hero.is-dark .navbar-menu{background-color:hsl(0,0%,21%)}}.hero.is-dark .navbar-item,.hero.is-dark .navbar-link{color:hsla(0,0%,100%,.7)}.hero.is-dark a.navbar-item:hover,.hero.is-dark a.navbar-item.is-active,.hero.is-dark .navbar-link:hover,.hero.is-dark .navbar-link.is-active{background-color:hsl(0,0%,16%);color:#fff}.hero.is-dark .tabs a{color:#fff;opacity:.9}.hero.is-dark .tabs a:hover{opacity:1}.hero.is-dark .tabs li.is-active a{color:hsl(0,0%,21%) !important;opacity:1}.hero.is-dark .tabs.is-boxed a,.hero.is-dark .tabs.is-toggle a{color:#fff}.hero.is-dark .tabs.is-boxed a:hover,.hero.is-dark .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-dark .tabs.is-boxed li.is-active a,.hero.is-dark .tabs.is-boxed li.is-active a:hover,.hero.is-dark .tabs.is-toggle li.is-active a,.hero.is-dark .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:hsl(0,0%,21%)}.hero.is-dark.is-bold{background-image:linear-gradient(141deg, hsl(350, 10%, 11%) 0%, hsl(0, 0%, 21%) 71%, hsl(10, 5%, 26%) 100%)}@media screen and (max-width: 768px){.hero.is-dark.is-bold .navbar-menu{background-image:linear-gradient(141deg, hsl(350, 10%, 11%) 0%, hsl(0, 0%, 21%) 71%, hsl(10, 5%, 26%) 100%)}}.hero.is-primary{background-color:#00875f;color:#fff}.hero.is-primary a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-primary strong{color:inherit}.hero.is-primary .title{color:#fff}.hero.is-primary .subtitle{color:hsla(0,0%,100%,.9)}.hero.is-primary .subtitle a:not(.button),.hero.is-primary .subtitle strong{color:#fff}@media screen and (max-width: 1023px){.hero.is-primary .navbar-menu{background-color:#00875f}}.hero.is-primary .navbar-item,.hero.is-primary .navbar-link{color:hsla(0,0%,100%,.7)}.hero.is-primary a.navbar-item:hover,.hero.is-primary a.navbar-item.is-active,.hero.is-primary .navbar-link:hover,.hero.is-primary .navbar-link.is-active{background-color:rgb(0,109.5,77.0555555556);color:#fff}.hero.is-primary .tabs a{color:#fff;opacity:.9}.hero.is-primary .tabs a:hover{opacity:1}.hero.is-primary .tabs li.is-active a{color:#00875f !important;opacity:1}.hero.is-primary .tabs.is-boxed a,.hero.is-primary .tabs.is-toggle a{color:#fff}.hero.is-primary .tabs.is-boxed a:hover,.hero.is-primary .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-primary .tabs.is-boxed li.is-active a,.hero.is-primary .tabs.is-boxed li.is-active a:hover,.hero.is-primary .tabs.is-toggle li.is-active a,.hero.is-primary .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#00875f}.hero.is-primary.is-bold{background-image:linear-gradient(141deg, rgb(0, 84, 45.1111111111) 0%, rgb(0, 135, 95) 71%, rgb(0, 160.5, 139.6944444444) 100%)}@media screen and (max-width: 768px){.hero.is-primary.is-bold .navbar-menu{background-image:linear-gradient(141deg, rgb(0, 84, 45.1111111111) 0%, rgb(0, 135, 95) 71%, rgb(0, 160.5, 139.6944444444) 100%)}}.hero.is-link{background-color:hsl(229,53%,53%);color:#fff}.hero.is-link a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-link strong{color:inherit}.hero.is-link .title{color:#fff}.hero.is-link .subtitle{color:hsla(0,0%,100%,.9)}.hero.is-link .subtitle a:not(.button),.hero.is-link .subtitle strong{color:#fff}@media screen and (max-width: 1023px){.hero.is-link .navbar-menu{background-color:hsl(229,53%,53%)}}.hero.is-link .navbar-item,.hero.is-link .navbar-link{color:hsla(0,0%,100%,.7)}.hero.is-link a.navbar-item:hover,.hero.is-link a.navbar-item.is-active,.hero.is-link .navbar-link:hover,.hero.is-link .navbar-link.is-active{background-color:hsl(229,53%,48%);color:#fff}.hero.is-link .tabs a{color:#fff;opacity:.9}.hero.is-link .tabs a:hover{opacity:1}.hero.is-link .tabs li.is-active a{color:hsl(229,53%,53%) !important;opacity:1}.hero.is-link .tabs.is-boxed a,.hero.is-link .tabs.is-toggle a{color:#fff}.hero.is-link .tabs.is-boxed a:hover,.hero.is-link .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-link .tabs.is-boxed li.is-active a,.hero.is-link .tabs.is-boxed li.is-active a:hover,.hero.is-link .tabs.is-toggle li.is-active a,.hero.is-link .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:hsl(229,53%,53%)}.hero.is-link.is-bold{background-image:linear-gradient(141deg, hsl(219, 63%, 43%) 0%, hsl(229, 53%, 53%) 71%, hsl(239, 58%, 58%) 100%)}@media screen and (max-width: 768px){.hero.is-link.is-bold .navbar-menu{background-image:linear-gradient(141deg, hsl(219, 63%, 43%) 0%, hsl(229, 53%, 53%) 71%, hsl(239, 58%, 58%) 100%)}}.hero.is-info{background-color:hsl(207,61%,53%);color:#fff}.hero.is-info a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-info strong{color:inherit}.hero.is-info .title{color:#fff}.hero.is-info .subtitle{color:hsla(0,0%,100%,.9)}.hero.is-info .subtitle a:not(.button),.hero.is-info .subtitle strong{color:#fff}@media screen and (max-width: 1023px){.hero.is-info .navbar-menu{background-color:hsl(207,61%,53%)}}.hero.is-info .navbar-item,.hero.is-info .navbar-link{color:hsla(0,0%,100%,.7)}.hero.is-info a.navbar-item:hover,.hero.is-info a.navbar-item.is-active,.hero.is-info .navbar-link:hover,.hero.is-info .navbar-link.is-active{background-color:hsl(207,61%,48%);color:#fff}.hero.is-info .tabs a{color:#fff;opacity:.9}.hero.is-info .tabs a:hover{opacity:1}.hero.is-info .tabs li.is-active a{color:hsl(207,61%,53%) !important;opacity:1}.hero.is-info .tabs.is-boxed a,.hero.is-info .tabs.is-toggle a{color:#fff}.hero.is-info .tabs.is-boxed a:hover,.hero.is-info .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-info .tabs.is-boxed li.is-active a,.hero.is-info .tabs.is-boxed li.is-active a:hover,.hero.is-info .tabs.is-toggle li.is-active a,.hero.is-info .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:hsl(207,61%,53%)}.hero.is-info.is-bold{background-image:linear-gradient(141deg, hsl(197, 71%, 43%) 0%, hsl(207, 61%, 53%) 71%, hsl(217, 66%, 58%) 100%)}@media screen and (max-width: 768px){.hero.is-info.is-bold .navbar-menu{background-image:linear-gradient(141deg, hsl(197, 71%, 43%) 0%, hsl(207, 61%, 53%) 71%, hsl(217, 66%, 58%) 100%)}}.hero.is-success{background-color:hsl(153,53%,53%);color:#fff}.hero.is-success a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-success strong{color:inherit}.hero.is-success .title{color:#fff}.hero.is-success .subtitle{color:hsla(0,0%,100%,.9)}.hero.is-success .subtitle a:not(.button),.hero.is-success .subtitle strong{color:#fff}@media screen and (max-width: 1023px){.hero.is-success .navbar-menu{background-color:hsl(153,53%,53%)}}.hero.is-success .navbar-item,.hero.is-success .navbar-link{color:hsla(0,0%,100%,.7)}.hero.is-success a.navbar-item:hover,.hero.is-success a.navbar-item.is-active,.hero.is-success .navbar-link:hover,.hero.is-success .navbar-link.is-active{background-color:hsl(153,53%,48%);color:#fff}.hero.is-success .tabs a{color:#fff;opacity:.9}.hero.is-success .tabs a:hover{opacity:1}.hero.is-success .tabs li.is-active a{color:hsl(153,53%,53%) !important;opacity:1}.hero.is-success .tabs.is-boxed a,.hero.is-success .tabs.is-toggle a{color:#fff}.hero.is-success .tabs.is-boxed a:hover,.hero.is-success .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-success .tabs.is-boxed li.is-active a,.hero.is-success .tabs.is-boxed li.is-active a:hover,.hero.is-success .tabs.is-toggle li.is-active a,.hero.is-success .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:hsl(153,53%,53%)}.hero.is-success.is-bold{background-image:linear-gradient(141deg, hsl(143, 63%, 43%) 0%, hsl(153, 53%, 53%) 71%, hsl(163, 58%, 58%) 100%)}@media screen and (max-width: 768px){.hero.is-success.is-bold .navbar-menu{background-image:linear-gradient(141deg, hsl(143, 63%, 43%) 0%, hsl(153, 53%, 53%) 71%, hsl(163, 58%, 58%) 100%)}}.hero.is-warning{background-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.hero.is-warning a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-warning strong{color:inherit}.hero.is-warning .title{color:rgba(0,0,0,.7)}.hero.is-warning .subtitle{color:rgba(0,0,0,.9)}.hero.is-warning .subtitle a:not(.button),.hero.is-warning .subtitle strong{color:rgba(0,0,0,.7)}@media screen and (max-width: 1023px){.hero.is-warning .navbar-menu{background-color:hsl(44,100%,77%)}}.hero.is-warning .navbar-item,.hero.is-warning .navbar-link{color:rgba(0,0,0,.7)}.hero.is-warning a.navbar-item:hover,.hero.is-warning a.navbar-item.is-active,.hero.is-warning .navbar-link:hover,.hero.is-warning .navbar-link.is-active{background-color:hsl(44,100%,72%);color:rgba(0,0,0,.7)}.hero.is-warning .tabs a{color:rgba(0,0,0,.7);opacity:.9}.hero.is-warning .tabs a:hover{opacity:1}.hero.is-warning .tabs li.is-active a{color:hsl(44,100%,77%) !important;opacity:1}.hero.is-warning .tabs.is-boxed a,.hero.is-warning .tabs.is-toggle a{color:rgba(0,0,0,.7)}.hero.is-warning .tabs.is-boxed a:hover,.hero.is-warning .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-warning .tabs.is-boxed li.is-active a,.hero.is-warning .tabs.is-boxed li.is-active a:hover,.hero.is-warning .tabs.is-toggle li.is-active a,.hero.is-warning .tabs.is-toggle li.is-active a:hover{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,.7);color:hsl(44,100%,77%)}.hero.is-warning.is-bold{background-image:linear-gradient(141deg, hsl(34, 100%, 67%) 0%, hsl(44, 100%, 77%) 71%, hsl(54, 100%, 82%) 100%)}@media screen and (max-width: 768px){.hero.is-warning.is-bold .navbar-menu{background-image:linear-gradient(141deg, hsl(34, 100%, 67%) 0%, hsl(44, 100%, 77%) 71%, hsl(54, 100%, 82%) 100%)}}.hero.is-danger{background-color:hsl(348,86%,61%);color:#fff}.hero.is-danger a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-danger strong{color:inherit}.hero.is-danger .title{color:#fff}.hero.is-danger .subtitle{color:hsla(0,0%,100%,.9)}.hero.is-danger .subtitle a:not(.button),.hero.is-danger .subtitle strong{color:#fff}@media screen and (max-width: 1023px){.hero.is-danger .navbar-menu{background-color:hsl(348,86%,61%)}}.hero.is-danger .navbar-item,.hero.is-danger .navbar-link{color:hsla(0,0%,100%,.7)}.hero.is-danger a.navbar-item:hover,.hero.is-danger a.navbar-item.is-active,.hero.is-danger .navbar-link:hover,.hero.is-danger .navbar-link.is-active{background-color:hsl(348,86%,56%);color:#fff}.hero.is-danger .tabs a{color:#fff;opacity:.9}.hero.is-danger .tabs a:hover{opacity:1}.hero.is-danger .tabs li.is-active a{color:hsl(348,86%,61%) !important;opacity:1}.hero.is-danger .tabs.is-boxed a,.hero.is-danger .tabs.is-toggle a{color:#fff}.hero.is-danger .tabs.is-boxed a:hover,.hero.is-danger .tabs.is-toggle a:hover{background-color:hsla(0,0%,4%,.1)}.hero.is-danger .tabs.is-boxed li.is-active a,.hero.is-danger .tabs.is-boxed li.is-active a:hover,.hero.is-danger .tabs.is-toggle li.is-active a,.hero.is-danger .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:hsl(348,86%,61%)}.hero.is-danger.is-bold{background-image:linear-gradient(141deg, hsl(338, 96%, 51%) 0%, hsl(348, 86%, 61%) 71%, hsl(358, 91%, 66%) 100%)}@media screen and (max-width: 768px){.hero.is-danger.is-bold .navbar-menu{background-image:linear-gradient(141deg, hsl(338, 96%, 51%) 0%, hsl(348, 86%, 61%) 71%, hsl(358, 91%, 66%) 100%)}}.hero.is-small .hero-body{padding:1.5rem}@media screen and (min-width: 769px),print{.hero.is-medium .hero-body{padding:9rem 4.5rem}}@media screen and (min-width: 769px),print{.hero.is-large .hero-body{padding:18rem 6rem}}.hero.is-halfheight .hero-body,.hero.is-fullheight .hero-body,.hero.is-fullheight-with-navbar .hero-body{align-items:center;display:flex}.hero.is-halfheight .hero-body>.container,.hero.is-fullheight .hero-body>.container,.hero.is-fullheight-with-navbar .hero-body>.container{flex-grow:1;flex-shrink:1}.hero.is-halfheight{min-height:50vh}.hero.is-fullheight{min-height:100vh}.hero-video{overflow:hidden}.hero-video video{left:50%;min-height:100%;min-width:100%;position:absolute;top:50%;transform:translate3d(-50%, -50%, 0)}.hero-video.is-transparent{opacity:.3}@media screen and (max-width: 768px){.hero-video{display:none}}.hero-buttons{margin-top:1.5rem}@media screen and (max-width: 768px){.hero-buttons .button{display:flex}.hero-buttons .button:not(:last-child){margin-bottom:.75rem}}@media screen and (min-width: 769px),print{.hero-buttons{display:flex;justify-content:center}.hero-buttons .button:not(:last-child){margin-right:1.5rem}}.hero-head,.hero-foot{flex-grow:0;flex-shrink:0}.hero-body{flex-grow:1;flex-shrink:0;padding:3rem 1.5rem}@media screen and (min-width: 769px),print{.hero-body{padding:3rem 3rem}}.section{padding:3rem 1.5rem}@media screen and (min-width: 1024px){.section{padding:3rem 3rem}.section.is-medium{padding:9rem 4.5rem}.section.is-large{padding:18rem 6rem}}.footer{background-color:hsl(0,0%,98%);padding:3rem 1.5rem 6rem}.is-noscroll{position:fixed;overflow-y:hidden;width:100%;bottom:0}@keyframes fadeOut{from{opacity:1}to{opacity:0}}.fadeOut{animation-name:fadeOut}@keyframes fadeOutDown{from{opacity:1}to{opacity:0;transform:translate3d(0, 100%, 0)}}.fadeOutDown{animation-name:fadeOutDown}@keyframes fadeOutUp{from{opacity:1}to{opacity:0;transform:translate3d(0, -100%, 0)}}.fadeOutUp{animation-name:fadeOutUp}@keyframes fadeIn{from{opacity:0}to{opacity:1}}.fadeIn{animation-name:fadeIn}@keyframes fadeInDown{from{opacity:0;transform:translate3d(0, -100%, 0)}to{opacity:1;transform:none}}.fadeInDown{animation-name:fadeInDown}@keyframes fadeInUp{from{opacity:0;transform:translate3d(0, 100%, 0)}to{opacity:1;transform:none}}.fadeInUp{animation-name:fadeInUp}.fade-enter-active,.fade-leave-active{transition:opacity 150ms ease-out}.fade-enter,.fade-leave-to{opacity:0}.zoom-in-enter-active,.zoom-in-leave-active{transition:opacity 150ms ease-out}.zoom-in-enter-active .animation-content,.zoom-in-enter-active .animation-content,.zoom-in-leave-active .animation-content,.zoom-in-leave-active .animation-content{transition:transform 150ms ease-out}.zoom-in-enter,.zoom-in-leave-active{opacity:0}.zoom-in-enter .animation-content,.zoom-in-enter .animation-content,.zoom-in-leave-active .animation-content,.zoom-in-leave-active .animation-content{transform:scale(0.95)}.zoom-out-enter-active,.zoom-out-leave-active{transition:opacity 150ms ease-out}.zoom-out-enter-active .animation-content,.zoom-out-enter-active .animation-content,.zoom-out-leave-active .animation-content,.zoom-out-leave-active .animation-content{transition:transform 150ms ease-out}.zoom-out-enter,.zoom-out-leave-active{opacity:0}.zoom-out-enter .animation-content,.zoom-out-enter .animation-content,.zoom-out-leave-active .animation-content,.zoom-out-leave-active .animation-content{transform:scale(1.05)}.slide-next-enter-active,.slide-next-leave-active,.slide-prev-enter-active,.slide-prev-leave-active{transition:transform 250ms cubic-bezier(0.785, 0.135, 0.15, 0.86)}.slide-prev-leave-to,.slide-next-enter{transform:translate3d(-100%, 0, 0);position:absolute;width:100%}.slide-prev-enter,.slide-next-leave-to{transform:translate3d(100%, 0, 0);position:absolute;width:100%}.slide-down-enter-active,.slide-down-leave-active,.slide-up-enter-active,.slide-up-leave-active{transition:transform 250ms cubic-bezier(0.785, 0.135, 0.15, 0.86)}.slide-up-leave-to,.slide-down-enter{transform:translate3d(0, -100%, 0);position:absolute;height:100%}.slide-up-enter,.slide-down-leave-to{transform:translate3d(0, 100%, 0);position:absolute;height:100%}.slide-enter-active{transition:150ms ease-out}.slide-leave-active{transition:150ms ease-out;transition-timing-function:cubic-bezier(0, 1, 0.5, 1)}.slide-enter-to,.slide-leave{max-height:100px;overflow:hidden}.slide-enter,.slide-leave-to{overflow:hidden;max-height:0}.autocomplete{position:relative}.autocomplete .dropdown-menu{display:block;width:100%}.autocomplete .dropdown-menu.is-opened-top{top:auto;bottom:100%}.autocomplete .dropdown-content{overflow:auto;max-height:200px}.autocomplete .dropdown-item,.autocomplete .dropdown .dropdown-menu .has-link a,.dropdown .dropdown-menu .has-link .autocomplete a{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.autocomplete .dropdown-item.is-hovered,.autocomplete .dropdown .dropdown-menu .has-link a.is-hovered,.dropdown .dropdown-menu .has-link .autocomplete a.is-hovered{background:hsl(0,0%,96%);color:hsl(0,0%,4%)}.autocomplete .dropdown-item.is-disabled,.autocomplete .dropdown .dropdown-menu .has-link a.is-disabled,.dropdown .dropdown-menu .has-link .autocomplete a.is-disabled{opacity:.5;cursor:not-allowed}.autocomplete.is-small{border-radius:2px;font-size:.75rem}.autocomplete.is-medium{font-size:1.25rem}.autocomplete.is-large{font-size:1.5rem}.carousel{min-height:120px;position:relative}.carousel.is-overlay{background-color:hsla(0,0%,4%,.86);align-items:center;flex-direction:column;justify-content:center;display:flex;max-height:100vh;position:fixed;z-index:40}.carousel.is-overlay .carousel-item img{cursor:default}.carousel.is-overlay .carousel-indicator.has-background{background:rgba(0,0,0,0)}.carousel .progress,.carousel .progress-wrapper.is-not-native{border-radius:2px;height:.25rem;margin-bottom:0}.carousel .carousel-items{position:relative;display:flex;overflow:hidden;width:100%}@media screen and (min-width: 769px),print{.carousel .carousel-items:hover .carousel-arrow.is-hovered{opacity:1}}.carousel .carousel-items .carousel-item{flex-shrink:0;width:100%}.carousel .carousel-pause{pointer-events:none;position:absolute;top:0;right:.15rem;z-index:1}.carousel .carousel-indicator{width:100%;padding:.5rem;display:flex;align-items:center;justify-content:center}.carousel .carousel-indicator.has-background{background:hsla(0,0%,4%,.5)}.carousel .carousel-indicator.has-custom{flex-wrap:nowrap;justify-content:flex-start;-webkit-overflow-scrolling:touch;overflow:hidden;overflow-x:auto}.carousel .carousel-indicator.has-custom.is-small .indicator-item{flex:1 0 10%}.carousel .carousel-indicator.has-custom.is-medium .indicator-item{flex:1 0 16.66667%}.carousel .carousel-indicator.is-inside{position:absolute}.carousel .carousel-indicator.is-inside.is-bottom{bottom:0}.carousel .carousel-indicator.is-inside.is-top{top:0}.carousel .carousel-indicator .indicator-item:not(:last-child){margin-right:.5rem}.carousel .carousel-indicator .indicator-item.is-active .indicator-style,.carousel .carousel-indicator .indicator-item .indicator-style:hover{background:#00875f;border:1px solid #fff}.carousel .carousel-indicator .indicator-item .indicator-style{display:block;border:1px solid #00875f;background:#fff;outline:none;transition:150ms ease-out}.carousel .carousel-indicator .indicator-item .indicator-style.is-boxes{height:10px;width:10px}.carousel .carousel-indicator .indicator-item .indicator-style.is-dots{border-radius:9999px;height:10px;width:10px}.carousel .carousel-indicator .indicator-item .indicator-style.is-lines{height:5px;width:25px}.carousel-list{position:relative;overflow:hidden;width:100%}.carousel-list.has-shadow{box-shadow:0px 0px 10px rgba(0,0,0,.25)}@media screen and (min-width: 769px),print{.carousel-list:hover .carousel-arrow.is-hovered{opacity:1}}.carousel-list .carousel-slides{position:relative;display:flex;width:100%}.carousel-list .carousel-slides:not(.is-dragging){transition:all 250ms ease-out 0s}.carousel-list .carousel-slides.has-grayscale .carousel-slide img{filter:grayscale(100%)}.carousel-list .carousel-slides.has-grayscale .carousel-slide.is-active img{filter:grayscale(0%)}.carousel-list .carousel-slides.has-opacity .carousel-slide img{opacity:.25}.carousel-list .carousel-slides.has-opacity .carousel-slide.is-active img{opacity:1}.carousel-list .carousel-slides .carousel-slide{border:2px solid rgba(0,0,0,0);flex-shrink:0}.carousel-arrow{transition:150ms ease-out}.carousel-arrow.is-hovered{opacity:0}.carousel-arrow .icon{background:#fff;color:#00875f;cursor:pointer;border:1px solid #fff;border-radius:9999px;outline:0}.carousel-arrow .icon:hover{border:1px solid #00875f;opacity:1}.carousel-arrow .icon.has-icons-left,.carousel-arrow .icon.has-icons-right{position:absolute;top:50%;transform:translateY(-50%);z-index:1}.carousel-arrow .icon.has-icons-left{left:1.5rem}.carousel-arrow .icon.has-icons-right{right:1.5rem}.b-checkbox.checkbox{outline:none;display:inline-flex;align-items:center}.b-checkbox.checkbox:not(.button){margin-right:.5em}.b-checkbox.checkbox:not(.button)+.checkbox:last-child{margin-right:0}.b-checkbox.checkbox input[type=checkbox]{position:absolute;left:0;opacity:0;outline:none;z-index:-1}.b-checkbox.checkbox input[type=checkbox]+.check{width:1.25em;height:1.25em;flex-shrink:0;border-radius:4px;border:2px solid hsl(0,0%,48%);transition:background 150ms ease-out;background:rgba(0,0,0,0)}.b-checkbox.checkbox input[type=checkbox]:checked+.check{background:#00875f url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:%23fff%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:#00875f}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-white{background:#fff url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:hsl%280, 0%25, 4%25%29%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:#fff}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-black{background:hsl(0,0%,4%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:hsl%280, 0%25, 100%25%29%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:hsl(0,0%,4%)}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-light{background:hsl(0,0%,96%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:rgba%280, 0, 0, 0.7%29%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:hsl(0,0%,96%)}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-dark{background:hsl(0,0%,21%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:%23fff%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:hsl(0,0%,21%)}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-primary{background:#00875f url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:%23fff%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:#00875f}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-link{background:hsl(229,53%,53%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:%23fff%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:hsl(229,53%,53%)}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-info{background:hsl(207,61%,53%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:%23fff%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:hsl(207,61%,53%)}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-success{background:hsl(153,53%,53%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:%23fff%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:hsl(153,53%,53%)}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-warning{background:hsl(44,100%,77%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:rgba%280, 0, 0, 0.7%29%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:hsl(44,100%,77%)}.b-checkbox.checkbox input[type=checkbox]:checked+.check.is-danger{background:hsl(348,86%,61%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:%23fff%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center;border-color:hsl(348,86%,61%)}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check{background:#00875f url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:%23fff%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:#00875f}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-white{background:#fff url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:hsl%280, 0%25, 4%25%29%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:#fff}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-black{background:hsl(0,0%,4%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:hsl%280, 0%25, 100%25%29%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:hsl(0,0%,4%)}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-light{background:hsl(0,0%,96%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:rgba%280, 0, 0, 0.7%29%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:hsl(0,0%,96%)}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-dark{background:hsl(0,0%,21%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:%23fff%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:hsl(0,0%,21%)}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-primary{background:#00875f url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:%23fff%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:#00875f}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-link{background:hsl(229,53%,53%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:%23fff%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:hsl(229,53%,53%)}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-info{background:hsl(207,61%,53%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:%23fff%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:hsl(207,61%,53%)}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-success{background:hsl(153,53%,53%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:%23fff%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:hsl(153,53%,53%)}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-warning{background:hsl(44,100%,77%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:rgba%280, 0, 0, 0.7%29%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:hsl(44,100%,77%)}.b-checkbox.checkbox input[type=checkbox]:indeterminate+.check.is-danger{background:hsl(348,86%,61%) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Crect style=%27fill:%23fff%27 width=%270.7%27 height=%270.2%27 x=%27.15%27 y=%27.4%27%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;border-color:hsl(348,86%,61%)}.b-checkbox.checkbox input[type=checkbox]:focus+.check{box-shadow:0 0 .5em hsla(0,0%,48%,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check{box-shadow:0 0 .5em rgba(0,135,95,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-white{box-shadow:0 0 .5em hsla(0,0%,100%,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-black{box-shadow:0 0 .5em hsla(0,0%,4%,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-light{box-shadow:0 0 .5em hsla(0,0%,96%,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-dark{box-shadow:0 0 .5em hsla(0,0%,21%,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-primary{box-shadow:0 0 .5em rgba(0,135,95,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-link{box-shadow:0 0 .5em hsla(229,53%,53%,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-info{box-shadow:0 0 .5em hsla(207,61%,53%,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-success{box-shadow:0 0 .5em hsla(153,53%,53%,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-warning{box-shadow:0 0 .5em hsla(44,100%,77%,.8)}.b-checkbox.checkbox input[type=checkbox]:focus:checked+.check.is-danger{box-shadow:0 0 .5em hsla(348,86%,61%,.8)}.b-checkbox.checkbox .control-label{padding-left:calc(.75em - 1px)}.b-checkbox.checkbox.button{display:flex}.b-checkbox.checkbox[disabled]{opacity:.5}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check{border-color:#00875f}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-white{border-color:#fff}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-black{border-color:hsl(0,0%,4%)}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-light{border-color:hsl(0,0%,96%)}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-dark{border-color:hsl(0,0%,21%)}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-primary{border-color:#00875f}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-link{border-color:hsl(229,53%,53%)}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-info{border-color:hsl(207,61%,53%)}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-success{border-color:hsl(153,53%,53%)}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-warning{border-color:hsl(44,100%,77%)}.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled)+.check.is-danger{border-color:hsl(348,86%,61%)}.b-checkbox.checkbox.is-small{border-radius:2px;font-size:.75rem}.b-checkbox.checkbox.is-medium{font-size:1.25rem}.b-checkbox.checkbox.is-large{font-size:1.5rem}.b-clockpicker .card-header{background-color:#00875f;color:#fff}.b-clockpicker .b-clockpicker-face:after{background-color:#00875f}.b-clockpicker .b-clockpicker-face-hand{background-color:#00875f;border-color:#00875f}.b-clockpicker .b-clockpicker-face-number.active{background-color:#00875f;color:#fff}.b-clockpicker.is-white .card-header{background-color:#fff;color:hsl(0,0%,4%)}.b-clockpicker.is-white .b-clockpicker-face:after{background-color:#fff}.b-clockpicker.is-white .b-clockpicker-face-hand{background-color:#fff;border-color:#fff}.b-clockpicker.is-white .b-clockpicker-face-number.active{background-color:#fff;color:hsl(0,0%,4%)}.b-clockpicker.is-black .card-header{background-color:hsl(0,0%,4%);color:#fff}.b-clockpicker.is-black .b-clockpicker-face:after{background-color:hsl(0,0%,4%)}.b-clockpicker.is-black .b-clockpicker-face-hand{background-color:hsl(0,0%,4%);border-color:hsl(0,0%,4%)}.b-clockpicker.is-black .b-clockpicker-face-number.active{background-color:hsl(0,0%,4%);color:#fff}.b-clockpicker.is-light .card-header{background-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.b-clockpicker.is-light .b-clockpicker-face:after{background-color:hsl(0,0%,96%)}.b-clockpicker.is-light .b-clockpicker-face-hand{background-color:hsl(0,0%,96%);border-color:hsl(0,0%,96%)}.b-clockpicker.is-light .b-clockpicker-face-number.active{background-color:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.b-clockpicker.is-dark .card-header{background-color:hsl(0,0%,21%);color:#fff}.b-clockpicker.is-dark .b-clockpicker-face:after{background-color:hsl(0,0%,21%)}.b-clockpicker.is-dark .b-clockpicker-face-hand{background-color:hsl(0,0%,21%);border-color:hsl(0,0%,21%)}.b-clockpicker.is-dark .b-clockpicker-face-number.active{background-color:hsl(0,0%,21%);color:#fff}.b-clockpicker.is-primary .card-header{background-color:#00875f;color:#fff}.b-clockpicker.is-primary .b-clockpicker-face:after{background-color:#00875f}.b-clockpicker.is-primary .b-clockpicker-face-hand{background-color:#00875f;border-color:#00875f}.b-clockpicker.is-primary .b-clockpicker-face-number.active{background-color:#00875f;color:#fff}.b-clockpicker.is-link .card-header{background-color:hsl(229,53%,53%);color:#fff}.b-clockpicker.is-link .b-clockpicker-face:after{background-color:hsl(229,53%,53%)}.b-clockpicker.is-link .b-clockpicker-face-hand{background-color:hsl(229,53%,53%);border-color:hsl(229,53%,53%)}.b-clockpicker.is-link .b-clockpicker-face-number.active{background-color:hsl(229,53%,53%);color:#fff}.b-clockpicker.is-info .card-header{background-color:hsl(207,61%,53%);color:#fff}.b-clockpicker.is-info .b-clockpicker-face:after{background-color:hsl(207,61%,53%)}.b-clockpicker.is-info .b-clockpicker-face-hand{background-color:hsl(207,61%,53%);border-color:hsl(207,61%,53%)}.b-clockpicker.is-info .b-clockpicker-face-number.active{background-color:hsl(207,61%,53%);color:#fff}.b-clockpicker.is-success .card-header{background-color:hsl(153,53%,53%);color:#fff}.b-clockpicker.is-success .b-clockpicker-face:after{background-color:hsl(153,53%,53%)}.b-clockpicker.is-success .b-clockpicker-face-hand{background-color:hsl(153,53%,53%);border-color:hsl(153,53%,53%)}.b-clockpicker.is-success .b-clockpicker-face-number.active{background-color:hsl(153,53%,53%);color:#fff}.b-clockpicker.is-warning .card-header{background-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.b-clockpicker.is-warning .b-clockpicker-face:after{background-color:hsl(44,100%,77%)}.b-clockpicker.is-warning .b-clockpicker-face-hand{background-color:hsl(44,100%,77%);border-color:hsl(44,100%,77%)}.b-clockpicker.is-warning .b-clockpicker-face-number.active{background-color:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.b-clockpicker.is-danger .card-header{background-color:hsl(348,86%,61%);color:#fff}.b-clockpicker.is-danger .b-clockpicker-face:after{background-color:hsl(348,86%,61%)}.b-clockpicker.is-danger .b-clockpicker-face-hand{background-color:hsl(348,86%,61%);border-color:hsl(348,86%,61%)}.b-clockpicker.is-danger .b-clockpicker-face-number.active{background-color:hsl(348,86%,61%);color:#fff}.b-clockpicker .dropdown-menu{min-width:0}.b-clockpicker .dropdown,.b-clockpicker .dropdown-trigger{width:100%}.b-clockpicker .dropdown .input[readonly],.b-clockpicker .dropdown-trigger .input[readonly]{cursor:pointer;box-shadow:inset 0 .0625em .125em hsla(0,0%,4%,.05)}.b-clockpicker .dropdown .input[readonly]:focus,.b-clockpicker .dropdown .input[readonly].is-focused,.b-clockpicker .dropdown .input[readonly]:active,.b-clockpicker .dropdown .input[readonly].is-active,.b-clockpicker .dropdown-trigger .input[readonly]:focus,.b-clockpicker .dropdown-trigger .input[readonly].is-focused,.b-clockpicker .dropdown-trigger .input[readonly]:active,.b-clockpicker .dropdown-trigger .input[readonly].is-active{box-shadow:0 0 0 .125em rgba(0,135,95,.25)}.b-clockpicker .dropdown-item,.b-clockpicker .dropdown .dropdown-menu .has-link a,.dropdown .dropdown-menu .has-link .b-clockpicker a{font-size:inherit;padding:0}.b-clockpicker .dropdown-content{padding-top:0;padding-bottom:0}.b-clockpicker .card{border-radius:.25rem}.b-clockpicker .card-header{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.b-clockpicker .card-content{padding:12px}.b-clockpicker-btn{cursor:pointer;opacity:.6}.b-clockpicker-btn:hover,.b-clockpicker-btn.active{opacity:1}.b-clockpicker-period .b-clockpicker-btn{font-size:16px}.b-clockpicker-time span{align-items:center;display:inline-flex;justify-content:center}.b-clockpicker-header{display:flex;line-height:1;justify-content:flex-end;color:inherit}.b-clockpicker-header .b-clockpicker-time{white-space:nowrap}.b-clockpicker-header .b-clockpicker-time span{height:60px;font-size:60px}.b-clockpicker-header .b-clockpicker-period{align-self:flex-end;display:flex;flex-direction:column;margin:8px 0 6px 8px}.b-clockpicker-body{transition:.9s cubic-bezier(0.25, 0.8, 0.5, 1)}.b-clockpicker-body .b-clockpicker-btn{padding:0 8px;border-radius:9999px;margin-bottom:2px}.b-clockpicker-body .b-clockpicker-btn:hover,.b-clockpicker-body .b-clockpicker-btn.active{background-color:#00875f;color:#fff}.b-clockpicker-body .b-clockpicker-period{position:absolute;top:5px;right:5px}.b-clockpicker-body .b-clockpicker-time{position:absolute;top:5px;left:5px;font-size:16px}.b-clockpicker-body .b-clockpicker-face{border-radius:50%;position:relative;background-color:hsl(0,0%,86%);width:100%;height:100%;align-items:center;display:flex;justify-content:center}.b-clockpicker-body .b-clockpicker-face:after{border-radius:50%;content:"";position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);width:12px;height:12px;z-index:10}.b-clockpicker-body .b-clockpicker-face-outer-ring{user-select:none;height:calc(100% - 50px);width:calc(100% - 50px);position:relative;border-radius:50%}.b-clockpicker-body .b-clockpicker-face-number{align-items:center;border-radius:100%;cursor:default;display:flex;font-size:18px;text-align:center;justify-content:center;position:absolute;width:40px;height:40px;left:calc(50% - 40px*.5);top:calc(50% - 40px*.5);user-select:none}.b-clockpicker-body .b-clockpicker-face-number>span{z-index:1}.b-clockpicker-body .b-clockpicker-face-number:before,.b-clockpicker-body .b-clockpicker-face-number:after{content:"";height:40px;width:40px;border-radius:100%;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.b-clockpicker-body .b-clockpicker-face-number.active{cursor:default;z-index:2}.b-clockpicker-body .b-clockpicker-face-number.disabled{pointer-events:none;opacity:.25}.b-clockpicker-body .b-clockpicker-face-hand{height:calc(50% - 6px);width:2px;bottom:50%;left:calc(50% - 1px);transform-origin:center bottom;position:absolute;will-change:transform;z-index:1}.b-clockpicker-body .b-clockpicker-face-hand:before{background:rgba(0,0,0,0);border-width:2px;border-style:solid;border-color:inherit;border-radius:100%;width:12px;height:12px;content:"";position:absolute;top:-6px;left:50%;transform:translate(-50%, -50%)}.b-clockpicker-footer{display:block;padding:12px}.b-clockpicker.is-small{border-radius:2px;font-size:.75rem}.b-clockpicker.is-medium{font-size:1.25rem}.b-clockpicker.is-large{font-size:1.5rem}.collapse .collapse-trigger{display:inline;cursor:pointer}.collapse .collapse-content{display:inherit}.colorpicker .color-name{font-family:monospace;text-transform:uppercase}.colorpicker .colorpicker-footer{margin-top:.875rem;padding-top:.875rem;border:1px solid #dbdbdb;border-width:1px 0 0 0}.colorpicker .colorpicker-footer .colorpicker-fields{margin-bottom:.875rem}.colorpicker .colorpicker-footer .colorpicker-fields .field{flex-grow:1}.colorpicker .colorpicker-footer .colorpicker-fields .field .field-label{margin-right:.5rem}.colorpicker .colorpicker-footer .colorpicker-fields .field .control{display:flex}.colorpicker .colorpicker-footer .colorpicker-fields .field .control .input{-webkit-appearance:none;-moz-appearance:textfield;appearance:textfield;flex-grow:1;width:0;min-width:42px;text-align:right}.colorpicker .colorpicker-footer .colorpicker-fields .field .control .input::-webkit-outer-spin-button,.colorpicker .colorpicker-footer .colorpicker-fields .field .control .input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}.colorpicker .dropdown.is-expanded .dropdown-menu,.colorpicker .dropdown.is-expanded.is-mobile-modal .dropdown-menu{width:unset}.colorpicker .dropdown-horizontal-colorpicker{display:flex;flex-direction:row}.colorpicker .dropdown-horizontal-colorpicker .colorpicker-footer{border-width:0 0 0 1px;margin-top:0;padding-top:0;margin-left:.875rem;padding-left:.875rem}svg.b-colorpicker-triangle{width:200px;height:200px;user-select:none}@media(max-width: 769px){svg.b-colorpicker-triangle{width:100%;height:auto}}svg.b-colorpicker-triangle .colorpicker-triangle-hue{width:100%;height:100%;background:conic-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)}svg.b-colorpicker-triangle .colorpicker-triangle-slider-hue,svg.b-colorpicker-triangle .colorpicker-triangle-slider-hue foreignObject,svg.b-colorpicker-triangle .colorpicker-triangle-slider-hue g,svg.b-colorpicker-triangle .colorpicker-triangle-slider-sl,svg.b-colorpicker-triangle .colorpicker-triangle-slider-sl foreignObject,svg.b-colorpicker-triangle .colorpicker-triangle-slider-sl g{transform-origin:50% 50%}svg.b-colorpicker-triangle .hue-range-thumb,svg.b-colorpicker-triangle .sl-range-thumb{background:rgba(0,0,0,0);border-radius:4px;box-shadow:inset 0 0 0 1px #fff,0 0 0 1px #dbdbdb;cursor:grab;height:calc(100% - .25em);width:calc(100% - .25em);margin:.125em}svg.b-colorpicker-triangle .hue-range-thumb:hover,svg.b-colorpicker-triangle .sl-range-thumb:hover{box-shadow:inset 0 0 0 1px #fff,0 0 0 1px #b5b5b5}svg.b-colorpicker-triangle .hue-range-thumb:focus,svg.b-colorpicker-triangle .sl-range-thumb:focus{box-shadow:inset 0 0 0 1px #fff,0 0 0 1px #00875f,0 0 0 .125em rgba(0,135,95,.25)}svg.b-colorpicker-triangle .hue-range-thumb:active,svg.b-colorpicker-triangle .sl-range-thumb:active{cursor:grabbing}div.b-colorpicker-square{position:relative;aspect-ratio:1/1;user-select:none}div.b-colorpicker-square .colorpicker-square-slider-hue{background:conic-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);border-radius:4px;position:absolute;top:0;right:0;bottom:0;left:0}div.b-colorpicker-square .colorpicker-square-slider-hue .hue-range-thumb{border-radius:calc(4px/1.75);position:absolute;aspect-ratio:1/1;transform:translate(-50%, -50%);box-shadow:inset 0 0 0 1px #fff,0 0 0 1px #dbdbdb;cursor:grab}div.b-colorpicker-square .colorpicker-square-slider-hue .hue-range-thumb:hover{box-shadow:inset 0 0 0 1px #fff,inset 0 0 0 2px #b5b5b5,0 0 0 1px #b5b5b5}div.b-colorpicker-square .colorpicker-square-slider-hue .hue-range-thumb:focus{box-shadow:inset 0 0 0 1px #fff,0 0 0 1px #00875f,0 0 0 .125em rgba(0,135,95,.25)}div.b-colorpicker-square .colorpicker-square-slider-hue .hue-range-thumb:active{cursor:grabbing}div.b-colorpicker-square .colorpicker-square-slider-sl{background:#fff;border-radius:2px;position:absolute;top:0;right:0;bottom:0;left:0}div.b-colorpicker-square .colorpicker-square-slider-sl::before{content:"";background:linear-gradient(0deg, #000, #fff);position:absolute;top:0;right:0;bottom:0;left:0;mix-blend-mode:hard-light}div.b-colorpicker-square .colorpicker-square-slider-sl .sl-range-thumb{display:block;border-radius:4px;position:absolute;width:8px;height:8px;transform:translate(-50%, -50%);box-shadow:inset 0 0 0 1px #fff,0 0 0 1px #dbdbdb;cursor:grab}div.b-colorpicker-square .colorpicker-square-slider-sl .sl-range-thumb:hover{box-shadow:inset 0 0 0 1px #fff,inset 0 0 0 2px #b5b5b5,0 0 0 1px #b5b5b5}div.b-colorpicker-square .colorpicker-square-slider-sl .sl-range-thumb:focus{box-shadow:inset 0 0 0 1px #fff,0 0 0 1px #00875f,0 0 0 .125em rgba(0,135,95,.25)}div.b-colorpicker-square .colorpicker-square-slider-sl .sl-range-thumb:active{cursor:grabbing}.b-colorpicker-alpha-slider{height:1em;background-image:linear-gradient(45deg, #c7c7c7 25%, transparent 25%, transparent 75%, #c7c7c7 75%, #c7c7c7),linear-gradient(45deg, #c7c7c7 25%, transparent 25%, transparent 75%, #c7c7c7 75%, #c7c7c7);background-size:1em 1em;background-position:.5em .5em,0 0;border-radius:4px;margin-top:.125em;margin-bottom:.875rem;position:relative}.b-colorpicker-alpha-slider .alpha-range-thumb{width:.4em;height:1.25em;border-radius:4px;box-shadow:inset 0 0 0 1px #fff,inset 0 0 0 2px #dbdbdb,0 0 0 1px #dbdbdb;cursor:grab;position:absolute;top:50%;left:0;transform:translate(-50%, -50%)}.b-colorpicker-alpha-slider .alpha-range-thumb:hover{box-shadow:inset 0 0 0 1px #fff,inset 0 0 0 2px #b5b5b5,0 0 0 1px #b5b5b5}.b-colorpicker-alpha-slider .alpha-range-thumb:focus{box-shadow:inset 0 0 0 1px #fff,0 0 0 1px #00875f,0 0 0 .125em rgba(0,135,95,.25)}.b-colorpicker-alpha-slider .alpha-range-thumb:active{cursor:grabbing}.b-colorpicker-alpha-slider .alpha-range-thumb>.b-tooltip,.b-colorpicker-alpha-slider .alpha-range-thumb>.b-tooltip>.tooltip-trigger{position:absolute;display:block;top:0;right:0;bottom:0;left:0}.datepicker{font-size:.875rem}.datepicker .dropdown,.datepicker .dropdown-trigger{width:100%}.datepicker .dropdown .input[readonly],.datepicker .dropdown-trigger .input[readonly]{cursor:pointer;box-shadow:inset 0 .0625em .125em hsla(0,0%,4%,.05)}.datepicker .dropdown .input[readonly]:focus,.datepicker .dropdown .input[readonly].is-focused,.datepicker .dropdown .input[readonly]:active,.datepicker .dropdown .input[readonly].is-active,.datepicker .dropdown-trigger .input[readonly]:focus,.datepicker .dropdown-trigger .input[readonly].is-focused,.datepicker .dropdown-trigger .input[readonly]:active,.datepicker .dropdown-trigger .input[readonly].is-active{box-shadow:0 0 0 .125em rgba(0,135,95,.25)}.datepicker .dropdown.is-disabled{opacity:1}.datepicker .dropdown-content{background-color:#fff;border-radius:4px;box-shadow:0 .5em 1em -0.125em hsla(0,0%,4%,.1),0 0px 0 1px hsla(0,0%,4%,.02)}.datepicker .dropdown-item,.datepicker .dropdown .dropdown-menu .has-link a,.dropdown .dropdown-menu .has-link .datepicker a{font-size:inherit}.datepicker .datepicker-header{padding-bottom:.875rem;margin-bottom:.875rem;border-bottom:1px solid hsl(0,0%,86%)}.datepicker .datepicker-footer{margin-top:.875rem;padding-top:.875rem;border-top:1px solid hsl(0,0%,86%)}.datepicker .datepicker-table{display:table;margin:0 auto 0 auto}.datepicker .datepicker-table .datepicker-cell{text-align:center;vertical-align:middle;display:table-cell;border-radius:4px;padding:.5rem .75rem}.datepicker .datepicker-table .datepicker-header{display:table-header-group}.datepicker .datepicker-table .datepicker-header .datepicker-cell{color:hsl(0,0%,48%);font-weight:600}.datepicker .datepicker-table .datepicker-body{display:table-row-group}.datepicker .datepicker-table .datepicker-body .datepicker-row{display:table-row}.datepicker .datepicker-table .datepicker-body .datepicker-months{display:inline-flex;flex-wrap:wrap;flex-direction:row;width:17rem}.datepicker .datepicker-table .datepicker-body .datepicker-months .datepicker-cell{display:flex;align-items:center;justify-content:center;width:33.33%;height:2.5rem}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-unselectable{color:hsl(0,0%,71%)}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-today{border:solid 1px rgba(0,135,95,.5)}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selectable{color:hsl(0,0%,29%)}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selectable:hover:not(.is-selected),.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selectable:focus:not(.is-selected){background-color:hsl(0,0%,96%);color:hsl(0,0%,4%);cursor:pointer}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selectable.is-within-hovered-range.is-first-hovered{background-color:hsl(0,0%,48%);color:hsl(0,0%,86%);border-bottom-right-radius:0;border-top-right-radius:0}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selectable.is-within-hovered-range.is-within-hovered{background-color:hsl(0,0%,96%);color:hsl(0,0%,4%);border-radius:0}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selectable.is-within-hovered-range.is-last-hovered{background-color:hsl(0,0%,48%);color:hsl(0,0%,86%);border-bottom-left-radius:0;border-top-left-radius:0}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selected{background-color:#00875f;color:#fff}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selected.is-first-selected{background-color:#00875f;color:#fff;border-bottom-right-radius:0;border-top-right-radius:0}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selected.is-within-selected{background-color:rgba(0,135,95,.5);border-radius:0}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-selected.is-last-selected{background-color:#00875f;color:#fff;border-bottom-left-radius:0;border-top-left-radius:0}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-nearby:not(.is-selected){color:hsl(0,0%,71%)}.datepicker .datepicker-table .datepicker-body .datepicker-cell.is-week-number{cursor:default}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell{padding:.3rem .75rem .75rem}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event{position:relative}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events{bottom:.425rem;display:flex;justify-content:center;left:0;padding:0 .35rem;position:absolute;width:100%}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-white{background-color:#fff}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-black{background-color:hsl(0,0%,4%)}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-light{background-color:hsl(0,0%,96%)}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-dark{background-color:hsl(0,0%,21%)}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-primary{background-color:#00875f}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-link{background-color:hsl(229,53%,53%)}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-info{background-color:hsl(207,61%,53%)}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-success{background-color:hsl(153,53%,53%)}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-warning{background-color:hsl(44,100%,77%)}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event .events .event.is-danger{background-color:hsl(348,86%,61%)}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event.dots .event{border-radius:50%;height:.35em;margin:0 .1em;width:.35em}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.has-event.bars .event{height:.25em;width:100%}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.is-selected{overflow:hidden}.datepicker .datepicker-table .datepicker-body.has-events .datepicker-cell.is-selected .events .event.is-primary{background-color:rgb(0,211.5,148.8333333333)}.datepicker.is-small{border-radius:2px;font-size:.75rem}.datepicker.is-medium{font-size:1.25rem}.datepicker.is-large{font-size:1.5rem}@media screen and (min-width: 1024px){.datepicker .footer-horizontal-timepicker{border:none;padding-left:10px;margin-left:5px;display:flex}.datepicker .dropdown-horizontal-timepicker{display:flex}.datepicker .content-horizontal-timepicker{border-right:1px solid #dbdbdb}}.dialog .modal-card{max-width:460px;width:auto}.dialog .modal-card .modal-card-head{font-size:1.25rem;font-weight:600}.dialog .modal-card .modal-card-body .field{margin-top:16px}.dialog .modal-card .modal-card-body.is-titleless{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.dialog .modal-card .modal-card-foot{justify-content:flex-end}.dialog .modal-card .modal-card-foot .button{display:inline;min-width:5em;font-weight:600}@media screen and (min-width: 769px),print{.dialog .modal-card{min-width:320px}}.dialog.is-small .modal-card,.dialog.is-small .input,.dialog.is-small .button{border-radius:2px;font-size:.75rem}.dialog.is-medium .modal-card,.dialog.is-medium .input,.dialog.is-medium .button{font-size:1.25rem}.dialog.is-large .modal-card,.dialog.is-large .input,.dialog.is-large .button{font-size:1.5rem}.dialog.has-custom-container{position:absolute}.dropdown+.dropdown{margin-left:.5em}.dropdown .background{bottom:0;left:0;position:absolute;right:0;top:0;position:fixed;background-color:hsla(0,0%,4%,.86);z-index:40;cursor:pointer}@media screen and (min-width: 1024px){.dropdown .background{display:none}}.dropdown.dropdown-menu-animation .dropdown-menu{display:block}.dropdown .dropdown-menu .dropdown-item.is-disabled,.dropdown .dropdown-menu .has-link a.is-disabled{cursor:not-allowed}.dropdown .dropdown-menu .dropdown-item.is-disabled:hover,.dropdown .dropdown-menu .has-link a.is-disabled:hover{background:inherit;color:inherit}.dropdown .dropdown-menu .has-link a{padding-right:3rem;white-space:nowrap}.dropdown.is-hoverable:not(.is-active) .dropdown-menu{display:none}.dropdown.is-hoverable:not(.is-touch-enabled):hover .dropdown-menu{display:inherit}.dropdown.is-expanded{width:100%}.dropdown.is-expanded .dropdown-trigger{width:100%}.dropdown.is-expanded .dropdown-menu{width:100%}.dropdown.is-expanded.is-mobile-modal .dropdown-menu{max-width:100%}.dropdown:not(.is-disabled) .dropdown-menu .dropdown-item.is-disabled,.dropdown:not(.is-disabled) .dropdown-menu .has-link a.is-disabled{opacity:.5}.dropdown .navbar-item{height:100%}.dropdown.is-disabled{opacity:.5;cursor:not-allowed}.dropdown.is-disabled .dropdown-trigger{pointer-events:none}.dropdown.is-inline .dropdown-menu{position:static;display:inline-block;padding:0}.dropdown.is-top-right .dropdown-menu{top:auto;bottom:100%}.dropdown.is-top-left .dropdown-menu{top:auto;bottom:100%;right:0;left:auto}.dropdown.is-bottom-left .dropdown-menu{right:0;left:auto}@media screen and (max-width: 1023px){.dropdown.is-mobile-modal:not(.is-hoverable)>.dropdown-menu,.dropdown.is-mobile-modal.is-touch-enabled>.dropdown-menu{position:fixed !important;width:calc(100vw - 40px);max-width:460px;max-height:calc(100vh - 120px);top:25% !important;left:50% !important;bottom:auto !important;right:auto !important;transform:translate3d(-50%, -25%, 0);white-space:normal;overflow-y:auto;z-index:50 !important}.dropdown.is-mobile-modal:not(.is-hoverable)>.dropdown-menu>.dropdown-content>.dropdown-item,.dropdown .dropdown-menu .has-link .dropdown.is-mobile-modal:not(.is-hoverable)>.dropdown-menu>.dropdown-content>a,.dropdown.is-mobile-modal:not(.is-hoverable)>.dropdown-menu>.dropdown-content>.has-link a,.dropdown.is-mobile-modal.is-touch-enabled>.dropdown-menu>.dropdown-content>.dropdown-item,.dropdown .dropdown-menu .has-link .dropdown.is-mobile-modal.is-touch-enabled>.dropdown-menu>.dropdown-content>a,.dropdown.is-mobile-modal.is-touch-enabled>.dropdown-menu>.dropdown-content>.has-link a{padding:1rem 1.5rem}}@media screen and (max-width: 1023px){html.is-clipped-touch{overflow:hidden !important}}.field.is-grouped .field{flex-shrink:0}.field.is-grouped .field:not(:last-child){margin-right:.75rem}.field.is-grouped .field.is-expanded{flex-grow:1;flex-shrink:1}.field.has-addons .control:first-child .control .button,.field.has-addons .control:first-child .control .input,.field.has-addons .control:first-child .control .select select{border-bottom-left-radius:4px;border-top-left-radius:4px}.field.has-addons .control:last-child .control .button,.field.has-addons .control:last-child .control .input,.field.has-addons .control:last-child .control .select select{border-bottom-right-radius:4px;border-top-right-radius:4px}.field.has-addons .control .control .button,.field.has-addons .control .control .input,.field.has-addons .control .control .select select{border-radius:0}.field.has-addons .b-numberinput:not(:first-child) .control:first-child .button,.field.has-addons .b-numberinput:not(:first-child) .control:first-child .input,.field.has-addons .b-numberinput:not(:first-child) .control:first-child .select select{border-bottom-left-radius:0;border-top-left-radius:0}.field.has-addons .b-numberinput:not(:last-child) .control:last-child .button,.field.has-addons .b-numberinput:not(:last-child) .control:last-child .input,.field.has-addons .b-numberinput:not(:last-child) .control:last-child .select select{border-bottom-right-radius:0;border-top-right-radius:0}.field.has-addons.b-numberinput .control{margin-right:unset}.field.is-floating-label,.field.is-floating-in-label{position:relative}.field.is-floating-label .label,.field.is-floating-in-label .label{position:absolute;left:1em;font-size:calc(1rem*.75);background-color:rgba(0,0,0,0);z-index:5;white-space:nowrap;text-overflow:ellipsis;max-width:calc(100% - 2em);overflow:hidden}.field.is-floating-label .label.is-small,.field.is-floating-in-label .label.is-small{font-size:calc(0.75rem*.75)}.field.is-floating-label .label.is-medium,.field.is-floating-in-label .label.is-medium{font-size:calc(1.25rem*.75)}.field.is-floating-label .label.is-large,.field.is-floating-in-label .label.is-large{font-size:calc(1.5rem*.75)}.field.is-floating-label .taginput .counter,.field.is-floating-in-label .taginput .counter{float:none;text-align:right}.field.is-floating-label.has-addons>.label+.control .button,.field.is-floating-label.has-addons>.label+.control .input,.field.is-floating-label.has-addons>.label+.control .select select,.field.is-floating-in-label.has-addons>.label+.control .button,.field.is-floating-in-label.has-addons>.label+.control .input,.field.is-floating-in-label.has-addons>.label+.control .select select{border-bottom-left-radius:4px;border-top-left-radius:4px}.field.is-floating-label .label{top:-0.775em;padding-left:.125em;padding-right:.125em}.field.is-floating-label .label:before{content:"";display:block;position:absolute;top:.775em;left:0;right:0;height:.375em;background-color:#fff;z-index:-1}.field.is-floating-label .input:focus,.field.is-floating-label .textarea:focus,.field.is-floating-label .select select:focus{box-shadow:none}.field.is-floating-label .taginput .taginput-container{padding-top:.475em}.field.is-floating-label .taginput .taginput-container.is-focused{box-shadow:none}.field.is-floating-in-label>.label{top:.25em}.field.is-floating-in-label>.label+.control.datepicker .input,.field.is-floating-in-label>.label+.control.timepicker .input{padding-top:calc(3.25em*.5 - 1.5rem*.75*.5);padding-bottom:1px;height:3.25em}.field.is-floating-in-label>.label+.control:not(.datepicker):not(.timepicker):not(.taginput) .input,.field.is-floating-in-label>.label+.control:not(.datepicker):not(.timepicker):not(.taginput) .textarea,.field.is-floating-in-label>.label+.control:not(.datepicker):not(.timepicker):not(.taginput) select{padding-top:calc(3.25em*.5 - 1.5rem*.75*.5);padding-bottom:1px;height:3.25em}.field.is-floating-in-label>.label+.control:not(.datepicker):not(.timepicker):not(.taginput) .select:not(multiple){height:3.25em}.field.is-floating-in-label>.label+.control:not(.datepicker):not(.timepicker):not(.taginput) .select:not(multiple).is-loading::after{margin-top:calc(3.25em*.5 - 1.5rem*.75*.5)}.field.is-floating-in-label>.label+.control:not(.datepicker):not(.timepicker):not(.taginput) .select:not(multiple)::after{margin-top:1px}.field.is-floating-in-label>.label+.control.taginput .taginput-container{padding-top:calc(3.25em*.5 - 1.5rem*.75*.5 + .275em - 1px)}.field.is-floating-in-label>.label+.control:not(.taginput) .is-left.icon,.field.is-floating-in-label>.label+.control:not(.taginput) .is-right.icon{height:3.25em}.field.is-floating-in-label>.label+.control:not(.taginput) .is-left.icon{padding-top:calc(3.25em*.5 - 1.5rem*.75*.5)}.field.is-floating-in-label>.label+.control.is-loading::after{margin-top:calc(3.25em*.5 - 1.5rem*.75*.5)}.field.is-floating-in-label>.label+.field-body>.is-grouped .control .input,.field.is-floating-in-label>.label+.field-body>.is-grouped .control .textarea,.field.is-floating-in-label>.label+.field-body>.is-grouped .control select,.field.is-floating-in-label>.label+.field-body>.has-addons .control .input,.field.is-floating-in-label>.label+.field-body>.has-addons .control .textarea,.field.is-floating-in-label>.label+.field-body>.has-addons .control select{padding-top:calc(3.25em*.5 - 1.5rem*.75*.5);padding-bottom:1px}.field.is-floating-in-label>.label+.field-body>.is-grouped .control .input,.field.is-floating-in-label>.label+.field-body>.is-grouped .control .textarea,.field.is-floating-in-label>.label+.field-body>.is-grouped .control select,.field.is-floating-in-label>.label+.field-body>.is-grouped .control .button,.field.is-floating-in-label>.label+.field-body>.has-addons .control .input,.field.is-floating-in-label>.label+.field-body>.has-addons .control .textarea,.field.is-floating-in-label>.label+.field-body>.has-addons .control select,.field.is-floating-in-label>.label+.field-body>.has-addons .control .button{height:3.25em}.field.is-floating-in-label.has-numberinput .b-numberinput .control .input,.field.is-floating-in-label.has-numberinput .b-numberinput .control .button{height:3.25em}.field.is-floating-label.has-numberinput .label,.field.is-floating-in-label.has-numberinput .label{margin-left:calc(1rem*3)}.field.is-floating-label.has-numberinput.has-numberinput-is-small .label,.field.is-floating-in-label.has-numberinput.has-numberinput-is-small .label{margin-left:calc(0.75rem*3)}.field.is-floating-label.has-numberinput.has-numberinput-is-medium .label,.field.is-floating-in-label.has-numberinput.has-numberinput-is-medium .label{margin-left:calc(1.25rem*3)}.field.is-floating-label.has-numberinput.has-numberinput-is-large .label,.field.is-floating-in-label.has-numberinput.has-numberinput-is-large .label{margin-left:calc(1.5rem*3)}.field.is-floating-label.has-numberinput-compact .label,.field.is-floating-in-label.has-numberinput-compact .label{margin-left:calc(1rem*2.25)}.field.is-floating-label.has-numberinput-compact.has-numberinput-is-small .label,.field.is-floating-in-label.has-numberinput-compact.has-numberinput-is-small .label{margin-left:calc(0.75rem*2.25)}.field.is-floating-label.has-numberinput-compact.has-numberinput-is-medium .label,.field.is-floating-in-label.has-numberinput-compact.has-numberinput-is-medium .label{margin-left:calc(1.25rem*2.25)}.field.is-floating-label.has-numberinput-compact.has-numberinput-is-large .label,.field.is-floating-in-label.has-numberinput-compact.has-numberinput-is-large .label{margin-left:calc(1.5rem*2.25)}.field.is-grouped-right.is-floating-in-label .label,.field.has-addons-right.is-floating-in-label .label{position:relative;left:5.25em}.field.is-grouped-right.is-floating-label .label,.field.has-addons-right.is-floating-label .label{position:relative;left:5.25em}.control .help.counter{float:right;margin-left:.5em}.control .icon.is-clickable{pointer-events:auto;cursor:pointer}.control.is-loading::after{top:calc(50% - .5em);right:.75em}.icon{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;cursor:inherit}.icon svg{background-color:rgba(0,0,0,0);fill:currentColor;stroke-width:0;stroke:currentColor;pointer-events:none;width:100%;height:100%}.b-image-wrapper>img{object-fit:cover}.b-image-wrapper>img.has-ratio,.b-image-wrapper>img.placeholder{height:100%;width:100%}.b-image-wrapper>img.placeholder{filter:blur(10px)}.loading-overlay{bottom:0;left:0;position:absolute;right:0;top:0;align-items:center;display:none;justify-content:center;overflow:hidden;z-index:29}.loading-overlay.is-active{display:flex}.loading-overlay.is-full-page{position:fixed;z-index:999}.loading-overlay.is-full-page .loading-icon:after{top:calc(50% - 2.5em);left:calc(50% - 2.5em);width:5em;height:5em}.loading-overlay .loading-background{bottom:0;left:0;position:absolute;right:0;top:0;background:#7f7f7f;background:hsla(0,0%,100%,.5)}.loading-overlay .loading-icon{position:relative}.loading-overlay .loading-icon:after{animation:spinAround 500ms infinite linear;border:2px solid hsl(0,0%,86%);border-radius:9999px;border-right-color:rgba(0,0,0,0);border-top-color:rgba(0,0,0,0);content:"";display:block;height:1em;position:relative;width:1em;position:absolute;top:calc(50% - 1.5em);left:calc(50% - 1.5em);width:3em;height:3em;border-width:.25em}.menu .menu-list li>a.is-disabled{pointer-events:none;cursor:not-allowed;opacity:.5}.message .media,.notification .media{padding-top:0;border:0}.message .auto-close-progress progress,.notification .auto-close-progress progress{border-top-left-radius:0 !important;border-top-right-radius:0 !important;border-bottom-left-radius:0 !important;border-bottom-right-radius:0 !important;height:10px !important}.modal.is-full-screen>.animation-content,.modal.is-full-screen>.animation-content>.modal-card{width:100%;height:100%;max-height:100vh;margin:0;background-color:hsl(0,0%,96%)}.modal .animation-content{margin:0 20px}.modal .animation-content .modal-card{margin:0}@media screen and (max-width: 768px){.modal .animation-content{width:100%}}.modal .modal-content{width:100%}@media screen and (max-width: 768px){.modal .modal-close{background-color:color-mix(in hsl, rgba(10.2, 10.2, 10.2, 0.86) 10%, transparent)}.modal .modal-close:hover,.modal .modal-close :focus{background-color:color-mix(in hsl, rgba(10.2, 10.2, 10.2, 0.86) 20%, transparent)}.modal .modal-close:active{background-color:color-mix(in hsl, rgba(10.2, 10.2, 10.2, 0.86) 30%, transparent)}}.navbar.has-navbar-centered .navbar-start{justify-content:center;margin-left:auto}.navbar.has-navbar-centered .navbar-end{margin-left:0}.navbar .navbar-dropdown.is-boxed{visibility:hidden;transition-property:opacity,visibility,transform}.navbar .navbar-item.has-dropdown.is-active .is-boxed,.navbar .navbar-item.has-dropdown.is-hoverable:hover .is-boxed{visibility:visible}.notices{position:fixed;display:flex;top:0;bottom:0;left:0;right:0;padding:2em;overflow:hidden;z-index:1000;pointer-events:none}.notices .toast{display:inline-flex;animation-duration:150ms;margin:.5em 0;text-align:center;box-shadow:0 1px 4px rgba(0,0,0,.12),0 0 6px rgba(0,0,0,.04);border-radius:2em;padding:.75em 1.5em;pointer-events:auto;opacity:.92}.notices .toast.is-white{color:hsl(0,0%,4%);background:#fff}.notices .toast.is-black{color:#fff;background:hsl(0,0%,4%)}.notices .toast.is-light{color:rgba(0,0,0,.7);background:hsl(0,0%,96%)}.notices .toast.is-dark{color:#fff;background:hsl(0,0%,21%)}.notices .toast.is-primary{color:#fff;background:#00875f}.notices .toast.is-link{color:#fff;background:hsl(229,53%,53%)}.notices .toast.is-info{color:#fff;background:hsl(207,61%,53%)}.notices .toast.is-success{color:#fff;background:hsl(153,53%,53%)}.notices .toast.is-warning{color:rgba(0,0,0,.7);background:hsl(44,100%,77%)}.notices .toast.is-danger{color:#fff;background:hsl(348,86%,61%)}.notices .snackbar{display:inline-flex;align-items:center;justify-content:space-around;animation-duration:150ms;margin:.5em 0;box-shadow:0 1px 4px rgba(0,0,0,.12),0 0 6px rgba(0,0,0,.04);border-radius:4px;pointer-events:auto;background:hsl(0,0%,21%);color:#fff;min-height:3em}.notices .snackbar .text{padding:.5em 1em}.notices .snackbar .action{margin-left:auto;padding:.5em;padding-left:0}.notices .snackbar .action .button{font-weight:600;text-transform:uppercase;background:hsl(0,0%,21%);border:rgba(0,0,0,0)}.notices .snackbar .action .button:hover{background:hsl(0,0%,16%)}.notices .snackbar .action .button:active{background:hsl(0,0%,16%)}.notices .snackbar .action.is-white .button{color:#fff}.notices .snackbar .action.is-black .button{color:hsl(0,0%,4%)}.notices .snackbar .action.is-light .button{color:hsl(0,0%,96%)}.notices .snackbar .action.is-dark .button{color:hsl(0,0%,21%)}.notices .snackbar .action.is-primary .button{color:#00875f}.notices .snackbar .action.is-link .button{color:hsl(229,53%,53%)}.notices .snackbar .action.is-info .button{color:hsl(207,61%,53%)}.notices .snackbar .action.is-success .button{color:hsl(153,53%,53%)}.notices .snackbar .action.is-warning .button{color:hsl(44,100%,77%)}.notices .snackbar .action.is-danger .button{color:hsl(348,86%,61%)}.notices .snackbar .action.is-cancel{padding-right:0}@media screen and (max-width: 768px){.notices .snackbar{width:100%;margin:0;border-radius:0}}@media screen and (min-width: 769px),print{.notices .snackbar{min-width:350px;max-width:600px;overflow:hidden}}.notices .notification{pointer-events:auto;max-width:600px}.notices .toast.is-top,.notices .toast.is-bottom,.notices .snackbar.is-top,.notices .snackbar.is-bottom,.notices .notification.is-top,.notices .notification.is-bottom{align-self:center}.notices .toast.is-top-right,.notices .toast.is-bottom-right,.notices .snackbar.is-top-right,.notices .snackbar.is-bottom-right,.notices .notification.is-top-right,.notices .notification.is-bottom-right{align-self:flex-end}.notices .toast.is-top-left,.notices .toast.is-bottom-left,.notices .snackbar.is-top-left,.notices .snackbar.is-bottom-left,.notices .notification.is-top-left,.notices .notification.is-bottom-left{align-self:flex-start}.notices .toast.is-toast,.notices .snackbar.is-toast,.notices .notification.is-toast{opacity:.92}.notices.is-top{flex-direction:column}.notices.is-bottom{flex-direction:column-reverse}.notices.is-bottom .notification{margin-bottom:0}.notices.is-bottom .notification:not(:first-child){margin-bottom:1.5rem}.notices.has-custom-container{position:absolute}@media screen and (max-width: 768px){.notices{padding:0;position:fixed !important}}.notification .auto-close-progress.progress-wrapper{position:absolute;bottom:0;left:0;width:100%}.b-numberinput.field{margin-bottom:0}.b-numberinput.field.is-grouped div.control{flex-grow:1;flex-shrink:1}.b-numberinput.field.has-addons.is-expanded{flex-grow:1;flex-shrink:1}.b-numberinput input[type=number]::-webkit-inner-spin-button,.b-numberinput input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none}.b-numberinput input[type=number]{-moz-appearance:textfield}.b-numberinput input[type=number]{text-align:center}.b-numberinput .button.is-rounded{padding-left:1em;padding-right:1em}.pagination .pagination-next,.pagination .pagination-previous{padding-left:.75em;padding-right:.75em}.pagination .pagination-next.is-disabled,.pagination .pagination-previous.is-disabled{pointer-events:none;cursor:not-allowed;opacity:.5}.pagination.is-simple{justify-content:normal}.pagination.is-simple.is-centered{justify-content:center}.pagination.is-simple.is-right{justify-content:flex-end}.pagination .is-current{pointer-events:none;cursor:not-allowed}@media screen and (min-width: 769px),print{.pagination.has-input .pagination-list{order:1}.pagination.has-input .pagination-input{order:2}.pagination.has-input .pagination-previous{order:3}.pagination.has-input .pagination-next{order:4}.pagination.has-input.is-centered .pagination-previous{order:1}.pagination.has-input.is-centered .pagination-list{order:2}.pagination.has-input.is-centered .pagination-input{order:3}.pagination.has-input.is-centered .pagination-next{order:4}.pagination.has-input.is-centered.is-input-right .pagination-previous{order:1}.pagination.has-input.is-centered.is-input-right .pagination-list{order:2}.pagination.has-input.is-centered.is-input-right .pagination-next{order:3}.pagination.has-input.is-centered.is-input-right .pagination-input{order:4}.pagination.has-input.is-centered.is-input-left .pagination-input{order:1}.pagination.has-input.is-centered.is-input-left .pagination-previous{order:2}.pagination.has-input.is-centered.is-input-left .pagination-list{order:3}.pagination.has-input.is-centered.is-input-left .pagination-next{order:4}.pagination.has-input.is-right .pagination-previous{order:1}.pagination.has-input.is-right .pagination-next{order:2}.pagination.has-input.is-right .pagination-input{order:3}.pagination.has-input.is-right .pagination-list{order:4}.pagination.has-input.is-right.is-input-right .pagination-previous{order:1}.pagination.has-input.is-right.is-input-right .pagination-next{order:2}.pagination.has-input.is-right.is-input-right .pagination-list{order:3}.pagination.has-input.is-right.is-input-right .pagination-input{order:4}.pagination.has-input.is-right.is-input-left .pagination-input{order:1}.pagination.has-input.is-right.is-input-left .pagination-previous{order:2}.pagination.has-input.is-right.is-input-left .pagination-next{order:3}.pagination.has-input.is-right.is-input-left .pagination-list{order:4}.pagination.has-input.is-input-right .pagination-list{order:1}.pagination.has-input.is-input-right .pagination-previous{order:2}.pagination.has-input.is-input-right .pagination-next{order:3}.pagination.has-input.is-input-right .pagination-input{order:4}.pagination.has-input.is-input-left .pagination-input{order:1}.pagination.has-input.is-input-left .pagination-list{order:2}.pagination.has-input.is-input-left .pagination-previous{order:3}.pagination.has-input.is-input-left .pagination-next{order:4}}.progress-wrapper{position:relative;overflow:hidden}.progress-wrapper:not(:last-child){margin-bottom:1.5rem}.progress-wrapper .progress-value{position:absolute;top:0;left:50%;transform:translateX(-50%);font-size:calc(1rem/1.5);line-height:1rem;font-weight:700;color:rgba(0,0,0,.7);white-space:nowrap}.progress-wrapper .progress,.progress-wrapper .progress-wrapper.is-not-native,.progress-wrapper .progress-bar{margin-bottom:0}.progress-wrapper .progress.is-small+.progress-value,.progress-wrapper .is-small.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress.is-small .progress-value,.progress-wrapper .is-small.progress-wrapper.is-not-native .progress-value,.progress-wrapper .progress-bar.is-small+.progress-value,.progress-wrapper .progress-bar.is-small .progress-value{font-size:calc(0.75rem/1.5);line-height:.75rem}.progress-wrapper .progress.is-medium+.progress-value,.progress-wrapper .is-medium.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress.is-medium .progress-value,.progress-wrapper .is-medium.progress-wrapper.is-not-native .progress-value,.progress-wrapper .progress-bar.is-medium+.progress-value,.progress-wrapper .progress-bar.is-medium .progress-value{font-size:calc(1.25rem/1.5);line-height:1.25rem}.progress-wrapper .progress.is-large+.progress-value,.progress-wrapper .is-large.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress.is-large .progress-value,.progress-wrapper .is-large.progress-wrapper.is-not-native .progress-value,.progress-wrapper .progress-bar.is-large+.progress-value,.progress-wrapper .progress-bar.is-large .progress-value{font-size:calc(1.5rem/1.5);line-height:1.5rem}.progress-wrapper .progress::-webkit-progress-value,.progress-wrapper .progress-wrapper.is-not-native::-webkit-progress-value,.progress-wrapper .progress-bar::-webkit-progress-value{transition:width .5s ease}.progress-wrapper .progress.is-more-than-half+.progress-value,.progress-wrapper .is-more-than-half.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half+.progress-value{color:#fff}.progress-wrapper .progress.is-more-than-half.is-white+.progress-value,.progress-wrapper .is-more-than-half.is-white.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-white+.progress-value{color:hsl(0,0%,4%)}.progress-wrapper .progress.is-more-than-half.is-black+.progress-value,.progress-wrapper .is-more-than-half.is-black.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-black+.progress-value{color:#fff}.progress-wrapper .progress.is-more-than-half.is-light+.progress-value,.progress-wrapper .is-more-than-half.is-light.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-light+.progress-value{color:rgba(0,0,0,.7)}.progress-wrapper .progress.is-more-than-half.is-dark+.progress-value,.progress-wrapper .is-more-than-half.is-dark.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-dark+.progress-value{color:#fff}.progress-wrapper .progress.is-more-than-half.is-primary+.progress-value,.progress-wrapper .is-more-than-half.is-primary.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-primary+.progress-value{color:#fff}.progress-wrapper .progress.is-more-than-half.is-link+.progress-value,.progress-wrapper .is-more-than-half.is-link.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-link+.progress-value{color:#fff}.progress-wrapper .progress.is-more-than-half.is-info+.progress-value,.progress-wrapper .is-more-than-half.is-info.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-info+.progress-value{color:#fff}.progress-wrapper .progress.is-more-than-half.is-success+.progress-value,.progress-wrapper .is-more-than-half.is-success.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-success+.progress-value{color:#fff}.progress-wrapper .progress.is-more-than-half.is-warning+.progress-value,.progress-wrapper .is-more-than-half.is-warning.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-warning+.progress-value{color:rgba(0,0,0,.7)}.progress-wrapper .progress.is-more-than-half.is-danger+.progress-value,.progress-wrapper .is-more-than-half.is-danger.progress-wrapper.is-not-native+.progress-value,.progress-wrapper .progress-bar.is-more-than-half.is-danger+.progress-value{color:#fff}.progress-wrapper .progress.is-squared,.progress-wrapper .is-squared.progress-wrapper.is-not-native,.progress-wrapper .progress-bar.is-squared{border-radius:0}.progress-wrapper.is-not-native{white-space:nowrap;background-color:hsl(0,0%,93%);border-radius:9999px}.progress-wrapper.is-not-native .progress-bar{position:relative;display:inline-block;vertical-align:top;height:100%;background-color:hsl(0,0%,29%)}.progress-wrapper.is-not-native .progress-bar .progress-value{color:#fff}.progress-wrapper.is-not-native .progress-bar.is-white{background-color:#fff}.progress-wrapper.is-not-native .progress-bar.is-white .progress-value{color:hsl(0,0%,4%)}.progress-wrapper.is-not-native .progress-bar.is-black{background-color:hsl(0,0%,4%)}.progress-wrapper.is-not-native .progress-bar.is-black .progress-value{color:#fff}.progress-wrapper.is-not-native .progress-bar.is-light{background-color:hsl(0,0%,96%)}.progress-wrapper.is-not-native .progress-bar.is-light .progress-value{color:rgba(0,0,0,.7)}.progress-wrapper.is-not-native .progress-bar.is-dark{background-color:hsl(0,0%,21%)}.progress-wrapper.is-not-native .progress-bar.is-dark .progress-value{color:#fff}.progress-wrapper.is-not-native .progress-bar.is-primary{background-color:#00875f}.progress-wrapper.is-not-native .progress-bar.is-primary .progress-value{color:#fff}.progress-wrapper.is-not-native .progress-bar.is-link{background-color:hsl(229,53%,53%)}.progress-wrapper.is-not-native .progress-bar.is-link .progress-value{color:#fff}.progress-wrapper.is-not-native .progress-bar.is-info{background-color:hsl(207,61%,53%)}.progress-wrapper.is-not-native .progress-bar.is-info .progress-value{color:#fff}.progress-wrapper.is-not-native .progress-bar.is-success{background-color:hsl(153,53%,53%)}.progress-wrapper.is-not-native .progress-bar.is-success .progress-value{color:#fff}.progress-wrapper.is-not-native .progress-bar.is-warning{background-color:hsl(44,100%,77%)}.progress-wrapper.is-not-native .progress-bar.is-warning .progress-value{color:rgba(0,0,0,.7)}.progress-wrapper.is-not-native .progress-bar.is-danger{background-color:hsl(348,86%,61%)}.progress-wrapper.is-not-native .progress-bar.is-danger .progress-value{color:#fff}.progress-wrapper.is-squared{border-radius:0}.b-radio.radio{outline:none;display:inline-flex;align-items:center}.b-radio.radio:not(.button){margin-right:.5em}.b-radio.radio:not(.button)+.radio:last-child{margin-right:0}.b-radio.radio+.radio{margin-left:0}.b-radio.radio input[type=radio]{position:absolute;left:0;opacity:0;outline:none;z-index:-1}.b-radio.radio input[type=radio]+.check{display:flex;flex-shrink:0;position:relative;cursor:pointer;width:1.25em;height:1.25em;transition:background 150ms ease-out;border-radius:50%;border:2px solid hsl(0,0%,48%)}.b-radio.radio input[type=radio]+.check:before{content:"";display:flex;position:absolute;left:50%;margin-left:calc(-1.25em*.5);bottom:50%;margin-bottom:calc(-1.25em*.5);width:1.25em;height:1.25em;transition:transform 150ms ease-out;border-radius:50%;transform:scale(0);background-color:#00875f}.b-radio.radio input[type=radio]+.check.is-white:before{background:#fff}.b-radio.radio input[type=radio]+.check.is-black:before{background:hsl(0,0%,4%)}.b-radio.radio input[type=radio]+.check.is-light:before{background:hsl(0,0%,96%)}.b-radio.radio input[type=radio]+.check.is-dark:before{background:hsl(0,0%,21%)}.b-radio.radio input[type=radio]+.check.is-primary:before{background:#00875f}.b-radio.radio input[type=radio]+.check.is-link:before{background:hsl(229,53%,53%)}.b-radio.radio input[type=radio]+.check.is-info:before{background:hsl(207,61%,53%)}.b-radio.radio input[type=radio]+.check.is-success:before{background:hsl(153,53%,53%)}.b-radio.radio input[type=radio]+.check.is-warning:before{background:hsl(44,100%,77%)}.b-radio.radio input[type=radio]+.check.is-danger:before{background:hsl(348,86%,61%)}.b-radio.radio input[type=radio]:checked+.check{border-color:#00875f}.b-radio.radio input[type=radio]:checked+.check.is-white{border-color:#fff}.b-radio.radio input[type=radio]:checked+.check.is-black{border-color:hsl(0,0%,4%)}.b-radio.radio input[type=radio]:checked+.check.is-light{border-color:hsl(0,0%,96%)}.b-radio.radio input[type=radio]:checked+.check.is-dark{border-color:hsl(0,0%,21%)}.b-radio.radio input[type=radio]:checked+.check.is-primary{border-color:#00875f}.b-radio.radio input[type=radio]:checked+.check.is-link{border-color:hsl(229,53%,53%)}.b-radio.radio input[type=radio]:checked+.check.is-info{border-color:hsl(207,61%,53%)}.b-radio.radio input[type=radio]:checked+.check.is-success{border-color:hsl(153,53%,53%)}.b-radio.radio input[type=radio]:checked+.check.is-warning{border-color:hsl(44,100%,77%)}.b-radio.radio input[type=radio]:checked+.check.is-danger{border-color:hsl(348,86%,61%)}.b-radio.radio input[type=radio]:checked+.check:before{transform:scale(0.5)}.b-radio.radio input[type=radio]:focus+.check{box-shadow:0 0 .5em hsla(0,0%,48%,.8)}.b-radio.radio input[type=radio]:focus:checked+.check{box-shadow:0 0 .5em rgba(0,135,95,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-white{box-shadow:0 0 .5em hsla(0,0%,100%,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-black{box-shadow:0 0 .5em hsla(0,0%,4%,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-light{box-shadow:0 0 .5em hsla(0,0%,96%,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-dark{box-shadow:0 0 .5em hsla(0,0%,21%,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-primary{box-shadow:0 0 .5em rgba(0,135,95,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-link{box-shadow:0 0 .5em hsla(229,53%,53%,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-info{box-shadow:0 0 .5em hsla(207,61%,53%,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-success{box-shadow:0 0 .5em hsla(153,53%,53%,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-warning{box-shadow:0 0 .5em hsla(44,100%,77%,.8)}.b-radio.radio input[type=radio]:focus:checked+.check.is-danger{box-shadow:0 0 .5em hsla(348,86%,61%,.8)}.b-radio.radio input[type=radio][disabled]+.check{cursor:not-allowed}.b-radio.radio .control-label{padding-left:calc(.75em - 1px)}.b-radio.radio.button{display:flex}.b-radio.radio.button.is-selected{z-index:1}.b-radio.radio[disabled]{opacity:.5}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check{border-color:#00875f}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-white{border-color:#fff}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-black{border-color:hsl(0,0%,4%)}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-light{border-color:hsl(0,0%,96%)}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-dark{border-color:hsl(0,0%,21%)}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-primary{border-color:#00875f}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-link{border-color:hsl(229,53%,53%)}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-info{border-color:hsl(207,61%,53%)}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-success{border-color:hsl(153,53%,53%)}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-warning{border-color:hsl(44,100%,77%)}.b-radio.radio:hover input[type=radio]:not(:disabled)+.check.is-danger{border-color:hsl(348,86%,61%)}.b-radio.radio.is-small{border-radius:2px;font-size:.75rem}.b-radio.radio.is-medium{font-size:1.25rem}.b-radio.radio.is-large{font-size:1.5rem}.rate{align-items:center;display:flex}.rate:not(:last-child){margin-bottom:.75rem}.rate.is-spaced .rate-item:not(:last-child){margin-right:.25rem}.rate.is-disabled .rate-item{cursor:initial}.rate.is-disabled .rate-item:hover{transform:none}.rate.is-rtl .rate-item{order:1}.rate.is-rtl .rate-text{margin-left:0;margin-right:.35rem}.rate .rate-item{cursor:pointer;display:inline-flex;position:relative;transition:all .3s}.rate .rate-item:hover{transform:scale(1.1)}.rate .rate-item.set-on .icon,.rate .rate-item.set-half .is-half{color:hsl(44,100%,72%)}.rate .rate-item.set-half .is-half{position:absolute;left:0;top:0;overflow:hidden}.rate .icon{color:hsl(0,0%,86%);line-height:1;pointer-events:none;width:inherit}.rate .is-half>i{position:absolute;left:0}.rate .rate-text{font-size:calc(1rem/1.25);margin-left:.35rem}.rate .rate-text.is-small{font-size:calc(0.75rem/1.25)}.rate .rate-text.is-medium{font-size:calc(1.25rem/1.25)}.rate .rate-text.is-large{font-size:calc(1.5rem/1.25)}.select select{text-rendering:auto !important;padding-right:2.5em}.select select option{color:hsl(0,0%,29%);padding:calc(.5em - 1px) calc(.75em - 1px)}.select select option:disabled{cursor:not-allowed;opacity:.5}.select select optgroup{color:hsl(0,0%,71%);font-weight:400;font-style:normal;padding:.25em 0}.select select[disabled]{opacity:1}.select.is-empty select{color:hsla(0,0%,48%,.7)}.select.is-loading::after{top:calc(50% - .5em);right:.75em}.b-skeleton{display:inline-flex;flex-direction:column;vertical-align:middle;width:100%}.b-skeleton>.b-skeleton-item{background:linear-gradient(90deg, hsl(0, 0%, 86%) 25%, rgba(219.3, 219.3, 219.3, 0.5) 50%, hsl(0, 0%, 86%) 75%);background-size:400% 100%;width:100%}.b-skeleton>.b-skeleton-item.is-rounded{border-radius:4px}.b-skeleton>.b-skeleton-item::after{content:" "}.b-skeleton>.b-skeleton-item+.b-skeleton-item{margin-top:.5rem}.b-skeleton.is-animated>.b-skeleton-item{animation:skeleton-loading 1.5s infinite}.b-skeleton.is-centered{align-items:center}.b-skeleton.is-right{align-items:flex-end}.b-skeleton+.b-skeleton{margin-top:.5rem}.b-skeleton>.b-skeleton-item{line-height:1rem}.b-skeleton.is-small>.b-skeleton-item{line-height:.75rem}.b-skeleton.is-medium>.b-skeleton-item{line-height:1.25rem}.b-skeleton.is-large>.b-skeleton-item{line-height:1.5rem}@keyframes skeleton-loading{0%{background-position:100% 50%}100%{background-position:0 50%}}.b-sidebar .sidebar-content{background-color:hsl(0,0%,96%);box-shadow:5px 0px 13px 3px hsla(0,0%,4%,.1);width:260px;z-index:39}.b-sidebar .sidebar-content.is-white{background-color:#fff}.b-sidebar .sidebar-content.is-black{background-color:hsl(0,0%,4%)}.b-sidebar .sidebar-content.is-light{background-color:hsl(0,0%,96%)}.b-sidebar .sidebar-content.is-dark{background-color:hsl(0,0%,21%)}.b-sidebar .sidebar-content.is-primary{background-color:#00875f}.b-sidebar .sidebar-content.is-link{background-color:hsl(229,53%,53%)}.b-sidebar .sidebar-content.is-info{background-color:hsl(207,61%,53%)}.b-sidebar .sidebar-content.is-success{background-color:hsl(153,53%,53%)}.b-sidebar .sidebar-content.is-warning{background-color:hsl(44,100%,77%)}.b-sidebar .sidebar-content.is-danger{background-color:hsl(348,86%,61%)}.b-sidebar .sidebar-content.is-fixed{position:fixed;left:0;top:0}.b-sidebar .sidebar-content.is-fixed.is-right{left:auto;right:0}.b-sidebar .sidebar-content.is-absolute{position:absolute;left:0;top:0}.b-sidebar .sidebar-content.is-absolute.is-right{left:auto;right:0}.b-sidebar .sidebar-content.is-mini{width:80px}.b-sidebar .sidebar-content.is-mini.is-mini-expand:hover:not(.is-mini-delayed){transition:width 150ms ease-out}.b-sidebar .sidebar-content.is-mini.is-mini-expand:hover:not(.is-mini-delayed):not(.is-fullwidth){width:260px}.b-sidebar .sidebar-content.is-mini.is-mini-expand:hover:not(.is-mini-delayed):not(.is-fullwidth).is-mini-expand-fixed{position:fixed}.b-sidebar .sidebar-content.is-static{position:static}.b-sidebar .sidebar-content.is-absolute,.b-sidebar .sidebar-content.is-static{transition:width 150ms ease-out}.b-sidebar .sidebar-content.is-fullwidth{width:100%;max-width:100%}.b-sidebar .sidebar-content.is-fullheight{height:100%;max-height:100%;overflow:hidden;overflow-y:auto;display:flex;flex-direction:column;align-content:stretch}@media screen and (max-width: 768px){.b-sidebar .sidebar-content.is-mini-mobile{width:80px}.b-sidebar .sidebar-content.is-mini-mobile.is-mini-expand:hover:not(.is-fullwidth-mobile){width:260px}.b-sidebar .sidebar-content.is-mini-mobile.is-mini-expand:hover:not(.is-fullwidth-mobile).is-mini-expand-fixed{position:fixed}.b-sidebar .sidebar-content.is-hidden-mobile{width:0;height:0;overflow:hidden}.b-sidebar .sidebar-content.is-fullwidth-mobile{width:100%;max-width:100%}}.b-sidebar .sidebar-content .sidebar-close{background:rgba(10,10,10,.3)}.b-sidebar .sidebar-content .sidebar-close:hover{background:rgba(10,10,10,.86)}.b-sidebar .sidebar-background{bottom:0;left:0;position:absolute;right:0;top:0;background:hsla(0,0%,4%,.86);position:fixed;z-index:38}.b-slider{margin:1em 0;background:rgba(0,0,0,0);width:100%}.b-slider .b-slider-track{display:flex;align-items:center;position:relative;cursor:pointer;background:hsl(0,0%,86%);border-radius:4px}.b-slider .b-slider-fill{position:absolute;height:100%;box-shadow:0px 0px 0px hsl(0,0%,48%);background:hsl(0,0%,86%);border-radius:4px;border:0px solid hsl(0,0%,48%);top:50%;transform:translateY(-50%)}.b-slider .b-slider-thumb-wrapper{display:inline-flex;align-items:center;position:absolute;cursor:grab;transform:translate(-50%, -50%);top:50%;flex-direction:column}.b-slider .b-slider-thumb-wrapper .b-slider-thumb{box-shadow:none;border:1px solid hsl(0,0%,71%);border-radius:4px;background:#fff}.b-slider .b-slider-thumb-wrapper .b-slider-thumb:focus{transform:scale(1.25)}.b-slider .b-slider-thumb-wrapper.is-dragging{cursor:grabbing}.b-slider .b-slider-thumb-wrapper.is-dragging .b-slider-thumb{transform:scale(1.25)}.b-slider .b-slider-thumb-wrapper.has-indicator .b-slider-thumb{padding:16px 10px;display:flex;align-items:center;width:auto}.b-slider.slider-focus{padding-top:20px;padding-bottom:20px;margin-top:-20px;margin-bottom:-20px;cursor:pointer}.b-slider.is-rounded .b-slider-thumb{border-radius:9999px}.b-slider.is-disabled .b-slider-track{cursor:not-allowed;opacity:.5}.b-slider.is-disabled .b-slider-thumb-wrapper{cursor:not-allowed}.b-slider.is-disabled .b-slider-thumb-wrapper .b-slider-thumb{transform:scale(1)}.b-slider .b-slider-track{height:.5rem}.b-slider .b-slider-thumb{height:1rem;width:1rem}.b-slider .b-slider-tick{height:.25rem}.b-slider .b-slider-tick-label{font-size:.75rem;position:absolute;top:calc(0.5rem*.5 + 2px);left:50%;transform:translateX(-50%)}.b-slider.is-small .b-slider-track{height:.375rem}.b-slider.is-small .b-slider-thumb{height:.75rem;width:.75rem}.b-slider.is-small .b-slider-tick{height:.1875rem}.b-slider.is-small .b-slider-tick-label{font-size:.75rem;position:absolute;top:calc(0.375rem*.5 + 2px);left:50%;transform:translateX(-50%)}.b-slider.is-medium .b-slider-track{height:.625rem}.b-slider.is-medium .b-slider-thumb{height:1.25rem;width:1.25rem}.b-slider.is-medium .b-slider-tick{height:.3125rem}.b-slider.is-medium .b-slider-tick-label{font-size:.75rem;position:absolute;top:calc(0.625rem*.5 + 2px);left:50%;transform:translateX(-50%)}.b-slider.is-large .b-slider-track{height:.75rem}.b-slider.is-large .b-slider-thumb{height:1.5rem;width:1.5rem}.b-slider.is-large .b-slider-tick{height:.375rem}.b-slider.is-large .b-slider-tick-label{font-size:.75rem;position:absolute;top:calc(0.75rem*.5 + 2px);left:50%;transform:translateX(-50%)}.b-slider.is-white .b-slider-fill{background:#fff !important}.b-slider.is-black .b-slider-fill{background:hsl(0,0%,4%) !important}.b-slider.is-light .b-slider-fill{background:hsl(0,0%,96%) !important}.b-slider.is-dark .b-slider-fill{background:hsl(0,0%,21%) !important}.b-slider.is-primary .b-slider-fill{background:#00875f !important}.b-slider.is-link .b-slider-fill{background:hsl(229,53%,53%) !important}.b-slider.is-info .b-slider-fill{background:hsl(207,61%,53%) !important}.b-slider.is-success .b-slider-fill{background:hsl(153,53%,53%) !important}.b-slider.is-warning .b-slider-fill{background:hsl(44,100%,77%) !important}.b-slider.is-danger .b-slider-fill{background:hsl(348,86%,61%) !important}.b-slider .b-slider-tick{position:absolute;width:3px;transform:translate(-50%, -50%);top:50%;background:hsl(0,0%,71%);border-radius:4px}.b-slider .b-slider-tick.is-tick-hidden{background:rgba(0,0,0,0)}.b-steps .steps .step-items{display:flex;flex-wrap:wrap}.b-steps .steps .step-items .step-item{margin-top:0;position:relative;flex-grow:1;flex-basis:1em}.b-steps .steps .step-items .step-item .step-link{display:flex;align-items:center;justify-content:center;flex-direction:column;color:hsl(0,0%,29%)}.b-steps .steps .step-items .step-item .step-link:not(.is-clickable){cursor:not-allowed}.b-steps .steps .step-items .step-item .step-marker{align-items:center;display:flex;border-radius:4px;font-weight:700;justify-content:center;background:hsl(0,0%,71%);color:#fff;border:.2em solid #fff;z-index:1;overflow:hidden}.b-steps .steps .step-items .step-item.is-white::before,.b-steps .steps .step-items .step-item.is-white::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, hsl(0, 0%, 100%) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-white.is-active .step-marker{background-color:#fff;border-color:#fff;color:#fff}.b-steps .steps .step-items .step-item.is-white.is-active::before,.b-steps .steps .step-items .step-item.is-white.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-white.is-previous .step-marker{color:hsl(0,0%,4%);background-color:#fff}.b-steps .steps .step-items .step-item.is-white.is-previous::before,.b-steps .steps .step-items .step-item.is-white.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-black::before,.b-steps .steps .step-items .step-item.is-black::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, hsl(0, 0%, 4%) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-black.is-active .step-marker{background-color:#fff;border-color:hsl(0,0%,4%);color:hsl(0,0%,4%)}.b-steps .steps .step-items .step-item.is-black.is-active::before,.b-steps .steps .step-items .step-item.is-black.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-black.is-previous .step-marker{color:#fff;background-color:hsl(0,0%,4%)}.b-steps .steps .step-items .step-item.is-black.is-previous::before,.b-steps .steps .step-items .step-item.is-black.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-light::before,.b-steps .steps .step-items .step-item.is-light::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, hsl(0, 0%, 96%) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-light.is-active .step-marker{background-color:#fff;border-color:hsl(0,0%,96%);color:hsl(0,0%,96%)}.b-steps .steps .step-items .step-item.is-light.is-active::before,.b-steps .steps .step-items .step-item.is-light.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-light.is-previous .step-marker{color:rgba(0,0,0,.7);background-color:hsl(0,0%,96%)}.b-steps .steps .step-items .step-item.is-light.is-previous::before,.b-steps .steps .step-items .step-item.is-light.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-dark::before,.b-steps .steps .step-items .step-item.is-dark::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, hsl(0, 0%, 21%) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-dark.is-active .step-marker{background-color:#fff;border-color:hsl(0,0%,21%);color:hsl(0,0%,21%)}.b-steps .steps .step-items .step-item.is-dark.is-active::before,.b-steps .steps .step-items .step-item.is-dark.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-dark.is-previous .step-marker{color:#fff;background-color:hsl(0,0%,21%)}.b-steps .steps .step-items .step-item.is-dark.is-previous::before,.b-steps .steps .step-items .step-item.is-dark.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-primary::before,.b-steps .steps .step-items .step-item.is-primary::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, rgb(0, 135, 95) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-primary.is-active .step-marker{background-color:#fff;border-color:#00875f;color:#00875f}.b-steps .steps .step-items .step-item.is-primary.is-active::before,.b-steps .steps .step-items .step-item.is-primary.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-primary.is-previous .step-marker{color:#fff;background-color:#00875f}.b-steps .steps .step-items .step-item.is-primary.is-previous::before,.b-steps .steps .step-items .step-item.is-primary.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-link::before,.b-steps .steps .step-items .step-item.is-link::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, hsl(229, 53%, 53%) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-link.is-active .step-marker{background-color:#fff;border-color:hsl(229,53%,53%);color:hsl(229,53%,53%)}.b-steps .steps .step-items .step-item.is-link.is-active::before,.b-steps .steps .step-items .step-item.is-link.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-link.is-previous .step-marker{color:#fff;background-color:hsl(229,53%,53%)}.b-steps .steps .step-items .step-item.is-link.is-previous::before,.b-steps .steps .step-items .step-item.is-link.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-info::before,.b-steps .steps .step-items .step-item.is-info::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, hsl(207, 61%, 53%) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-info.is-active .step-marker{background-color:#fff;border-color:hsl(207,61%,53%);color:hsl(207,61%,53%)}.b-steps .steps .step-items .step-item.is-info.is-active::before,.b-steps .steps .step-items .step-item.is-info.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-info.is-previous .step-marker{color:#fff;background-color:hsl(207,61%,53%)}.b-steps .steps .step-items .step-item.is-info.is-previous::before,.b-steps .steps .step-items .step-item.is-info.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-success::before,.b-steps .steps .step-items .step-item.is-success::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, hsl(153, 53%, 53%) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-success.is-active .step-marker{background-color:#fff;border-color:hsl(153,53%,53%);color:hsl(153,53%,53%)}.b-steps .steps .step-items .step-item.is-success.is-active::before,.b-steps .steps .step-items .step-item.is-success.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-success.is-previous .step-marker{color:#fff;background-color:hsl(153,53%,53%)}.b-steps .steps .step-items .step-item.is-success.is-previous::before,.b-steps .steps .step-items .step-item.is-success.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-warning::before,.b-steps .steps .step-items .step-item.is-warning::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, hsl(44, 100%, 77%) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-warning.is-active .step-marker{background-color:#fff;border-color:hsl(44,100%,77%);color:hsl(44,100%,77%)}.b-steps .steps .step-items .step-item.is-warning.is-active::before,.b-steps .steps .step-items .step-item.is-warning.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-warning.is-previous .step-marker{color:rgba(0,0,0,.7);background-color:hsl(44,100%,77%)}.b-steps .steps .step-items .step-item.is-warning.is-previous::before,.b-steps .steps .step-items .step-item.is-warning.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-danger::before,.b-steps .steps .step-items .step-item.is-danger::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, hsl(348, 86%, 61%) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-danger.is-active .step-marker{background-color:#fff;border-color:hsl(348,86%,61%);color:hsl(348,86%,61%)}.b-steps .steps .step-items .step-item.is-danger.is-active::before,.b-steps .steps .step-items .step-item.is-danger.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-danger.is-previous .step-marker{color:#fff;background-color:hsl(348,86%,61%)}.b-steps .steps .step-items .step-item.is-danger.is-previous::before,.b-steps .steps .step-items .step-item.is-danger.is-previous::after{background-position:left bottom}.b-steps .steps .step-items .step-item .step-marker{color:#fff}.b-steps .steps .step-items .step-item .step-details{text-align:center;z-index:1}.b-steps .steps .step-items .step-item:not(:first-child),.b-steps .steps .step-items .step-item:only-child{flex-shrink:1}.b-steps .steps .step-items .step-item:not(:first-child)::before,.b-steps .steps .step-items .step-item:only-child::before{content:" ";display:block;position:absolute;width:100%;bottom:0;left:-50%}.b-steps .steps .step-items .step-item:only-child::after{content:" ";display:block;position:absolute;height:.2em;bottom:0}.b-steps .steps .step-items .step-item:only-child::before,.b-steps .steps .step-items .step-item:only-child::after{width:25%;left:50%}.b-steps .steps .step-items .step-item:only-child::before{right:50%;left:auto}.b-steps .steps .step-items .step-item::before,.b-steps .steps .step-items .step-item::after{background:linear-gradient(to left, hsl(0, 0%, 86%) 50%, rgb(0, 135, 95) 50%);background-size:200% 100%;background-position:right bottom}.b-steps .steps .step-items .step-item.is-active .step-link{cursor:default}.b-steps .steps .step-items .step-item.is-active .step-marker{background-color:#fff;border-color:#00875f;color:#00875f}.b-steps .steps .step-items .step-item.is-active::before,.b-steps .steps .step-items .step-item.is-active::after{background-position:left bottom}.b-steps .steps .step-items .step-item.is-previous .step-marker{color:#fff;background-color:#00875f}.b-steps .steps .step-items .step-item.is-previous::before,.b-steps .steps .step-items .step-item.is-previous::after{background-position:left bottom}.b-steps .steps+.step-content{position:relative;overflow:visible;display:flex;flex-direction:column;padding:1rem}.b-steps .steps+.step-content .step-item{flex-shrink:0;flex-basis:auto}.b-steps .steps+.step-content .step-item:focus{outline:none}.b-steps .steps+.step-content.is-transitioning{overflow:hidden}.b-steps .steps.is-rounded .step-item .step-marker{border-radius:9999px}.b-steps .steps.is-animated .step-item:not(:first-child)::before,.b-steps .steps.is-animated .step-item:only-child::before{transition:background 150ms ease-out}.b-steps .steps.has-label-right .step-items .step-item .step-link,.b-steps .steps.has-label-left .step-items .step-item .step-link{flex-direction:row}.b-steps .steps.has-label-right .step-items .step-item .step-link>.step-details,.b-steps .steps.has-label-left .step-items .step-item .step-link>.step-details{background-color:#fff;padding:.2em}.b-steps .steps.has-label-left .step-items .step-item .step-link{flex-direction:row-reverse}.b-steps .steps{font-size:1rem;min-height:2rem}.b-steps .steps .step-items .step-item .step-marker{height:2rem;width:2rem}.b-steps .steps .step-items .step-item .step-marker .icon *,.b-steps .steps .step-items .step-item .step-marker .icon *:before{font-size:1rem}.b-steps .steps .step-items .step-item .step-details .step-title{font-size:1.2rem;font-weight:600;line-height:1rem}.b-steps .steps .step-items .step-item:not(:first-child)::before,.b-steps .steps .step-items .step-item:only-child::before{height:.2em;top:1rem}.b-steps .steps .step-items .step-item:only-child::after{top:1rem}@media screen and (max-width: 768px){.b-steps .steps .step-items .step-item::before,.b-steps .steps .step-items .step-item::after,.b-steps .steps .step-items .step-item:not(:first-child)::before{top:1rem}}.b-steps.is-vertical>.steps.has-label-right .step-items .step-item:not(:first-child)::before,.b-steps.is-vertical>.steps.has-label-right .step-items .step-item:not(:first-child)::after,.b-steps.is-vertical>.steps.has-label-right .step-items .step-item:only-child::before,.b-steps.is-vertical>.steps.has-label-right .step-items .step-item:only-child::after{left:calc(1rem - 0.1em)}.b-steps.is-vertical>.steps.has-label-left .step-items .step-item:not(:first-child)::before,.b-steps.is-vertical>.steps.has-label-left .step-items .step-item:not(:first-child)::after,.b-steps.is-vertical>.steps.has-label-left .step-items .step-item:only-child::before,.b-steps.is-vertical>.steps.has-label-left .step-items .step-item:only-child::after{left:auto;right:calc(1rem - 0.1em)}.b-steps.is-vertical.is-right>.steps.has-label-right .step-items .step-item:not(:first-child)::before,.b-steps.is-vertical.is-right>.steps.has-label-right .step-items .step-item:not(:first-child)::after,.b-steps.is-vertical.is-right>.steps.has-label-right .step-items .step-item:only-child::before,.b-steps.is-vertical.is-right>.steps.has-label-right .step-items .step-item:only-child::after{left:calc(1rem - 0.1em)}.b-steps.is-vertical.is-right>.steps.has-label-left .step-items .step-item:not(:first-child)::before,.b-steps.is-vertical.is-right>.steps.has-label-left .step-items .step-item:not(:first-child)::after,.b-steps.is-vertical.is-right>.steps.has-label-left .step-items .step-item:only-child::before,.b-steps.is-vertical.is-right>.steps.has-label-left .step-items .step-item:only-child::after{left:auto;right:calc(1rem - 0.1em)}.b-steps.is-small .steps{font-size:.75rem;min-height:1.5rem}.b-steps.is-small .steps .step-items .step-item .step-marker{height:1.5rem;width:1.5rem}.b-steps.is-small .steps .step-items .step-item .step-marker .icon *,.b-steps.is-small .steps .step-items .step-item .step-marker .icon *:before{font-size:.75rem}.b-steps.is-small .steps .step-items .step-item .step-details .step-title{font-size:.9rem;font-weight:600;line-height:.75rem}.b-steps.is-small .steps .step-items .step-item:not(:first-child)::before,.b-steps.is-small .steps .step-items .step-item:only-child::before{height:.2em;top:0.75rem}.b-steps.is-small .steps .step-items .step-item:only-child::after{top:0.75rem}@media screen and (max-width: 768px){.b-steps.is-small .steps .step-items .step-item::before,.b-steps.is-small .steps .step-items .step-item::after,.b-steps.is-small .steps .step-items .step-item:not(:first-child)::before{top:0.75rem}}.b-steps.is-small.is-vertical>.steps.has-label-right .step-items .step-item:not(:first-child)::before,.b-steps.is-small.is-vertical>.steps.has-label-right .step-items .step-item:not(:first-child)::after,.b-steps.is-small.is-vertical>.steps.has-label-right .step-items .step-item:only-child::before,.b-steps.is-small.is-vertical>.steps.has-label-right .step-items .step-item:only-child::after{left:calc(0.75rem - 0.1em)}.b-steps.is-small.is-vertical>.steps.has-label-left .step-items .step-item:not(:first-child)::before,.b-steps.is-small.is-vertical>.steps.has-label-left .step-items .step-item:not(:first-child)::after,.b-steps.is-small.is-vertical>.steps.has-label-left .step-items .step-item:only-child::before,.b-steps.is-small.is-vertical>.steps.has-label-left .step-items .step-item:only-child::after{left:auto;right:calc(0.75rem - 0.1em)}.b-steps.is-small.is-vertical.is-right>.steps.has-label-right .step-items .step-item:not(:first-child)::before,.b-steps.is-small.is-vertical.is-right>.steps.has-label-right .step-items .step-item:not(:first-child)::after,.b-steps.is-small.is-vertical.is-right>.steps.has-label-right .step-items .step-item:only-child::before,.b-steps.is-small.is-vertical.is-right>.steps.has-label-right .step-items .step-item:only-child::after{left:calc(0.75rem - 0.1em)}.b-steps.is-small.is-vertical.is-right>.steps.has-label-left .step-items .step-item:not(:first-child)::before,.b-steps.is-small.is-vertical.is-right>.steps.has-label-left .step-items .step-item:not(:first-child)::after,.b-steps.is-small.is-vertical.is-right>.steps.has-label-left .step-items .step-item:only-child::before,.b-steps.is-small.is-vertical.is-right>.steps.has-label-left .step-items .step-item:only-child::after{left:auto;right:calc(0.75rem - 0.1em)}.b-steps.is-medium .steps{font-size:1.25rem;min-height:2.5rem}.b-steps.is-medium .steps .step-items .step-item .step-marker{height:2.5rem;width:2.5rem}.b-steps.is-medium .steps .step-items .step-item .step-marker .icon *,.b-steps.is-medium .steps .step-items .step-item .step-marker .icon *:before{font-size:1.25rem}.b-steps.is-medium .steps .step-items .step-item .step-details .step-title{font-size:1.5rem;font-weight:600;line-height:1.25rem}.b-steps.is-medium .steps .step-items .step-item:not(:first-child)::before,.b-steps.is-medium .steps .step-items .step-item:only-child::before{height:.2em;top:1.25rem}.b-steps.is-medium .steps .step-items .step-item:only-child::after{top:1.25rem}@media screen and (max-width: 768px){.b-steps.is-medium .steps .step-items .step-item::before,.b-steps.is-medium .steps .step-items .step-item::after,.b-steps.is-medium .steps .step-items .step-item:not(:first-child)::before{top:1.25rem}}.b-steps.is-medium.is-vertical>.steps.has-label-right .step-items .step-item:not(:first-child)::before,.b-steps.is-medium.is-vertical>.steps.has-label-right .step-items .step-item:not(:first-child)::after,.b-steps.is-medium.is-vertical>.steps.has-label-right .step-items .step-item:only-child::before,.b-steps.is-medium.is-vertical>.steps.has-label-right .step-items .step-item:only-child::after{left:calc(1.25rem - 0.1em)}.b-steps.is-medium.is-vertical>.steps.has-label-left .step-items .step-item:not(:first-child)::before,.b-steps.is-medium.is-vertical>.steps.has-label-left .step-items .step-item:not(:first-child)::after,.b-steps.is-medium.is-vertical>.steps.has-label-left .step-items .step-item:only-child::before,.b-steps.is-medium.is-vertical>.steps.has-label-left .step-items .step-item:only-child::after{left:auto;right:calc(1.25rem - 0.1em)}.b-steps.is-medium.is-vertical.is-right>.steps.has-label-right .step-items .step-item:not(:first-child)::before,.b-steps.is-medium.is-vertical.is-right>.steps.has-label-right .step-items .step-item:not(:first-child)::after,.b-steps.is-medium.is-vertical.is-right>.steps.has-label-right .step-items .step-item:only-child::before,.b-steps.is-medium.is-vertical.is-right>.steps.has-label-right .step-items .step-item:only-child::after{left:calc(1.25rem - 0.1em)}.b-steps.is-medium.is-vertical.is-right>.steps.has-label-left .step-items .step-item:not(:first-child)::before,.b-steps.is-medium.is-vertical.is-right>.steps.has-label-left .step-items .step-item:not(:first-child)::after,.b-steps.is-medium.is-vertical.is-right>.steps.has-label-left .step-items .step-item:only-child::before,.b-steps.is-medium.is-vertical.is-right>.steps.has-label-left .step-items .step-item:only-child::after{left:auto;right:calc(1.25rem - 0.1em)}.b-steps.is-large .steps{font-size:1.5rem;min-height:3rem}.b-steps.is-large .steps .step-items .step-item .step-marker{height:3rem;width:3rem}.b-steps.is-large .steps .step-items .step-item .step-marker .icon *,.b-steps.is-large .steps .step-items .step-item .step-marker .icon *:before{font-size:1.5rem}.b-steps.is-large .steps .step-items .step-item .step-details .step-title{font-size:1.8rem;font-weight:600;line-height:1.5rem}.b-steps.is-large .steps .step-items .step-item:not(:first-child)::before,.b-steps.is-large .steps .step-items .step-item:only-child::before{height:.2em;top:1.5rem}.b-steps.is-large .steps .step-items .step-item:only-child::after{top:1.5rem}@media screen and (max-width: 768px){.b-steps.is-large .steps .step-items .step-item::before,.b-steps.is-large .steps .step-items .step-item::after,.b-steps.is-large .steps .step-items .step-item:not(:first-child)::before{top:1.5rem}}.b-steps.is-large.is-vertical>.steps.has-label-right .step-items .step-item:not(:first-child)::before,.b-steps.is-large.is-vertical>.steps.has-label-right .step-items .step-item:not(:first-child)::after,.b-steps.is-large.is-vertical>.steps.has-label-right .step-items .step-item:only-child::before,.b-steps.is-large.is-vertical>.steps.has-label-right .step-items .step-item:only-child::after{left:calc(1.5rem - 0.1em)}.b-steps.is-large.is-vertical>.steps.has-label-left .step-items .step-item:not(:first-child)::before,.b-steps.is-large.is-vertical>.steps.has-label-left .step-items .step-item:not(:first-child)::after,.b-steps.is-large.is-vertical>.steps.has-label-left .step-items .step-item:only-child::before,.b-steps.is-large.is-vertical>.steps.has-label-left .step-items .step-item:only-child::after{left:auto;right:calc(1.5rem - 0.1em)}.b-steps.is-large.is-vertical.is-right>.steps.has-label-right .step-items .step-item:not(:first-child)::before,.b-steps.is-large.is-vertical.is-right>.steps.has-label-right .step-items .step-item:not(:first-child)::after,.b-steps.is-large.is-vertical.is-right>.steps.has-label-right .step-items .step-item:only-child::before,.b-steps.is-large.is-vertical.is-right>.steps.has-label-right .step-items .step-item:only-child::after{left:calc(1.5rem - 0.1em)}.b-steps.is-large.is-vertical.is-right>.steps.has-label-left .step-items .step-item:not(:first-child)::before,.b-steps.is-large.is-vertical.is-right>.steps.has-label-left .step-items .step-item:not(:first-child)::after,.b-steps.is-large.is-vertical.is-right>.steps.has-label-left .step-items .step-item:only-child::before,.b-steps.is-large.is-vertical.is-right>.steps.has-label-left .step-items .step-item:only-child::after{left:auto;right:calc(1.5rem - 0.1em)}.b-steps.is-vertical{display:flex;flex-direction:row;flex-wrap:wrap}.b-steps.is-vertical>.steps .step-items{height:100%;flex-direction:column;border-bottom-color:rgba(0,0,0,0)}.b-steps.is-vertical>.steps .step-items .step-item{width:100%;display:flex;align-items:center;justify-content:center;padding:1em 0}.b-steps.is-vertical>.steps .step-items .step-item::before,.b-steps.is-vertical>.steps .step-items .step-item::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, rgb(0, 135, 95) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-white::before,.b-steps.is-vertical>.steps .step-items .step-item.is-white::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, hsl(0, 0%, 100%) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-black::before,.b-steps.is-vertical>.steps .step-items .step-item.is-black::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, hsl(0, 0%, 4%) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-light::before,.b-steps.is-vertical>.steps .step-items .step-item.is-light::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, hsl(0, 0%, 96%) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-dark::before,.b-steps.is-vertical>.steps .step-items .step-item.is-dark::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, hsl(0, 0%, 21%) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-primary::before,.b-steps.is-vertical>.steps .step-items .step-item.is-primary::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, rgb(0, 135, 95) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-link::before,.b-steps.is-vertical>.steps .step-items .step-item.is-link::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, hsl(229, 53%, 53%) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-info::before,.b-steps.is-vertical>.steps .step-items .step-item.is-info::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, hsl(207, 61%, 53%) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-success::before,.b-steps.is-vertical>.steps .step-items .step-item.is-success::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, hsl(153, 53%, 53%) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-warning::before,.b-steps.is-vertical>.steps .step-items .step-item.is-warning::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, hsl(44, 100%, 77%) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item.is-danger::before,.b-steps.is-vertical>.steps .step-items .step-item.is-danger::after{background:linear-gradient(to top, hsl(0, 0%, 86%) 50%, hsl(348, 86%, 61%) 50%);background-size:100% 200%;background-position:left bottom}.b-steps.is-vertical>.steps .step-items .step-item:not(:first-child)::before,.b-steps.is-vertical>.steps .step-items .step-item:only-child::before{height:100%;width:.2em;top:-50%;left:calc(50% - 0.1em)}.b-steps.is-vertical>.steps .step-items .step-item.is-active::before,.b-steps.is-vertical>.steps .step-items .step-item.is-active::after,.b-steps.is-vertical>.steps .step-items .step-item.is-previous::before,.b-steps.is-vertical>.steps .step-items .step-item.is-previous::after{background-position:right top}.b-steps.is-vertical>.steps .step-items .step-item:only-child::before{top:50%}.b-steps.is-vertical>.steps .step-items .step-item:only-child::after{width:.2em;top:auto;bottom:50%}.b-steps.is-vertical>.steps .step-items .step-item:only-child::before,.b-steps.is-vertical>.steps .step-items .step-item:only-child::after{height:25%}.b-steps.is-vertical>.steps.has-label-right .step-items .step-item{justify-content:flex-start}.b-steps.is-vertical>.steps.has-label-left .step-items .step-item{justify-content:flex-end}.b-steps.is-vertical>.steps:not(.has-label-right):not(.has-label-left) .step-items .step-item .step-link>.step-details{background-color:#fff}.b-steps.is-vertical>.step-content{flex-grow:1}.b-steps.is-vertical>.step-navigation{flex-basis:100%}.b-steps.is-vertical.is-right{flex-direction:row-reverse}@media screen and (max-width: 768px){.b-steps:not(.is-vertical) .steps.mobile-minimalist .step-items .step-item:not(.is-active){display:none}.b-steps:not(.is-vertical) .steps.mobile-minimalist .step-items .step-item::before,.b-steps:not(.is-vertical) .steps.mobile-minimalist .step-items .step-item::after,.b-steps:not(.is-vertical) .steps.mobile-minimalist .step-items .step-item:not(:first-child)::before{content:" ";display:block;position:absolute;height:.2em;width:25%;bottom:0;left:50%}.b-steps:not(.is-vertical) .steps.mobile-minimalist .step-items .step-item::before,.b-steps:not(.is-vertical) .steps.mobile-minimalist .step-items .step-item:not(:first-child)::before{right:50%;left:auto}.b-steps:not(.is-vertical) .steps.mobile-compact .step-items .step-item:not(.is-active) .step-details{display:none}}.switch{cursor:pointer;display:inline-flex;align-items:center;position:relative;margin-right:.5em}.switch+.switch:last-child{margin-right:0}.switch input[type=checkbox]{position:absolute;left:0;opacity:0;outline:none;z-index:-1}.switch input[type=checkbox]+.check{display:flex;align-items:center;flex-shrink:0;width:2.75em;height:1.575em;padding:.2em;background:hsl(0,0%,71%);border-radius:4px;transition:background 150ms ease-out,box-shadow 150ms ease-out}.switch input[type=checkbox]+.check.is-white-passive,.switch input[type=checkbox]+.check:hover{background:#fff}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check.is-black-passive,.switch input[type=checkbox]+.check:hover{background:hsl(0,0%,4%)}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check.is-light-passive,.switch input[type=checkbox]+.check:hover{background:hsl(0,0%,96%)}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check.is-dark-passive,.switch input[type=checkbox]+.check:hover{background:hsl(0,0%,21%)}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check.is-primary-passive,.switch input[type=checkbox]+.check:hover{background:#00875f}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check.is-link-passive,.switch input[type=checkbox]+.check:hover{background:hsl(229,53%,53%)}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check.is-info-passive,.switch input[type=checkbox]+.check:hover{background:hsl(207,61%,53%)}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check.is-success-passive,.switch input[type=checkbox]+.check:hover{background:hsl(153,53%,53%)}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check.is-warning-passive,.switch input[type=checkbox]+.check:hover{background:hsl(44,100%,77%)}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check.is-danger-passive,.switch input[type=checkbox]+.check:hover{background:hsl(348,86%,61%)}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:"pink"}.switch input[type=checkbox]+.check:before{content:"";display:block;border-radius:4px;width:1.175em;height:1.175em;background:hsl(0,0%,96%);box-shadow:0 3px 1px 0 rgba(0,0,0,.05),0 2px 2px 0 rgba(0,0,0,.1),0 3px 3px 0 rgba(0,0,0,.05);transition:transform 150ms ease-out;will-change:transform;transform-origin:left}.switch input[type=checkbox]+.check.is-elastic:before{transform:scaleX(1.5);border-radius:4px}.switch input[type=checkbox]:checked+.check{background:#00875f}.switch input[type=checkbox]:checked+.check.is-white{background:#fff}.switch input[type=checkbox]:checked+.check.is-black{background:hsl(0,0%,4%)}.switch input[type=checkbox]:checked+.check.is-light{background:hsl(0,0%,96%)}.switch input[type=checkbox]:checked+.check.is-dark{background:hsl(0,0%,21%)}.switch input[type=checkbox]:checked+.check.is-primary{background:#00875f}.switch input[type=checkbox]:checked+.check.is-link{background:hsl(229,53%,53%)}.switch input[type=checkbox]:checked+.check.is-info{background:hsl(207,61%,53%)}.switch input[type=checkbox]:checked+.check.is-success{background:hsl(153,53%,53%)}.switch input[type=checkbox]:checked+.check.is-warning{background:hsl(44,100%,77%)}.switch input[type=checkbox]:checked+.check.is-danger{background:hsl(348,86%,61%)}.switch input[type=checkbox]:checked+.check:before{transform:translate3d(100%, 0, 0)}.switch input[type=checkbox]:checked+.check.is-elastic:before{transform:translate3d(50%, 0, 0) scaleX(1.5)}.switch input[type=checkbox]:focus,.switch input[type=checkbox]:active{outline:none}.switch input[type=checkbox]:focus+.check,.switch input[type=checkbox]:active+.check{box-shadow:0 0 .5em hsla(0,0%,48%,.6)}.switch input[type=checkbox]:focus+.check.is-white-passive,.switch input[type=checkbox]:active+.check.is-white-passive{box-shadow:0 0 .5em hsla(0,0%,100%,.8)}.switch input[type=checkbox]:focus+.check.is-black-passive,.switch input[type=checkbox]:active+.check.is-black-passive{box-shadow:0 0 .5em hsla(0,0%,4%,.8)}.switch input[type=checkbox]:focus+.check.is-light-passive,.switch input[type=checkbox]:active+.check.is-light-passive{box-shadow:0 0 .5em hsla(0,0%,96%,.8)}.switch input[type=checkbox]:focus+.check.is-dark-passive,.switch input[type=checkbox]:active+.check.is-dark-passive{box-shadow:0 0 .5em hsla(0,0%,21%,.8)}.switch input[type=checkbox]:focus+.check.is-primary-passive,.switch input[type=checkbox]:active+.check.is-primary-passive{box-shadow:0 0 .5em rgba(0,135,95,.8)}.switch input[type=checkbox]:focus+.check.is-link-passive,.switch input[type=checkbox]:active+.check.is-link-passive{box-shadow:0 0 .5em hsla(229,53%,53%,.8)}.switch input[type=checkbox]:focus+.check.is-info-passive,.switch input[type=checkbox]:active+.check.is-info-passive{box-shadow:0 0 .5em hsla(207,61%,53%,.8)}.switch input[type=checkbox]:focus+.check.is-success-passive,.switch input[type=checkbox]:active+.check.is-success-passive{box-shadow:0 0 .5em hsla(153,53%,53%,.8)}.switch input[type=checkbox]:focus+.check.is-warning-passive,.switch input[type=checkbox]:active+.check.is-warning-passive{box-shadow:0 0 .5em hsla(44,100%,77%,.8)}.switch input[type=checkbox]:focus+.check.is-danger-passive,.switch input[type=checkbox]:active+.check.is-danger-passive{box-shadow:0 0 .5em hsla(348,86%,61%,.8)}.switch input[type=checkbox]:focus:checked+.check,.switch input[type=checkbox]:active:checked+.check{box-shadow:0 0 .5em rgba(0,135,95,.8)}.switch input[type=checkbox]:focus:checked+.check.is-white,.switch input[type=checkbox]:active:checked+.check.is-white{box-shadow:0 0 .5em hsla(0,0%,100%,.8)}.switch input[type=checkbox]:focus:checked+.check.is-black,.switch input[type=checkbox]:active:checked+.check.is-black{box-shadow:0 0 .5em hsla(0,0%,4%,.8)}.switch input[type=checkbox]:focus:checked+.check.is-light,.switch input[type=checkbox]:active:checked+.check.is-light{box-shadow:0 0 .5em hsla(0,0%,96%,.8)}.switch input[type=checkbox]:focus:checked+.check.is-dark,.switch input[type=checkbox]:active:checked+.check.is-dark{box-shadow:0 0 .5em hsla(0,0%,21%,.8)}.switch input[type=checkbox]:focus:checked+.check.is-primary,.switch input[type=checkbox]:active:checked+.check.is-primary{box-shadow:0 0 .5em rgba(0,135,95,.8)}.switch input[type=checkbox]:focus:checked+.check.is-link,.switch input[type=checkbox]:active:checked+.check.is-link{box-shadow:0 0 .5em hsla(229,53%,53%,.8)}.switch input[type=checkbox]:focus:checked+.check.is-info,.switch input[type=checkbox]:active:checked+.check.is-info{box-shadow:0 0 .5em hsla(207,61%,53%,.8)}.switch input[type=checkbox]:focus:checked+.check.is-success,.switch input[type=checkbox]:active:checked+.check.is-success{box-shadow:0 0 .5em hsla(153,53%,53%,.8)}.switch input[type=checkbox]:focus:checked+.check.is-warning,.switch input[type=checkbox]:active:checked+.check.is-warning{box-shadow:0 0 .5em hsla(44,100%,77%,.8)}.switch input[type=checkbox]:focus:checked+.check.is-danger,.switch input[type=checkbox]:active:checked+.check.is-danger{box-shadow:0 0 .5em hsla(348,86%,61%,.8)}.switch.has-left-label{flex-direction:row-reverse}.switch.has-left-label .control-label{padding-right:calc(.75em - 1px)}.switch:not(.has-left-label) .control-label{padding-left:calc(.75em - 1px)}.switch:hover input[type=checkbox]+.check{background:hsla(0,0%,71%,.9)}.switch:hover input[type=checkbox]+.check.is-white-passive{background:hsla(0,0%,100%,.9)}.switch:hover input[type=checkbox]+.check.is-black-passive{background:hsla(0,0%,4%,.9)}.switch:hover input[type=checkbox]+.check.is-light-passive{background:hsla(0,0%,96%,.9)}.switch:hover input[type=checkbox]+.check.is-dark-passive{background:hsla(0,0%,21%,.9)}.switch:hover input[type=checkbox]+.check.is-primary-passive{background:rgba(0,135,95,.9)}.switch:hover input[type=checkbox]+.check.is-link-passive{background:hsla(229,53%,53%,.9)}.switch:hover input[type=checkbox]+.check.is-info-passive{background:hsla(207,61%,53%,.9)}.switch:hover input[type=checkbox]+.check.is-success-passive{background:hsla(153,53%,53%,.9)}.switch:hover input[type=checkbox]+.check.is-warning-passive{background:hsla(44,100%,77%,.9)}.switch:hover input[type=checkbox]+.check.is-danger-passive{background:hsla(348,86%,61%,.9)}.switch:hover input[type=checkbox]:checked+.check{background:rgba(0,135,95,.9)}.switch:hover input[type=checkbox]:checked+.check.is-white{background:hsla(0,0%,100%,.9)}.switch:hover input[type=checkbox]:checked+.check.is-black{background:hsla(0,0%,4%,.9)}.switch:hover input[type=checkbox]:checked+.check.is-light{background:hsla(0,0%,96%,.9)}.switch:hover input[type=checkbox]:checked+.check.is-dark{background:hsla(0,0%,21%,.9)}.switch:hover input[type=checkbox]:checked+.check.is-primary{background:rgba(0,135,95,.9)}.switch:hover input[type=checkbox]:checked+.check.is-link{background:hsla(229,53%,53%,.9)}.switch:hover input[type=checkbox]:checked+.check.is-info{background:hsla(207,61%,53%,.9)}.switch:hover input[type=checkbox]:checked+.check.is-success{background:hsla(153,53%,53%,.9)}.switch:hover input[type=checkbox]:checked+.check.is-warning{background:hsla(44,100%,77%,.9)}.switch:hover input[type=checkbox]:checked+.check.is-danger{background:hsla(348,86%,61%,.9)}.switch.is-rounded input[type=checkbox]+.check{border-radius:9999px}.switch.is-rounded input[type=checkbox]+.check:before{border-radius:9999px}.switch.is-rounded input[type=checkbox].is-elastic:before{transform:scaleX(1.5);border-radius:9999px}.switch.is-outlined input[type=checkbox]+.check{background:rgba(0,0,0,0);border:.1rem solid hsl(0,0%,71%)}.switch.is-outlined input[type=checkbox]+.check.is-white-passive{border:.1rem solid hsla(0,0%,100%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-white-passive:before{background:#fff}.switch.is-outlined input[type=checkbox]+.check.is-white-passive:hover{border-color:hsla(0,0%,100%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-black-passive{border:.1rem solid hsla(0,0%,4%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-black-passive:before{background:hsl(0,0%,4%)}.switch.is-outlined input[type=checkbox]+.check.is-black-passive:hover{border-color:hsla(0,0%,4%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-light-passive{border:.1rem solid hsla(0,0%,96%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-light-passive:before{background:hsl(0,0%,96%)}.switch.is-outlined input[type=checkbox]+.check.is-light-passive:hover{border-color:hsla(0,0%,96%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-dark-passive{border:.1rem solid hsla(0,0%,21%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-dark-passive:before{background:hsl(0,0%,21%)}.switch.is-outlined input[type=checkbox]+.check.is-dark-passive:hover{border-color:hsla(0,0%,21%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-primary-passive{border:.1rem solid rgba(0,135,95,.9)}.switch.is-outlined input[type=checkbox]+.check.is-primary-passive:before{background:#00875f}.switch.is-outlined input[type=checkbox]+.check.is-primary-passive:hover{border-color:rgba(0,135,95,.9)}.switch.is-outlined input[type=checkbox]+.check.is-link-passive{border:.1rem solid hsla(229,53%,53%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-link-passive:before{background:hsl(229,53%,53%)}.switch.is-outlined input[type=checkbox]+.check.is-link-passive:hover{border-color:hsla(229,53%,53%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-info-passive{border:.1rem solid hsla(207,61%,53%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-info-passive:before{background:hsl(207,61%,53%)}.switch.is-outlined input[type=checkbox]+.check.is-info-passive:hover{border-color:hsla(207,61%,53%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-success-passive{border:.1rem solid hsla(153,53%,53%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-success-passive:before{background:hsl(153,53%,53%)}.switch.is-outlined input[type=checkbox]+.check.is-success-passive:hover{border-color:hsla(153,53%,53%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-warning-passive{border:.1rem solid hsla(44,100%,77%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-warning-passive:before{background:hsl(44,100%,77%)}.switch.is-outlined input[type=checkbox]+.check.is-warning-passive:hover{border-color:hsla(44,100%,77%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-danger-passive{border:.1rem solid hsla(348,86%,61%,.9)}.switch.is-outlined input[type=checkbox]+.check.is-danger-passive:before{background:hsl(348,86%,61%)}.switch.is-outlined input[type=checkbox]+.check.is-danger-passive:hover{border-color:hsla(348,86%,61%,.9)}.switch.is-outlined input[type=checkbox]+.check:before{background:hsl(0,0%,71%)}.switch.is-outlined input[type=checkbox]:checked+.check{border-color:#00875f}.switch.is-outlined input[type=checkbox]:checked+.check.is-white{background:rgba(0,0,0,0);border-color:#fff}.switch.is-outlined input[type=checkbox]:checked+.check.is-white:before{background:#fff}.switch.is-outlined input[type=checkbox]:checked+.check.is-black{background:rgba(0,0,0,0);border-color:hsl(0,0%,4%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-black:before{background:hsl(0,0%,4%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-light{background:rgba(0,0,0,0);border-color:hsl(0,0%,96%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-light:before{background:hsl(0,0%,96%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-dark{background:rgba(0,0,0,0);border-color:hsl(0,0%,21%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-dark:before{background:hsl(0,0%,21%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-primary{background:rgba(0,0,0,0);border-color:#00875f}.switch.is-outlined input[type=checkbox]:checked+.check.is-primary:before{background:#00875f}.switch.is-outlined input[type=checkbox]:checked+.check.is-link{background:rgba(0,0,0,0);border-color:hsl(229,53%,53%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-link:before{background:hsl(229,53%,53%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-info{background:rgba(0,0,0,0);border-color:hsl(207,61%,53%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-info:before{background:hsl(207,61%,53%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-success{background:rgba(0,0,0,0);border-color:hsl(153,53%,53%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-success:before{background:hsl(153,53%,53%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-warning{background:rgba(0,0,0,0);border-color:hsl(44,100%,77%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-warning:before{background:hsl(44,100%,77%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-danger{background:rgba(0,0,0,0);border-color:hsl(348,86%,61%)}.switch.is-outlined input[type=checkbox]:checked+.check.is-danger:before{background:hsl(348,86%,61%)}.switch.is-outlined input[type=checkbox]:checked+.check:before{background:#00875f}.switch.is-outlined:hover input[type=checkbox]+.check{background:rgba(0,0,0,0);border-color:hsla(0,0%,71%,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check{background:rgba(0,0,0,0);border-color:rgba(0,135,95,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-white{border-color:hsla(0,0%,100%,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-black{border-color:hsla(0,0%,4%,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-light{border-color:hsla(0,0%,96%,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-dark{border-color:hsla(0,0%,21%,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-primary{border-color:rgba(0,135,95,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-link{border-color:hsla(229,53%,53%,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-info{border-color:hsla(207,61%,53%,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-success{border-color:hsla(153,53%,53%,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-warning{border-color:hsla(44,100%,77%,.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-danger{border-color:hsla(348,86%,61%,.9)}.switch.is-small{border-radius:2px;font-size:.75rem}.switch.is-medium{font-size:1.25rem}.switch.is-large{font-size:1.5rem}.switch[disabled]{opacity:.5;cursor:not-allowed;color:hsl(0,0%,48%)}.table-wrapper .table{margin-bottom:0}.table-wrapper:not(:last-child){margin-bottom:1.5rem}@media screen and (max-width: 1023px){.table-wrapper{overflow-x:auto}}.b-table{transition:opacity 86ms ease-out}@media screen and (min-width: 769px),print{.b-table .table-mobile-sort{display:none}}.b-table .icon{transition:transform 150ms ease-out,opacity 86ms ease-out}.b-table .icon.is-desc{transform:rotate(180deg)}.b-table .icon.is-expanded{transform:rotate(90deg)}.b-table .sort-icon.icon.is-desc{transform:rotate(180deg) translateY(-50%) !important}.b-table .table{width:100%;border:1px solid rgba(0,0,0,0);border-radius:4px;border-collapse:separate}.b-table .table th{font-weight:600}.b-table .table th .th-wrap{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center}.b-table .table th .th-wrap .icon{margin-left:.5rem;margin-right:0;font-size:1rem}.b-table .table th .th-wrap.is-numeric{flex-direction:row-reverse;text-align:right;width:95%}.b-table .table th .th-wrap.is-numeric .sort-icon{margin-left:0;margin-right:.5rem;left:0;right:auto}.b-table .table th .th-wrap.is-centered{justify-content:center;text-align:center}.b-table .table th.is-current-sort{border-color:hsl(0,0%,48%);font-weight:700}.b-table .table th.is-sortable:hover{border-color:hsl(0,0%,48%)}.b-table .table th.is-sortable,.b-table .table th.is-sortable .th-wrap{cursor:pointer}.b-table .table th.is-sortable .is-relative,.b-table .table th.is-sortable .th-wrap .is-relative{position:absolute}.b-table .table th .sort-icon{position:absolute;bottom:50%;right:0;transform:translateY(50%)}.b-table .table th .multi-sort-icons{display:flex;align-items:center}.b-table .table th .multi-sort-icons .multi-sort-cancel-icon{margin-left:10px}.b-table .table th.is-sticky{position:-webkit-sticky;position:sticky;left:0;z-index:3 !important;background:rgba(0,0,0,0)}.b-table .table tr.is-selected .checkbox input:checked+.check{background:#fff url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 1 1%27%3E%3Cpath style=%27fill:rgb%280, 135, 95%29%27 d=%27M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z%27%3E%3C/path%3E%3C/svg%3E") no-repeat center center}.b-table .table tr.is-selected .checkbox input+.check{border-color:#fff}.b-table .table tr.is-empty:hover{background-color:rgba(0,0,0,0)}.b-table .table .chevron-cell{vertical-align:middle}.b-table .table .chevron-cell>a{color:#00875f !important}.b-table .table .checkbox-cell{width:40px}.b-table .table .checkbox-cell .checkbox{vertical-align:middle}.b-table .table .checkbox-cell .checkbox .check{transition:none}.b-table .table tr.detail{box-shadow:inset 0 1px 3px hsl(0,0%,86%);background:hsl(0,0%,98%)}.b-table .table tr.detail .detail-container{padding:1rem}.b-table .table:focus{border-color:#00875f;box-shadow:0 0 0 .125em rgba(0,135,95,.25)}.b-table .table.is-bordered th.is-current-sort,.b-table .table.is-bordered th.is-sortable:hover{border-color:hsl(0,0%,86%);background:hsl(0,0%,96%)}.b-table .table td.is-sticky{position:-webkit-sticky;position:sticky;left:0;z-index:1;background:#fff}.b-table .table.is-striped tbody tr:not(.is-selected):nth-child(even) td.is-sticky{background:hsl(0,0%,98%)}.b-table .level:not(.top){padding-bottom:1.5rem}.b-table .table-wrapper{position:relative}.b-table .table-wrapper.has-sticky-header{height:300px;overflow-y:auto}@media screen and (max-width: 768px){.b-table .table-wrapper.has-sticky-header.has-mobile-cards{height:initial !important;overflow-y:initial !important}}.b-table .table-wrapper.has-sticky-header tr:first-child th{position:-webkit-sticky;position:sticky;top:0;z-index:2;background:#fff}@media screen and (max-width: 768px){.b-table .table-wrapper.has-mobile-cards .table{background-color:rgba(0,0,0,0)}.b-table .table-wrapper.has-mobile-cards thead tr{box-shadow:none;border-width:0}.b-table .table-wrapper.has-mobile-cards thead tr th{display:none}.b-table .table-wrapper.has-mobile-cards thead tr .checkbox-cell{display:block;width:100%;text-align:right;margin-bottom:1rem;border:0}.b-table .table-wrapper.has-mobile-cards tfoot th{border:0;display:inherit}.b-table .table-wrapper.has-mobile-cards tr{box-shadow:0 2px 3px hsla(0,0%,4%,.1),0 0 0 1px hsla(0,0%,4%,.1);max-width:100%;position:relative;display:block}.b-table .table-wrapper.has-mobile-cards tr td{border:0;display:inherit}.b-table .table-wrapper.has-mobile-cards tr td:last-child{border-bottom:0}.b-table .table-wrapper.has-mobile-cards tr:not(:last-child){margin-bottom:1rem}.b-table .table-wrapper.has-mobile-cards tr:not([class*=is-]){background:#fff}.b-table .table-wrapper.has-mobile-cards tr:not([class*=is-]):hover{background-color:#fff}.b-table .table-wrapper.has-mobile-cards tr.detail{margin-top:-1rem}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td{display:flex;width:auto;justify-content:space-between;text-align:right;border-bottom:1px solid hsl(0,0%,96%)}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td:before{content:attr(data-label);font-weight:600;padding-right:.5em;text-align:left}}.b-table .table-wrapper.is-card-list .table{background-color:rgba(0,0,0,0)}.b-table .table-wrapper.is-card-list thead tr{box-shadow:none;border-width:0}.b-table .table-wrapper.is-card-list thead tr th{display:none}.b-table .table-wrapper.is-card-list thead tr .checkbox-cell{display:block;width:100%;text-align:right;margin-bottom:1rem;border:0}.b-table .table-wrapper.is-card-list tfoot th{border:0;display:inherit}.b-table .table-wrapper.is-card-list tr{box-shadow:0 2px 3px hsla(0,0%,4%,.1),0 0 0 1px hsla(0,0%,4%,.1);max-width:100%;position:relative;display:block}.b-table .table-wrapper.is-card-list tr td{border:0;display:inherit}.b-table .table-wrapper.is-card-list tr td:last-child{border-bottom:0}.b-table .table-wrapper.is-card-list tr:not(:last-child){margin-bottom:1rem}.b-table .table-wrapper.is-card-list tr:not([class*=is-]){background:#fff}.b-table .table-wrapper.is-card-list tr:not([class*=is-]):hover{background-color:#fff}.b-table .table-wrapper.is-card-list tr.detail{margin-top:-1rem}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td{display:flex;width:auto;justify-content:space-between;text-align:right;border-bottom:1px solid hsl(0,0%,96%)}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td:before{content:attr(data-label);font-weight:600;padding-right:.5em;text-align:left}.touch-dragged-cell{position:absolute}.touch-dragged-cell,.touch-dragged-cell .table{pointer-events:none;background-color:color-mix(in srgb, hsl(0, 0%, 100%) 10%, transparent)}.touch-dragged-cell .table{width:100%}.touch-dragged-cell .table tr,.touch-dragged-cell .table td{background-color:rgba(0,0,0,0)}@media screen and (max-width: 768px){.touch-dragged-cell.has-mobile-cards .table tr{display:block}.touch-dragged-cell.has-mobile-cards .table tr td{display:flex;justify-content:space-between;text-align:right}.touch-dragged-cell.has-mobile-cards .table tr td:before{content:attr(data-label);font-weight:600;padding-right:.5em;text-align:left}}.b-tabs .tabs{margin-bottom:0;flex-shrink:0}.b-tabs .tabs li a:focus{outline:none;border-bottom-color:#00875f}.b-tabs .tabs li:not(.is-active) a:focus{border-bottom-color:hsl(0,0%,21%)}.b-tabs .tabs li.is-disabled{pointer-events:none;cursor:not-allowed;opacity:.5}.b-tabs .tabs.is-boxed li a:focus{background-color:#fff;border-bottom-color:rgba(0,0,0,0)}.b-tabs .tabs.is-boxed li:not(.is-active) a:focus{background-color:hsl(0,0%,96%);border-bottom-color:hsl(0,0%,86%)}.b-tabs .tabs.is-toggle li a:focus{background-color:#00875f;border-color:#00875f}.b-tabs .tabs.is-toggle li:not(.is-active) a:focus{background-color:hsl(0,0%,96%);border-color:hsl(0,0%,71%)}.b-tabs .tab-content{position:relative;overflow:visible;display:flex;flex-direction:column;padding:1rem}.b-tabs .tab-content .tab-item{flex-shrink:0;flex-basis:auto}.b-tabs .tab-content .tab-item:focus{outline:none}.b-tabs .tab-content.is-transitioning{overflow:hidden}.b-tabs:not(:last-child){margin-bottom:1.5rem}.b-tabs.is-fullwidth{width:100%}.b-tabs.is-vertical{display:flex;flex-direction:row;flex-wrap:wrap}.b-tabs.is-vertical>.tabs ul{flex-direction:column;border-bottom-color:rgba(0,0,0,0)}.b-tabs.is-vertical>.tabs ul li{width:100%}.b-tabs.is-vertical>.tabs ul li a{justify-content:left}.b-tabs.is-vertical>.tabs.is-boxed li a{border-bottom-color:rgba(0,0,0,0) !important;border-right-color:hsl(0,0%,86%) !important;border-radius:4px 0 0 4px}.b-tabs.is-vertical>.tabs.is-boxed li.is-active a{border-bottom-color:hsl(0,0%,86%) !important;border-right-color:rgba(0,0,0,0) !important}.b-tabs.is-vertical>.tabs.is-toggle li+li{margin-left:0}.b-tabs.is-vertical>.tabs.is-toggle li:first-child a{border-radius:4px 4px 0 0}.b-tabs.is-vertical>.tabs.is-toggle li:last-child a{border-radius:0 0 4px 4px}.b-tabs.is-vertical>.tabs.is-fullwidth li a{height:100%}.b-tabs.is-vertical>.tab-content{flex-grow:1}.b-tabs.is-vertical.is-right{flex-direction:row-reverse}.b-tabs.is-vertical.is-right>.tabs ul a{flex-direction:row-reverse}.b-tabs.is-vertical.is-right>.tabs ul a .icon:first-child{margin-right:0;margin-left:.5em}.b-tabs.is-vertical.is-right>.tabs.is-boxed li a{border-bottom-color:rgba(0,0,0,0) !important;border-right-color:rgba(0,0,0,0) !important;border-left-color:hsl(0,0%,86%) !important;border-radius:0 4px 4px 0}.b-tabs.is-vertical.is-right>.tabs.is-boxed li.is-active a{border-bottom-color:hsl(0,0%,86%) !important;border-right-color:hsl(0,0%,86%) !important;border-left-color:rgba(0,0,0,0) !important}.b-tabs.is-multiline>.tabs ul{flex-wrap:wrap;flex-shrink:1}.tag .has-ellipsis{max-width:10em;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.tag .delete.is-white,.tag.is-delete.is-white,.tag.has-delete-icon.is-white{background:#fff}.tag .delete.is-white:hover,.tag.is-delete.is-white:hover,.tag.has-delete-icon.is-white:hover{background-color:hsl(0,0%,90%);text-decoration:none}.tag .delete.is-black,.tag.is-delete.is-black,.tag.has-delete-icon.is-black{background:hsl(0,0%,4%)}.tag .delete.is-black:hover,.tag.is-delete.is-black:hover,.tag.has-delete-icon.is-black:hover{background-color:#000;text-decoration:none}.tag .delete.is-light,.tag.is-delete.is-light,.tag.has-delete-icon.is-light{background:hsl(0,0%,96%)}.tag .delete.is-light:hover,.tag.is-delete.is-light:hover,.tag.has-delete-icon.is-light:hover{background-color:hsl(0,0%,86%);text-decoration:none}.tag .delete.is-dark,.tag.is-delete.is-dark,.tag.has-delete-icon.is-dark{background:hsl(0,0%,21%)}.tag .delete.is-dark:hover,.tag.is-delete.is-dark:hover,.tag.has-delete-icon.is-dark:hover{background-color:hsl(0,0%,11%);text-decoration:none}.tag .delete.is-primary,.tag.is-delete.is-primary,.tag.has-delete-icon.is-primary{background:#00875f}.tag .delete.is-primary:hover,.tag.is-delete.is-primary:hover,.tag.has-delete-icon.is-primary:hover{background-color:rgb(0,84,59.1111111111);text-decoration:none}.tag .delete.is-link,.tag.is-delete.is-link,.tag.has-delete-icon.is-link{background:hsl(229,53%,53%)}.tag .delete.is-link:hover,.tag.is-delete.is-link:hover,.tag.has-delete-icon.is-link:hover{background-color:hsl(229,53%,43%);text-decoration:none}.tag .delete.is-info,.tag.is-delete.is-info,.tag.has-delete-icon.is-info{background:hsl(207,61%,53%)}.tag .delete.is-info:hover,.tag.is-delete.is-info:hover,.tag.has-delete-icon.is-info:hover{background-color:hsl(207,61%,43%);text-decoration:none}.tag .delete.is-success,.tag.is-delete.is-success,.tag.has-delete-icon.is-success{background:hsl(153,53%,53%)}.tag .delete.is-success:hover,.tag.is-delete.is-success:hover,.tag.has-delete-icon.is-success:hover{background-color:hsl(153,53%,43%);text-decoration:none}.tag .delete.is-warning,.tag.is-delete.is-warning,.tag.has-delete-icon.is-warning{background:hsl(44,100%,77%)}.tag .delete.is-warning:hover,.tag.is-delete.is-warning:hover,.tag.has-delete-icon.is-warning:hover{background-color:hsl(44,100%,67%);text-decoration:none}.tag .delete.is-danger,.tag.is-delete.is-danger,.tag.has-delete-icon.is-danger{background:hsl(348,86%,61%)}.tag .delete.is-danger:hover,.tag.is-delete.is-danger:hover,.tag.has-delete-icon.is-danger:hover{background-color:hsl(348,86%,51%);text-decoration:none}.tag.has-delete-icon{padding:0px}.tag.has-delete-icon .icon:first-child:not(:last-child){margin-right:0px;margin-left:0px}.tags.inline-tags{margin-bottom:0px}.tags.inline-tags:not(:last-child){margin-right:.5rem}.taginput .taginput-container{display:flex}.taginput .taginput-container.is-focusable{padding-bottom:0;padding-top:calc(.275em - 1px);padding-left:0;padding-right:0;align-items:center;flex-wrap:wrap;justify-content:flex-start;height:auto;cursor:text}.taginput .taginput-container:not(.is-focusable){align-items:center;flex-wrap:wrap;justify-content:flex-start;height:auto}.taginput .taginput-container:not(.is-focusable).is-small{border-radius:2px;font-size:.75rem}.taginput .taginput-container:not(.is-focusable).is-medium{font-size:1.25rem}.taginput .taginput-container:not(.is-focusable).is-large{font-size:1.5rem}.taginput .taginput-container>.tag,.taginput .taginput-container>.tags{margin-left:.275rem;margin-bottom:calc(.275em - 1px);font-size:.9em;height:calc(2em - 1px)}.taginput .taginput-container>.tag .tag,.taginput .taginput-container>.tags .tag{margin-bottom:0;font-size:.9em;height:calc(2em - 1px)}.taginput .taginput-container>.tag .tag.is-delete,.taginput .taginput-container>.tags .tag.is-delete{width:calc(2em - 1px)}.taginput .taginput-container .autocomplete{position:static;flex:1}.taginput .taginput-container .autocomplete input{height:calc(2em - 1px);margin-bottom:calc(.275em - 1px);padding-top:0;padding-bottom:0;border:none;box-shadow:none;min-width:8em}.taginput .taginput-container .autocomplete input:focus{box-shadow:none !important}.taginput .taginput-container .autocomplete .icon{height:calc(2em - 1px)}.taginput .taginput-container .autocomplete>.control.is-loading::after{top:.375em}.timepicker .dropdown-menu{min-width:0}.timepicker .dropdown,.timepicker .dropdown-trigger{width:100%}.timepicker .dropdown .input[readonly],.timepicker .dropdown-trigger .input[readonly]{cursor:pointer;box-shadow:inset 0 .0625em .125em hsla(0,0%,4%,.05)}.timepicker .dropdown .input[readonly]:focus,.timepicker .dropdown .input[readonly].is-focused,.timepicker .dropdown .input[readonly]:active,.timepicker .dropdown .input[readonly].is-active,.timepicker .dropdown-trigger .input[readonly]:focus,.timepicker .dropdown-trigger .input[readonly].is-focused,.timepicker .dropdown-trigger .input[readonly]:active,.timepicker .dropdown-trigger .input[readonly].is-active{box-shadow:0 0 0 .125em rgba(0,135,95,.25)}.timepicker .dropdown.is-disabled{opacity:1}.timepicker .dropdown-item,.timepicker .dropdown .dropdown-menu .has-link a,.dropdown .dropdown-menu .has-link .timepicker a{font-size:inherit;padding:0}.timepicker .timepicker-footer{padding:0 .5rem 0 .5rem}.timepicker .dropdown-content .control{font-size:1.25em;margin-right:0 !important}.timepicker .dropdown-content .control .select{margin:0 .125em}.timepicker .dropdown-content .control .select select{font-weight:600;padding-right:calc(.75em - 1px);border:0}.timepicker .dropdown-content .control .select select option:disabled{color:hsla(0,0%,48%,.7)}.timepicker .dropdown-content .control .select:after{display:none}.timepicker .dropdown-content .control.is-colon{font-size:1.7em;line-height:1.7em}.timepicker .dropdown-content .control.is-colon:last-child{padding-right:calc(.75em - 1px)}.timepicker.is-small{border-radius:2px;font-size:.75rem}.timepicker.is-medium{font-size:1.25rem}.timepicker.is-large{font-size:1.5rem}.b-tooltip.is-top .tooltip-content{top:auto;right:auto;bottom:calc(100% + 5px + 2px);left:50%;transform:translateX(-50%)}.b-tooltip.is-top .tooltip-content::before{top:100%;right:auto;bottom:auto;left:50%;transform:translateX(-50%);border-top:5px solid #00875f;border-right:5px solid rgba(0,0,0,0);border-left:5px solid rgba(0,0,0,0)}.b-tooltip.is-top.is-white .tooltip-content::before{border-top-color:#fff}.b-tooltip.is-top.is-black .tooltip-content::before{border-top-color:hsl(0,0%,4%)}.b-tooltip.is-top.is-light .tooltip-content::before{border-top-color:hsl(0,0%,96%)}.b-tooltip.is-top.is-dark .tooltip-content::before{border-top-color:hsl(0,0%,21%)}.b-tooltip.is-top.is-primary .tooltip-content::before{border-top-color:#00875f}.b-tooltip.is-top.is-primary.is-light .tooltip-content::before{border-top-color:hsl(162.2222222222,100%,96%)}.b-tooltip.is-top.is-link .tooltip-content::before{border-top-color:hsl(229,53%,53%)}.b-tooltip.is-top.is-link.is-light .tooltip-content::before{border-top-color:hsl(229,53%,96%)}.b-tooltip.is-top.is-info .tooltip-content::before{border-top-color:hsl(207,61%,53%)}.b-tooltip.is-top.is-info.is-light .tooltip-content::before{border-top-color:hsl(207,61%,96%)}.b-tooltip.is-top.is-success .tooltip-content::before{border-top-color:hsl(153,53%,53%)}.b-tooltip.is-top.is-success.is-light .tooltip-content::before{border-top-color:hsl(153,53%,96%)}.b-tooltip.is-top.is-warning .tooltip-content::before{border-top-color:hsl(44,100%,77%)}.b-tooltip.is-top.is-warning.is-light .tooltip-content::before{border-top-color:hsl(44,100%,96%)}.b-tooltip.is-top.is-danger .tooltip-content::before{border-top-color:hsl(348,86%,61%)}.b-tooltip.is-top.is-danger.is-light .tooltip-content::before{border-top-color:hsl(348,86%,96%)}.b-tooltip.is-right .tooltip-content{top:50%;right:auto;bottom:auto;left:calc(100% + 5px + 2px);transform:translateY(-50%)}.b-tooltip.is-right .tooltip-content::before{top:50%;right:100%;bottom:auto;left:auto;transform:translateY(-50%);border-top:5px solid rgba(0,0,0,0);border-right:5px solid #00875f;border-bottom:5px solid rgba(0,0,0,0)}.b-tooltip.is-right.is-white .tooltip-content::before{border-right-color:#fff}.b-tooltip.is-right.is-black .tooltip-content::before{border-right-color:hsl(0,0%,4%)}.b-tooltip.is-right.is-light .tooltip-content::before{border-right-color:hsl(0,0%,96%)}.b-tooltip.is-right.is-dark .tooltip-content::before{border-right-color:hsl(0,0%,21%)}.b-tooltip.is-right.is-primary .tooltip-content::before{border-right-color:#00875f}.b-tooltip.is-right.is-primary.is-light .tooltip-content::before{border-right-color:hsl(162.2222222222,100%,96%)}.b-tooltip.is-right.is-link .tooltip-content::before{border-right-color:hsl(229,53%,53%)}.b-tooltip.is-right.is-link.is-light .tooltip-content::before{border-right-color:hsl(229,53%,96%)}.b-tooltip.is-right.is-info .tooltip-content::before{border-right-color:hsl(207,61%,53%)}.b-tooltip.is-right.is-info.is-light .tooltip-content::before{border-right-color:hsl(207,61%,96%)}.b-tooltip.is-right.is-success .tooltip-content::before{border-right-color:hsl(153,53%,53%)}.b-tooltip.is-right.is-success.is-light .tooltip-content::before{border-right-color:hsl(153,53%,96%)}.b-tooltip.is-right.is-warning .tooltip-content::before{border-right-color:hsl(44,100%,77%)}.b-tooltip.is-right.is-warning.is-light .tooltip-content::before{border-right-color:hsl(44,100%,96%)}.b-tooltip.is-right.is-danger .tooltip-content::before{border-right-color:hsl(348,86%,61%)}.b-tooltip.is-right.is-danger.is-light .tooltip-content::before{border-right-color:hsl(348,86%,96%)}.b-tooltip.is-bottom .tooltip-content{top:calc(100% + 5px + 2px);right:auto;bottom:auto;left:50%;transform:translateX(-50%)}.b-tooltip.is-bottom .tooltip-content::before{top:auto;right:auto;bottom:100%;left:50%;transform:translateX(-50%);border-right:5px solid rgba(0,0,0,0);border-bottom:5px solid #00875f;border-left:5px solid rgba(0,0,0,0)}.b-tooltip.is-bottom.is-white .tooltip-content::before{border-bottom-color:#fff}.b-tooltip.is-bottom.is-black .tooltip-content::before{border-bottom-color:hsl(0,0%,4%)}.b-tooltip.is-bottom.is-light .tooltip-content::before{border-bottom-color:hsl(0,0%,96%)}.b-tooltip.is-bottom.is-dark .tooltip-content::before{border-bottom-color:hsl(0,0%,21%)}.b-tooltip.is-bottom.is-primary .tooltip-content::before{border-bottom-color:#00875f}.b-tooltip.is-bottom.is-primary.is-light .tooltip-content::before{border-bottom-color:hsl(162.2222222222,100%,96%)}.b-tooltip.is-bottom.is-link .tooltip-content::before{border-bottom-color:hsl(229,53%,53%)}.b-tooltip.is-bottom.is-link.is-light .tooltip-content::before{border-bottom-color:hsl(229,53%,96%)}.b-tooltip.is-bottom.is-info .tooltip-content::before{border-bottom-color:hsl(207,61%,53%)}.b-tooltip.is-bottom.is-info.is-light .tooltip-content::before{border-bottom-color:hsl(207,61%,96%)}.b-tooltip.is-bottom.is-success .tooltip-content::before{border-bottom-color:hsl(153,53%,53%)}.b-tooltip.is-bottom.is-success.is-light .tooltip-content::before{border-bottom-color:hsl(153,53%,96%)}.b-tooltip.is-bottom.is-warning .tooltip-content::before{border-bottom-color:hsl(44,100%,77%)}.b-tooltip.is-bottom.is-warning.is-light .tooltip-content::before{border-bottom-color:hsl(44,100%,96%)}.b-tooltip.is-bottom.is-danger .tooltip-content::before{border-bottom-color:hsl(348,86%,61%)}.b-tooltip.is-bottom.is-danger.is-light .tooltip-content::before{border-bottom-color:hsl(348,86%,96%)}.b-tooltip.is-left .tooltip-content{top:50%;right:calc(100% + 5px + 2px);bottom:auto;left:auto;transform:translateY(-50%)}.b-tooltip.is-left .tooltip-content::before{top:50%;right:auto;bottom:auto;left:100%;transform:translateY(-50%);border-top:5px solid rgba(0,0,0,0);border-bottom:5px solid rgba(0,0,0,0);border-left:5px solid #00875f}.b-tooltip.is-left.is-white .tooltip-content::before{border-left-color:#fff}.b-tooltip.is-left.is-black .tooltip-content::before{border-left-color:hsl(0,0%,4%)}.b-tooltip.is-left.is-light .tooltip-content::before{border-left-color:hsl(0,0%,96%)}.b-tooltip.is-left.is-dark .tooltip-content::before{border-left-color:hsl(0,0%,21%)}.b-tooltip.is-left.is-primary .tooltip-content::before{border-left-color:#00875f}.b-tooltip.is-left.is-primary.is-light .tooltip-content::before{border-left-color:hsl(162.2222222222,100%,96%)}.b-tooltip.is-left.is-link .tooltip-content::before{border-left-color:hsl(229,53%,53%)}.b-tooltip.is-left.is-link.is-light .tooltip-content::before{border-left-color:hsl(229,53%,96%)}.b-tooltip.is-left.is-info .tooltip-content::before{border-left-color:hsl(207,61%,53%)}.b-tooltip.is-left.is-info.is-light .tooltip-content::before{border-left-color:hsl(207,61%,96%)}.b-tooltip.is-left.is-success .tooltip-content::before{border-left-color:hsl(153,53%,53%)}.b-tooltip.is-left.is-success.is-light .tooltip-content::before{border-left-color:hsl(153,53%,96%)}.b-tooltip.is-left.is-warning .tooltip-content::before{border-left-color:hsl(44,100%,77%)}.b-tooltip.is-left.is-warning.is-light .tooltip-content::before{border-left-color:hsl(44,100%,96%)}.b-tooltip.is-left.is-danger .tooltip-content::before{border-left-color:hsl(348,86%,61%)}.b-tooltip.is-left.is-danger.is-light .tooltip-content::before{border-left-color:hsl(348,86%,96%)}.b-tooltip{position:relative;display:inline-flex}.b-tooltip .tooltip-content{width:auto;padding:.35rem .75rem;border-radius:6px;font-size:.85rem;font-weight:400;box-shadow:0px 1px 2px 1px rgba(0,1,0,.2);z-index:38;white-space:nowrap;position:absolute}.b-tooltip .tooltip-content::before{position:absolute;content:"";pointer-events:none;z-index:38}.b-tooltip .tooltip-trigger{width:100%}.b-tooltip.is-white .tooltip-content{background:#fff;color:hsl(0,0%,4%)}.b-tooltip.is-black .tooltip-content{background:hsl(0,0%,4%);color:#fff}.b-tooltip.is-light .tooltip-content{background:hsl(0,0%,96%);color:rgba(0,0,0,.7)}.b-tooltip.is-dark .tooltip-content{background:hsl(0,0%,21%);color:#fff}.b-tooltip.is-primary .tooltip-content{background:#00875f;color:#fff}.b-tooltip.is-primary.is-light .tooltip-content{background:hsl(162.2222222222,100%,96%);color:rgb(0,229.5,161.5)}.b-tooltip.is-link .tooltip-content{background:hsl(229,53%,53%);color:#fff}.b-tooltip.is-link.is-light .tooltip-content{background:hsl(229,53%,96%);color:hsl(229,53%,47%)}.b-tooltip.is-info .tooltip-content{background:hsl(207,61%,53%);color:#fff}.b-tooltip.is-info.is-light .tooltip-content{background:hsl(207,61%,96%);color:hsl(207,61%,41%)}.b-tooltip.is-success .tooltip-content{background:hsl(153,53%,53%);color:#fff}.b-tooltip.is-success.is-light .tooltip-content{background:hsl(153,53%,96%);color:hsl(153,53%,31%)}.b-tooltip.is-warning .tooltip-content{background:hsl(44,100%,77%);color:rgba(0,0,0,.7)}.b-tooltip.is-warning.is-light .tooltip-content{background:hsl(44,100%,96%);color:hsl(44,100%,29%)}.b-tooltip.is-danger .tooltip-content{background:hsl(348,86%,61%);color:#fff}.b-tooltip.is-danger.is-light .tooltip-content{background:hsl(348,86%,96%);color:hsl(348,86%,43%)}.b-tooltip.is-always .tooltip-content::before,.b-tooltip.is-always .tooltip-content{opacity:1;visibility:visible}.b-tooltip.is-multiline .tooltip-content{display:flex-block;text-align:center;white-space:normal}.b-tooltip.is-multiline.is-small .tooltip-content{width:180px}.b-tooltip.is-multiline.is-medium .tooltip-content{width:240px}.b-tooltip.is-multiline.is-large .tooltip-content{width:300px}.b-tooltip.is-dashed .tooltip-trigger{border-bottom:1px dashed hsl(0,0%,71%);cursor:default}.b-tooltip.is-square .tooltip-content{border-radius:0}.upload{position:relative;display:inline-flex}.upload input[type=file]{position:absolute;top:0;left:0;width:100%;height:100%;opacity:0;outline:none;cursor:pointer;z-index:-1}.upload .upload-draggable{cursor:pointer;padding:.25em;border:1px dashed hsl(0,0%,71%);border-radius:6px}.upload .upload-draggable.is-disabled{opacity:.5;cursor:not-allowed}.upload .upload-draggable.is-loading{position:relative;pointer-events:none;opacity:.5}.upload .upload-draggable.is-loading:after{animation:spinAround 500ms infinite linear;border:2px solid hsl(0,0%,86%);border-radius:9999px;border-right-color:rgba(0,0,0,0);border-top-color:rgba(0,0,0,0);content:"";display:block;height:1em;position:relative;width:1em;top:0;left:calc(50% - 1.5em);width:3em;height:3em;border-width:.25em}.upload .upload-draggable:hover.is-white,.upload .upload-draggable.is-hovered.is-white{border-color:#fff;background:hsla(0,0%,100%,.05)}.upload .upload-draggable:hover.is-black,.upload .upload-draggable.is-hovered.is-black{border-color:hsl(0,0%,4%);background:hsla(0,0%,4%,.05)}.upload .upload-draggable:hover.is-light,.upload .upload-draggable.is-hovered.is-light{border-color:hsl(0,0%,96%);background:hsla(0,0%,96%,.05)}.upload .upload-draggable:hover.is-dark,.upload .upload-draggable.is-hovered.is-dark{border-color:hsl(0,0%,21%);background:hsla(0,0%,21%,.05)}.upload .upload-draggable:hover.is-primary,.upload .upload-draggable.is-hovered.is-primary{border-color:#00875f;background:rgba(0,135,95,.05)}.upload .upload-draggable:hover.is-link,.upload .upload-draggable.is-hovered.is-link{border-color:hsl(229,53%,53%);background:hsla(229,53%,53%,.05)}.upload .upload-draggable:hover.is-info,.upload .upload-draggable.is-hovered.is-info{border-color:hsl(207,61%,53%);background:hsla(207,61%,53%,.05)}.upload .upload-draggable:hover.is-success,.upload .upload-draggable.is-hovered.is-success{border-color:hsl(153,53%,53%);background:hsla(153,53%,53%,.05)}.upload .upload-draggable:hover.is-warning,.upload .upload-draggable.is-hovered.is-warning{border-color:hsl(44,100%,77%);background:hsla(44,100%,77%,.05)}.upload .upload-draggable:hover.is-danger,.upload .upload-draggable.is-hovered.is-danger{border-color:hsl(348,86%,61%);background:hsla(348,86%,61%,.05)}.upload .upload-draggable.is-expanded{width:100%}.upload.is-expanded{width:100%}.upload.is-rounded{border-radius:9999px}.upload.is-rounded .file-name{border-top-right-radius:9999px;border-bottom-right-radius:9999px}@media screen and (-ms-high-contrast: active),screen and (-ms-high-contrast: none){.upload input[type=file]{z-index:auto}.upload .upload-draggable+input[type=file]{z-index:-1}} diff --git a/tasks.py b/tasks.py index 23e4323..03fc70f 100644 --- a/tasks.py +++ b/tasks.py @@ -22,9 +22,6 @@ def release(c, skip_tests=False): if os.path.exists("dist"): shutil.rmtree("dist") - # custom styles for buefy - update_style(c) - c.run("python -m build") c.run("twine upload dist/*") From a17d269adb35d0ae8386e2091e6775da6bef7b93 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 14:10:26 -0600 Subject: [PATCH 036/166] docs: add docs link to readme --- README.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index 5385326..301161c 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,4 @@ include: - possibly add more schema / extra features - possibly sync data back to farmOS - -## Quick Start - -Make a virtual environment and install the app: - - python3 -m venv .venv - .venv/bin/pip install -e . - .venv/bin/wuttafarm install - -For more info see -https://docs.wuttaproject.org/wuttjamaican/narr/install/index.html +See full docs at https://docs.wuttaproject.org/wuttafarm/ From 9cc7237bfb103672087b99d0fd6ecd90da58e62c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 14:35:42 -0600 Subject: [PATCH 037/166] =?UTF-8?q?bump:=20version=200.2.2=20=E2=86=92=200?= =?UTF-8?q?.2.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e873766..b5241ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.2.3 (2026-02-08) + +### Fix + +- add custom (built) buefy css to repo + ## v0.2.2 (2026-02-08) ### Fix diff --git a/pyproject.toml b/pyproject.toml index b7e4ae6..fbc8df2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.2.2" +version = "0.2.3" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 09042747a0aa816cca19a83d6151d0a74a65094d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2026 18:29:08 -0600 Subject: [PATCH 038/166] feat: add "See raw JSON data" button for farmOS API views --- .../web/templates/farmos/master/view.mako | 45 +++++++++++++++++++ .../web/views/farmos/animal_types.py | 1 + src/wuttafarm/web/views/farmos/animals.py | 1 + src/wuttafarm/web/views/farmos/asset_types.py | 1 + src/wuttafarm/web/views/farmos/groups.py | 3 +- src/wuttafarm/web/views/farmos/land_assets.py | 1 + src/wuttafarm/web/views/farmos/land_types.py | 1 + src/wuttafarm/web/views/farmos/log_types.py | 1 + .../web/views/farmos/logs_activity.py | 1 + src/wuttafarm/web/views/farmos/master.py | 22 +++++++++ .../web/views/farmos/structure_types.py | 1 + src/wuttafarm/web/views/farmos/structures.py | 2 +- src/wuttafarm/web/views/farmos/users.py | 1 + 13 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 src/wuttafarm/web/templates/farmos/master/view.mako diff --git a/src/wuttafarm/web/templates/farmos/master/view.mako b/src/wuttafarm/web/templates/farmos/master/view.mako new file mode 100644 index 0000000..5e7bcd0 --- /dev/null +++ b/src/wuttafarm/web/templates/farmos/master/view.mako @@ -0,0 +1,45 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="tool_panels()"> + ${parent.tool_panels()} + ${self.tool_panel_tools()} + + +<%def name="tool_panel_tools()"> + % if raw_json: + + + + See raw JSON data + + + + <${b}-modal :width="1200" + % if request.use_oruga: + v-model:active="viewJsonShowDialog" + % else: + :active.sync="viewJsonShowDialog" + % endif + > +
+
+ ${rendered_json|n} +
+
+ + + % endif + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if raw_json: + + % endif + diff --git a/src/wuttafarm/web/views/farmos/animal_types.py b/src/wuttafarm/web/views/farmos/animal_types.py index a974242..0e8e4df 100644 --- a/src/wuttafarm/web/views/farmos/animal_types.py +++ b/src/wuttafarm/web/views/farmos/animal_types.py @@ -79,6 +79,7 @@ class AnimalTypeView(FarmOSMasterView): animal_type = self.farmos_client.resource.get_id( "taxonomy_term", "animal_type", self.request.matchdict["uuid"] ) + self.raw_json = animal_type return self.normalize_animal_type(animal_type["data"]) def get_instance_title(self, animal_type): diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 8eca5af..029ecfb 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -99,6 +99,7 @@ class AnimalView(FarmOSMasterView): animal = self.farmos_client.resource.get_id( "asset", "animal", self.request.matchdict["uuid"] ) + self.raw_json = animal # instance data data = self.normalize_animal(animal["data"]) diff --git a/src/wuttafarm/web/views/farmos/asset_types.py b/src/wuttafarm/web/views/farmos/asset_types.py index 75eebbe..de285e1 100644 --- a/src/wuttafarm/web/views/farmos/asset_types.py +++ b/src/wuttafarm/web/views/farmos/asset_types.py @@ -69,6 +69,7 @@ class AssetTypeView(FarmOSMasterView): asset_type = self.farmos_client.resource.get_id( "asset_type", "asset_type", self.request.matchdict["uuid"] ) + self.raw_json = asset_type return self.normalize_asset_type(asset_type["data"]) def get_instance_title(self, asset_type): diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index 4664a6b..127dd43 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -88,11 +88,10 @@ class GroupView(FarmOSMasterView): g.set_renderer("changed", "datetime") def get_instance(self): - group = self.farmos_client.resource.get_id( "asset", "group", self.request.matchdict["uuid"] ) - + self.raw_json = group return self.normalize_group(group["data"]) def get_instance_title(self, group): diff --git a/src/wuttafarm/web/views/farmos/land_assets.py b/src/wuttafarm/web/views/farmos/land_assets.py index a496cc5..5a8cc24 100644 --- a/src/wuttafarm/web/views/farmos/land_assets.py +++ b/src/wuttafarm/web/views/farmos/land_assets.py @@ -95,6 +95,7 @@ class LandAssetView(FarmOSMasterView): land_asset = self.farmos_client.resource.get_id( "asset", "land", self.request.matchdict["uuid"] ) + self.raw_json = land_asset return self.normalize_land_asset(land_asset["data"]) def get_instance_title(self, land_asset): diff --git a/src/wuttafarm/web/views/farmos/land_types.py b/src/wuttafarm/web/views/farmos/land_types.py index aadece8..02c0560 100644 --- a/src/wuttafarm/web/views/farmos/land_types.py +++ b/src/wuttafarm/web/views/farmos/land_types.py @@ -64,6 +64,7 @@ class LandTypeView(FarmOSMasterView): land_type = self.farmos_client.resource.get_id( "land_type", "land_type", self.request.matchdict["uuid"] ) + self.raw_json = land_type return self.normalize_land_type(land_type["data"]) def get_instance_title(self, land_type): diff --git a/src/wuttafarm/web/views/farmos/log_types.py b/src/wuttafarm/web/views/farmos/log_types.py index 6e72f8f..6d25c10 100644 --- a/src/wuttafarm/web/views/farmos/log_types.py +++ b/src/wuttafarm/web/views/farmos/log_types.py @@ -66,6 +66,7 @@ class LogTypeView(FarmOSMasterView): log_type = self.farmos_client.resource.get_id( "log_type", "log_type", self.request.matchdict["uuid"] ) + self.raw_json = log_type return self.normalize_log_type(log_type["data"]) def get_instance_title(self, log_type): diff --git a/src/wuttafarm/web/views/farmos/logs_activity.py b/src/wuttafarm/web/views/farmos/logs_activity.py index 61b4e85..33448ab 100644 --- a/src/wuttafarm/web/views/farmos/logs_activity.py +++ b/src/wuttafarm/web/views/farmos/logs_activity.py @@ -79,6 +79,7 @@ class ActivityLogView(FarmOSMasterView): def get_instance(self): log = self.farmos_client.log.get_id("activity", self.request.matchdict["uuid"]) + self.raw_json = log return self.normalize_log(log["data"]) def get_instance_title(self, log): diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 59003d0..955120b 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -23,6 +23,10 @@ Base class for farmOS master views """ +import json + +import markdown + from wuttaweb.views import MasterView from wuttafarm.web.util import save_farmos_oauth2_token @@ -54,6 +58,7 @@ class FarmOSMasterView(MasterView): def __init__(self, request, context=None): super().__init__(request, context=context) self.farmos_client = self.get_farmos_client() + self.raw_json = None def get_farmos_client(self): token = self.request.session.get("farmos.oauth2.token") @@ -71,9 +76,26 @@ class FarmOSMasterView(MasterView): return self.app.get_farmos_client(token=token, token_updater=token_updater) + def get_fallback_templates(self, template): + """ """ + templates = super().get_fallback_templates(template) + + if template == "view": + templates.insert(0, "/farmos/master/view.mako") + + return templates + def get_template_context(self, context): if self.listing and self.farmos_refurl_path: context["farmos_refurl"] = self.app.get_farmos_url(self.farmos_refurl_path) + if self.viewing and self.raw_json: + context["raw_json"] = self.raw_json + code = "```json\n" + json.dumps(self.raw_json, indent=2) + "\n```" + # TODO: this does not seem to be adding syntax highlight + context["rendered_json"] = markdown.markdown( + code, extensions=["fenced_code", "codehilite"] + ) + return context diff --git a/src/wuttafarm/web/views/farmos/structure_types.py b/src/wuttafarm/web/views/farmos/structure_types.py index 3fe4741..6a2342e 100644 --- a/src/wuttafarm/web/views/farmos/structure_types.py +++ b/src/wuttafarm/web/views/farmos/structure_types.py @@ -66,6 +66,7 @@ class StructureTypeView(FarmOSMasterView): structure_type = self.farmos_client.resource.get_id( "structure_type", "structure_type", self.request.matchdict["uuid"] ) + self.raw_json = structure_type return self.normalize_structure_type(structure_type["data"]) def get_instance_title(self, structure_type): diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py index bbc4f1f..cb91de5 100644 --- a/src/wuttafarm/web/views/farmos/structures.py +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -94,7 +94,7 @@ class StructureView(FarmOSMasterView): structure = self.farmos_client.resource.get_id( "asset", "structure", self.request.matchdict["uuid"] ) - + self.raw_json = structure data = self.normalize_structure(structure["data"]) if relationships := structure["data"].get("relationships"): diff --git a/src/wuttafarm/web/views/farmos/users.py b/src/wuttafarm/web/views/farmos/users.py index 317bfe3..fa47d34 100644 --- a/src/wuttafarm/web/views/farmos/users.py +++ b/src/wuttafarm/web/views/farmos/users.py @@ -77,6 +77,7 @@ class UserView(FarmOSMasterView): user = self.farmos_client.resource.get_id( "user", "user", self.request.matchdict["uuid"] ) + self.raw_json = user return self.normalize_user(user["data"]) def get_instance_title(self, user): From 4a517bf7bf161aa09dac30a23673c0878a67b596 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Feb 2026 21:05:40 -0600 Subject: [PATCH 039/166] feat: add native table for Animal Types; import from farmOS API --- docs/api/wuttafarm.cli.base.rst | 6 + docs/api/wuttafarm.cli.import_farmos.rst | 6 + docs/api/wuttafarm.cli.install.rst | 6 + docs/api/wuttafarm.importing.farmos.rst | 6 + docs/api/wuttafarm.importing.rst | 6 + docs/index.rst | 11 +- pyproject.toml | 7 + src/wuttafarm/app.py | 8 + src/wuttafarm/cli/__init__.py | 30 ++++ src/wuttafarm/cli/base.py | 31 ++++ src/wuttafarm/cli/import_farmos.py | 41 +++++ src/wuttafarm/{cli.py => cli/install.py} | 7 +- .../versions/2b6385d0fa17_add_animal_types.py | 120 ++++++++++++++ src/wuttafarm/db/model/__init__.py | 3 +- src/wuttafarm/db/model/animals.py | 94 +++++++++++ src/wuttafarm/emails.py | 32 ++++ src/wuttafarm/importing/__init__.py | 24 +++ src/wuttafarm/importing/farmos.py | 154 ++++++++++++++++++ src/wuttafarm/web/menus.py | 14 ++ src/wuttafarm/web/views/__init__.py | 5 + src/wuttafarm/web/views/animal_types.py | 99 +++++++++++ .../web/views/farmos/animal_types.py | 25 ++- src/wuttafarm/web/views/master.py | 63 +++++++ 23 files changed, 786 insertions(+), 12 deletions(-) create mode 100644 docs/api/wuttafarm.cli.base.rst create mode 100644 docs/api/wuttafarm.cli.import_farmos.rst create mode 100644 docs/api/wuttafarm.cli.install.rst create mode 100644 docs/api/wuttafarm.importing.farmos.rst create mode 100644 docs/api/wuttafarm.importing.rst create mode 100644 src/wuttafarm/cli/__init__.py create mode 100644 src/wuttafarm/cli/base.py create mode 100644 src/wuttafarm/cli/import_farmos.py rename src/wuttafarm/{cli.py => cli/install.py} (89%) create mode 100644 src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py create mode 100644 src/wuttafarm/db/model/animals.py create mode 100644 src/wuttafarm/emails.py create mode 100644 src/wuttafarm/importing/__init__.py create mode 100644 src/wuttafarm/importing/farmos.py create mode 100644 src/wuttafarm/web/views/animal_types.py create mode 100644 src/wuttafarm/web/views/master.py diff --git a/docs/api/wuttafarm.cli.base.rst b/docs/api/wuttafarm.cli.base.rst new file mode 100644 index 0000000..19afd5c --- /dev/null +++ b/docs/api/wuttafarm.cli.base.rst @@ -0,0 +1,6 @@ + +``wuttafarm.cli.base`` +====================== + +.. automodule:: wuttafarm.cli.base + :members: diff --git a/docs/api/wuttafarm.cli.import_farmos.rst b/docs/api/wuttafarm.cli.import_farmos.rst new file mode 100644 index 0000000..12a6d03 --- /dev/null +++ b/docs/api/wuttafarm.cli.import_farmos.rst @@ -0,0 +1,6 @@ + +``wuttafarm.cli.import_farmos`` +=============================== + +.. automodule:: wuttafarm.cli.import_farmos + :members: diff --git a/docs/api/wuttafarm.cli.install.rst b/docs/api/wuttafarm.cli.install.rst new file mode 100644 index 0000000..e825989 --- /dev/null +++ b/docs/api/wuttafarm.cli.install.rst @@ -0,0 +1,6 @@ + +``wuttafarm.cli.install`` +========================= + +.. automodule:: wuttafarm.cli.install + :members: diff --git a/docs/api/wuttafarm.importing.farmos.rst b/docs/api/wuttafarm.importing.farmos.rst new file mode 100644 index 0000000..b6e00b4 --- /dev/null +++ b/docs/api/wuttafarm.importing.farmos.rst @@ -0,0 +1,6 @@ + +``wuttafarm.importing.farmos`` +============================== + +.. automodule:: wuttafarm.importing.farmos + :members: diff --git a/docs/api/wuttafarm.importing.rst b/docs/api/wuttafarm.importing.rst new file mode 100644 index 0000000..5c331b9 --- /dev/null +++ b/docs/api/wuttafarm.importing.rst @@ -0,0 +1,6 @@ + +``wuttafarm.importing`` +======================= + +.. automodule:: wuttafarm.importing + :members: diff --git a/docs/index.rst b/docs/index.rst index 4c7887b..a68b748 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,9 +8,6 @@ and extend `farmOS`_. .. _WuttaWeb: https://wuttaproject.org .. _farmOS: https://farmos.org -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black - It is just an experiment so far; the ideas I hope to play with include: @@ -19,6 +16,9 @@ include: - possibly add more schema / extra features - possibly sync data back to farmOS +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/psf/black + .. toctree:: :maxdepth: 2 @@ -37,11 +37,16 @@ include: api/wuttafarm.app api/wuttafarm.auth api/wuttafarm.cli + api/wuttafarm.cli.base + api/wuttafarm.cli.import_farmos + api/wuttafarm.cli.install api/wuttafarm.config api/wuttafarm.db api/wuttafarm.db.model api/wuttafarm.farmos api/wuttafarm.farmos.handler + api/wuttafarm.importing + api/wuttafarm.importing.farmos api/wuttafarm.install api/wuttafarm.web api/wuttafarm.web.app diff --git a/pyproject.toml b/pyproject.toml index fbc8df2..4c3d4d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "psycopg2", "pyramid_exclog", "uvicorn[standard]", + "WuttaSync", "WuttaWeb[continuum]>=0.27.4", ] @@ -47,12 +48,18 @@ docs = ["Sphinx", "furo"] [project.entry-points."paste.app_factory"] "main" = "wuttafarm.web.app:main" +[project.entry-points."wutta.app.providers"] +wuttafarm = "wuttafarm.app:WuttaFarmAppProvider" + [project.entry-points."wutta.config.extensions"] "wuttafarm" = "wuttafarm.config:WuttaFarmConfig" [project.entry-points."wutta.web.menus"] "wuttafarm" = "wuttafarm.web.menus:WuttaFarmMenuHandler" +[project.entry-points."wuttasync.importing"] +"import.to_wuttafarm.from_farmos" = "wuttafarm.importing.farmos:FromFarmOSToWuttaFarm" + [project.urls] Homepage = "https://forgejo.wuttaproject.org/wutta/wuttafarm" diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 26c6ef8..72dd675 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -64,3 +64,11 @@ class WuttaFarmAppHandler(base.AppHandler): """ handler = self.get_farmos_handler() return handler.get_farmos_client(*args, **kwargs) + + +class WuttaFarmAppProvider(base.AppProvider): + """ + The :term:`app provider` for WuttaFarm. + """ + + email_modules = ["wuttafarm.emails"] diff --git a/src/wuttafarm/cli/__init__.py b/src/wuttafarm/cli/__init__.py new file mode 100644 index 0000000..7f6c2bb --- /dev/null +++ b/src/wuttafarm/cli/__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 . +# +################################################################################ +""" +WuttaFarm CLI +""" + +from .base import wuttafarm_typer + +# nb. must bring in all modules for discovery to work +from . import import_farmos +from . import install diff --git a/src/wuttafarm/cli/base.py b/src/wuttafarm/cli/base.py new file mode 100644 index 0000000..de16ead --- /dev/null +++ b/src/wuttafarm/cli/base.py @@ -0,0 +1,31 @@ +# -*- 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 . +# +################################################################################ +""" +WuttaFarm CLI - base Typer instance +""" + +from wuttjamaican.cli import make_typer + + +wuttafarm_typer = make_typer( + name="wuttafarm", help="WuttaFarm -- Web app to integrate with and extend farmOS" +) diff --git a/src/wuttafarm/cli/import_farmos.py b/src/wuttafarm/cli/import_farmos.py new file mode 100644 index 0000000..4343d43 --- /dev/null +++ b/src/wuttafarm/cli/import_farmos.py @@ -0,0 +1,41 @@ +# -*- 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 . +# +################################################################################ +""" +See also: :ref:`wuttafarm-import-farmos` +""" + +import typer + +from wuttasync.cli import import_command, ImportCommandHandler + +from wuttafarm.cli import wuttafarm_typer + + +@wuttafarm_typer.command() +@import_command +def import_farmos(ctx: typer.Context, **kwargs): + """ + Import data from farmOS API to WuttaFarm + """ + config = ctx.parent.wutta_config + handler = ImportCommandHandler(config, key="import.to_wuttafarm.from_farmos") + handler.run(ctx) diff --git a/src/wuttafarm/cli.py b/src/wuttafarm/cli/install.py similarity index 89% rename from src/wuttafarm/cli.py rename to src/wuttafarm/cli/install.py index 2f377a3..c82dab2 100644 --- a/src/wuttafarm/cli.py +++ b/src/wuttafarm/cli/install.py @@ -25,12 +25,7 @@ WuttaFarm CLI import typer -from wuttjamaican.cli import make_typer - - -wuttafarm_typer = make_typer( - name="wuttafarm", help="WuttaFarm -- Web app to integrate with and extend farmOS" -) +from wuttafarm.cli import wuttafarm_typer @wuttafarm_typer.command() diff --git a/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py b/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py new file mode 100644 index 0000000..7ddc814 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py @@ -0,0 +1,120 @@ +"""add Animal Types + +Revision ID: 2b6385d0fa17 +Revises: +Create Date: 2026-02-08 14:55:42.236918 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "2b6385d0fa17" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = ("wuttafarm",) +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # animal_type + op.create_table( + "animal_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("changed", sa.DateTime(), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal_type")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_animal_type_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_animal_type_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_animal_type_name")), + ) + op.create_table( + "animal_type_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_animal_type_version") + ), + ) + op.create_index( + op.f("ix_animal_type_version_end_transaction_id"), + "animal_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_animal_type_version_operation_type"), + "animal_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_animal_type_version_pk_transaction_id", + "animal_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_animal_type_version_pk_validity", + "animal_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_animal_type_version_transaction_id"), + "animal_type_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # animal_type + op.drop_index( + op.f("ix_animal_type_version_transaction_id"), table_name="animal_type_version" + ) + op.drop_index( + "ix_animal_type_version_pk_validity", table_name="animal_type_version" + ) + op.drop_index( + "ix_animal_type_version_pk_transaction_id", table_name="animal_type_version" + ) + op.drop_index( + op.f("ix_animal_type_version_operation_type"), table_name="animal_type_version" + ) + op.drop_index( + op.f("ix_animal_type_version_end_transaction_id"), + table_name="animal_type_version", + ) + op.drop_table("animal_type_version") + op.drop_table("animal_type") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index b52d7c8..d0693cb 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -26,4 +26,5 @@ WuttaFarm data models # bring in all of wutta from wuttjamaican.db.model import * -# TODO: import other/custom models here... +# wuttafarm models +from .animals import AnimalType diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py new file mode 100644 index 0000000..a26b966 --- /dev/null +++ b/src/wuttafarm/db/model/animals.py @@ -0,0 +1,94 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Animal Types +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + + +class AnimalType(model.Base): + """ + Represents an "animal type" (taxonomy term) from farmOS + """ + + __tablename__ = "animal_type" + __versioned__ = { + "exclude": [ + "changed", + ], + } + __wutta_hint__ = { + "model_title": "Animal Type", + "model_title_plural": "Animal Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the animal type. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional description for the animal type. + """, + ) + + changed = sa.Column( + sa.DateTime(), + nullable=True, + doc=""" + When the animal type was last changed, according to farmOS. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the animal type within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the animal type. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/emails.py b/src/wuttafarm/emails.py new file mode 100644 index 0000000..55b1612 --- /dev/null +++ b/src/wuttafarm/emails.py @@ -0,0 +1,32 @@ +# -*- 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 . +# +################################################################################ +""" +Email sending config for WuttaFarm +""" + +from wuttasync.emails import ImportExportWarning + + +class import_to_wuttafarm_from_farmos_warning(ImportExportWarning): + """ + Diff warning for farmOS → WuttaFarm import. + """ diff --git a/src/wuttafarm/importing/__init__.py b/src/wuttafarm/importing/__init__.py new file mode 100644 index 0000000..6711d56 --- /dev/null +++ b/src/wuttafarm/importing/__init__.py @@ -0,0 +1,24 @@ +# -*- 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 . +# +################################################################################ +""" +Importing data to WuttaFarm +""" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py new file mode 100644 index 0000000..e9e4735 --- /dev/null +++ b/src/wuttafarm/importing/farmos.py @@ -0,0 +1,154 @@ +# -*- 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 . +# +################################################################################ +""" +Data import for farmOS -> WuttaFarm +""" + +import datetime +from uuid import UUID + +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2Session + +from wuttasync.importing import ImportHandler, ToWuttaHandler, Importer, ToWutta + +from wuttafarm.db import model + + +class FromFarmOSHandler(ImportHandler): + """ + Base class for import handler using farmOS API as data source. + """ + + source_key = "farmos" + generic_source_title = "farmOS" + + def begin_source_transaction(self): + """ + Establish the farmOS API client. + """ + token = self.get_farmos_oauth2_token() + self.farmos_client = self.app.get_farmos_client(token=token) + + def get_farmos_oauth2_token(self): + + client_id = self.config.get( + "farmos.oauth2.importing.client_id", default="wuttafarm" + ) + client_secret = self.config.require("farmos.oauth2.importing.client_secret") + scope = self.config.get("farmos.oauth2.importing.scope", default="farm_manager") + + client = BackendApplicationClient(client_id=client_id) + oauth = OAuth2Session(client=client) + + return oauth.fetch_token( + token_url=self.app.get_farmos_url("/oauth/token"), + include_client_id=True, + client_secret=client_secret, + scope=scope, + ) + + def get_importer_kwargs(self, key, **kwargs): + kwargs = super().get_importer_kwargs(key, **kwargs) + kwargs["farmos_client"] = self.farmos_client + return kwargs + + +class ToWuttaFarmHandler(ToWuttaHandler): + """ + Base class for import handler targeting WuttaFarm + """ + + target_key = "wuttafarm" + + +class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): + """ + Handler for farmOS → WuttaFarm import. + """ + + def define_importers(self): + """ """ + importers = super().define_importers() + importers["AnimalType"] = AnimalTypeImporter + return importers + + +class FromFarmOS(Importer): + """ + Base class for importers using farmOS API as data source. + """ + + key = "farmos_uuid" + + def get_supported_fields(self): + """ + Auto-remove the ``uuid`` field, since we use ``farmos_uuid`` + instead for the importer key. + """ + fields = list(super().get_supported_fields()) + if "uuid" in fields: + fields.remove("uuid") + return fields + + def normalize_datetime(self, dt): + """ + Convert a farmOS datetime value to naive UTC used by + WuttaFarm. + + :param dt: Date/time string value "as-is" from the farmOS API. + + :returns: Equivalent naive UTC ``datetime`` + """ + dt = datetime.datetime.fromisoformat(dt) + return self.app.make_utc(dt) + + +class AnimalTypeImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Animal Types + """ + + model_class = model.AnimalType + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + "description", + "changed", + ] + + def get_source_objects(self): + """ """ + animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type") + return animal_types["data"] + + def normalize_source_object(self, animal_type): + """ """ + return { + "farmos_uuid": UUID(animal_type["id"]), + "drupal_internal_id": animal_type["attributes"]["drupal_internal__tid"], + "name": animal_type["attributes"]["name"], + "description": animal_type["attributes"]["description"], + "changed": self.normalize_datetime(animal_type["attributes"]["changed"]), + } diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index ab6f440..7571b3c 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -33,10 +33,24 @@ class WuttaFarmMenuHandler(base.MenuHandler): def make_menus(self, request, **kwargs): return [ + self.make_asset_menu(request), self.make_farmos_menu(request), self.make_admin_menu(request, include_people=True), ] + def make_asset_menu(self, request): + return { + "title": "Assets", + "type": "menu", + "items": [ + { + "title": "Animal Types", + "route": "animal_types", + "perm": "animal_types.list", + }, + ], + } + def make_farmos_menu(self, request): config = request.wutta_config app = config.get_app() diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 63ce536..86dcd81 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -25,6 +25,8 @@ WuttaFarm Views from wuttaweb.views import essential +from .master import WuttaFarmMasterView + def includeme(config): @@ -37,5 +39,8 @@ def includeme(config): } ) + # native table views + config.include("wuttafarm.web.views.animal_types") + # views for farmOS config.include("wuttafarm.web.views.farmos") diff --git a/src/wuttafarm/web/views/animal_types.py b/src/wuttafarm/web/views/animal_types.py new file mode 100644 index 0000000..ecd136c --- /dev/null +++ b/src/wuttafarm/web/views/animal_types.py @@ -0,0 +1,99 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Animal Types +""" + +from wuttafarm.db.model.animals import AnimalType +from wuttafarm.web.views import WuttaFarmMasterView + + +class AnimalTypeView(WuttaFarmMasterView): + """ + Master view for Animal Types + """ + + model_class = AnimalType + route_prefix = "animal_types" + url_prefix = "/animal-types" + + farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview" + + grid_columns = [ + "name", + "description", + "changed", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "changed", + "farmos_uuid", + "drupal_internal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_farmos_url(self, animal_type): + return self.app.get_farmos_url( + f"/taxonomy/term/{animal_type.drupal_internal_id}" + ) + + def get_xref_buttons(self, animal_type): + buttons = super().get_xref_buttons(animal_type) + + if animal_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_animal_types.view", uuid=animal_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + AnimalTypeView = kwargs.get("AnimalTypeView", base["AnimalTypeView"]) + AnimalTypeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/animal_types.py b/src/wuttafarm/web/views/farmos/animal_types.py index 0e8e4df..ae0b0d4 100644 --- a/src/wuttafarm/web/views/farmos/animal_types.py +++ b/src/wuttafarm/web/views/farmos/animal_types.py @@ -113,7 +113,10 @@ class AnimalTypeView(FarmOSMasterView): f.set_node("changed", WuttaDateTime()) def get_xref_buttons(self, animal_type): - return [ + model = self.app.model + session = self.Session() + + buttons = [ self.make_button( "View in farmOS", primary=True, @@ -122,9 +125,27 @@ class AnimalTypeView(FarmOSMasterView): ), target="_blank", icon_left="external-link-alt", - ), + ) ] + if wf_animal_type := ( + session.query(model.AnimalType) + .filter(model.AnimalType.farmos_uuid == animal_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "animal_types.view", uuid=wf_animal_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py new file mode 100644 index 0000000..69a7f89 --- /dev/null +++ b/src/wuttafarm/web/views/master.py @@ -0,0 +1,63 @@ +# -*- 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 WuttaFarm master views +""" + +from wuttaweb.views import MasterView + + +class WuttaFarmMasterView(MasterView): + """ + Base class for WuttaFarm master views + """ + + farmos_refurl_path = None + + labels = { + "farmos_uuid": "farmOS UUID", + "drupal_internal_id": "Drupal Internal ID", + } + + def get_farmos_url(self, obj): + return None + + def get_template_context(self, context): + + if self.listing and self.farmos_refurl_path: + context["farmos_refurl"] = self.app.get_farmos_url(self.farmos_refurl_path) + + return context + + def get_xref_buttons(self, obj): + url = self.get_farmos_url(obj) + if url: + return [ + self.make_button( + "View in farmOS", + primary=True, + url=url, + target="_blank", + icon_left="external-link-alt", + ) + ] + return [] From fd2f09fcf3085b7a77b17830594c23e762e12d37 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Feb 2026 21:08:44 -0600 Subject: [PATCH 040/166] feat: add extension table for Users; import from farmOS API --- .../6c56bcd1c028_add_wuttafarmuser.py | 114 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 5 +- src/wuttafarm/db/model/users.py | 80 ++++++++++++ src/wuttafarm/importing/farmos.py | 54 +++++++++ src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/farmos/users.py | 27 ++++- src/wuttafarm/web/views/users.py | 97 +++++++++++++++ 7 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py create mode 100644 src/wuttafarm/db/model/users.py create mode 100644 src/wuttafarm/web/views/users.py diff --git a/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py b/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py new file mode 100644 index 0000000..32fa175 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py @@ -0,0 +1,114 @@ +"""add WuttaFarmUser + +Revision ID: 6c56bcd1c028 +Revises: 2b6385d0fa17 +Create Date: 2026-02-09 20:46:20.995903 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "6c56bcd1c028" +down_revision: Union[str, None] = "2b6385d0fa17" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # wuttafarm_user + op.create_table( + "wuttafarm_user", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["uuid"], ["user.uuid"], name=op.f("fk_wuttafarm_user_uuid_user") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_wuttafarm_user")), + ) + op.create_table( + "wuttafarm_user_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_wuttafarm_user_version") + ), + ) + op.create_index( + op.f("ix_wuttafarm_user_version_end_transaction_id"), + "wuttafarm_user_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_wuttafarm_user_version_operation_type"), + "wuttafarm_user_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_wuttafarm_user_version_pk_transaction_id", + "wuttafarm_user_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_wuttafarm_user_version_pk_validity", + "wuttafarm_user_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_wuttafarm_user_version_transaction_id"), + "wuttafarm_user_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # wuttafarm_user + op.drop_index( + op.f("ix_wuttafarm_user_version_transaction_id"), + table_name="wuttafarm_user_version", + ) + op.drop_index( + "ix_wuttafarm_user_version_pk_validity", table_name="wuttafarm_user_version" + ) + op.drop_index( + "ix_wuttafarm_user_version_pk_transaction_id", + table_name="wuttafarm_user_version", + ) + op.drop_index( + op.f("ix_wuttafarm_user_version_operation_type"), + table_name="wuttafarm_user_version", + ) + op.drop_index( + op.f("ix_wuttafarm_user_version_end_transaction_id"), + table_name="wuttafarm_user_version", + ) + op.drop_table("wuttafarm_user_version") + op.drop_table("wuttafarm_user") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index d0693cb..29e4dc0 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -26,5 +26,8 @@ WuttaFarm data models # bring in all of wutta from wuttjamaican.db.model import * -# wuttafarm models +# wutta model extensions +from .users import WuttaFarmUser + +# wuttafarm proper models from .animals import AnimalType diff --git a/src/wuttafarm/db/model/users.py b/src/wuttafarm/db/model/users.py new file mode 100644 index 0000000..2cad429 --- /dev/null +++ b/src/wuttafarm/db/model/users.py @@ -0,0 +1,80 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Users (extension) +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + + +class WuttaFarmUser(model.Base): + """ + WuttaFarm extension for the User model. + """ + + __tablename__ = "wuttafarm_user" + __versioned__ = {} + + uuid = model.uuid_column(sa.ForeignKey("user.uuid"), default=None) + + user = orm.relationship( + model.User, + doc=""" + Reference to the User which this record extends. + """, + backref=orm.backref( + "_wuttafarm", + uselist=False, + cascade="all, delete-orphan", + cascade_backrefs=False, + doc=""" + Reference to the WuttaFarm-specific extension record for + the user. + """, + ), + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + doc=""" + UUID for the user within farmOS + """, + ) + + drupal_internal_id = sa.Column( + sa.Integer(), + nullable=True, + doc=""" + Drupal internal ID for the user. + """, + ) + + def __str__(self): + return str(self.user or "") + + +WuttaFarmUser.make_proxy(model.User, "_wuttafarm", "farmos_uuid") +WuttaFarmUser.make_proxy(model.User, "_wuttafarm", "drupal_internal_id") diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index e9e4735..3c6eea9 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -89,6 +89,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): def define_importers(self): """ """ importers = super().define_importers() + importers["User"] = UserImporter importers["AnimalType"] = AnimalTypeImporter return importers @@ -152,3 +153,56 @@ class AnimalTypeImporter(FromFarmOS, ToWutta): "description": animal_type["attributes"]["description"], "changed": self.normalize_datetime(animal_type["attributes"]["changed"]), } + + +class UserImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Users + """ + + model_class = model.User + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "username", + ] + + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare extension fields + fields.extend( + [ + "farmos_uuid", + "drupal_internal_id", + ] + ) + return fields + + def get_source_objects(self): + """ """ + users = self.farmos_client.resource.get("user") + return users["data"] + + def normalize_source_object(self, user): + """ """ + + # nb. skip Anonymous user which does not have drupal id + drupal_internal_id = user["attributes"].get("drupal_internal__uid") + if not drupal_internal_id: + return None + + return { + "farmos_uuid": UUID(user["id"]), + "drupal_internal_id": drupal_internal_id, + "username": user["attributes"]["name"], + } + + def can_delete_object(self, user, data=None): + """ + Prevent delete for users which do not exist in farmOS. + """ + if not user.farmos_uuid: + return False + return True diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 86dcd81..25a4054 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -36,6 +36,7 @@ def includeme(config): **{ "wuttaweb.views.auth": "wuttafarm.web.views.auth", "wuttaweb.views.common": "wuttafarm.web.views.common", + "wuttaweb.views.users": "wuttafarm.web.views.users", } ) diff --git a/src/wuttafarm/web/views/farmos/users.py b/src/wuttafarm/web/views/farmos/users.py index fa47d34..bb93066 100644 --- a/src/wuttafarm/web/views/farmos/users.py +++ b/src/wuttafarm/web/views/farmos/users.py @@ -116,17 +116,36 @@ class UserView(FarmOSMasterView): f.set_node("changed", WuttaDateTime()) def get_xref_buttons(self, user): + model = self.app.model + session = self.Session() + buttons = [] + if drupal_id := user["drupal_internal_id"]: - return [ + buttons.append( self.make_button( "View in farmOS", primary=True, url=self.app.get_farmos_url(f"/user/{drupal_id}"), target="_blank", icon_left="external-link-alt", - ), - ] - return None + ) + ) + + if wf_user := ( + session.query(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == user["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("users.view", uuid=wf_user.uuid), + icon_left="eye", + ) + ) + + return buttons def defaults(config, **kwargs): diff --git a/src/wuttafarm/web/views/users.py b/src/wuttafarm/web/views/users.py new file mode 100644 index 0000000..782ab16 --- /dev/null +++ b/src/wuttafarm/web/views/users.py @@ -0,0 +1,97 @@ +# -*- 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 . +# +################################################################################ +""" +Views for Users +""" + +from wuttaweb.views import users as base + + +class UserView(base.UserView): + """ + Custom master view for Users. + """ + + labels = { + "farmos_uuid": "farmOS UUID", + "drupal_internal_id": "Drupal Internal ID", + } + + def get_template_context(self, context): + context = super().get_template_context(context) + + if self.listing: + context["farmos_refurl"] = self.app.get_farmos_url("/admin/people") + + return context + + def configure_form(self, form): + """ """ + f = form + super().configure_form(f) + user = f.model_instance + + # farmos_uuid + f.fields.append("farmos_uuid") + f.set_default("farmos_uuid", user.farmos_uuid) + + # drupal_internal_id + f.fields.append("drupal_internal_id") + f.set_default("drupal_internal_id", user.drupal_internal_id) + + def get_xref_buttons(self, user): + buttons = [] + + if user.drupal_internal_id: + buttons.append( + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/user/{user.drupal_internal_id}"), + target="_blank", + icon_left="external-link-alt", + ) + ) + + if user.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_users.view", uuid=user.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + local = globals() + UserView = kwargs.get("UserView", local["UserView"]) + base.defaults(config, **{"UserView": UserView}) + + +def includeme(config): + defaults(config) From 10666de488f1dadb6e6a4ae9d9788dd7f7c58db3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2026 19:04:44 -0600 Subject: [PATCH 041/166] feat: add native table for Asset Types; import from farmOS API --- .../versions/cf3f8f46d8bc_add_asset_types.py | 120 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 1 + src/wuttafarm/db/model/assets.py | 82 ++++++++++++ src/wuttafarm/importing/farmos.py | 30 +++++ src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/asset_types.py | 90 +++++++++++++ src/wuttafarm/web/views/farmos/asset_types.py | 23 ++++ 8 files changed, 352 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py create mode 100644 src/wuttafarm/db/model/assets.py create mode 100644 src/wuttafarm/web/views/asset_types.py diff --git a/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py b/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py new file mode 100644 index 0000000..2a5f570 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py @@ -0,0 +1,120 @@ +"""add Asset Types + +Revision ID: cf3f8f46d8bc +Revises: 6c56bcd1c028 +Create Date: 2026-02-10 18:42:24.560312 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "cf3f8f46d8bc" +down_revision: Union[str, None] = "6c56bcd1c028" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_type + op.create_table( + "asset_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_type")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_asset_type_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_asset_type_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_asset_type_name")), + ) + op.create_table( + "asset_type_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", + sa.String(length=50), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_type_version") + ), + ) + op.create_index( + op.f("ix_asset_type_version_end_transaction_id"), + "asset_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_type_version_operation_type"), + "asset_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_type_version_pk_transaction_id", + "asset_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_type_version_pk_validity", + "asset_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_type_version_transaction_id"), + "asset_type_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # asset_type + op.drop_index( + op.f("ix_asset_type_version_transaction_id"), table_name="asset_type_version" + ) + op.drop_index("ix_asset_type_version_pk_validity", table_name="asset_type_version") + op.drop_index( + "ix_asset_type_version_pk_transaction_id", table_name="asset_type_version" + ) + op.drop_index( + op.f("ix_asset_type_version_operation_type"), table_name="asset_type_version" + ) + op.drop_index( + op.f("ix_asset_type_version_end_transaction_id"), + table_name="asset_type_version", + ) + op.drop_table("asset_type_version") + op.drop_table("asset_type") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 29e4dc0..f07057f 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,4 +30,5 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models +from .assets import AssetType from .animals import AnimalType diff --git a/src/wuttafarm/db/model/assets.py b/src/wuttafarm/db/model/assets.py new file mode 100644 index 0000000..d726632 --- /dev/null +++ b/src/wuttafarm/db/model/assets.py @@ -0,0 +1,82 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Asset Types +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + + +class AssetType(model.Base): + """ + Represents an "asset type" from farmOS + """ + + __tablename__ = "asset_type" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Asset Type", + "model_title_plural": "Asset Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the asset type. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Description for the asset type. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the asset type within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.String(length=50), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the asset type. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 3c6eea9..8470496 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -90,6 +90,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): """ """ importers = super().define_importers() importers["User"] = UserImporter + importers["AssetType"] = AssetTypeImporter importers["AnimalType"] = AnimalTypeImporter return importers @@ -155,6 +156,35 @@ class AnimalTypeImporter(FromFarmOS, ToWutta): } +class AssetTypeImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Asset Types + """ + + model_class = model.AssetType + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + asset_types = self.farmos_client.resource.get("asset_type") + return asset_types["data"] + + def normalize_source_object(self, asset_type): + """ """ + return { + "farmos_uuid": UUID(asset_type["id"]), + "drupal_internal_id": asset_type["attributes"]["drupal_internal__id"], + "name": asset_type["attributes"]["label"], + "description": asset_type["attributes"]["description"], + } + + class UserImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Users diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 7571b3c..402eb28 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -43,6 +43,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "title": "Assets", "type": "menu", "items": [ + { + "title": "Asset Types", + "route": "asset_types", + "perm": "asset_types.list", + }, { "title": "Animal Types", "route": "animal_types", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 25a4054..51a7f7e 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -41,6 +41,7 @@ def includeme(config): ) # native table views + config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.animal_types") # views for farmOS diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py new file mode 100644 index 0000000..d23498e --- /dev/null +++ b/src/wuttafarm/web/views/asset_types.py @@ -0,0 +1,90 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Asset Types +""" + +from wuttafarm.db.model.assets import AssetType +from wuttafarm.web.views import WuttaFarmMasterView + + +class AssetTypeView(WuttaFarmMasterView): + """ + Master view for Asset Types + """ + + model_class = AssetType + route_prefix = "asset_types" + url_prefix = "/asset-types" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_internal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, asset_type): + buttons = super().get_xref_buttons(asset_type) + + if asset_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_asset_types.view", uuid=asset_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + AssetTypeView = kwargs.get("AssetTypeView", base["AssetTypeView"]) + AssetTypeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/asset_types.py b/src/wuttafarm/web/views/farmos/asset_types.py index de285e1..d033ae6 100644 --- a/src/wuttafarm/web/views/farmos/asset_types.py +++ b/src/wuttafarm/web/views/farmos/asset_types.py @@ -90,6 +90,29 @@ class AssetTypeView(FarmOSMasterView): # description f.set_widget("description", "notes") + def get_xref_buttons(self, asset_type): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_asset_type := ( + session.query(model.AssetType) + .filter(model.AssetType.farmos_uuid == asset_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "asset_types.view", uuid=wf_asset_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() From b573ae459ecac9f7f41126bf2d9d2afba2bc8187 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2026 19:16:55 -0600 Subject: [PATCH 042/166] feat: add native table for Land Types; import from farmOS API --- .../versions/9f2243df9566_add_land_types.py | 115 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 1 + src/wuttafarm/db/model/land.py | 74 +++++++++++ src/wuttafarm/importing/farmos.py | 28 +++++ src/wuttafarm/web/menus.py | 15 ++- src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/farmos/land_types.py | 23 ++++ src/wuttafarm/web/views/land_types.py | 88 ++++++++++++++ 8 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py create mode 100644 src/wuttafarm/db/model/land.py create mode 100644 src/wuttafarm/web/views/land_types.py diff --git a/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py b/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py new file mode 100644 index 0000000..4e45439 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py @@ -0,0 +1,115 @@ +"""add Land Types + +Revision ID: 9f2243df9566 +Revises: cf3f8f46d8bc +Create Date: 2026-02-10 19:10:02.851756 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "9f2243df9566" +down_revision: Union[str, None] = "cf3f8f46d8bc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # land_type + op.create_table( + "land_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_type")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_land_type_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_land_type_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_land_type_name")), + ) + op.create_table( + "land_type_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", + sa.String(length=50), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_land_type_version") + ), + ) + op.create_index( + op.f("ix_land_type_version_end_transaction_id"), + "land_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_type_version_operation_type"), + "land_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_land_type_version_pk_transaction_id", + "land_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_land_type_version_pk_validity", + "land_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_type_version_transaction_id"), + "land_type_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # land_type + op.drop_index( + op.f("ix_land_type_version_transaction_id"), table_name="land_type_version" + ) + op.drop_index("ix_land_type_version_pk_validity", table_name="land_type_version") + op.drop_index( + "ix_land_type_version_pk_transaction_id", table_name="land_type_version" + ) + op.drop_index( + op.f("ix_land_type_version_operation_type"), table_name="land_type_version" + ) + op.drop_index( + op.f("ix_land_type_version_end_transaction_id"), table_name="land_type_version" + ) + op.drop_table("land_type_version") + op.drop_table("land_type") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index f07057f..0951c72 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -31,4 +31,5 @@ from .users import WuttaFarmUser # wuttafarm proper models from .assets import AssetType +from .land import LandType from .animals import AnimalType diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py new file mode 100644 index 0000000..dc4f0f3 --- /dev/null +++ b/src/wuttafarm/db/model/land.py @@ -0,0 +1,74 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Land Types +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + + +class LandType(model.Base): + """ + Represents a "land type" from farmOS + """ + + __tablename__ = "land_type" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Land Type", + "model_title_plural": "Land Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the land type. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the land type within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.String(length=50), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the land type. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 8470496..842ba76 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -91,6 +91,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers = super().define_importers() importers["User"] = UserImporter importers["AssetType"] = AssetTypeImporter + importers["LandType"] = LandTypeImporter importers["AnimalType"] = AnimalTypeImporter return importers @@ -185,6 +186,33 @@ class AssetTypeImporter(FromFarmOS, ToWutta): } +class LandTypeImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Land Types + """ + + model_class = model.LandType + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + ] + + def get_source_objects(self): + """ """ + land_types = self.farmos_client.resource.get("land_type") + return land_types["data"] + + def normalize_source_object(self, land_type): + """ """ + return { + "farmos_uuid": UUID(land_type["id"]), + "drupal_internal_id": land_type["attributes"]["drupal_internal__id"], + "name": land_type["attributes"]["label"], + } + + class UserImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Users diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 402eb28..017e0ab 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -43,16 +43,21 @@ class WuttaFarmMenuHandler(base.MenuHandler): "title": "Assets", "type": "menu", "items": [ - { - "title": "Asset Types", - "route": "asset_types", - "perm": "asset_types.list", - }, { "title": "Animal Types", "route": "animal_types", "perm": "animal_types.list", }, + { + "title": "Land Types", + "route": "land_types", + "perm": "land_types.list", + }, + { + "title": "Asset Types", + "route": "asset_types", + "perm": "asset_types.list", + }, ], } diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 51a7f7e..ecb4a69 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -42,6 +42,7 @@ def includeme(config): # native table views config.include("wuttafarm.web.views.asset_types") + config.include("wuttafarm.web.views.land_types") config.include("wuttafarm.web.views.animal_types") # views for farmOS diff --git a/src/wuttafarm/web/views/farmos/land_types.py b/src/wuttafarm/web/views/farmos/land_types.py index 02c0560..9a7bb8b 100644 --- a/src/wuttafarm/web/views/farmos/land_types.py +++ b/src/wuttafarm/web/views/farmos/land_types.py @@ -77,6 +77,29 @@ class LandTypeView(FarmOSMasterView): "label": land_type["attributes"]["label"], } + def get_xref_buttons(self, land_type): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_land_type := ( + session.query(model.LandType) + .filter(model.LandType.farmos_uuid == land_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "land_types.view", uuid=wf_land_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/land_types.py b/src/wuttafarm/web/views/land_types.py new file mode 100644 index 0000000..c9711a4 --- /dev/null +++ b/src/wuttafarm/web/views/land_types.py @@ -0,0 +1,88 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Land Types +""" + +from wuttafarm.db.model.land import LandType +from wuttafarm.web.views import WuttaFarmMasterView + + +class LandTypeView(WuttaFarmMasterView): + """ + Master view for Land Types + """ + + model_class = LandType + route_prefix = "land_types" + url_prefix = "/land-types" + + grid_columns = [ + "name", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "farmos_uuid", + "drupal_internal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, land_type): + buttons = super().get_xref_buttons(land_type) + + if land_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_land_types.view", uuid=land_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + LandTypeView = kwargs.get("LandTypeView", base["LandTypeView"]) + LandTypeView.defaults(config) + + +def includeme(config): + defaults(config) From 5189c12f43cb80e292964b72ce9586ec23d7334b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2026 19:31:21 -0600 Subject: [PATCH 043/166] feat: add native table for Structure Types; import from farmOS API --- .../d7479d7161a8_add_structure_types.py | 121 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 1 + src/wuttafarm/db/model/structures.py | 74 +++++++++++ src/wuttafarm/importing/farmos.py | 28 ++++ src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/__init__.py | 1 + .../web/views/farmos/structure_types.py | 23 ++++ src/wuttafarm/web/views/structure_types.py | 88 +++++++++++++ 8 files changed, 341 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py create mode 100644 src/wuttafarm/db/model/structures.py create mode 100644 src/wuttafarm/web/views/structure_types.py diff --git a/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py b/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py new file mode 100644 index 0000000..9dce53b --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py @@ -0,0 +1,121 @@ +"""add Structure Types + +Revision ID: d7479d7161a8 +Revises: 9f2243df9566 +Create Date: 2026-02-10 19:24:20.249826 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "d7479d7161a8" +down_revision: Union[str, None] = "9f2243df9566" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # structure_type + op.create_table( + "structure_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure_type")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_structure_type_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_structure_type_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_structure_type_name")), + ) + op.create_table( + "structure_type_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", + sa.String(length=50), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_structure_type_version") + ), + ) + op.create_index( + op.f("ix_structure_type_version_end_transaction_id"), + "structure_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_structure_type_version_operation_type"), + "structure_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_structure_type_version_pk_transaction_id", + "structure_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_structure_type_version_pk_validity", + "structure_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_structure_type_version_transaction_id"), + "structure_type_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # structure_type + op.drop_index( + op.f("ix_structure_type_version_transaction_id"), + table_name="structure_type_version", + ) + op.drop_index( + "ix_structure_type_version_pk_validity", table_name="structure_type_version" + ) + op.drop_index( + "ix_structure_type_version_pk_transaction_id", + table_name="structure_type_version", + ) + op.drop_index( + op.f("ix_structure_type_version_operation_type"), + table_name="structure_type_version", + ) + op.drop_index( + op.f("ix_structure_type_version_end_transaction_id"), + table_name="structure_type_version", + ) + op.drop_table("structure_type_version") + op.drop_table("structure_type") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 0951c72..b0329de 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -32,4 +32,5 @@ from .users import WuttaFarmUser # wuttafarm proper models from .assets import AssetType from .land import LandType +from .structures import StructureType from .animals import AnimalType diff --git a/src/wuttafarm/db/model/structures.py b/src/wuttafarm/db/model/structures.py new file mode 100644 index 0000000..2dc0851 --- /dev/null +++ b/src/wuttafarm/db/model/structures.py @@ -0,0 +1,74 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Structure Types +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + + +class StructureType(model.Base): + """ + Represents a "structure type" from farmOS + """ + + __tablename__ = "structure_type" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Structure Type", + "model_title_plural": "Structure Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the structure type. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the structure type within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.String(length=50), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the structure type. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 842ba76..9ec0dbb 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -92,6 +92,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["User"] = UserImporter importers["AssetType"] = AssetTypeImporter importers["LandType"] = LandTypeImporter + importers["StructureType"] = StructureTypeImporter importers["AnimalType"] = AnimalTypeImporter return importers @@ -213,6 +214,33 @@ class LandTypeImporter(FromFarmOS, ToWutta): } +class StructureTypeImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Structure Types + """ + + model_class = model.StructureType + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + ] + + def get_source_objects(self): + """ """ + structure_types = self.farmos_client.resource.get("structure_type") + return structure_types["data"] + + def normalize_source_object(self, structure_type): + """ """ + return { + "farmos_uuid": UUID(structure_type["id"]), + "drupal_internal_id": structure_type["attributes"]["drupal_internal__id"], + "name": structure_type["attributes"]["label"], + } + + class UserImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Users diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 017e0ab..c692e7c 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -53,6 +53,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "land_types", "perm": "land_types.list", }, + { + "title": "Structure Types", + "route": "structure_types", + "perm": "structure_types.list", + }, { "title": "Asset Types", "route": "asset_types", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index ecb4a69..359a0dc 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -43,6 +43,7 @@ def includeme(config): # native table views config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.land_types") + config.include("wuttafarm.web.views.structure_types") config.include("wuttafarm.web.views.animal_types") # views for farmOS diff --git a/src/wuttafarm/web/views/farmos/structure_types.py b/src/wuttafarm/web/views/farmos/structure_types.py index 6a2342e..b41e2a3 100644 --- a/src/wuttafarm/web/views/farmos/structure_types.py +++ b/src/wuttafarm/web/views/farmos/structure_types.py @@ -79,6 +79,29 @@ class StructureTypeView(FarmOSMasterView): "label": structure_type["attributes"]["label"], } + def get_xref_buttons(self, structure_type): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_structure_type := ( + session.query(model.StructureType) + .filter(model.StructureType.farmos_uuid == structure_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "structure_types.view", uuid=wf_structure_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/structure_types.py b/src/wuttafarm/web/views/structure_types.py new file mode 100644 index 0000000..4d05184 --- /dev/null +++ b/src/wuttafarm/web/views/structure_types.py @@ -0,0 +1,88 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Structure Types +""" + +from wuttafarm.db.model.structures import StructureType +from wuttafarm.web.views import WuttaFarmMasterView + + +class StructureTypeView(WuttaFarmMasterView): + """ + Master view for Structure Types + """ + + model_class = StructureType + route_prefix = "structure_types" + url_prefix = "/structure-types" + + grid_columns = [ + "name", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "farmos_uuid", + "drupal_internal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, structure_type): + buttons = super().get_xref_buttons(structure_type) + + if structure_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_structure_types.view", uuid=structure_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + StructureTypeView = kwargs.get("StructureTypeView", base["StructureTypeView"]) + StructureTypeView.defaults(config) + + +def includeme(config): + defaults(config) From 6204db8ae3a2b96ddae0f0c9334232f87165808e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2026 19:42:02 -0600 Subject: [PATCH 044/166] feat: add native table for Log Types; import from farmOS API --- .../versions/e0d9f72575d6_add_log_types.py | 119 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 1 + src/wuttafarm/db/model/logs.py | 82 ++++++++++++ src/wuttafarm/importing/farmos.py | 30 +++++ src/wuttafarm/web/menus.py | 24 +++- src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/farmos/log_types.py | 21 ++++ src/wuttafarm/web/views/log_types.py | 90 +++++++++++++ 8 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py create mode 100644 src/wuttafarm/db/model/logs.py create mode 100644 src/wuttafarm/web/views/log_types.py diff --git a/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py b/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py new file mode 100644 index 0000000..7180412 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py @@ -0,0 +1,119 @@ +"""add Log Types + +Revision ID: e0d9f72575d6 +Revises: d7479d7161a8 +Create Date: 2026-02-10 19:35:06.631814 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "e0d9f72575d6" +down_revision: Union[str, None] = "d7479d7161a8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_type + op.create_table( + "log_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_type")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_log_type_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_type_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_log_type_name")), + ) + op.create_table( + "log_type_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", + sa.String(length=50), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_type_version") + ), + ) + op.create_index( + op.f("ix_log_type_version_end_transaction_id"), + "log_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_type_version_operation_type"), + "log_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_type_version_pk_transaction_id", + "log_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_type_version_pk_validity", + "log_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_type_version_transaction_id"), + "log_type_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_type + op.drop_index( + op.f("ix_log_type_version_transaction_id"), table_name="log_type_version" + ) + op.drop_index("ix_log_type_version_pk_validity", table_name="log_type_version") + op.drop_index( + "ix_log_type_version_pk_transaction_id", table_name="log_type_version" + ) + op.drop_index( + op.f("ix_log_type_version_operation_type"), table_name="log_type_version" + ) + op.drop_index( + op.f("ix_log_type_version_end_transaction_id"), table_name="log_type_version" + ) + op.drop_table("log_type_version") + op.drop_table("log_type") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index b0329de..4b5f558 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -34,3 +34,4 @@ from .assets import AssetType from .land import LandType from .structures import StructureType from .animals import AnimalType +from .logs import LogType diff --git a/src/wuttafarm/db/model/logs.py b/src/wuttafarm/db/model/logs.py new file mode 100644 index 0000000..70e3585 --- /dev/null +++ b/src/wuttafarm/db/model/logs.py @@ -0,0 +1,82 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Log Types +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + + +class LogType(model.Base): + """ + Represents a "log type" from farmOS + """ + + __tablename__ = "log_type" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Log Type", + "model_title_plural": "Log Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the log type. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional description for the log type. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the log type within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.String(length=50), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the log type. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 9ec0dbb..b2f6997 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -94,6 +94,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["LandType"] = LandTypeImporter importers["StructureType"] = StructureTypeImporter importers["AnimalType"] = AnimalTypeImporter + importers["LogType"] = LogTypeImporter return importers @@ -214,6 +215,35 @@ class LandTypeImporter(FromFarmOS, ToWutta): } +class LogTypeImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Log Types + """ + + model_class = model.LogType + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + log_types = self.farmos_client.resource.get("log_type") + return log_types["data"] + + def normalize_source_object(self, log_type): + """ """ + return { + "farmos_uuid": UUID(log_type["id"]), + "drupal_internal_id": log_type["attributes"]["drupal_internal__id"], + "name": log_type["attributes"]["label"], + "description": log_type["attributes"]["description"], + } + + class StructureTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Structure Types diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index c692e7c..5bfee69 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -34,6 +34,7 @@ class WuttaFarmMenuHandler(base.MenuHandler): def make_menus(self, request, **kwargs): return [ self.make_asset_menu(request), + self.make_log_menu(request), self.make_farmos_menu(request), self.make_admin_menu(request, include_people=True), ] @@ -48,16 +49,16 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "animal_types", "perm": "animal_types.list", }, - { - "title": "Land Types", - "route": "land_types", - "perm": "land_types.list", - }, { "title": "Structure Types", "route": "structure_types", "perm": "structure_types.list", }, + { + "title": "Land Types", + "route": "land_types", + "perm": "land_types.list", + }, { "title": "Asset Types", "route": "asset_types", @@ -66,6 +67,19 @@ class WuttaFarmMenuHandler(base.MenuHandler): ], } + def make_log_menu(self, request): + return { + "title": "Logs", + "type": "menu", + "items": [ + { + "title": "Log Types", + "route": "log_types", + "perm": "log_types.list", + }, + ], + } + def make_farmos_menu(self, request): config = request.wutta_config app = config.get_app() diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 359a0dc..412619c 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -45,6 +45,7 @@ def includeme(config): config.include("wuttafarm.web.views.land_types") config.include("wuttafarm.web.views.structure_types") config.include("wuttafarm.web.views.animal_types") + config.include("wuttafarm.web.views.log_types") # views for farmOS config.include("wuttafarm.web.views.farmos") diff --git a/src/wuttafarm/web/views/farmos/log_types.py b/src/wuttafarm/web/views/farmos/log_types.py index 6d25c10..db96fd0 100644 --- a/src/wuttafarm/web/views/farmos/log_types.py +++ b/src/wuttafarm/web/views/farmos/log_types.py @@ -87,6 +87,27 @@ class LogTypeView(FarmOSMasterView): # description f.set_widget("description", "notes") + def get_xref_buttons(self, log_type): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_log_type := ( + session.query(model.LogType) + .filter(model.LogType.farmos_uuid == log_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("log_types.view", uuid=wf_log_type.uuid), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/log_types.py b/src/wuttafarm/web/views/log_types.py new file mode 100644 index 0000000..5c4a8ca --- /dev/null +++ b/src/wuttafarm/web/views/log_types.py @@ -0,0 +1,90 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Log Types +""" + +from wuttafarm.db.model.logs import LogType +from wuttafarm.web.views import WuttaFarmMasterView + + +class LogTypeView(WuttaFarmMasterView): + """ + Master view for Log Types + """ + + model_class = LogType + route_prefix = "log_types" + url_prefix = "/log-types" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_internal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, log_type): + buttons = super().get_xref_buttons(log_type) + + if log_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_log_types.view", uuid=log_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) + LogTypeView.defaults(config) + + +def includeme(config): + defaults(config) From 1d898cb580f2a6e6c5bd3c3438c324ba7aa8a9f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2026 20:15:44 -0600 Subject: [PATCH 045/166] feat: add native table for Land Assets; import from farmOS API --- .../versions/e416b96467fc_add_land_assets.py | 136 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 2 +- src/wuttafarm/db/model/land.py | 82 +++++++++++ src/wuttafarm/importing/farmos.py | 61 ++++++++ src/wuttafarm/web/forms/schema.py | 27 ++++ src/wuttafarm/web/menus.py | 6 + src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/farmos/land_assets.py | 33 +++++ src/wuttafarm/web/views/land_assets.py | 117 +++++++++++++++ src/wuttafarm/web/views/land_types.py | 32 ++++- src/wuttafarm/web/views/master.py | 5 + 11 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py create mode 100644 src/wuttafarm/web/views/land_assets.py diff --git a/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py b/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py new file mode 100644 index 0000000..ede9b22 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py @@ -0,0 +1,136 @@ +"""add Land Assets + +Revision ID: e416b96467fc +Revises: e0d9f72575d6 +Create Date: 2026-02-13 09:39:31.327442 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "e416b96467fc" +down_revision: Union[str, None] = "e0d9f72575d6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # land_asset + op.create_table( + "land_asset", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("land_type_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("is_location", sa.Boolean(), nullable=False), + sa.Column("is_fixed", sa.Boolean(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["land_type_uuid"], + ["land_type.uuid"], + name=op.f("fk_land_asset_land_type_uuid_land_type"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_land_asset_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_land_asset_farmos_uuid")), + sa.UniqueConstraint( + "land_type_uuid", name=op.f("uq_land_asset_land_type_uuid") + ), + sa.UniqueConstraint("name", name=op.f("uq_land_asset_name")), + ) + op.create_table( + "land_asset_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "land_type_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("notes", sa.Text(), autoincrement=False, nullable=True), + sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_land_asset_version") + ), + ) + op.create_index( + op.f("ix_land_asset_version_end_transaction_id"), + "land_asset_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_version_operation_type"), + "land_asset_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_land_asset_version_pk_transaction_id", + "land_asset_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_land_asset_version_pk_validity", + "land_asset_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_version_transaction_id"), + "land_asset_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # land_asset + op.drop_index( + op.f("ix_land_asset_version_transaction_id"), table_name="land_asset_version" + ) + op.drop_index("ix_land_asset_version_pk_validity", table_name="land_asset_version") + op.drop_index( + "ix_land_asset_version_pk_transaction_id", table_name="land_asset_version" + ) + op.drop_index( + op.f("ix_land_asset_version_operation_type"), table_name="land_asset_version" + ) + op.drop_index( + op.f("ix_land_asset_version_end_transaction_id"), + table_name="land_asset_version", + ) + op.drop_table("land_asset_version") + op.drop_table("land_asset") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 4b5f558..3b6b479 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -31,7 +31,7 @@ from .users import WuttaFarmUser # wuttafarm proper models from .assets import AssetType -from .land import LandType +from .land import LandType, LandAsset from .structures import StructureType from .animals import AnimalType from .logs import LogType diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py index dc4f0f3..ca18d70 100644 --- a/src/wuttafarm/db/model/land.py +++ b/src/wuttafarm/db/model/land.py @@ -70,5 +70,87 @@ class LandType(model.Base): """, ) + land_assets = orm.relationship("LandAsset", back_populates="land_type") + + def __str__(self): + return self.name or "" + + +class LandAsset(model.Base): + """ + Represents a "land asset" from farmOS + """ + + __tablename__ = "land_asset" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Land Asset", + "model_title_plural": "Land Assets", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the land asset. + """, + ) + + land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False, unique=True) + land_type = orm.relationship(LandType, back_populates="land_assets") + + is_location = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the land asset should be considered a location. + """, + ) + + is_fixed = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the land asset's location is fixed. + """, + ) + + notes = sa.Column( + sa.Text(), + nullable=True, + doc=""" + Notes for the land asset. + """, + ) + + active = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the land asset is currently active. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the land asset within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the land asset. + """, + ) + def __str__(self): return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index b2f6997..232aab2 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -24,6 +24,7 @@ Data import for farmOS -> WuttaFarm """ import datetime +import logging from uuid import UUID from oauthlib.oauth2 import BackendApplicationClient @@ -34,6 +35,9 @@ from wuttasync.importing import ImportHandler, ToWuttaHandler, Importer, ToWutta from wuttafarm.db import model +log = logging.getLogger(__name__) + + class FromFarmOSHandler(ImportHandler): """ Base class for import handler using farmOS API as data source. @@ -92,6 +96,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["User"] = UserImporter importers["AssetType"] = AssetTypeImporter importers["LandType"] = LandTypeImporter + importers["LandAsset"] = LandAssetImporter importers["StructureType"] = StructureTypeImporter importers["AnimalType"] = AnimalTypeImporter importers["LogType"] = LogTypeImporter @@ -188,6 +193,62 @@ class AssetTypeImporter(FromFarmOS, ToWutta): } +class LandAssetImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Land Assets + """ + + model_class = model.LandAsset + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + "land_type_uuid", + "is_location", + "is_fixed", + "notes", + "active", + ] + + def setup(self): + super().setup() + model = self.app.model + + self.land_types_by_id = {} + for land_type in self.target_session.query(model.LandType): + self.land_types_by_id[land_type.drupal_internal_id] = land_type + + def get_source_objects(self): + """ """ + land_assets = self.farmos_client.asset.get("land") + return land_assets["data"] + + def normalize_source_object(self, land): + """ """ + land_type_id = land["attributes"]["land_type"] + land_type = self.land_types_by_id.get(land_type_id) + if not land_type: + log.warning( + "invalid land_type '%s' for farmOS Land Asset: %s", land_type_id, land + ) + return None + + if notes := land["attributes"]["notes"]: + notes = notes["value"] + + return { + "farmos_uuid": UUID(land["id"]), + "drupal_internal_id": land["attributes"]["drupal_internal__id"], + "name": land["attributes"]["name"], + "land_type_uuid": land_type.uuid, + "is_location": land["attributes"]["is_location"], + "is_fixed": land["attributes"]["is_fixed"], + "active": land["attributes"]["status"] == "active", + "notes": notes, + } + + class LandTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Land Types diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index a38588a..9085935 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -27,6 +27,8 @@ import json import colander +from wuttaweb.forms.schema import ObjectRef + class AnimalTypeType(colander.SchemaType): @@ -47,6 +49,31 @@ class AnimalTypeType(colander.SchemaType): return AnimalTypeWidget(self.request, **kwargs) +class LandTypeRef(ObjectRef): + """ + Custom schema type for a + :class:`~wuttafarm.db.model.land.LandType` reference field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): # pylint: disable=empty-docstring + """ """ + model = self.app.model + return model.LandType + + def sort_query(self, query): # pylint: disable=empty-docstring + """ """ + return query.order_by(self.model_class.name) + + def get_object_url(self, obj): # pylint: disable=empty-docstring + """ """ + land_type = obj + return self.request.route_url("land_types.view", uuid=land_type.uuid) + + class StructureType(colander.SchemaType): def __init__(self, request, *args, **kwargs): diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 5bfee69..fa5c803 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -44,6 +44,12 @@ class WuttaFarmMenuHandler(base.MenuHandler): "title": "Assets", "type": "menu", "items": [ + { + "title": "Land", + "route": "land_assets", + "perm": "land_assets.list", + }, + {"type": "sep"}, { "title": "Animal Types", "route": "animal_types", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 412619c..78a917d 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -45,6 +45,7 @@ def includeme(config): config.include("wuttafarm.web.views.land_types") config.include("wuttafarm.web.views.structure_types") config.include("wuttafarm.web.views.animal_types") + config.include("wuttafarm.web.views.land_assets") config.include("wuttafarm.web.views.log_types") # views for farmOS diff --git a/src/wuttafarm/web/views/farmos/land_assets.py b/src/wuttafarm/web/views/farmos/land_assets.py index 5a8cc24..185c9d0 100644 --- a/src/wuttafarm/web/views/farmos/land_assets.py +++ b/src/wuttafarm/web/views/farmos/land_assets.py @@ -49,6 +49,7 @@ class LandAssetView(FarmOSMasterView): grid_columns = [ "name", + "land_type", "is_fixed", "is_location", "status", @@ -59,6 +60,7 @@ class LandAssetView(FarmOSMasterView): form_fields = [ "name", + "land_type", "is_fixed", "is_location", "status", @@ -118,6 +120,7 @@ class LandAssetView(FarmOSMasterView): "uuid": land["id"], "drupal_internal_id": land["attributes"]["drupal_internal__id"], "name": land["attributes"]["name"], + "land_type": land["attributes"]["land_type"], "created": created, "changed": changed, "is_fixed": land["attributes"]["is_fixed"], @@ -158,6 +161,36 @@ class LandAssetView(FarmOSMasterView): ), ] + def get_xref_buttons(self, land): + model = self.app.model + session = self.Session() + + buttons = [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/asset/{land['drupal_internal_id']}"), + target="_blank", + icon_left="external-link-alt", + ), + ] + + if wf_land := ( + session.query(model.LandAsset) + .filter(model.LandAsset.farmos_uuid == land["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("land_assets.view", uuid=wf_land.uuid), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/land_assets.py b/src/wuttafarm/web/views/land_assets.py new file mode 100644 index 0000000..d1025eb --- /dev/null +++ b/src/wuttafarm/web/views/land_assets.py @@ -0,0 +1,117 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Land Assets +""" + +from wuttafarm.db.model.land import LandAsset +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.forms.schema import LandTypeRef + + +class LandAssetView(WuttaFarmMasterView): + """ + Master view for Land Assets + """ + + model_class = LandAsset + route_prefix = "land_assets" + url_prefix = "/land-assets" + + farmos_refurl_path = "/assets/land" + + grid_columns = [ + "name", + "land_type", + "is_location", + "is_fixed", + "notes", + "active", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "land_type", + "is_location", + "is_fixed", + "notes", + "active", + "farmos_uuid", + "drupal_internal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + + # name + g.set_link("name") + + # land_type + g.set_joiner("land_type", lambda q: q.join(model.LandType)) + g.set_sorter("land_type", model.LandType.name) + g.set_filter("land_type", model.LandType.name, label="Land Type Name") + + def configure_form(self, form): + f = form + super().configure_form(f) + + # land_type + f.set_node("land_type", LandTypeRef(self.request)) + + def get_farmos_url(self, land): + return self.app.get_farmos_url(f"/asset/{land.drupal_internal_id}") + + def get_xref_buttons(self, land_asset): + buttons = super().get_xref_buttons(land_asset) + + if land_asset.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_land_assets.view", uuid=land_asset.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + LandAssetView = kwargs.get("LandAssetView", base["LandAssetView"]) + LandAssetView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/land_types.py b/src/wuttafarm/web/views/land_types.py index c9711a4..05a0644 100644 --- a/src/wuttafarm/web/views/land_types.py +++ b/src/wuttafarm/web/views/land_types.py @@ -23,7 +23,7 @@ Master view for Land Types """ -from wuttafarm.db.model.land import LandType +from wuttafarm.db.model.land import LandType, LandAsset from wuttafarm.web.views import WuttaFarmMasterView @@ -52,6 +52,19 @@ class LandTypeView(WuttaFarmMasterView): "drupal_internal_id", ] + has_rows = True + row_model_class = LandAsset + rows_viewable = True + + row_grid_columns = [ + "name", + "is_location", + "is_fixed", + "active", + ] + + rows_sort_defaults = "name" + def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -76,6 +89,23 @@ class LandTypeView(WuttaFarmMasterView): return buttons + def get_row_grid_data(self, land_type): + model = self.app.model + session = self.Session() + return session.query(model.LandAsset).filter( + model.LandAsset.land_type == land_type + ) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + + # name + g.set_link("name") + + def get_row_action_url_view(self, land_asset, i): + return self.request.route_url("land_assets.view", uuid=land_asset.uuid) + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 69a7f89..4242cd1 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -38,6 +38,11 @@ class WuttaFarmMasterView(MasterView): "drupal_internal_id": "Drupal Internal ID", } + row_labels = { + "farmos_uuid": "farmOS UUID", + "drupal_internal_id": "Drupal Internal ID", + } + def get_farmos_url(self, obj): return None From c38d00a7ccf60a987c750403a68cae442c66564e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 10:45:38 -0600 Subject: [PATCH 046/166] feat: add native table for Structures; import from farmOS API --- .../versions/4dbba8aeb1e5_add_structures.py | 136 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 2 +- src/wuttafarm/db/model/structures.py | 93 ++++++++++++ src/wuttafarm/importing/farmos.py | 75 ++++++++++ src/wuttafarm/web/forms/schema.py | 25 ++++ src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/farmos/structures.py | 28 +++- src/wuttafarm/web/views/master.py | 2 + src/wuttafarm/web/views/structure_types.py | 32 ++++- src/wuttafarm/web/views/structures.py | 127 ++++++++++++++++ 11 files changed, 521 insertions(+), 5 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py create mode 100644 src/wuttafarm/web/views/structures.py diff --git a/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py b/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py new file mode 100644 index 0000000..823f826 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py @@ -0,0 +1,136 @@ +"""add Structures + +Revision ID: 4dbba8aeb1e5 +Revises: e416b96467fc +Create Date: 2026-02-13 10:17:15.179202 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "4dbba8aeb1e5" +down_revision: Union[str, None] = "e416b96467fc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # structure + op.create_table( + "structure", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("structure_type_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("is_location", sa.Boolean(), nullable=False), + sa.Column("is_fixed", sa.Boolean(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("image_url", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["structure_type_uuid"], + ["structure_type.uuid"], + name=op.f("fk_structure_structure_type_uuid_structure_type"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_structure_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_structure_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_structure_name")), + ) + op.create_table( + "structure_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column( + "structure_type_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("notes", sa.Text(), autoincrement=False, nullable=True), + sa.Column( + "image_url", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_structure_version") + ), + ) + op.create_index( + op.f("ix_structure_version_end_transaction_id"), + "structure_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_structure_version_operation_type"), + "structure_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_structure_version_pk_transaction_id", + "structure_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_structure_version_pk_validity", + "structure_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_structure_version_transaction_id"), + "structure_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # structure + op.drop_index( + op.f("ix_structure_version_transaction_id"), table_name="structure_version" + ) + op.drop_index("ix_structure_version_pk_validity", table_name="structure_version") + op.drop_index( + "ix_structure_version_pk_transaction_id", table_name="structure_version" + ) + op.drop_index( + op.f("ix_structure_version_operation_type"), table_name="structure_version" + ) + op.drop_index( + op.f("ix_structure_version_end_transaction_id"), table_name="structure_version" + ) + op.drop_table("structure_version") + op.drop_table("structure") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 3b6b479..0414168 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -32,6 +32,6 @@ from .users import WuttaFarmUser # wuttafarm proper models from .assets import AssetType from .land import LandType, LandAsset -from .structures import StructureType +from .structures import StructureType, Structure from .animals import AnimalType from .logs import LogType diff --git a/src/wuttafarm/db/model/structures.py b/src/wuttafarm/db/model/structures.py index 2dc0851..412e59a 100644 --- a/src/wuttafarm/db/model/structures.py +++ b/src/wuttafarm/db/model/structures.py @@ -72,3 +72,96 @@ class StructureType(model.Base): def __str__(self): return self.name or "" + + +class Structure(model.Base): + """ + Represents a structure from farmOS + """ + + __tablename__ = "structure" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Structure", + "model_title_plural": "Structures", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name for the structure. + """, + ) + + active = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the structure is currently active. + """, + ) + + structure_type_uuid = model.uuid_fk_column("structure_type.uuid", nullable=False) + structure_type = orm.relationship( + "StructureType", + doc=""" + Reference to the type of structure. + """, + ) + + is_location = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the structure is considered a location. + """, + ) + + is_fixed = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the structure location is fixed. + """, + ) + + notes = sa.Column( + sa.Text(), + nullable=True, + doc=""" + Arbitrary notes for the structure. + """, + ) + + image_url = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional image URL for the structure. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the structure within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the structure. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 232aab2..53182bb 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -98,6 +98,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["LandType"] = LandTypeImporter importers["LandAsset"] = LandAssetImporter importers["StructureType"] = StructureTypeImporter + importers["Structure"] = StructureImporter importers["AnimalType"] = AnimalTypeImporter importers["LogType"] = LogTypeImporter return importers @@ -305,6 +306,80 @@ class LogTypeImporter(FromFarmOS, ToWutta): } +class StructureImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Structures + """ + + model_class = model.Structure + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + "structure_type_uuid", + "is_location", + "is_fixed", + "notes", + "active", + "image_url", + ] + + def setup(self): + super().setup() + model = self.app.model + + self.structure_types_by_id = {} + for structure_type in self.target_session.query(model.StructureType): + self.structure_types_by_id[structure_type.drupal_internal_id] = ( + structure_type + ) + + def get_source_objects(self): + """ """ + structures = self.farmos_client.asset.get("structure") + return structures["data"] + + def normalize_source_object(self, structure): + """ """ + structure_type_id = structure["attributes"]["structure_type"] + structure_type = self.structure_types_by_id.get(structure_type_id) + if not structure_type: + log.warning( + "invalid structure_type '%s' for farmOS Structure: %s", + structure_type_id, + structure, + ) + return None + + if notes := structure["attributes"]["notes"]: + notes = notes["value"] + + image_url = None + if relationships := structure.get("relationships"): + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + if image_style := image["data"]["attributes"].get( + "image_style_uri" + ): + image_url = image_style["large"] + + return { + "farmos_uuid": UUID(structure["id"]), + "drupal_internal_id": structure["attributes"]["drupal_internal__id"], + "name": structure["attributes"]["name"], + "structure_type_uuid": structure_type.uuid, + "is_location": structure["attributes"]["is_location"], + "is_fixed": structure["attributes"]["is_fixed"], + "active": structure["attributes"]["status"] == "active", + "notes": notes, + "image_url": image_url, + } + + class StructureTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Structure Types diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 9085935..e981dee 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -93,6 +93,31 @@ class StructureType(colander.SchemaType): return StructureWidget(self.request, **kwargs) +class StructureTypeRef(ObjectRef): + """ + Custom schema type for a + :class:`~wuttafarm.db.model.structures.Structure` reference field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): # pylint: disable=empty-docstring + """ """ + model = self.app.model + return model.StructureType + + def sort_query(self, query): # pylint: disable=empty-docstring + """ """ + return query.order_by(self.model_class.name) + + def get_object_url(self, obj): # pylint: disable=empty-docstring + """ """ + structure_type = obj + return self.request.route_url("structure_types.view", uuid=structure_type.uuid) + + class UsersType(colander.SchemaType): def __init__(self, request, *args, **kwargs): diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index fa5c803..135fa40 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -44,6 +44,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "title": "Assets", "type": "menu", "items": [ + { + "title": "Structures", + "route": "structures", + "perm": "structures.list", + }, { "title": "Land", "route": "land_assets", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 78a917d..f45169b 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -46,6 +46,7 @@ def includeme(config): config.include("wuttafarm.web.views.structure_types") config.include("wuttafarm.web.views.animal_types") config.include("wuttafarm.web.views.land_assets") + config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.log_types") # views for farmOS diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py index cb91de5..f1d23e7 100644 --- a/src/wuttafarm/web/views/farmos/structures.py +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -186,17 +186,39 @@ class StructureView(FarmOSMasterView): f.set_default("image", url) def get_xref_buttons(self, structure): - drupal_id = structure["drupal_internal_id"] - return [ + model = self.app.model + session = self.Session() + + buttons = [ self.make_button( "View in farmOS", primary=True, - url=self.app.get_farmos_url(f"/asset/{drupal_id}"), + url=self.app.get_farmos_url( + f"/asset/{structure['drupal_internal_id']}" + ), target="_blank", icon_left="external-link-alt", ), ] + if wf_structure := ( + session.query(model.Structure) + .filter(model.Structure.farmos_uuid == structure["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "structures.view", uuid=wf_structure.uuid + ), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 4242cd1..5538989 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -36,11 +36,13 @@ class WuttaFarmMasterView(MasterView): labels = { "farmos_uuid": "farmOS UUID", "drupal_internal_id": "Drupal Internal ID", + "image_url": "Image URL", } row_labels = { "farmos_uuid": "farmOS UUID", "drupal_internal_id": "Drupal Internal ID", + "image_url": "Image URL", } def get_farmos_url(self, obj): diff --git a/src/wuttafarm/web/views/structure_types.py b/src/wuttafarm/web/views/structure_types.py index 4d05184..c5ca542 100644 --- a/src/wuttafarm/web/views/structure_types.py +++ b/src/wuttafarm/web/views/structure_types.py @@ -23,7 +23,7 @@ Master view for Structure Types """ -from wuttafarm.db.model.structures import StructureType +from wuttafarm.db.model.structures import StructureType, Structure from wuttafarm.web.views import WuttaFarmMasterView @@ -52,6 +52,19 @@ class StructureTypeView(WuttaFarmMasterView): "drupal_internal_id", ] + has_rows = True + row_model_class = Structure + rows_viewable = True + + row_grid_columns = [ + "name", + "is_location", + "is_fixed", + "active", + ] + + rows_sort_defaults = "name" + def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -76,6 +89,23 @@ class StructureTypeView(WuttaFarmMasterView): return buttons + def get_row_grid_data(self, structure_type): + model = self.app.model + session = self.Session() + return session.query(model.Structure).filter( + model.Structure.structure_type == structure_type + ) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + + # name + g.set_link("name") + + def get_row_action_url_view(self, structure, i): + return self.request.route_url("structures.view", uuid=structure.uuid) + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py new file mode 100644 index 0000000..1b0df40 --- /dev/null +++ b/src/wuttafarm/web/views/structures.py @@ -0,0 +1,127 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Structures +""" + +from wuttafarm.db.model.structures import Structure +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.forms.schema import StructureTypeRef +from wuttafarm.web.forms.widgets import ImageWidget + + +class StructureView(WuttaFarmMasterView): + """ + Master view for Structures + """ + + model_class = Structure + route_prefix = "structures" + url_prefix = "/structures" + + farmos_refurl_path = "/assets/structure" + + grid_columns = [ + "name", + "structure_type", + "is_location", + "is_fixed", + "active", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "structure_type", + "is_location", + "is_fixed", + "notes", + "active", + "farmos_uuid", + "drupal_internal_id", + "image_url", + "image", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + + # name + g.set_link("name") + + # structure_type + g.set_joiner("structure_type", lambda q: q.join(model.StructureType)) + g.set_sorter("structure_type", model.StructureType.name) + g.set_filter( + "structure_type", model.StructureType.name, label="Structure Type Name" + ) + + def configure_form(self, form): + f = form + super().configure_form(f) + structure = form.model_instance + + # structure_type + f.set_node("structure_type", StructureTypeRef(self.request)) + + # image + if structure.image_url: + f.set_widget("image", ImageWidget("structure image")) + f.set_default("image", structure.image_url) + + def get_farmos_url(self, structure): + return self.app.get_farmos_url(f"/asset/{structure.drupal_internal_id}") + + def get_xref_buttons(self, structure): + buttons = super().get_xref_buttons(structure) + + if structure.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_structures.view", uuid=structure.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + StructureView = kwargs.get("StructureView", base["StructureView"]) + StructureView.defaults(config) + + +def includeme(config): + defaults(config) From 3e5ca3483e4db2d67c1664c542fca2821b9f9bcd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 12:28:41 -0600 Subject: [PATCH 047/166] feat: add native table for Animals; import from farmOS API --- .../versions/1b2d3224e5dc_add_animals.py | 131 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 2 +- src/wuttafarm/db/model/animals.py | 100 +++++++++++++ src/wuttafarm/importing/farmos.py | 85 ++++++++++++ src/wuttafarm/web/forms/schema.py | 25 ++++ src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/animal_types.py | 33 ++++- src/wuttafarm/web/views/animals.py | 130 +++++++++++++++++ src/wuttafarm/web/views/farmos/animals.py | 29 +++- 10 files changed, 537 insertions(+), 4 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py create mode 100644 src/wuttafarm/web/views/animals.py diff --git a/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py b/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py new file mode 100644 index 0000000..18ab1c0 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py @@ -0,0 +1,131 @@ +"""add Animals + +Revision ID: 1b2d3224e5dc +Revises: 4dbba8aeb1e5 +Create Date: 2026-02-13 11:55:19.564221 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "1b2d3224e5dc" +down_revision: Union[str, None] = "4dbba8aeb1e5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # animal + op.create_table( + "animal", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("animal_type_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("birthdate", sa.DateTime(), nullable=True), + sa.Column("sex", sa.String(length=1), nullable=True), + sa.Column("is_sterile", sa.Boolean(), nullable=True), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("image_url", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["animal_type_uuid"], + ["animal_type.uuid"], + name=op.f("fk_animal_animal_type_uuid_animal_type"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_animal_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_animal_farmos_uuid")), + ) + op.create_table( + "animal_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "animal_type_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("sex", sa.String(length=1), autoincrement=False, nullable=True), + sa.Column("is_sterile", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("notes", sa.Text(), autoincrement=False, nullable=True), + sa.Column( + "image_url", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_animal_version") + ), + ) + op.create_index( + op.f("ix_animal_version_end_transaction_id"), + "animal_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_animal_version_operation_type"), + "animal_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_animal_version_pk_transaction_id", + "animal_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_animal_version_pk_validity", + "animal_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_animal_version_transaction_id"), + "animal_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # animal + op.drop_index(op.f("ix_animal_version_transaction_id"), table_name="animal_version") + op.drop_index("ix_animal_version_pk_validity", table_name="animal_version") + op.drop_index("ix_animal_version_pk_transaction_id", table_name="animal_version") + op.drop_index(op.f("ix_animal_version_operation_type"), table_name="animal_version") + op.drop_index( + op.f("ix_animal_version_end_transaction_id"), table_name="animal_version" + ) + op.drop_table("animal_version") + op.drop_table("animal") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 0414168..feda137 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -33,5 +33,5 @@ from .users import WuttaFarmUser from .assets import AssetType from .land import LandType, LandAsset from .structures import StructureType, Structure -from .animals import AnimalType +from .animals import AnimalType, Animal from .logs import LogType diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py index a26b966..0eb6c0b 100644 --- a/src/wuttafarm/db/model/animals.py +++ b/src/wuttafarm/db/model/animals.py @@ -92,3 +92,103 @@ class AnimalType(model.Base): def __str__(self): return self.name or "" + + +class Animal(model.Base): + """ + Represents an animal from farmOS + """ + + __tablename__ = "animal" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Animal", + "model_title_plural": "Animals", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + doc=""" + Name for the animal. + """, + ) + + animal_type_uuid = model.uuid_fk_column("animal_type.uuid", nullable=False) + animal_type = orm.relationship( + "AnimalType", + doc=""" + Reference to the animal type. + """, + ) + + birthdate = sa.Column( + sa.DateTime(), + nullable=True, + doc=""" + Birth date (and time) for the animal, if known. + """, + ) + + sex = sa.Column( + sa.String(length=1), + nullable=True, + doc=""" + Sex of the animal. + """, + ) + + is_sterile = sa.Column( + sa.Boolean(), + nullable=True, + doc=""" + Whether the animal is sterile (e.g. castrated). + """, + ) + + active = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the animal is currently active. + """, + ) + + notes = sa.Column( + sa.Text(), + nullable=True, + doc=""" + Arbitrary notes for the animal. + """, + ) + + image_url = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional image URL for the animal. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the animal within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the animal. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 53182bb..871c560 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -100,6 +100,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["StructureType"] = StructureTypeImporter importers["Structure"] = StructureImporter importers["AnimalType"] = AnimalTypeImporter + importers["Animal"] = AnimalImporter importers["LogType"] = LogTypeImporter return importers @@ -134,6 +135,90 @@ class FromFarmOS(Importer): return self.app.make_utc(dt) +class AnimalImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Animals + """ + + model_class = model.Animal + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + "animal_type_uuid", + "sex", + "is_sterile", + "birthdate", + "notes", + "active", + "image_url", + ] + + def setup(self): + super().setup() + model = self.app.model + + self.animal_types_by_farmos_uuid = {} + for animal_type in self.target_session.query(model.AnimalType): + if animal_type.farmos_uuid: + self.animal_types_by_farmos_uuid[animal_type.farmos_uuid] = animal_type + + def get_source_objects(self): + """ """ + animals = self.farmos_client.asset.get("animal") + return animals["data"] + + def normalize_source_object(self, animal): + """ """ + animal_type_uuid = None + image_url = None + if relationships := animal.get("relationships"): + + if animal_type := relationships.get("animal_type"): + if animal_type["data"]: + if animal_type := self.animal_types_by_farmos_uuid.get( + UUID(animal_type["data"]["id"]) + ): + animal_type_uuid = animal_type.uuid + + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + if image_style := image["data"]["attributes"].get( + "image_style_uri" + ): + image_url = image_style["large"] + + if not animal_type_uuid: + log.warning("missing/invalid animal_type for farmOS Animal: %s", animal) + return None + + birthdate = animal["attributes"]["birthdate"] + if birthdate: + birthdate = datetime.datetime.fromisoformat(birthdate) + birthdate = self.app.localtime(birthdate) + birthdate = self.app.make_utc(birthdate) + + if notes := animal["attributes"]["notes"]: + notes = notes["value"] + + return { + "farmos_uuid": UUID(animal["id"]), + "drupal_internal_id": animal["attributes"]["drupal_internal__id"], + "name": animal["attributes"]["name"], + "animal_type_uuid": animal_type.uuid, + "sex": animal["attributes"]["sex"], + "is_sterile": animal["attributes"]["is_castrated"], + "birthdate": birthdate, + "active": animal["attributes"]["status"] == "active", + "notes": notes, + "image_url": image_url, + } + + class AnimalTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Animal Types diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index e981dee..f646a96 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -30,6 +30,31 @@ import colander from wuttaweb.forms.schema import ObjectRef +class AnimalTypeRef(ObjectRef): + """ + Custom schema type for a + :class:`~wuttafarm.db.model.animals.AnimalType` reference field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): # pylint: disable=empty-docstring + """ """ + model = self.app.model + return model.AnimalType + + def sort_query(self, query): # pylint: disable=empty-docstring + """ """ + return query.order_by(self.model_class.name) + + def get_object_url(self, obj): # pylint: disable=empty-docstring + """ """ + animal_type = obj + return self.request.route_url("animal_types.view", uuid=animal_type.uuid) + + class AnimalTypeType(colander.SchemaType): def __init__(self, request, *args, **kwargs): diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 135fa40..3dfe7ca 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -44,6 +44,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "title": "Assets", "type": "menu", "items": [ + { + "title": "Animals", + "route": "animals", + "perm": "animals.list", + }, { "title": "Structures", "route": "structures", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index f45169b..205ad98 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -47,6 +47,7 @@ def includeme(config): config.include("wuttafarm.web.views.animal_types") config.include("wuttafarm.web.views.land_assets") config.include("wuttafarm.web.views.structures") + config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.log_types") # views for farmOS diff --git a/src/wuttafarm/web/views/animal_types.py b/src/wuttafarm/web/views/animal_types.py index ecd136c..2e10f30 100644 --- a/src/wuttafarm/web/views/animal_types.py +++ b/src/wuttafarm/web/views/animal_types.py @@ -23,7 +23,7 @@ Master view for Animal Types """ -from wuttafarm.db.model.animals import AnimalType +from wuttafarm.db.model.animals import AnimalType, Animal from wuttafarm.web.views import WuttaFarmMasterView @@ -58,6 +58,20 @@ class AnimalTypeView(WuttaFarmMasterView): "drupal_internal_id", ] + has_rows = True + row_model_class = Animal + rows_viewable = True + + row_grid_columns = [ + "name", + "sex", + "is_sterile", + "birthdate", + "active", + ] + + rows_sort_defaults = "name" + def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -87,6 +101,23 @@ class AnimalTypeView(WuttaFarmMasterView): return buttons + def get_row_grid_data(self, animal_type): + model = self.app.model + session = self.Session() + return session.query(model.Animal).filter( + model.Animal.animal_type == animal_type + ) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + + # name + g.set_link("name") + + def get_row_action_url_view(self, animal, i): + return self.request.route_url("animals.view", uuid=animal.uuid) + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py new file mode 100644 index 0000000..2f5d894 --- /dev/null +++ b/src/wuttafarm/web/views/animals.py @@ -0,0 +1,130 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Animals +""" + +from wuttafarm.db.model.animals import Animal +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.forms.schema import AnimalTypeRef +from wuttafarm.web.forms.widgets import ImageWidget + + +class AnimalView(WuttaFarmMasterView): + """ + Master view for Animals + """ + + model_class = Animal + route_prefix = "animals" + url_prefix = "/animals" + + farmos_refurl_path = "/assets/animal" + + grid_columns = [ + "name", + "animal_type", + "sex", + "is_sterile", + "birthdate", + "active", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "animal_type", + "birthdate", + "sex", + "is_sterile", + "active", + "notes", + "farmos_uuid", + "drupal_internal_id", + "image_url", + "image", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + + # name + g.set_link("name") + + # animal_type + g.set_joiner("animal_type", lambda q: q.join(model.AnimalType)) + g.set_sorter("animal_type", model.AnimalType.name) + g.set_filter("animal_type", model.AnimalType.name, label="Animal Type Name") + + def configure_form(self, form): + f = form + super().configure_form(f) + animal = form.model_instance + + # animal_type + f.set_node("animal_type", AnimalTypeRef(self.request)) + + # notes + f.set_widget("notes", "notes") + + # image + if animal.image_url: + f.set_widget("image", ImageWidget("animal image")) + f.set_default("image", animal.image_url) + + def get_farmos_url(self, animal): + return self.app.get_farmos_url(f"/asset/{animal.drupal_internal_id}") + + def get_xref_buttons(self, animal): + buttons = super().get_xref_buttons(animal) + + if animal.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_animals.view", uuid=animal.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + AnimalView = kwargs.get("AnimalView", base["AnimalView"]) + AnimalView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 029ecfb..5a1ecd5 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -27,8 +27,10 @@ import datetime import colander -from wuttafarm.web.views.farmos import FarmOSMasterView +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 UsersType, AnimalTypeType, StructureType from wuttafarm.web.forms.widgets import ImageWidget @@ -191,6 +193,10 @@ class AnimalView(FarmOSMasterView): # animal_type f.set_node("animal_type", AnimalTypeType(self.request)) + # birthdate + f.set_node("birthdate", WuttaDateTime()) + f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + # is_castrated f.set_node("is_castrated", colander.Boolean()) @@ -209,7 +215,10 @@ class AnimalView(FarmOSMasterView): f.set_default("image", url) def get_xref_buttons(self, animal): - return [ + model = self.app.model + session = self.Session() + + buttons = [ self.make_button( "View in farmOS", primary=True, @@ -219,6 +228,22 @@ class AnimalView(FarmOSMasterView): ), ] + if wf_animal := ( + session.query(model.Animal) + .filter(model.Animal.farmos_uuid == animal["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("animals.view", uuid=wf_animal.uuid), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() From 81daa5d9137e483d950ef410462aed37d749f204 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 13:18:52 -0600 Subject: [PATCH 048/166] feat: add native table for Groups; import from farmOS API --- .../versions/92b813360b99_add_groups.py | 114 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 1 + src/wuttafarm/db/model/groups.py | 106 ++++++++++++++++ src/wuttafarm/importing/farmos.py | 39 ++++++ src/wuttafarm/web/menus.py | 5 + src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/farmos/groups.py | 21 +++- src/wuttafarm/web/views/groups.py | 107 ++++++++++++++++ 8 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py create mode 100644 src/wuttafarm/db/model/groups.py create mode 100644 src/wuttafarm/web/views/groups.py diff --git a/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py b/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py new file mode 100644 index 0000000..66a79ec --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py @@ -0,0 +1,114 @@ +"""add Groups + +Revision ID: 92b813360b99 +Revises: 1b2d3224e5dc +Create Date: 2026-02-13 13:09:48.718064 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "92b813360b99" +down_revision: Union[str, None] = "1b2d3224e5dc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # group + op.create_table( + "group", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("is_location", sa.Boolean(), nullable=False), + sa.Column("is_fixed", sa.Boolean(), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_group")), + sa.UniqueConstraint( + "drupal_internal_id", name=op.f("uq_group_drupal_internal_id") + ), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_group_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_group_name")), + ) + op.create_table( + "group_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("notes", sa.Text(), autoincrement=False, nullable=True), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_group_version") + ), + ) + op.create_index( + op.f("ix_group_version_end_transaction_id"), + "group_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_group_version_operation_type"), + "group_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_group_version_pk_transaction_id", + "group_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_group_version_pk_validity", + "group_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_group_version_transaction_id"), + "group_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # group + op.drop_index(op.f("ix_group_version_transaction_id"), table_name="group_version") + op.drop_index("ix_group_version_pk_validity", table_name="group_version") + op.drop_index("ix_group_version_pk_transaction_id", table_name="group_version") + op.drop_index(op.f("ix_group_version_operation_type"), table_name="group_version") + op.drop_index( + op.f("ix_group_version_end_transaction_id"), table_name="group_version" + ) + op.drop_table("group_version") + op.drop_table("group") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index feda137..1a3e677 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -34,4 +34,5 @@ from .assets import AssetType from .land import LandType, LandAsset from .structures import StructureType, Structure from .animals import AnimalType, Animal +from .groups import Group from .logs import LogType diff --git a/src/wuttafarm/db/model/groups.py b/src/wuttafarm/db/model/groups.py new file mode 100644 index 0000000..3bcac19 --- /dev/null +++ b/src/wuttafarm/db/model/groups.py @@ -0,0 +1,106 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Groups +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + + +class Group(model.Base): + """ + Represents a "group" from farmOS + """ + + __tablename__ = "group" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Group", + "model_title_plural": "Groups", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name for the group. + """, + ) + + is_location = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the group is considered to be a location. + """, + ) + + is_fixed = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the group location is fixed. + """, + ) + + active = sa.Column( + sa.Boolean(), + nullable=False, + doc=""" + Whether the group is active. + """, + ) + + notes = sa.Column( + sa.Text(), + nullable=True, + doc=""" + Arbitrary notes for the group. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the group within farmOS. + """, + ) + + drupal_internal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the group. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 871c560..2623eab 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -101,6 +101,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["Structure"] = StructureImporter importers["AnimalType"] = AnimalTypeImporter importers["Animal"] = AnimalImporter + importers["Group"] = GroupImporter importers["LogType"] = LogTypeImporter return importers @@ -279,6 +280,44 @@ class AssetTypeImporter(FromFarmOS, ToWutta): } +class GroupImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Groups + """ + + model_class = model.Group + + supported_fields = [ + "farmos_uuid", + "drupal_internal_id", + "name", + "is_location", + "is_fixed", + "notes", + "active", + ] + + def get_source_objects(self): + """ """ + groups = self.farmos_client.asset.get("group") + return groups["data"] + + def normalize_source_object(self, group): + """ """ + if notes := group["attributes"]["notes"]: + notes = notes["value"] + + return { + "farmos_uuid": UUID(group["id"]), + "drupal_internal_id": group["attributes"]["drupal_internal__id"], + "name": group["attributes"]["name"], + "is_location": group["attributes"]["is_location"], + "is_fixed": group["attributes"]["is_fixed"], + "active": group["attributes"]["status"] == "active", + "notes": notes, + } + + class LandAssetImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Land Assets diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 3dfe7ca..9606c86 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -49,6 +49,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "animals", "perm": "animals.list", }, + { + "title": "Groups", + "route": "groups", + "perm": "groups.list", + }, { "title": "Structures", "route": "structures", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 205ad98..8606025 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -48,6 +48,7 @@ def includeme(config): config.include("wuttafarm.web.views.land_assets") config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.animals") + config.include("wuttafarm.web.views.groups") config.include("wuttafarm.web.views.log_types") # views for farmOS diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index 127dd43..b41a987 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -141,7 +141,10 @@ class GroupView(FarmOSMasterView): f.set_widget("changed", WuttaDateTimeWidget(self.request)) def get_xref_buttons(self, group): - return [ + model = self.app.model + session = self.Session() + + buttons = [ self.make_button( "View in farmOS", primary=True, @@ -151,6 +154,22 @@ class GroupView(FarmOSMasterView): ), ] + if wf_group := ( + session.query(model.Group) + .filter(model.Group.farmos_uuid == group["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("groups.view", uuid=wf_group.uuid), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py new file mode 100644 index 0000000..5c7fee9 --- /dev/null +++ b/src/wuttafarm/web/views/groups.py @@ -0,0 +1,107 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Groups +""" + +from wuttafarm.db.model.groups import Group +from wuttafarm.web.views import WuttaFarmMasterView + + +class GroupView(WuttaFarmMasterView): + """ + Master view for Groups + """ + + model_class = Group + route_prefix = "groups" + url_prefix = "/groups" + + farmos_refurl_path = "/assets/group" + + grid_columns = [ + "name", + "is_location", + "is_fixed", + "active", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "is_location", + "is_fixed", + "active", + "notes", + "farmos_uuid", + "drupal_internal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def configure_form(self, form): + f = form + super().configure_form(f) + + # notes + f.set_widget("notes", "notes") + + def get_farmos_url(self, group): + return self.app.get_farmos_url(f"/asset/{group.drupal_internal_id}") + + def get_xref_buttons(self, group): + buttons = super().get_xref_buttons(group) + + if group.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_groups.view", uuid=group.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + GroupView = kwargs.get("GroupView", base["GroupView"]) + GroupView.defaults(config) + + +def includeme(config): + defaults(config) From f4e4c3efb3260be8d1c006d26a2b9653cdcca0a9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 13:29:57 -0600 Subject: [PATCH 049/166] fix: rename `drupal_internal_id` => `drupal_id` --- .../versions/1b2d3224e5dc_add_animals.py | 10 ++-- .../versions/2b6385d0fa17_add_animal_types.py | 10 ++-- .../versions/4dbba8aeb1e5_add_structures.py | 10 ++-- .../6c56bcd1c028_add_wuttafarmuser.py | 6 +-- .../versions/92b813360b99_add_groups.py | 10 ++-- .../versions/9f2243df9566_add_land_types.py | 11 ++-- .../versions/cf3f8f46d8bc_add_asset_types.py | 11 ++-- .../d7479d7161a8_add_structure_types.py | 11 ++-- .../versions/e0d9f72575d6_add_log_types.py | 11 ++-- .../versions/e416b96467fc_add_land_assets.py | 10 ++-- src/wuttafarm/db/model/animals.py | 4 +- src/wuttafarm/db/model/assets.py | 2 +- src/wuttafarm/db/model/groups.py | 2 +- src/wuttafarm/db/model/land.py | 4 +- src/wuttafarm/db/model/logs.py | 2 +- src/wuttafarm/db/model/structures.py | 4 +- src/wuttafarm/db/model/users.py | 4 +- src/wuttafarm/importing/farmos.py | 52 +++++++++---------- src/wuttafarm/web/views/animal_types.py | 6 +-- src/wuttafarm/web/views/animals.py | 4 +- src/wuttafarm/web/views/asset_types.py | 2 +- .../web/views/farmos/animal_types.py | 4 +- src/wuttafarm/web/views/farmos/animals.py | 4 +- src/wuttafarm/web/views/farmos/asset_types.py | 2 +- src/wuttafarm/web/views/farmos/groups.py | 4 +- src/wuttafarm/web/views/farmos/land_assets.py | 6 +-- src/wuttafarm/web/views/farmos/land_types.py | 2 +- src/wuttafarm/web/views/farmos/log_types.py | 2 +- .../web/views/farmos/logs_activity.py | 4 +- .../web/views/farmos/structure_types.py | 2 +- src/wuttafarm/web/views/farmos/structures.py | 6 +-- src/wuttafarm/web/views/farmos/users.py | 4 +- src/wuttafarm/web/views/groups.py | 4 +- src/wuttafarm/web/views/land_assets.py | 4 +- src/wuttafarm/web/views/land_types.py | 2 +- src/wuttafarm/web/views/log_types.py | 2 +- src/wuttafarm/web/views/master.py | 4 +- src/wuttafarm/web/views/structure_types.py | 2 +- src/wuttafarm/web/views/structures.py | 4 +- src/wuttafarm/web/views/users.py | 12 ++--- 40 files changed, 106 insertions(+), 154 deletions(-) diff --git a/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py b/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py index 18ab1c0..78400ac 100644 --- a/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py +++ b/src/wuttafarm/db/alembic/versions/1b2d3224e5dc_add_animals.py @@ -35,16 +35,14 @@ def upgrade() -> None: sa.Column("notes", sa.Text(), nullable=True), sa.Column("image_url", sa.String(length=255), nullable=True), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["animal_type_uuid"], ["animal_type.uuid"], name=op.f("fk_animal_animal_type_uuid_animal_type"), ), sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal")), - sa.UniqueConstraint( - "drupal_internal_id", name=op.f("uq_animal_drupal_internal_id") - ), + sa.UniqueConstraint("drupal_id", name=op.f("uq_animal_drupal_id")), sa.UniqueConstraint("farmos_uuid", name=op.f("uq_animal_farmos_uuid")), ) op.create_table( @@ -73,9 +71,7 @@ def upgrade() -> None: autoincrement=False, nullable=True, ), - sa.Column( - "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True - ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False ), diff --git a/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py b/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py index 7ddc814..4e1481f 100644 --- a/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py +++ b/src/wuttafarm/db/alembic/versions/2b6385d0fa17_add_animal_types.py @@ -30,11 +30,9 @@ def upgrade() -> None: sa.Column("description", sa.String(length=255), nullable=True), sa.Column("changed", sa.DateTime(), nullable=True), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal_type")), - sa.UniqueConstraint( - "drupal_internal_id", name=op.f("uq_animal_type_drupal_internal_id") - ), + sa.UniqueConstraint("drupal_id", name=op.f("uq_animal_type_drupal_id")), sa.UniqueConstraint("farmos_uuid", name=op.f("uq_animal_type_farmos_uuid")), sa.UniqueConstraint("name", name=op.f("uq_animal_type_name")), ) @@ -53,9 +51,7 @@ def upgrade() -> None: autoincrement=False, nullable=True, ), - sa.Column( - "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True - ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False ), diff --git a/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py b/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py index 823f826..94e8186 100644 --- a/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py +++ b/src/wuttafarm/db/alembic/versions/4dbba8aeb1e5_add_structures.py @@ -34,16 +34,14 @@ def upgrade() -> None: sa.Column("notes", sa.Text(), nullable=True), sa.Column("image_url", sa.String(length=255), nullable=True), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["structure_type_uuid"], ["structure_type.uuid"], name=op.f("fk_structure_structure_type_uuid_structure_type"), ), sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure")), - sa.UniqueConstraint( - "drupal_internal_id", name=op.f("uq_structure_drupal_internal_id") - ), + sa.UniqueConstraint("drupal_id", name=op.f("uq_structure_drupal_id")), sa.UniqueConstraint("farmos_uuid", name=op.f("uq_structure_farmos_uuid")), sa.UniqueConstraint("name", name=op.f("uq_structure_name")), ) @@ -72,9 +70,7 @@ def upgrade() -> None: autoincrement=False, nullable=True, ), - sa.Column( - "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True - ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False ), diff --git a/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py b/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py index 32fa175..0dc2d29 100644 --- a/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py +++ b/src/wuttafarm/db/alembic/versions/6c56bcd1c028_add_wuttafarmuser.py @@ -27,7 +27,7 @@ def upgrade() -> None: "wuttafarm_user", sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["uuid"], ["user.uuid"], name=op.f("fk_wuttafarm_user_uuid_user") ), @@ -44,9 +44,7 @@ def upgrade() -> None: autoincrement=False, nullable=True, ), - sa.Column( - "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True - ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False ), diff --git a/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py b/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py index 66a79ec..7223844 100644 --- a/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py +++ b/src/wuttafarm/db/alembic/versions/92b813360b99_add_groups.py @@ -32,11 +32,9 @@ def upgrade() -> None: sa.Column("active", sa.Boolean(), nullable=False), sa.Column("notes", sa.Text(), nullable=True), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), sa.PrimaryKeyConstraint("uuid", name=op.f("pk_group")), - sa.UniqueConstraint( - "drupal_internal_id", name=op.f("uq_group_drupal_internal_id") - ), + sa.UniqueConstraint("drupal_id", name=op.f("uq_group_drupal_id")), sa.UniqueConstraint("farmos_uuid", name=op.f("uq_group_farmos_uuid")), sa.UniqueConstraint("name", name=op.f("uq_group_name")), ) @@ -56,9 +54,7 @@ def upgrade() -> None: autoincrement=False, nullable=True, ), - sa.Column( - "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True - ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False ), diff --git a/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py b/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py index 4e45439..15d89fa 100644 --- a/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py +++ b/src/wuttafarm/db/alembic/versions/9f2243df9566_add_land_types.py @@ -28,11 +28,9 @@ def upgrade() -> None: sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), sa.Column("name", sa.String(length=100), nullable=False), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.String(length=50), nullable=True), + sa.Column("drupal_id", sa.String(length=50), nullable=True), sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_type")), - sa.UniqueConstraint( - "drupal_internal_id", name=op.f("uq_land_type_drupal_internal_id") - ), + sa.UniqueConstraint("drupal_id", name=op.f("uq_land_type_drupal_id")), sa.UniqueConstraint("farmos_uuid", name=op.f("uq_land_type_farmos_uuid")), sa.UniqueConstraint("name", name=op.f("uq_land_type_name")), ) @@ -49,10 +47,7 @@ def upgrade() -> None: nullable=True, ), sa.Column( - "drupal_internal_id", - sa.String(length=50), - autoincrement=False, - nullable=True, + "drupal_id", sa.String(length=50), autoincrement=False, nullable=True ), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False diff --git a/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py b/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py index 2a5f570..ed4c344 100644 --- a/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py +++ b/src/wuttafarm/db/alembic/versions/cf3f8f46d8bc_add_asset_types.py @@ -29,11 +29,9 @@ def upgrade() -> None: sa.Column("name", sa.String(length=100), nullable=False), sa.Column("description", sa.String(length=255), nullable=True), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.String(length=50), nullable=True), + sa.Column("drupal_id", sa.String(length=50), nullable=True), sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_type")), - sa.UniqueConstraint( - "drupal_internal_id", name=op.f("uq_asset_type_drupal_internal_id") - ), + sa.UniqueConstraint("drupal_id", name=op.f("uq_asset_type_drupal_id")), sa.UniqueConstraint("farmos_uuid", name=op.f("uq_asset_type_farmos_uuid")), sa.UniqueConstraint("name", name=op.f("uq_asset_type_name")), ) @@ -53,10 +51,7 @@ def upgrade() -> None: nullable=True, ), sa.Column( - "drupal_internal_id", - sa.String(length=50), - autoincrement=False, - nullable=True, + "drupal_id", sa.String(length=50), autoincrement=False, nullable=True ), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False diff --git a/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py b/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py index 9dce53b..b71c4a6 100644 --- a/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py +++ b/src/wuttafarm/db/alembic/versions/d7479d7161a8_add_structure_types.py @@ -28,11 +28,9 @@ def upgrade() -> None: sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), sa.Column("name", sa.String(length=100), nullable=False), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.String(length=50), nullable=True), + sa.Column("drupal_id", sa.String(length=50), nullable=True), sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure_type")), - sa.UniqueConstraint( - "drupal_internal_id", name=op.f("uq_structure_type_drupal_internal_id") - ), + sa.UniqueConstraint("drupal_id", name=op.f("uq_structure_type_drupal_id")), sa.UniqueConstraint("farmos_uuid", name=op.f("uq_structure_type_farmos_uuid")), sa.UniqueConstraint("name", name=op.f("uq_structure_type_name")), ) @@ -49,10 +47,7 @@ def upgrade() -> None: nullable=True, ), sa.Column( - "drupal_internal_id", - sa.String(length=50), - autoincrement=False, - nullable=True, + "drupal_id", sa.String(length=50), autoincrement=False, nullable=True ), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False diff --git a/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py b/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py index 7180412..862d3be 100644 --- a/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py +++ b/src/wuttafarm/db/alembic/versions/e0d9f72575d6_add_log_types.py @@ -29,11 +29,9 @@ def upgrade() -> None: sa.Column("name", sa.String(length=100), nullable=False), sa.Column("description", sa.String(length=255), nullable=True), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.String(length=50), nullable=True), + sa.Column("drupal_id", sa.String(length=50), nullable=True), sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_type")), - sa.UniqueConstraint( - "drupal_internal_id", name=op.f("uq_log_type_drupal_internal_id") - ), + sa.UniqueConstraint("drupal_id", name=op.f("uq_log_type_drupal_id")), sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_type_farmos_uuid")), sa.UniqueConstraint("name", name=op.f("uq_log_type_name")), ) @@ -53,10 +51,7 @@ def upgrade() -> None: nullable=True, ), sa.Column( - "drupal_internal_id", - sa.String(length=50), - autoincrement=False, - nullable=True, + "drupal_id", sa.String(length=50), autoincrement=False, nullable=True ), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False diff --git a/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py b/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py index ede9b22..5f7dd87 100644 --- a/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py +++ b/src/wuttafarm/db/alembic/versions/e416b96467fc_add_land_assets.py @@ -33,16 +33,14 @@ def upgrade() -> None: sa.Column("notes", sa.Text(), nullable=True), sa.Column("active", sa.Boolean(), nullable=False), sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), - sa.Column("drupal_internal_id", sa.Integer(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["land_type_uuid"], ["land_type.uuid"], name=op.f("fk_land_asset_land_type_uuid_land_type"), ), sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset")), - sa.UniqueConstraint( - "drupal_internal_id", name=op.f("uq_land_asset_drupal_internal_id") - ), + sa.UniqueConstraint("drupal_id", name=op.f("uq_land_asset_drupal_id")), sa.UniqueConstraint("farmos_uuid", name=op.f("uq_land_asset_farmos_uuid")), sa.UniqueConstraint( "land_type_uuid", name=op.f("uq_land_asset_land_type_uuid") @@ -71,9 +69,7 @@ def upgrade() -> None: autoincrement=False, nullable=True, ), - sa.Column( - "drupal_internal_id", sa.Integer(), autoincrement=False, nullable=True - ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), sa.Column( "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False ), diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py index 0eb6c0b..e23f0c5 100644 --- a/src/wuttafarm/db/model/animals.py +++ b/src/wuttafarm/db/model/animals.py @@ -81,7 +81,7 @@ class AnimalType(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.Integer(), nullable=True, unique=True, @@ -181,7 +181,7 @@ class Animal(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.Integer(), nullable=True, unique=True, diff --git a/src/wuttafarm/db/model/assets.py b/src/wuttafarm/db/model/assets.py index d726632..581be62 100644 --- a/src/wuttafarm/db/model/assets.py +++ b/src/wuttafarm/db/model/assets.py @@ -69,7 +69,7 @@ class AssetType(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.String(length=50), nullable=True, unique=True, diff --git a/src/wuttafarm/db/model/groups.py b/src/wuttafarm/db/model/groups.py index 3bcac19..eae034f 100644 --- a/src/wuttafarm/db/model/groups.py +++ b/src/wuttafarm/db/model/groups.py @@ -93,7 +93,7 @@ class Group(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.Integer(), nullable=True, unique=True, diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py index ca18d70..53c93cf 100644 --- a/src/wuttafarm/db/model/land.py +++ b/src/wuttafarm/db/model/land.py @@ -61,7 +61,7 @@ class LandType(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.String(length=50), nullable=True, unique=True, @@ -143,7 +143,7 @@ class LandAsset(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.Integer(), nullable=True, unique=True, diff --git a/src/wuttafarm/db/model/logs.py b/src/wuttafarm/db/model/logs.py index 70e3585..b215be5 100644 --- a/src/wuttafarm/db/model/logs.py +++ b/src/wuttafarm/db/model/logs.py @@ -69,7 +69,7 @@ class LogType(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.String(length=50), nullable=True, unique=True, diff --git a/src/wuttafarm/db/model/structures.py b/src/wuttafarm/db/model/structures.py index 412e59a..d9fccdb 100644 --- a/src/wuttafarm/db/model/structures.py +++ b/src/wuttafarm/db/model/structures.py @@ -61,7 +61,7 @@ class StructureType(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.String(length=50), nullable=True, unique=True, @@ -154,7 +154,7 @@ class Structure(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.Integer(), nullable=True, unique=True, diff --git a/src/wuttafarm/db/model/users.py b/src/wuttafarm/db/model/users.py index 2cad429..d194175 100644 --- a/src/wuttafarm/db/model/users.py +++ b/src/wuttafarm/db/model/users.py @@ -64,7 +64,7 @@ class WuttaFarmUser(model.Base): """, ) - drupal_internal_id = sa.Column( + drupal_id = sa.Column( sa.Integer(), nullable=True, doc=""" @@ -77,4 +77,4 @@ class WuttaFarmUser(model.Base): WuttaFarmUser.make_proxy(model.User, "_wuttafarm", "farmos_uuid") -WuttaFarmUser.make_proxy(model.User, "_wuttafarm", "drupal_internal_id") +WuttaFarmUser.make_proxy(model.User, "_wuttafarm", "drupal_id") diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 2623eab..68990cb 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -145,7 +145,7 @@ class AnimalImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "name", "animal_type_uuid", "sex", @@ -208,7 +208,7 @@ class AnimalImporter(FromFarmOS, ToWutta): return { "farmos_uuid": UUID(animal["id"]), - "drupal_internal_id": animal["attributes"]["drupal_internal__id"], + "drupal_id": animal["attributes"]["drupal_internal__id"], "name": animal["attributes"]["name"], "animal_type_uuid": animal_type.uuid, "sex": animal["attributes"]["sex"], @@ -229,7 +229,7 @@ class AnimalTypeImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "name", "description", "changed", @@ -244,7 +244,7 @@ class AnimalTypeImporter(FromFarmOS, ToWutta): """ """ return { "farmos_uuid": UUID(animal_type["id"]), - "drupal_internal_id": animal_type["attributes"]["drupal_internal__tid"], + "drupal_id": animal_type["attributes"]["drupal_internal__tid"], "name": animal_type["attributes"]["name"], "description": animal_type["attributes"]["description"], "changed": self.normalize_datetime(animal_type["attributes"]["changed"]), @@ -260,7 +260,7 @@ class AssetTypeImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "name", "description", ] @@ -274,7 +274,7 @@ class AssetTypeImporter(FromFarmOS, ToWutta): """ """ return { "farmos_uuid": UUID(asset_type["id"]), - "drupal_internal_id": asset_type["attributes"]["drupal_internal__id"], + "drupal_id": asset_type["attributes"]["drupal_internal__id"], "name": asset_type["attributes"]["label"], "description": asset_type["attributes"]["description"], } @@ -289,7 +289,7 @@ class GroupImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "name", "is_location", "is_fixed", @@ -309,7 +309,7 @@ class GroupImporter(FromFarmOS, ToWutta): return { "farmos_uuid": UUID(group["id"]), - "drupal_internal_id": group["attributes"]["drupal_internal__id"], + "drupal_id": group["attributes"]["drupal_internal__id"], "name": group["attributes"]["name"], "is_location": group["attributes"]["is_location"], "is_fixed": group["attributes"]["is_fixed"], @@ -327,7 +327,7 @@ class LandAssetImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "name", "land_type_uuid", "is_location", @@ -342,7 +342,7 @@ class LandAssetImporter(FromFarmOS, ToWutta): self.land_types_by_id = {} for land_type in self.target_session.query(model.LandType): - self.land_types_by_id[land_type.drupal_internal_id] = land_type + self.land_types_by_id[land_type.drupal_id] = land_type def get_source_objects(self): """ """ @@ -364,7 +364,7 @@ class LandAssetImporter(FromFarmOS, ToWutta): return { "farmos_uuid": UUID(land["id"]), - "drupal_internal_id": land["attributes"]["drupal_internal__id"], + "drupal_id": land["attributes"]["drupal_internal__id"], "name": land["attributes"]["name"], "land_type_uuid": land_type.uuid, "is_location": land["attributes"]["is_location"], @@ -383,7 +383,7 @@ class LandTypeImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "name", ] @@ -396,7 +396,7 @@ class LandTypeImporter(FromFarmOS, ToWutta): """ """ return { "farmos_uuid": UUID(land_type["id"]), - "drupal_internal_id": land_type["attributes"]["drupal_internal__id"], + "drupal_id": land_type["attributes"]["drupal_internal__id"], "name": land_type["attributes"]["label"], } @@ -410,7 +410,7 @@ class LogTypeImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "name", "description", ] @@ -424,7 +424,7 @@ class LogTypeImporter(FromFarmOS, ToWutta): """ """ return { "farmos_uuid": UUID(log_type["id"]), - "drupal_internal_id": log_type["attributes"]["drupal_internal__id"], + "drupal_id": log_type["attributes"]["drupal_internal__id"], "name": log_type["attributes"]["label"], "description": log_type["attributes"]["description"], } @@ -439,7 +439,7 @@ class StructureImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "name", "structure_type_uuid", "is_location", @@ -455,9 +455,7 @@ class StructureImporter(FromFarmOS, ToWutta): self.structure_types_by_id = {} for structure_type in self.target_session.query(model.StructureType): - self.structure_types_by_id[structure_type.drupal_internal_id] = ( - structure_type - ) + self.structure_types_by_id[structure_type.drupal_id] = structure_type def get_source_objects(self): """ """ @@ -493,7 +491,7 @@ class StructureImporter(FromFarmOS, ToWutta): return { "farmos_uuid": UUID(structure["id"]), - "drupal_internal_id": structure["attributes"]["drupal_internal__id"], + "drupal_id": structure["attributes"]["drupal_internal__id"], "name": structure["attributes"]["name"], "structure_type_uuid": structure_type.uuid, "is_location": structure["attributes"]["is_location"], @@ -513,7 +511,7 @@ class StructureTypeImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "name", ] @@ -526,7 +524,7 @@ class StructureTypeImporter(FromFarmOS, ToWutta): """ """ return { "farmos_uuid": UUID(structure_type["id"]), - "drupal_internal_id": structure_type["attributes"]["drupal_internal__id"], + "drupal_id": structure_type["attributes"]["drupal_internal__id"], "name": structure_type["attributes"]["label"], } @@ -540,7 +538,7 @@ class UserImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", "username", ] @@ -551,7 +549,7 @@ class UserImporter(FromFarmOS, ToWutta): fields.extend( [ "farmos_uuid", - "drupal_internal_id", + "drupal_id", ] ) return fields @@ -565,13 +563,13 @@ class UserImporter(FromFarmOS, ToWutta): """ """ # nb. skip Anonymous user which does not have drupal id - drupal_internal_id = user["attributes"].get("drupal_internal__uid") - if not drupal_internal_id: + drupal_id = user["attributes"].get("drupal_internal__uid") + if not drupal_id: return None return { "farmos_uuid": UUID(user["id"]), - "drupal_internal_id": drupal_internal_id, + "drupal_id": drupal_id, "username": user["attributes"]["name"], } diff --git a/src/wuttafarm/web/views/animal_types.py b/src/wuttafarm/web/views/animal_types.py index 2e10f30..09d1e25 100644 --- a/src/wuttafarm/web/views/animal_types.py +++ b/src/wuttafarm/web/views/animal_types.py @@ -55,7 +55,7 @@ class AnimalTypeView(WuttaFarmMasterView): "description", "changed", "farmos_uuid", - "drupal_internal_id", + "drupal_id", ] has_rows = True @@ -80,9 +80,7 @@ class AnimalTypeView(WuttaFarmMasterView): g.set_link("name") def get_farmos_url(self, animal_type): - return self.app.get_farmos_url( - f"/taxonomy/term/{animal_type.drupal_internal_id}" - ) + return self.app.get_farmos_url(f"/taxonomy/term/{animal_type.drupal_id}") def get_xref_buttons(self, animal_type): buttons = super().get_xref_buttons(animal_type) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 2f5d894..e22095e 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -64,7 +64,7 @@ class AnimalView(WuttaFarmMasterView): "active", "notes", "farmos_uuid", - "drupal_internal_id", + "drupal_id", "image_url", "image", ] @@ -99,7 +99,7 @@ class AnimalView(WuttaFarmMasterView): f.set_default("image", animal.image_url) def get_farmos_url(self, animal): - return self.app.get_farmos_url(f"/asset/{animal.drupal_internal_id}") + return self.app.get_farmos_url(f"/asset/{animal.drupal_id}") def get_xref_buttons(self, animal): buttons = super().get_xref_buttons(animal) diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py index d23498e..775fa3a 100644 --- a/src/wuttafarm/web/views/asset_types.py +++ b/src/wuttafarm/web/views/asset_types.py @@ -51,7 +51,7 @@ class AssetTypeView(WuttaFarmMasterView): "name", "description", "farmos_uuid", - "drupal_internal_id", + "drupal_id", ] def configure_grid(self, grid): diff --git a/src/wuttafarm/web/views/farmos/animal_types.py b/src/wuttafarm/web/views/farmos/animal_types.py index ae0b0d4..94d02d8 100644 --- a/src/wuttafarm/web/views/farmos/animal_types.py +++ b/src/wuttafarm/web/views/farmos/animal_types.py @@ -96,7 +96,7 @@ class AnimalTypeView(FarmOSMasterView): return { "uuid": animal_type["id"], - "drupal_internal_id": animal_type["attributes"]["drupal_internal__tid"], + "drupal_id": animal_type["attributes"]["drupal_internal__tid"], "name": animal_type["attributes"]["name"], "description": description or colander.null, "changed": changed, @@ -121,7 +121,7 @@ class AnimalTypeView(FarmOSMasterView): "View in farmOS", primary=True, url=self.app.get_farmos_url( - f"/taxonomy/term/{animal_type['drupal_internal_id']}" + f"/taxonomy/term/{animal_type['drupal_id']}" ), target="_blank", icon_left="external-link-alt", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 5a1ecd5..760ad34 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -175,7 +175,7 @@ class AnimalView(FarmOSMasterView): return { "uuid": animal["id"], - "drupal_internal_id": animal["attributes"]["drupal_internal__id"], + "drupal_id": animal["attributes"]["drupal_internal__id"], "name": animal["attributes"]["name"], "birthdate": birthdate, "sex": animal["attributes"]["sex"], @@ -222,7 +222,7 @@ class AnimalView(FarmOSMasterView): self.make_button( "View in farmOS", primary=True, - url=self.app.get_farmos_url(f"/asset/{animal['drupal_internal_id']}"), + url=self.app.get_farmos_url(f"/asset/{animal['drupal_id']}"), target="_blank", icon_left="external-link-alt", ), diff --git a/src/wuttafarm/web/views/farmos/asset_types.py b/src/wuttafarm/web/views/farmos/asset_types.py index d033ae6..a2fac2f 100644 --- a/src/wuttafarm/web/views/farmos/asset_types.py +++ b/src/wuttafarm/web/views/farmos/asset_types.py @@ -78,7 +78,7 @@ class AssetTypeView(FarmOSMasterView): def normalize_asset_type(self, asset_type): return { "uuid": asset_type["id"], - "drupal_internal_id": asset_type["attributes"]["drupal_internal__id"], + "drupal_id": asset_type["attributes"]["drupal_internal__id"], "label": asset_type["attributes"]["label"], "description": asset_type["attributes"]["description"], } diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index b41a987..66224fe 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -109,7 +109,7 @@ class GroupView(FarmOSMasterView): return { "uuid": group["id"], - "drupal_internal_id": group["attributes"]["drupal_internal__id"], + "drupal_id": group["attributes"]["drupal_internal__id"], "name": group["attributes"]["name"], "created": created, "changed": changed, @@ -148,7 +148,7 @@ class GroupView(FarmOSMasterView): self.make_button( "View in farmOS", primary=True, - url=self.app.get_farmos_url(f"/asset/{group['drupal_internal_id']}"), + url=self.app.get_farmos_url(f"/asset/{group['drupal_id']}"), target="_blank", icon_left="external-link-alt", ), diff --git a/src/wuttafarm/web/views/farmos/land_assets.py b/src/wuttafarm/web/views/farmos/land_assets.py index 185c9d0..64f43cc 100644 --- a/src/wuttafarm/web/views/farmos/land_assets.py +++ b/src/wuttafarm/web/views/farmos/land_assets.py @@ -118,7 +118,7 @@ class LandAssetView(FarmOSMasterView): return { "uuid": land["id"], - "drupal_internal_id": land["attributes"]["drupal_internal__id"], + "drupal_id": land["attributes"]["drupal_internal__id"], "name": land["attributes"]["name"], "land_type": land["attributes"]["land_type"], "created": created, @@ -155,7 +155,7 @@ class LandAssetView(FarmOSMasterView): self.make_button( "View in farmOS", primary=True, - url=self.app.get_farmos_url(f"/asset/{land['drupal_internal_id']}"), + url=self.app.get_farmos_url(f"/asset/{land['drupal_id']}"), target="_blank", icon_left="external-link-alt", ), @@ -169,7 +169,7 @@ class LandAssetView(FarmOSMasterView): self.make_button( "View in farmOS", primary=True, - url=self.app.get_farmos_url(f"/asset/{land['drupal_internal_id']}"), + url=self.app.get_farmos_url(f"/asset/{land['drupal_id']}"), target="_blank", icon_left="external-link-alt", ), diff --git a/src/wuttafarm/web/views/farmos/land_types.py b/src/wuttafarm/web/views/farmos/land_types.py index 9a7bb8b..e9eccea 100644 --- a/src/wuttafarm/web/views/farmos/land_types.py +++ b/src/wuttafarm/web/views/farmos/land_types.py @@ -73,7 +73,7 @@ class LandTypeView(FarmOSMasterView): def normalize_land_type(self, land_type): return { "uuid": land_type["id"], - "drupal_internal_id": land_type["attributes"]["drupal_internal__id"], + "drupal_id": land_type["attributes"]["drupal_internal__id"], "label": land_type["attributes"]["label"], } diff --git a/src/wuttafarm/web/views/farmos/log_types.py b/src/wuttafarm/web/views/farmos/log_types.py index db96fd0..1f6404a 100644 --- a/src/wuttafarm/web/views/farmos/log_types.py +++ b/src/wuttafarm/web/views/farmos/log_types.py @@ -75,7 +75,7 @@ class LogTypeView(FarmOSMasterView): def normalize_log_type(self, log_type): return { "uuid": log_type["id"], - "drupal_internal_id": log_type["attributes"]["drupal_internal__id"], + "drupal_id": log_type["attributes"]["drupal_internal__id"], "label": log_type["attributes"]["label"], "description": log_type["attributes"]["description"], } diff --git a/src/wuttafarm/web/views/farmos/logs_activity.py b/src/wuttafarm/web/views/farmos/logs_activity.py index 33448ab..6bba9cc 100644 --- a/src/wuttafarm/web/views/farmos/logs_activity.py +++ b/src/wuttafarm/web/views/farmos/logs_activity.py @@ -96,7 +96,7 @@ class ActivityLogView(FarmOSMasterView): return { "uuid": log["id"], - "drupal_internal_id": log["attributes"]["drupal_internal__id"], + "drupal_id": log["attributes"]["drupal_internal__id"], "name": log["attributes"]["name"], "timestamp": timestamp, "status": log["attributes"]["status"], @@ -119,7 +119,7 @@ class ActivityLogView(FarmOSMasterView): self.make_button( "View in farmOS", primary=True, - url=self.app.get_farmos_url(f"/log/{log['drupal_internal_id']}"), + url=self.app.get_farmos_url(f"/log/{log['drupal_id']}"), target="_blank", icon_left="external-link-alt", ), diff --git a/src/wuttafarm/web/views/farmos/structure_types.py b/src/wuttafarm/web/views/farmos/structure_types.py index b41e2a3..b7e58d8 100644 --- a/src/wuttafarm/web/views/farmos/structure_types.py +++ b/src/wuttafarm/web/views/farmos/structure_types.py @@ -75,7 +75,7 @@ class StructureTypeView(FarmOSMasterView): def normalize_structure_type(self, structure_type): return { "uuid": structure_type["id"], - "drupal_internal_id": structure_type["attributes"]["drupal_internal__id"], + "drupal_id": structure_type["attributes"]["drupal_internal__id"], "label": structure_type["attributes"]["label"], } diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py index f1d23e7..3626fb1 100644 --- a/src/wuttafarm/web/views/farmos/structures.py +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -147,7 +147,7 @@ class StructureView(FarmOSMasterView): return { "uuid": structure["id"], - "drupal_internal_id": structure["attributes"]["drupal_internal__id"], + "drupal_id": structure["attributes"]["drupal_internal__id"], "name": structure["attributes"]["name"], "structure_type": structure["attributes"]["structure_type"], "is_fixed": structure["attributes"]["is_fixed"], @@ -193,9 +193,7 @@ class StructureView(FarmOSMasterView): self.make_button( "View in farmOS", primary=True, - url=self.app.get_farmos_url( - f"/asset/{structure['drupal_internal_id']}" - ), + url=self.app.get_farmos_url(f"/asset/{structure['drupal_id']}"), target="_blank", icon_left="external-link-alt", ), diff --git a/src/wuttafarm/web/views/farmos/users.py b/src/wuttafarm/web/views/farmos/users.py index bb93066..bb004ee 100644 --- a/src/wuttafarm/web/views/farmos/users.py +++ b/src/wuttafarm/web/views/farmos/users.py @@ -95,7 +95,7 @@ class UserView(FarmOSMasterView): return { "uuid": user["id"], - "drupal_internal_id": user["attributes"].get("drupal_internal__uid"), + "drupal_id": user["attributes"].get("drupal_internal__uid"), "display_name": user["attributes"]["display_name"], "name": user["attributes"].get("name") or colander.null, "mail": user["attributes"].get("mail") or colander.null, @@ -120,7 +120,7 @@ class UserView(FarmOSMasterView): session = self.Session() buttons = [] - if drupal_id := user["drupal_internal_id"]: + if drupal_id := user["drupal_id"]: buttons.append( self.make_button( "View in farmOS", diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 5c7fee9..5f2746b 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -58,7 +58,7 @@ class GroupView(WuttaFarmMasterView): "active", "notes", "farmos_uuid", - "drupal_internal_id", + "drupal_id", ] def configure_grid(self, grid): @@ -76,7 +76,7 @@ class GroupView(WuttaFarmMasterView): f.set_widget("notes", "notes") def get_farmos_url(self, group): - return self.app.get_farmos_url(f"/asset/{group.drupal_internal_id}") + return self.app.get_farmos_url(f"/asset/{group.drupal_id}") def get_xref_buttons(self, group): buttons = super().get_xref_buttons(group) diff --git a/src/wuttafarm/web/views/land_assets.py b/src/wuttafarm/web/views/land_assets.py index d1025eb..18f7a3d 100644 --- a/src/wuttafarm/web/views/land_assets.py +++ b/src/wuttafarm/web/views/land_assets.py @@ -62,7 +62,7 @@ class LandAssetView(WuttaFarmMasterView): "notes", "active", "farmos_uuid", - "drupal_internal_id", + "drupal_id", ] def configure_grid(self, grid): @@ -86,7 +86,7 @@ class LandAssetView(WuttaFarmMasterView): f.set_node("land_type", LandTypeRef(self.request)) def get_farmos_url(self, land): - return self.app.get_farmos_url(f"/asset/{land.drupal_internal_id}") + return self.app.get_farmos_url(f"/asset/{land.drupal_id}") def get_xref_buttons(self, land_asset): buttons = super().get_xref_buttons(land_asset) diff --git a/src/wuttafarm/web/views/land_types.py b/src/wuttafarm/web/views/land_types.py index 05a0644..21bfabc 100644 --- a/src/wuttafarm/web/views/land_types.py +++ b/src/wuttafarm/web/views/land_types.py @@ -49,7 +49,7 @@ class LandTypeView(WuttaFarmMasterView): form_fields = [ "name", "farmos_uuid", - "drupal_internal_id", + "drupal_id", ] has_rows = True diff --git a/src/wuttafarm/web/views/log_types.py b/src/wuttafarm/web/views/log_types.py index 5c4a8ca..13ea35f 100644 --- a/src/wuttafarm/web/views/log_types.py +++ b/src/wuttafarm/web/views/log_types.py @@ -51,7 +51,7 @@ class LogTypeView(WuttaFarmMasterView): "name", "description", "farmos_uuid", - "drupal_internal_id", + "drupal_id", ] def configure_grid(self, grid): diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 5538989..7ff165b 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -35,13 +35,13 @@ class WuttaFarmMasterView(MasterView): labels = { "farmos_uuid": "farmOS UUID", - "drupal_internal_id": "Drupal Internal ID", + "drupal_id": "Drupal ID", "image_url": "Image URL", } row_labels = { "farmos_uuid": "farmOS UUID", - "drupal_internal_id": "Drupal Internal ID", + "drupal_id": "Drupal ID", "image_url": "Image URL", } diff --git a/src/wuttafarm/web/views/structure_types.py b/src/wuttafarm/web/views/structure_types.py index c5ca542..ca85fb9 100644 --- a/src/wuttafarm/web/views/structure_types.py +++ b/src/wuttafarm/web/views/structure_types.py @@ -49,7 +49,7 @@ class StructureTypeView(WuttaFarmMasterView): form_fields = [ "name", "farmos_uuid", - "drupal_internal_id", + "drupal_id", ] has_rows = True diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 1b0df40..df58fda 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -62,7 +62,7 @@ class StructureView(WuttaFarmMasterView): "notes", "active", "farmos_uuid", - "drupal_internal_id", + "drupal_id", "image_url", "image", ] @@ -96,7 +96,7 @@ class StructureView(WuttaFarmMasterView): f.set_default("image", structure.image_url) def get_farmos_url(self, structure): - return self.app.get_farmos_url(f"/asset/{structure.drupal_internal_id}") + return self.app.get_farmos_url(f"/asset/{structure.drupal_id}") def get_xref_buttons(self, structure): buttons = super().get_xref_buttons(structure) diff --git a/src/wuttafarm/web/views/users.py b/src/wuttafarm/web/views/users.py index 782ab16..2a51399 100644 --- a/src/wuttafarm/web/views/users.py +++ b/src/wuttafarm/web/views/users.py @@ -33,7 +33,7 @@ class UserView(base.UserView): labels = { "farmos_uuid": "farmOS UUID", - "drupal_internal_id": "Drupal Internal ID", + "drupal_id": "Drupal ID", } def get_template_context(self, context): @@ -54,19 +54,19 @@ class UserView(base.UserView): f.fields.append("farmos_uuid") f.set_default("farmos_uuid", user.farmos_uuid) - # drupal_internal_id - f.fields.append("drupal_internal_id") - f.set_default("drupal_internal_id", user.drupal_internal_id) + # drupal_id + f.fields.append("drupal_id") + f.set_default("drupal_id", user.drupal_id) def get_xref_buttons(self, user): buttons = [] - if user.drupal_internal_id: + if user.drupal_id: buttons.append( self.make_button( "View in farmOS", primary=True, - url=self.app.get_farmos_url(f"/user/{user.drupal_internal_id}"), + url=self.app.get_farmos_url(f"/user/{user.drupal_id}"), target="_blank", icon_left="external-link-alt", ) From ed768a83d045b3091d238f86a333e0ec02cf028e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 14:50:52 -0600 Subject: [PATCH 050/166] feat: add native table for Activity Logs; import from farmOS API --- .../3e2ef02bf264_add_activity_logs.py | 118 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 2 +- src/wuttafarm/db/model/logs.py | 68 ++++++++++ src/wuttafarm/importing/farmos.py | 38 ++++++ src/wuttafarm/web/menus.py | 6 + src/wuttafarm/web/views/__init__.py | 1 + .../web/views/farmos/logs_activity.py | 21 +++- src/wuttafarm/web/views/logs_activity.py | 105 ++++++++++++++++ 8 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/3e2ef02bf264_add_activity_logs.py create mode 100644 src/wuttafarm/web/views/logs_activity.py diff --git a/src/wuttafarm/db/alembic/versions/3e2ef02bf264_add_activity_logs.py b/src/wuttafarm/db/alembic/versions/3e2ef02bf264_add_activity_logs.py new file mode 100644 index 0000000..5fca4be --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/3e2ef02bf264_add_activity_logs.py @@ -0,0 +1,118 @@ +"""add Activity Logs + +Revision ID: 3e2ef02bf264 +Revises: 92b813360b99 +Create Date: 2026-02-13 14:36:47.191922 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "3e2ef02bf264" +down_revision: Union[str, None] = "92b813360b99" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_activity + op.create_table( + "log_activity", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("message", sa.String(length=255), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_activity")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_log_activity_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_activity_farmos_uuid")), + ) + op.create_table( + "log_activity_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("message", sa.String(length=255), autoincrement=False, nullable=True), + sa.Column("timestamp", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("status", sa.String(length=20), autoincrement=False, nullable=True), + sa.Column("notes", sa.Text(), autoincrement=False, nullable=True), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_activity_version") + ), + ) + op.create_index( + op.f("ix_log_activity_version_end_transaction_id"), + "log_activity_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_activity_version_operation_type"), + "log_activity_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_activity_version_pk_transaction_id", + "log_activity_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_activity_version_pk_validity", + "log_activity_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_activity_version_transaction_id"), + "log_activity_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_activity + op.drop_index( + op.f("ix_log_activity_version_transaction_id"), + table_name="log_activity_version", + ) + op.drop_index( + "ix_log_activity_version_pk_validity", table_name="log_activity_version" + ) + op.drop_index( + "ix_log_activity_version_pk_transaction_id", table_name="log_activity_version" + ) + op.drop_index( + op.f("ix_log_activity_version_operation_type"), + table_name="log_activity_version", + ) + op.drop_index( + op.f("ix_log_activity_version_end_transaction_id"), + table_name="log_activity_version", + ) + op.drop_table("log_activity_version") + op.drop_table("log_activity") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 1a3e677..27d0070 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -35,4 +35,4 @@ from .land import LandType, LandAsset from .structures import StructureType, Structure from .animals import AnimalType, Animal from .groups import Group -from .logs import LogType +from .logs import LogType, ActivityLog diff --git a/src/wuttafarm/db/model/logs.py b/src/wuttafarm/db/model/logs.py index b215be5..76f7715 100644 --- a/src/wuttafarm/db/model/logs.py +++ b/src/wuttafarm/db/model/logs.py @@ -80,3 +80,71 @@ class LogType(model.Base): def __str__(self): return self.name or "" + + +class ActivityLog(model.Base): + """ + Represents an activity log from farmOS + """ + + __tablename__ = "log_activity" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Activity Log", + "model_title_plural": "Activity Logs", + } + + uuid = model.uuid_column() + + message = sa.Column( + sa.String(length=255), + nullable=False, + doc=""" + Message text for the log. + """, + ) + + timestamp = sa.Column( + sa.DateTime(), + nullable=False, + doc=""" + Date and time when the log event occurred / will occur. + """, + ) + + status = sa.Column( + sa.String(length=20), + nullable=False, + doc=""" + Current status of the log event. + """, + ) + + notes = sa.Column( + sa.Text(), + nullable=True, + doc=""" + Arbitrary notes for the log event. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the log within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the log. + """, + ) + + def __str__(self): + return self.message or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 68990cb..4717c78 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -103,6 +103,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["Animal"] = AnimalImporter importers["Group"] = GroupImporter importers["LogType"] = LogTypeImporter + importers["ActivityLog"] = ActivityLogImporter return importers @@ -136,6 +137,43 @@ class FromFarmOS(Importer): return self.app.make_utc(dt) +class ActivityLogImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Activity Logs + """ + + model_class = model.ActivityLog + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "message", + "timestamp", + "notes", + "status", + ] + + def get_source_objects(self): + """ """ + logs = self.farmos_client.log.get("activity") + return logs["data"] + + def normalize_source_object(self, log): + """ """ + + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + return { + "farmos_uuid": UUID(log["id"]), + "drupal_id": log["attributes"]["drupal_internal__id"], + "message": log["attributes"]["name"], + "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), + "notes": notes, + "status": log["attributes"]["status"], + } + + class AnimalImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Animals diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 9606c86..3e5bb46 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -93,6 +93,12 @@ class WuttaFarmMenuHandler(base.MenuHandler): "title": "Logs", "type": "menu", "items": [ + { + "title": "Activity Logs", + "route": "activity_logs", + "perm": "activity_logs.list", + }, + {"type": "sep"}, { "title": "Log Types", "route": "log_types", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 8606025..a4d12dd 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -50,6 +50,7 @@ def includeme(config): config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.groups") config.include("wuttafarm.web.views.log_types") + config.include("wuttafarm.web.views.logs_activity") # views for farmOS config.include("wuttafarm.web.views.farmos") diff --git a/src/wuttafarm/web/views/farmos/logs_activity.py b/src/wuttafarm/web/views/farmos/logs_activity.py index 6bba9cc..e966810 100644 --- a/src/wuttafarm/web/views/farmos/logs_activity.py +++ b/src/wuttafarm/web/views/farmos/logs_activity.py @@ -115,7 +115,10 @@ class ActivityLogView(FarmOSMasterView): f.set_widget("notes", "notes") def get_xref_buttons(self, log): - return [ + model = self.app.model + session = self.Session() + + buttons = [ self.make_button( "View in farmOS", primary=True, @@ -125,6 +128,22 @@ class ActivityLogView(FarmOSMasterView): ), ] + if wf_log := ( + session.query(model.ActivityLog) + .filter(model.ActivityLog.farmos_uuid == log["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("activity_logs.view", uuid=wf_log.uuid), + icon_left="eye", + ) + ) + + return buttons + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/logs_activity.py b/src/wuttafarm/web/views/logs_activity.py new file mode 100644 index 0000000..a2b2154 --- /dev/null +++ b/src/wuttafarm/web/views/logs_activity.py @@ -0,0 +1,105 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Activity Logs +""" + +from wuttafarm.db.model.logs import ActivityLog +from wuttafarm.web.views import WuttaFarmMasterView + + +class ActivityLogView(WuttaFarmMasterView): + """ + Master view for Activity Logs + """ + + model_class = ActivityLog + route_prefix = "activity_logs" + url_prefix = "/logs/activity" + + farmos_refurl_path = "/logs/activity" + + grid_columns = [ + "message", + "timestamp", + "status", + ] + + sort_defaults = ("timestamp", "desc") + + filter_defaults = { + "message": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "message", + "timestamp", + "status", + "notes", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # message + g.set_link("message") + + def configure_form(self, form): + f = form + super().configure_form(f) + + # notes + f.set_widget("notes", "notes") + + def get_farmos_url(self, log): + return self.app.get_farmos_url(f"/log/{log.drupal_id}") + + def get_xref_buttons(self, log): + buttons = super().get_xref_buttons(log) + + if log.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_logs_activity.view", uuid=log.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + ActivityLogView = kwargs.get("ActivityLogView", base["ActivityLogView"]) + ActivityLogView.defaults(config) + + +def includeme(config): + defaults(config) From 1dbf14f3bbe560079947fca962e9dc8d09e158fa Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 15:06:52 -0600 Subject: [PATCH 051/166] fix: add more perms to Site Admin role in app setup --- src/wuttafarm/web/views/common.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index f46c018..1c88f2b 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -48,6 +48,18 @@ class CommonView(base.CommonView): site_admin = session.query(model.Role).filter_by(name="Site Admin").first() if site_admin: site_admin_perms = [ + "activity_logs.list", + "activity_logs.view", + "activity_logs.versions", + "animal_types.list", + "animal_types.view", + "animal_types.versions", + "animals.list", + "animals.view", + "animals.versions", + "asset_types.list", + "asset_types.view", + "asset_types.versions", "farmos_animal_types.list", "farmos_animal_types.view", "farmos_animals.list", @@ -70,6 +82,24 @@ class CommonView(base.CommonView): "farmos_structures.view", "farmos_users.list", "farmos_users.view", + "groups.list", + "groups.view", + "groups.versions", + "land_assets.list", + "land_assets.view", + "land_assets.versions", + "land_types.list", + "land_types.view", + "land_types.versions", + "log_types.list", + "log_types.view", + "log_types.versions", + "structure_types.list", + "structure_types.view", + "structure_types.versions", + "structures.list", + "structures.view", + "structures.versions", ] for perm in site_admin_perms: auth.grant_permission(site_admin, perm) From 935c64464acc60b3abc52db22b93602bcad1027c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 15:06:47 -0600 Subject: [PATCH 052/166] fix: avoid error for Create User form --- src/wuttafarm/web/views/users.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/wuttafarm/web/views/users.py b/src/wuttafarm/web/views/users.py index 2a51399..f35aef7 100644 --- a/src/wuttafarm/web/views/users.py +++ b/src/wuttafarm/web/views/users.py @@ -51,12 +51,14 @@ class UserView(base.UserView): user = f.model_instance # farmos_uuid - f.fields.append("farmos_uuid") - f.set_default("farmos_uuid", user.farmos_uuid) + if not self.creating: + f.fields.append("farmos_uuid") + f.set_default("farmos_uuid", user.farmos_uuid) # drupal_id - f.fields.append("drupal_id") - f.set_default("drupal_id", user.drupal_id) + if not self.creating: + f.fields.append("drupal_id") + f.set_default("drupal_id", user.drupal_id) def get_xref_buttons(self, user): buttons = [] From 311a2c328b7cb65af1221bc972d6a4ddcde1a807 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 15:11:10 -0600 Subject: [PATCH 053/166] fix: always make 'farmos' system user in app setup mainly for sake of attributing data changes coming from farmOS --- src/wuttafarm/web/views/common.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 1c88f2b..cd68b78 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -45,6 +45,9 @@ class CommonView(base.CommonView): farm_viewer = auth.get_role_farm_viewer(session) farm_viewer.notes = "this is meant to mirror the corresponding role in farmOS" + # create system user to represent farmOS + auth.make_user(session, username="farmos", prevent_edit=True) + site_admin = session.query(model.Role).filter_by(name="Site Admin").first() if site_admin: site_admin_perms = [ From be64b4959a39b492ba669eeb2e4c2b24ba9e7330 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 15:18:53 -0600 Subject: [PATCH 054/166] docs: add basic docs for CLI commands --- docs/conf.py | 1 + docs/index.rst | 1 + docs/narr/cli.rst | 39 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 docs/narr/cli.rst diff --git a/docs/conf.py b/docs/conf.py index 3caa6e3..f3fd9e2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,6 +21,7 @@ extensions = [ "sphinx.ext.intersphinx", "sphinx.ext.viewcode", "sphinx.ext.todo", + "sphinxcontrib.programoutput", ] templates_path = ["_templates"] diff --git a/docs/index.rst b/docs/index.rst index a68b748..be04bee 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -27,6 +27,7 @@ include: narr/install narr/auth narr/features + narr/cli .. toctree:: diff --git a/docs/narr/cli.rst b/docs/narr/cli.rst new file mode 100644 index 0000000..70b7c1e --- /dev/null +++ b/docs/narr/cli.rst @@ -0,0 +1,39 @@ + +======================== + Command Line Interface +======================== + +WuttaFarm ships with the following commands. + +For more general info about CLI see +:doc:`wuttjamaican:narr/cli/index`. + + +.. _wuttafarm-install: + +``wuttafarm install`` +--------------------- + +Run the WuttaFarm app installer. + +This will create the :term:`app dir` and initial config files, and +create the schema within the :term:`app database`. + +Defined in: :mod:`wuttafarm.cli.install` + +.. program-output:: wuttafarm install --help + + +.. _wuttafarm-import-farmos: + +``wuttafarm import-farmos`` +--------------------------- + +Import data from the farmOS API into the WuttaFarm :term:`app +database`. + +Defined in: :mod:`wuttafarm.cli.import_farmos` + +.. program-output:: wuttafarm import-farmos --help + + diff --git a/pyproject.toml b/pyproject.toml index 4c3d4d2..04b1d05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ [project.optional-dependencies] -docs = ["Sphinx", "furo"] +docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"] [project.scripts] From 061dac39f9dd2f406361f05b79a8c62e9cef4d64 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 15:50:32 -0600 Subject: [PATCH 055/166] docs: add basic docs for oauth2 setup, import data from farmOS --- docs/narr/auth.rst | 8 +++- docs/narr/features.rst | 4 ++ docs/narr/install.rst | 90 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/docs/narr/auth.rst b/docs/narr/auth.rst index 67a63fa..536f3d0 100644 --- a/docs/narr/auth.rst +++ b/docs/narr/auth.rst @@ -36,7 +36,13 @@ browse farmOS data within the WuttaFarm views. If you login to WuttaFarm directly with username/password, then your user session will not have a farmOS access token and so the - farmOS data views in WuttaFarm will not work. + farmOS data views in WuttaFarm will not work (i.e. anything under + the **farmOS** menu). + + (However this does not affect the "native" data views for + WuttaFarm. Users can see data which was already imported from + farmOS without an access token - if they have appropriate + permissions in WuttaFarm.) On the login page, click the "Login via farmOS / OAuth2" button. This will initiate the OAuth2 workflow, at which point you may be asked to diff --git a/docs/narr/features.rst b/docs/narr/features.rst index 00e435b..60a9120 100644 --- a/docs/narr/features.rst +++ b/docs/narr/features.rst @@ -14,6 +14,10 @@ Here is the list of features currently supported: * performance isn't bad, but data is not very "complete" * more data could be fetched, but not sure this is the best way..? +* import some data from farmOS + * limited data is imported from farmOS API into native app tables + * this data is exposed in views, similar to direct farmOS views (above) + Screenshots ----------- diff --git a/docs/narr/install.rst b/docs/narr/install.rst index fdb9958..1147a6d 100644 --- a/docs/narr/install.rst +++ b/docs/narr/install.rst @@ -60,3 +60,93 @@ are encouraged to enable it anyway. When the installer completes it will output a command you can then use to run the web app. Do that and you can then view the app in a browser at http://localhost:9080 + + +OAuth2 Setup +------------ + +At this point the web app should be ready for OAuth2 login; however +the OAuth2 provider in farmOS needs some more config before it will +work. + +WuttaFarm uses the default ``farm`` consumer, so the only thing you +should have to do here is edit that to add your redirect URL. This +will vary based on your WuttaFarm site name, e.g. + +.. code-block:: none + + https://wuttafarm.example.com/farmos/oauth/callback + +With that in place you should be able to login via OAuth2; see also +:doc:`/narr/auth`. + +However while you're there, you should also do some setup for the sake +of the farmOS → WuttaFarm data import. This import will also use the +farmOS API and therefore also needs an oauth2 access token; however it +uses the Client Credentials workflow instead of the Authorization Code +workflow. Therefore you must create a new *user* and a new OAuth2 +*consumer* for it. + +First add a new user in farmOS, named ``wuttafarm``. It should +probably be given the Manager role, since WuttaFarm will eventually +also support "exporting" data back to farmOS. + +Then add a new OAuth2 consumer (aka. client) with these attributes: + +* **Label:** WuttaFarm +* **Client ID:** wuttafarm +* **New Secret:** (put something in here, to be used as client secret) +* **Grant Types:** Client Credentials, Refresh Token (maybe more?) +* **User:** wuttafarm +* **3rd Party?** yes +* **Confidential?** yes +* **Access Token Expiration Time:** maybe set to 3600? or maybe 300 + default is okay? +* **Allowed Origins:** put your oauth callback URL here (same as for + default ``farm`` consumer) + +WuttaFarm also needs to know the client secret for sake of running the +import; so add this to your ``app/wutta.conf`` file. Of course +replace the value with whatever client secret you gave the new +consumer: + +.. code-block:: ini + + [farmos.oauth2] + importing.client_secret = you_cant_guess_me + + +Import Data from farmOS +----------------------- + +You must have done all the OAuth2 setup (previous section) before the +import will work. + +But now that you did all that, importing should be quick and easy. + +The very first import will be limited and "special" to account for any +users which were already created in WuttaFarm. This command will +ensure WuttaFarm gets *all* user accounts and each is appropriately +mapped to the farmOS account: + +.. code-block:: sh + + ./venv/bin/wuttafarm --runas farmos import-farmos User --key username + +Note also the ``--runas farmos`` arg which helps the WuttaFarm data +versioning know "who" is responsible for the changes. We use a +dedicated ``farmos`` user account in WuttaFarm, to represent the +farmOS system as a whole. + +From now on you can run the "full" import normally: + +.. code-block:: sh + + ./venv/bin/wuttafarm --runas farmos import-farmos + +And it can sometimes be helpful to "double-check" in order to make +sure all data is fully synced: + +.. code-block:: sh + + ./venv/bin/wuttafarm --runas farmos import-farmos --delete --dry-run -W From 34cb6b210d172a5a17ae6407730fda53c0a5db9c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2026 15:51:52 -0600 Subject: [PATCH 056/166] =?UTF-8?q?bump:=20version=200.2.3=20=E2=86=92=200?= =?UTF-8?q?.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 24 ++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5241ed..f6b8703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.3.0 (2026-02-13) + +### Feat + +- add native table for Activity Logs; import from farmOS API +- add native table for Groups; import from farmOS API +- add native table for Animals; import from farmOS API +- add native table for Structures; import from farmOS API +- add native table for Land Assets; import from farmOS API +- add native table for Log Types; import from farmOS API +- add native table for Structure Types; import from farmOS API +- add native table for Land Types; import from farmOS API +- add native table for Asset Types; import from farmOS API +- add extension table for Users; import from farmOS API +- add native table for Animal Types; import from farmOS API +- add "See raw JSON data" button for farmOS API views + +### Fix + +- always make 'farmos' system user in app setup +- avoid error for Create User form +- add more perms to Site Admin role in app setup +- rename `drupal_internal_id` => `drupal_id` + ## v0.2.3 (2026-02-08) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 04b1d05..f8fc499 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.2.3" +version = "0.3.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 35068c0cb10420cdb78b5da97b053acbcb902cf2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 08:44:24 -0600 Subject: [PATCH 057/166] docs: add some notes on email setup --- docs/narr/install.rst | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/narr/install.rst b/docs/narr/install.rst index 1147a6d..ba5d8e3 100644 --- a/docs/narr/install.rst +++ b/docs/narr/install.rst @@ -53,9 +53,9 @@ The app installer (last command above) will prompt you for DB credentials, and the farmOS URL. One of the questions is about data versioning with -:doc:`wutta-continuum:index`. This feature will be leveraged more in -the future but for the moment doesn't do a whole lot in this app. You -are encouraged to enable it anyway. +:doc:`wutta-continuum:index`. You should probaby enable that even +though as of writing the default is disabled. It adds "revision +history" for most types of records in the WuttaFarm app DB. When the installer completes it will output a command you can then use to run the web app. Do that and you can then view the app in a @@ -116,6 +116,39 @@ consumer: importing.client_secret = you_cant_guess_me +Email Setup +----------- + +WuttaFarm can send emails of various kinds; of note are: + +* when user submits Feedback via button in top right of screen +* importer diff warning for farmOS → WuttaFarm + +That last one is optional, triggered via the ``-W`` flag in the +importer command line. + +Anyway the app basically assumes there is a Postfix or similar mail +server running on "localhost" which it can use as the SMTP server, and +which is in turn responsible for "really" sending the email out via +some configured relay. This has always worked very well for me since +I tend to want to have email working for other reasons on each Linux +server I maintain. (And since I have not traditionally used Docker +and/or containers.) + +So if you need something else, touch base and we'll figure something +out. But assuming localhost is okay to use: + +In the web app menu, see Admin -> App Info and then click Configure. +Check the box to enable email and plug in the default sender and +recipient (which should be the admin responsible for the app). I +often create an alias so I can use e.g. wuttafarm@edbob.org as +sender - aliased back to myself in case it generates bounces so I can +see them. + +From there you can also see Admin -> Email Settings in the menu; this +lets you control and preview each type of email separately. + + Import Data from farmOS ----------------------- From 985d224cb816c44f153c758848d45400e59152ff Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 14:37:41 -0600 Subject: [PATCH 058/166] fix: update sterile, archived flags per farmOS 4.x 3.x should still work okay too though --- src/wuttafarm/app.py | 18 ++++++++ src/wuttafarm/farmos/handler.py | 29 ++++++++++++ src/wuttafarm/importing/farmos.py | 37 +++++++++++++-- src/wuttafarm/web/views/farmos/animals.py | 46 ++++++++++++++----- src/wuttafarm/web/views/farmos/groups.py | 17 +++++-- src/wuttafarm/web/views/farmos/land_assets.py | 17 +++++-- src/wuttafarm/web/views/farmos/master.py | 1 + src/wuttafarm/web/views/farmos/structures.py | 17 +++++-- 8 files changed, 156 insertions(+), 26 deletions(-) diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 72dd675..9cfe25d 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -65,6 +65,24 @@ class WuttaFarmAppHandler(base.AppHandler): handler = self.get_farmos_handler() return handler.get_farmos_client(*args, **kwargs) + def is_farmos_3x(self, *args, **kwargs): + """ + Check if the farmOS version is 3.x. This is a convenience + wrapper around + :meth:`~wuttafarm.farmos.handler.FarmOSHandler.is_farmos_3x()`. + """ + handler = self.get_farmos_handler() + return handler.is_farmos_3x(*args, **kwargs) + + def is_farmos_4x(self, *args, **kwargs): + """ + Check if the farmOS version is 4.x. This is a convenience + wrapper around + :meth:`~wuttafarm.farmos.handler.FarmOSHandler.is_farmos_4x()`. + """ + handler = self.get_farmos_handler() + return handler.is_farmos_4x(*args, **kwargs) + class WuttaFarmAppProvider(base.AppProvider): """ diff --git a/src/wuttafarm/farmos/handler.py b/src/wuttafarm/farmos/handler.py index 78e76b6..6eee14f 100644 --- a/src/wuttafarm/farmos/handler.py +++ b/src/wuttafarm/farmos/handler.py @@ -42,6 +42,35 @@ class FarmOSHandler(GenericHandler): hostname = self.get_farmos_url() return farmOS(hostname, **kwargs) + def get_farmos_version(self, client=None, *args, **kwargs): + """ + Returns the farmOS version in use. + """ + if not client: + client = self.get_farmos_client(*args, **kwargs) + info = client.info() + return info["meta"]["farm"]["version"] + + def is_farmos_3x(self, client=None, *args, **kwargs): + """ + Check if the farmOS version is 3.x. + """ + if not client: + client = self.get_farmos_client(*args, **kwargs) + + version = self.get_farmos_version(client) + return version[0] == "3" + + def is_farmos_4x(self, client=None, *args, **kwargs): + """ + Check if the farmOS version is 4.x. + """ + if not client: + client = self.get_farmos_client(*args, **kwargs) + + version = self.get_farmos_version(client) + return version[0] == "4" + def get_farmos_url(self, path=None, require=True): """ Returns the base URL for farmOS, or one with ``path`` appended. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 4717c78..4acbe24 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -52,6 +52,7 @@ class FromFarmOSHandler(ImportHandler): """ token = self.get_farmos_oauth2_token() self.farmos_client = self.app.get_farmos_client(token=token) + self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) def get_farmos_oauth2_token(self): @@ -74,6 +75,7 @@ class FromFarmOSHandler(ImportHandler): def get_importer_kwargs(self, key, **kwargs): kwargs = super().get_importer_kwargs(key, **kwargs) kwargs["farmos_client"] = self.farmos_client + kwargs["farmos_4x"] = self.farmos_4x return kwargs @@ -241,18 +243,28 @@ class AnimalImporter(FromFarmOS, ToWutta): birthdate = self.app.localtime(birthdate) birthdate = self.app.make_utc(birthdate) + if self.farmos_4x: + sterile = animal["attributes"]["is_sterile"] + else: + sterile = animal["attributes"]["is_castrated"] + if notes := animal["attributes"]["notes"]: notes = notes["value"] + if self.farmos_4x: + active = not animal["attributes"]["archived"] + else: + active = animal["attributes"]["status"] == "active" + return { "farmos_uuid": UUID(animal["id"]), "drupal_id": animal["attributes"]["drupal_internal__id"], "name": animal["attributes"]["name"], "animal_type_uuid": animal_type.uuid, "sex": animal["attributes"]["sex"], - "is_sterile": animal["attributes"]["is_castrated"], + "is_sterile": sterile, "birthdate": birthdate, - "active": animal["attributes"]["status"] == "active", + "active": active, "notes": notes, "image_url": image_url, } @@ -345,13 +357,18 @@ class GroupImporter(FromFarmOS, ToWutta): if notes := group["attributes"]["notes"]: notes = notes["value"] + if self.farmos_4x: + active = not group["attributes"]["archived"] + else: + active = group["attributes"]["status"] == "active" + return { "farmos_uuid": UUID(group["id"]), "drupal_id": group["attributes"]["drupal_internal__id"], "name": group["attributes"]["name"], "is_location": group["attributes"]["is_location"], "is_fixed": group["attributes"]["is_fixed"], - "active": group["attributes"]["status"] == "active", + "active": active, "notes": notes, } @@ -400,6 +417,11 @@ class LandAssetImporter(FromFarmOS, ToWutta): if notes := land["attributes"]["notes"]: notes = notes["value"] + if self.farmos_4x: + active = not land["attributes"]["archived"] + else: + active = land["attributes"]["status"] == "active" + return { "farmos_uuid": UUID(land["id"]), "drupal_id": land["attributes"]["drupal_internal__id"], @@ -407,7 +429,7 @@ class LandAssetImporter(FromFarmOS, ToWutta): "land_type_uuid": land_type.uuid, "is_location": land["attributes"]["is_location"], "is_fixed": land["attributes"]["is_fixed"], - "active": land["attributes"]["status"] == "active", + "active": active, "notes": notes, } @@ -527,6 +549,11 @@ class StructureImporter(FromFarmOS, ToWutta): ): image_url = image_style["large"] + if self.farmos_4x: + active = not structure["attributes"]["archived"] + else: + active = structure["attributes"]["status"] == "active" + return { "farmos_uuid": UUID(structure["id"]), "drupal_id": structure["attributes"]["drupal_internal__id"], @@ -534,7 +561,7 @@ class StructureImporter(FromFarmOS, ToWutta): "structure_type_uuid": structure_type.uuid, "is_location": structure["attributes"]["is_location"], "is_fixed": structure["attributes"]["is_fixed"], - "active": structure["attributes"]["status"] == "active", + "active": active, "notes": notes, "image_url": image_url, } diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 760ad34..d181a02 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -51,7 +51,6 @@ class AnimalView(FarmOSMasterView): labels = { "animal_type": "Species / Breed", - "is_castrated": "Castrated", "location": "Current Location", } @@ -59,8 +58,8 @@ class AnimalView(FarmOSMasterView): "name", "birthdate", "sex", - "is_castrated", - "status", + "is_sterile", + "archived", ] sort_defaults = "name" @@ -70,8 +69,8 @@ class AnimalView(FarmOSMasterView): "animal_type", "birthdate", "sex", - "is_castrated", - "status", + "is_sterile", + "archived", "owners", "location", "notes", @@ -96,6 +95,12 @@ class AnimalView(FarmOSMasterView): # birthdate g.set_renderer("birthdate", "date") + # is_sterile + g.set_renderer("is_sterile", "boolean") + + # archived + g.set_renderer("archived", "boolean") + def get_instance(self): animal = self.farmos_client.resource.get_id( @@ -173,16 +178,30 @@ class AnimalView(FarmOSMasterView): birthdate = datetime.datetime.fromisoformat(birthdate) birthdate = self.app.localtime(birthdate) + sterile = None + if self.farmos_4x: + sterile = animal["attributes"]["is_sterile"] + else: + sterile = animal["attributes"]["is_castrated"] + + if notes := animal["attributes"]["notes"]: + notes = notes["value"] + + if self.farmos_4x: + archived = animal["attributes"]["archived"] + else: + archived = animal["attributes"]["status"] == "archived" + return { "uuid": animal["id"], "drupal_id": animal["attributes"]["drupal_internal__id"], "name": animal["attributes"]["name"], "birthdate": birthdate, - "sex": animal["attributes"]["sex"], - "is_castrated": animal["attributes"]["is_castrated"], - "location": "", # TODO - "status": animal["attributes"]["status"], - "notes": animal["attributes"]["notes"]["value"], + "sex": animal["attributes"]["sex"] or colander.null, + "is_sterile": sterile, + "location": colander.null, # TODO + "archived": archived, + "notes": notes or colander.null, } def configure_form(self, form): @@ -197,8 +216,8 @@ class AnimalView(FarmOSMasterView): f.set_node("birthdate", WuttaDateTime()) f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) - # is_castrated - f.set_node("is_castrated", colander.Boolean()) + # is_sterile + f.set_node("is_sterile", colander.Boolean()) # location f.set_node("location", StructureType(self.request)) @@ -209,6 +228,9 @@ class AnimalView(FarmOSMasterView): # notes f.set_widget("notes", "notes") + # archived + f.set_node("archived", colander.Boolean()) + # image if url := animal.get("large_image_url"): f.set_widget("image", ImageWidget("animal image")) diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index 66224fe..df54b04 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -50,7 +50,7 @@ class GroupView(FarmOSMasterView): "name", "is_fixed", "is_location", - "status", + "archived", "changed", ] @@ -60,7 +60,7 @@ class GroupView(FarmOSMasterView): "name", "is_fixed", "is_location", - "status", + "archived", "notes", "created", "changed", @@ -87,6 +87,9 @@ class GroupView(FarmOSMasterView): # changed g.set_renderer("changed", "datetime") + # archived + g.set_renderer("archived", "boolean") + def get_instance(self): group = self.farmos_client.resource.get_id( "asset", "group", self.request.matchdict["uuid"] @@ -107,6 +110,11 @@ class GroupView(FarmOSMasterView): changed = datetime.datetime.fromisoformat(changed) changed = self.app.localtime(changed) + if self.farmos_4x: + archived = group["attributes"]["archived"] + else: + archived = group["attributes"]["status"] == "archived" + return { "uuid": group["id"], "drupal_id": group["attributes"]["drupal_internal__id"], @@ -115,7 +123,7 @@ class GroupView(FarmOSMasterView): "changed": changed, "is_fixed": group["attributes"]["is_fixed"], "is_location": group["attributes"]["is_location"], - "status": group["attributes"]["status"], + "archived": archived, "notes": group["attributes"]["notes"]["value"], } @@ -140,6 +148,9 @@ class GroupView(FarmOSMasterView): f.set_node("changed", WuttaDateTime()) f.set_widget("changed", WuttaDateTimeWidget(self.request)) + # archived + f.set_node("archived", colander.Boolean()) + def get_xref_buttons(self, group): model = self.app.model session = self.Session() diff --git a/src/wuttafarm/web/views/farmos/land_assets.py b/src/wuttafarm/web/views/farmos/land_assets.py index 64f43cc..ffea76d 100644 --- a/src/wuttafarm/web/views/farmos/land_assets.py +++ b/src/wuttafarm/web/views/farmos/land_assets.py @@ -52,7 +52,7 @@ class LandAssetView(FarmOSMasterView): "land_type", "is_fixed", "is_location", - "status", + "archived", "changed", ] @@ -63,7 +63,7 @@ class LandAssetView(FarmOSMasterView): "land_type", "is_fixed", "is_location", - "status", + "archived", "notes", "created", "changed", @@ -93,6 +93,9 @@ class LandAssetView(FarmOSMasterView): # changed g.set_renderer("changed", "datetime") + # archived + g.set_renderer("archived", "boolean") + def get_instance(self): land_asset = self.farmos_client.resource.get_id( "asset", "land", self.request.matchdict["uuid"] @@ -116,6 +119,11 @@ class LandAssetView(FarmOSMasterView): if notes := land["attributes"]["notes"]: notes = notes["value"] + if self.farmos_4x: + archived = land["attributes"]["archived"] + else: + archived = land["attributes"]["status"] == "archived" + return { "uuid": land["id"], "drupal_id": land["attributes"]["drupal_internal__id"], @@ -125,7 +133,7 @@ class LandAssetView(FarmOSMasterView): "changed": changed, "is_fixed": land["attributes"]["is_fixed"], "is_location": land["attributes"]["is_location"], - "status": land["attributes"]["status"], + "archived": archived, "notes": notes or colander.null, } @@ -150,6 +158,9 @@ class LandAssetView(FarmOSMasterView): f.set_node("changed", WuttaDateTime()) f.set_widget("changed", WuttaDateTimeWidget(self.request)) + # archived + f.set_node("archived", colander.Boolean()) + def get_xref_buttons(self, land): return [ self.make_button( diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 955120b..fff3d2c 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -58,6 +58,7 @@ class FarmOSMasterView(MasterView): 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) self.raw_json = None def get_farmos_client(self): diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py index 3626fb1..618c2fa 100644 --- a/src/wuttafarm/web/views/farmos/structures.py +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -50,7 +50,7 @@ class StructureView(FarmOSMasterView): grid_columns = [ "name", - "status", + "archived", "created", "changed", ] @@ -59,7 +59,7 @@ class StructureView(FarmOSMasterView): form_fields = [ "name", - "status", + "archived", "structure_type", "is_location", "is_fixed", @@ -90,6 +90,9 @@ class StructureView(FarmOSMasterView): # changed g.set_renderer("changed", "datetime") + # archived + g.set_renderer("archived", "boolean") + def get_instance(self): structure = self.farmos_client.resource.get_id( "asset", "structure", self.request.matchdict["uuid"] @@ -145,6 +148,11 @@ class StructureView(FarmOSMasterView): changed = datetime.datetime.fromisoformat(changed) changed = self.app.localtime(changed) + if self.farmos_4x: + archived = structure["attributes"]["archived"] + else: + archived = structure["attributes"]["status"] == "archived" + return { "uuid": structure["id"], "drupal_id": structure["attributes"]["drupal_internal__id"], @@ -153,7 +161,7 @@ class StructureView(FarmOSMasterView): "is_fixed": structure["attributes"]["is_fixed"], "is_location": structure["attributes"]["is_location"], "notes": structure["attributes"]["notes"] or colander.null, - "status": structure["attributes"]["status"], + "archived": archived, "created": created, "changed": changed, } @@ -180,6 +188,9 @@ class StructureView(FarmOSMasterView): f.set_node("changed", WuttaDateTime()) f.set_widget("changed", WuttaDateTimeWidget(self.request)) + # archived + f.set_node("archived", colander.Boolean()) + # image if url := structure.get("large_image_url"): f.set_widget("image", ImageWidget("structure image")) From 02d022295c0bf272488334dd235818e67508642f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 15:04:44 -0600 Subject: [PATCH 059/166] =?UTF-8?q?bump:=20version=200.3.0=20=E2=86=92=200?= =?UTF-8?q?.3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b8703..2111345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.3.1 (2026-02-14) + +### Fix + +- update sterile, archived flags per farmOS 4.x + ## v0.3.0 (2026-02-13) ### Feat diff --git a/pyproject.toml b/pyproject.toml index f8fc499..073879b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.3.0" +version = "0.3.1" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 96d575feb7f3e6169133975f94482e98f27ff372 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 15:07:10 -0600 Subject: [PATCH 060/166] fix: customize page footer to mention farmOS --- src/wuttafarm/web/templates/base_meta.mako | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/templates/base_meta.mako b/src/wuttafarm/web/templates/base_meta.mako index 78c1d53..36bf3be 100644 --- a/src/wuttafarm/web/templates/base_meta.mako +++ b/src/wuttafarm/web/templates/base_meta.mako @@ -12,5 +12,10 @@ <%def name="footer()"> - ${parent.footer()} +

+ powered by + ${h.link_to("WuttaWeb", 'https://wuttaproject.org/', target='_blank')} + and + ${h.link_to("farmOS", 'https://farmos.org/', target='_blank')} +

From 98be276bd12f1787a6af4d65379ec13a32621cde Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 15:43:47 -0600 Subject: [PATCH 061/166] fix: suppress output when user farmos/drupal keys are empty --- src/wuttafarm/web/views/users.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wuttafarm/web/views/users.py b/src/wuttafarm/web/views/users.py index f35aef7..21e26d9 100644 --- a/src/wuttafarm/web/views/users.py +++ b/src/wuttafarm/web/views/users.py @@ -23,6 +23,8 @@ Views for Users """ +import colander + from wuttaweb.views import users as base @@ -53,12 +55,12 @@ class UserView(base.UserView): # farmos_uuid if not self.creating: f.fields.append("farmos_uuid") - f.set_default("farmos_uuid", user.farmos_uuid) + f.set_default("farmos_uuid", user.farmos_uuid or colander.null) # drupal_id if not self.creating: f.fields.append("drupal_id") - f.set_default("drupal_id", user.drupal_id) + f.set_default("drupal_id", user.drupal_id or colander.null) def get_xref_buttons(self, user): buttons = [] From 4ed61380dedca088594f80c64e6321d30e92da81 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 18:52:49 -0600 Subject: [PATCH 062/166] fix: convert `active` flag to `archived` to better mirror farmOS --- ...8898184c5c75_convert_active_to_archived.py | 250 ++++++++++++++++++ src/wuttafarm/db/model/animals.py | 5 +- src/wuttafarm/db/model/groups.py | 5 +- src/wuttafarm/db/model/land.py | 5 +- src/wuttafarm/db/model/structures.py | 5 +- src/wuttafarm/importing/farmos.py | 32 +-- src/wuttafarm/web/views/animal_types.py | 2 +- src/wuttafarm/web/views/animals.py | 8 +- src/wuttafarm/web/views/groups.py | 4 +- src/wuttafarm/web/views/land_assets.py | 4 +- src/wuttafarm/web/views/land_types.py | 2 +- src/wuttafarm/web/views/structure_types.py | 2 +- src/wuttafarm/web/views/structures.py | 4 +- 13 files changed, 292 insertions(+), 36 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/8898184c5c75_convert_active_to_archived.py diff --git a/src/wuttafarm/db/alembic/versions/8898184c5c75_convert_active_to_archived.py b/src/wuttafarm/db/alembic/versions/8898184c5c75_convert_active_to_archived.py new file mode 100644 index 0000000..70bbe2c --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/8898184c5c75_convert_active_to_archived.py @@ -0,0 +1,250 @@ +"""convert active to archived + +Revision ID: 8898184c5c75 +Revises: 3e2ef02bf264 +Create Date: 2026-02-14 18:41:23.042951 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "8898184c5c75" +down_revision: Union[str, None] = "3e2ef02bf264" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # animal + op.alter_column("animal", "active", new_column_name="archived") + animal = sa.sql.table( + "animal", + sa.sql.column("uuid"), + sa.sql.column("archived"), + ) + cursor = op.get_bind().execute(animal.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + animal.update() + .where(animal.c.uuid == row.uuid) + .values({"archived": not row.archived}) + ) + op.alter_column("animal_version", "active", new_column_name="archived") + animal_version = sa.sql.table( + "animal_version", + sa.sql.column("uuid"), + sa.sql.column("archived"), + ) + cursor = op.get_bind().execute(animal_version.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + animal_version.update() + .where(animal_version.c.uuid == row.uuid) + .values({"archived": not row.archived}) + ) + + # group + op.alter_column("group", "active", new_column_name="archived") + group = sa.sql.table( + "group", + sa.sql.column("uuid"), + sa.sql.column("archived"), + ) + cursor = op.get_bind().execute(group.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + group.update() + .where(group.c.uuid == row.uuid) + .values({"archived": not row.archived}) + ) + op.alter_column("group_version", "active", new_column_name="archived") + group_version = sa.sql.table( + "group_version", + sa.sql.column("uuid"), + sa.sql.column("archived"), + ) + cursor = op.get_bind().execute(group_version.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + group_version.update() + .where(group_version.c.uuid == row.uuid) + .values({"archived": not row.archived}) + ) + + # land_asset + op.alter_column("land_asset", "active", new_column_name="archived") + land_asset = sa.sql.table( + "land_asset", + sa.sql.column("uuid"), + sa.sql.column("archived"), + ) + cursor = op.get_bind().execute(land_asset.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + land_asset.update() + .where(land_asset.c.uuid == row.uuid) + .values({"archived": not row.archived}) + ) + op.alter_column("land_asset_version", "active", new_column_name="archived") + land_asset_version = sa.sql.table( + "land_asset_version", + sa.sql.column("uuid"), + sa.sql.column("archived"), + ) + cursor = op.get_bind().execute(land_asset_version.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + land_asset_version.update() + .where(land_asset_version.c.uuid == row.uuid) + .values({"archived": not row.archived}) + ) + + # structure + op.alter_column("structure", "active", new_column_name="archived") + structure = sa.sql.table( + "structure", + sa.sql.column("uuid"), + sa.sql.column("archived"), + ) + cursor = op.get_bind().execute(structure.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + structure.update() + .where(structure.c.uuid == row.uuid) + .values({"archived": not row.archived}) + ) + op.alter_column("structure_version", "active", new_column_name="archived") + structure_version = sa.sql.table( + "structure_version", + sa.sql.column("uuid"), + sa.sql.column("archived"), + ) + cursor = op.get_bind().execute(structure_version.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + structure_version.update() + .where(structure_version.c.uuid == row.uuid) + .values({"archived": not row.archived}) + ) + + +def downgrade() -> None: + + # structure + op.alter_column("structure", "archived", new_column_name="active") + structure = sa.sql.table( + "structure", + sa.sql.column("uuid"), + sa.sql.column("active"), + ) + cursor = op.get_bind().execute(structure.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + structure.update() + .where(structure.c.uuid == row.uuid) + .values({"active": not row.active}) + ) + op.alter_column("structure_version", "archived", new_column_name="active") + structure_version = sa.sql.table( + "structure_version", + sa.sql.column("uuid"), + sa.sql.column("active"), + ) + cursor = op.get_bind().execute(structure_version.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + structure_version.update() + .where(structure_version.c.uuid == row.uuid) + .values({"active": not row.active}) + ) + + # land_asset + op.alter_column("land_asset", "archived", new_column_name="active") + land_asset = sa.sql.table( + "land_asset", + sa.sql.column("uuid"), + sa.sql.column("active"), + ) + cursor = op.get_bind().execute(land_asset.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + land_asset.update() + .where(land_asset.c.uuid == row.uuid) + .values({"active": not row.active}) + ) + op.alter_column("land_asset_version", "archived", new_column_name="active") + land_asset_version = sa.sql.table( + "land_asset_version", + sa.sql.column("uuid"), + sa.sql.column("active"), + ) + cursor = op.get_bind().execute(land_asset_version.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + land_asset_version.update() + .where(land_asset_version.c.uuid == row.uuid) + .values({"active": not row.active}) + ) + + # group + op.alter_column("group", "archived", new_column_name="active") + group = sa.sql.table( + "group", + sa.sql.column("uuid"), + sa.sql.column("active"), + ) + cursor = op.get_bind().execute(group.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + group.update() + .where(group.c.uuid == row.uuid) + .values({"active": not row.active}) + ) + op.alter_column("group_version", "archived", new_column_name="active") + group_version = sa.sql.table( + "group_version", + sa.sql.column("uuid"), + sa.sql.column("active"), + ) + cursor = op.get_bind().execute(group_version.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + group_version.update() + .where(group_version.c.uuid == row.uuid) + .values({"active": not row.active}) + ) + + # animal + op.alter_column("animal", "archived", new_column_name="active") + animal = sa.sql.table( + "animal", + sa.sql.column("uuid"), + sa.sql.column("active"), + ) + cursor = op.get_bind().execute(animal.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + animal.update() + .where(animal.c.uuid == row.uuid) + .values({"active": not row.active}) + ) + op.alter_column("animal_version", "archived", new_column_name="active") + animal_version = sa.sql.table( + "animal_version", + sa.sql.column("uuid"), + sa.sql.column("active"), + ) + cursor = op.get_bind().execute(animal_version.select()) + for row in cursor.fetchall(): + op.get_bind().execute( + animal_version.update() + .where(animal_version.c.uuid == row.uuid) + .values({"active": not row.active}) + ) diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py index e23f0c5..4bc1f8a 100644 --- a/src/wuttafarm/db/model/animals.py +++ b/src/wuttafarm/db/model/animals.py @@ -148,11 +148,12 @@ class Animal(model.Base): """, ) - active = sa.Column( + archived = sa.Column( sa.Boolean(), nullable=False, + default=False, doc=""" - Whether the animal is currently active. + Whether the animal is archived. """, ) diff --git a/src/wuttafarm/db/model/groups.py b/src/wuttafarm/db/model/groups.py index eae034f..2cec3eb 100644 --- a/src/wuttafarm/db/model/groups.py +++ b/src/wuttafarm/db/model/groups.py @@ -68,11 +68,12 @@ class Group(model.Base): """, ) - active = sa.Column( + archived = sa.Column( sa.Boolean(), nullable=False, + default=False, doc=""" - Whether the group is active. + Whether the group is archived. """, ) diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py index 53c93cf..17e22c1 100644 --- a/src/wuttafarm/db/model/land.py +++ b/src/wuttafarm/db/model/land.py @@ -126,11 +126,12 @@ class LandAsset(model.Base): """, ) - active = sa.Column( + archived = sa.Column( sa.Boolean(), nullable=False, + default=False, doc=""" - Whether the land asset is currently active. + Whether the land asset is archived. """, ) diff --git a/src/wuttafarm/db/model/structures.py b/src/wuttafarm/db/model/structures.py index d9fccdb..546c9a1 100644 --- a/src/wuttafarm/db/model/structures.py +++ b/src/wuttafarm/db/model/structures.py @@ -97,11 +97,12 @@ class Structure(model.Base): """, ) - active = sa.Column( + archived = sa.Column( sa.Boolean(), nullable=False, + default=False, doc=""" - Whether the structure is currently active. + Whether the structure is archived. """, ) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 4acbe24..f337226 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -192,7 +192,7 @@ class AnimalImporter(FromFarmOS, ToWutta): "is_sterile", "birthdate", "notes", - "active", + "archived", "image_url", ] @@ -252,9 +252,9 @@ class AnimalImporter(FromFarmOS, ToWutta): notes = notes["value"] if self.farmos_4x: - active = not animal["attributes"]["archived"] + archived = animal["attributes"]["archived"] else: - active = animal["attributes"]["status"] == "active" + archived = animal["attributes"]["status"] == "archived" return { "farmos_uuid": UUID(animal["id"]), @@ -264,7 +264,7 @@ class AnimalImporter(FromFarmOS, ToWutta): "sex": animal["attributes"]["sex"], "is_sterile": sterile, "birthdate": birthdate, - "active": active, + "archived": archived, "notes": notes, "image_url": image_url, } @@ -344,7 +344,7 @@ class GroupImporter(FromFarmOS, ToWutta): "is_location", "is_fixed", "notes", - "active", + "archived", ] def get_source_objects(self): @@ -358,9 +358,9 @@ class GroupImporter(FromFarmOS, ToWutta): notes = notes["value"] if self.farmos_4x: - active = not group["attributes"]["archived"] + archived = group["attributes"]["archived"] else: - active = group["attributes"]["status"] == "active" + archived = group["attributes"]["status"] == "archived" return { "farmos_uuid": UUID(group["id"]), @@ -368,7 +368,7 @@ class GroupImporter(FromFarmOS, ToWutta): "name": group["attributes"]["name"], "is_location": group["attributes"]["is_location"], "is_fixed": group["attributes"]["is_fixed"], - "active": active, + "archived": archived, "notes": notes, } @@ -388,7 +388,7 @@ class LandAssetImporter(FromFarmOS, ToWutta): "is_location", "is_fixed", "notes", - "active", + "archived", ] def setup(self): @@ -418,9 +418,9 @@ class LandAssetImporter(FromFarmOS, ToWutta): notes = notes["value"] if self.farmos_4x: - active = not land["attributes"]["archived"] + archived = land["attributes"]["archived"] else: - active = land["attributes"]["status"] == "active" + archived = land["attributes"]["status"] == "archived" return { "farmos_uuid": UUID(land["id"]), @@ -429,7 +429,7 @@ class LandAssetImporter(FromFarmOS, ToWutta): "land_type_uuid": land_type.uuid, "is_location": land["attributes"]["is_location"], "is_fixed": land["attributes"]["is_fixed"], - "active": active, + "archived": archived, "notes": notes, } @@ -505,7 +505,7 @@ class StructureImporter(FromFarmOS, ToWutta): "is_location", "is_fixed", "notes", - "active", + "archived", "image_url", ] @@ -550,9 +550,9 @@ class StructureImporter(FromFarmOS, ToWutta): image_url = image_style["large"] if self.farmos_4x: - active = not structure["attributes"]["archived"] + archived = structure["attributes"]["archived"] else: - active = structure["attributes"]["status"] == "active" + archived = structure["attributes"]["status"] == "archived" return { "farmos_uuid": UUID(structure["id"]), @@ -561,7 +561,7 @@ class StructureImporter(FromFarmOS, ToWutta): "structure_type_uuid": structure_type.uuid, "is_location": structure["attributes"]["is_location"], "is_fixed": structure["attributes"]["is_fixed"], - "active": active, + "archived": archived, "notes": notes, "image_url": image_url, } diff --git a/src/wuttafarm/web/views/animal_types.py b/src/wuttafarm/web/views/animal_types.py index 09d1e25..acb1bd9 100644 --- a/src/wuttafarm/web/views/animal_types.py +++ b/src/wuttafarm/web/views/animal_types.py @@ -67,7 +67,7 @@ class AnimalTypeView(WuttaFarmMasterView): "sex", "is_sterile", "birthdate", - "active", + "archived", ] rows_sort_defaults = "name" diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index e22095e..adddf2a 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -46,7 +46,7 @@ class AnimalView(WuttaFarmMasterView): "sex", "is_sterile", "birthdate", - "active", + "archived", ] sort_defaults = "name" @@ -61,7 +61,7 @@ class AnimalView(WuttaFarmMasterView): "birthdate", "sex", "is_sterile", - "active", + "archived", "notes", "farmos_uuid", "drupal_id", @@ -94,7 +94,9 @@ class AnimalView(WuttaFarmMasterView): f.set_widget("notes", "notes") # image - if animal.image_url: + if self.creating: + f.remove("image") + elif animal.image_url: f.set_widget("image", ImageWidget("animal image")) f.set_default("image", animal.image_url) diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 5f2746b..2524bff 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -42,7 +42,7 @@ class GroupView(WuttaFarmMasterView): "name", "is_location", "is_fixed", - "active", + "archived", ] sort_defaults = "name" @@ -55,7 +55,7 @@ class GroupView(WuttaFarmMasterView): "name", "is_location", "is_fixed", - "active", + "archived", "notes", "farmos_uuid", "drupal_id", diff --git a/src/wuttafarm/web/views/land_assets.py b/src/wuttafarm/web/views/land_assets.py index 18f7a3d..1cad870 100644 --- a/src/wuttafarm/web/views/land_assets.py +++ b/src/wuttafarm/web/views/land_assets.py @@ -45,7 +45,7 @@ class LandAssetView(WuttaFarmMasterView): "is_location", "is_fixed", "notes", - "active", + "archived", ] sort_defaults = "name" @@ -60,7 +60,7 @@ class LandAssetView(WuttaFarmMasterView): "is_location", "is_fixed", "notes", - "active", + "archived", "farmos_uuid", "drupal_id", ] diff --git a/src/wuttafarm/web/views/land_types.py b/src/wuttafarm/web/views/land_types.py index 21bfabc..20d8a21 100644 --- a/src/wuttafarm/web/views/land_types.py +++ b/src/wuttafarm/web/views/land_types.py @@ -60,7 +60,7 @@ class LandTypeView(WuttaFarmMasterView): "name", "is_location", "is_fixed", - "active", + "archived", ] rows_sort_defaults = "name" diff --git a/src/wuttafarm/web/views/structure_types.py b/src/wuttafarm/web/views/structure_types.py index ca85fb9..f9d9bf0 100644 --- a/src/wuttafarm/web/views/structure_types.py +++ b/src/wuttafarm/web/views/structure_types.py @@ -60,7 +60,7 @@ class StructureTypeView(WuttaFarmMasterView): "name", "is_location", "is_fixed", - "active", + "archived", ] rows_sort_defaults = "name" diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index df58fda..b9da13e 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -45,7 +45,7 @@ class StructureView(WuttaFarmMasterView): "structure_type", "is_location", "is_fixed", - "active", + "archived", ] sort_defaults = "name" @@ -60,7 +60,7 @@ class StructureView(WuttaFarmMasterView): "is_location", "is_fixed", "notes", - "active", + "archived", "farmos_uuid", "drupal_id", "image_url", From e9161e8c93fd20e8f7220447a964f8bd013aaad1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 19:24:16 -0600 Subject: [PATCH 063/166] fix: use same datetime display format as farmOS at least i think this is right..anyway this is how you change it --- src/wuttafarm/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 9cfe25d..72e9f00 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -31,6 +31,8 @@ class WuttaFarmAppHandler(base.AppHandler): Custom :term:`app handler` for WuttaFarm. """ + display_format_datetime = "%a, %m/%d/%Y - %H:%M" + default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler" default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler" From df4536741d5d7bf85a15af39e68e87c128e0fb1b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 19:25:34 -0600 Subject: [PATCH 064/166] fix: prevent direct edit of `farmos_uuid` and `drupal_id` fields --- src/wuttafarm/web/views/master.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 7ff165b..482bdf2 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -68,3 +68,20 @@ class WuttaFarmMasterView(MasterView): ) ] return [] + + def configure_form(self, form): + """ """ + f = form + super().configure_form(f) + + # farmos_uuid + if self.creating: + f.remove("farmos_uuid") + else: + f.set_readonly("farmos_uuid") + + # drupal_id + if self.creating: + f.remove("drupal_id") + else: + f.set_readonly("drupal_id") From 25b2dc6ceca9f1295dfcc2614f14adb26a11a4f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 19:27:19 -0600 Subject: [PATCH 065/166] fix: use Male/Female dict enum for animal sex field and some related changes to make Animal views more like farmOS --- src/wuttafarm/config.py | 3 +- src/wuttafarm/enum.py | 36 ++++++++++++++++++++++++ src/wuttafarm/web/views/animals.py | 44 +++++++++++++++++++++++++++--- 3 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 src/wuttafarm/enum.py diff --git a/src/wuttafarm/config.py b/src/wuttafarm/config.py index fcc8aae..5828299 100644 --- a/src/wuttafarm/config.py +++ b/src/wuttafarm/config.py @@ -39,8 +39,9 @@ class WuttaFarmConfig(WuttaConfigExtension): config.setdefault(f"{config.appname}.app_title", "WuttaFarm") config.setdefault(f"{config.appname}.app_dist", "WuttaFarm") - # app model + # app model/enum config.setdefault(f"{config.appname}.model_spec", "wuttafarm.db.model") + config.setdefault(f"{config.appname}.enum_spec", "wuttafarm.enum") # app handler config.setdefault( diff --git a/src/wuttafarm/enum.py b/src/wuttafarm/enum.py new file mode 100644 index 0000000..41bf597 --- /dev/null +++ b/src/wuttafarm/enum.py @@ -0,0 +1,36 @@ +# -*- 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 . +# +################################################################################ +""" +WuttaFarm enum values +""" + +from collections import OrderedDict + +from wuttjamaican.enum import * + + +ANIMAL_SEX = OrderedDict( + [ + ("M", "Male"), + ("F", "Female"), + ] +) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index adddf2a..0c53770 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -23,6 +23,8 @@ Master view for Animals """ +from wuttaweb.forms.schema import WuttaDictEnum + from wuttafarm.db.model.animals import Animal from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.web.forms.schema import AnimalTypeRef @@ -40,12 +42,19 @@ class AnimalView(WuttaFarmMasterView): farmos_refurl_path = "/assets/animal" + labels = { + "name": "Asset Name", + "animal_type": "Species/Breed", + "is_sterile": "Sterile", + } + grid_columns = [ + "drupal_id", "name", "animal_type", - "sex", - "is_sterile", "birthdate", + "is_sterile", + "sex", "archived", ] @@ -53,6 +62,7 @@ class AnimalView(WuttaFarmMasterView): filter_defaults = { "name": {"active": True, "verb": "contains"}, + "archived": {"active": True, "verb": "is_false"}, } form_fields = [ @@ -61,8 +71,9 @@ class AnimalView(WuttaFarmMasterView): "birthdate", "sex", "is_sterile", - "archived", "notes", + "asset_type", + "archived", "farmos_uuid", "drupal_id", "image_url", @@ -73,6 +84,10 @@ class AnimalView(WuttaFarmMasterView): g = grid super().configure_grid(g) model = self.app.model + enum = self.app.enum + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) # name g.set_link("name") @@ -82,19 +97,40 @@ class AnimalView(WuttaFarmMasterView): g.set_sorter("animal_type", model.AnimalType.name) g.set_filter("animal_type", model.AnimalType.name, label="Animal Type Name") + # birthdate + g.set_renderer("birthdate", "date") + + # sex + g.set_enum("sex", enum.ANIMAL_SEX) + def configure_form(self, form): f = form super().configure_form(f) + enum = self.app.enum animal = form.model_instance # animal_type f.set_node("animal_type", AnimalTypeRef(self.request)) + # sex + f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) + # notes f.set_widget("notes", "notes") - # image + # asset_type if self.creating: + f.remove("asset_type") + else: + f.set_default("asset_type", "Animal") + f.set_readonly("asset_type") + + # image_url + if self.creating or self.editing: + f.remove("image_url") + + # image + if self.creating or self.editing: f.remove("image") elif animal.image_url: f.set_widget("image", ImageWidget("animal image")) From e120812eae9aec325c16838e146afc6dbd4eb87c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 19:31:36 -0600 Subject: [PATCH 066/166] fix: improve handling of 'archived' records for grid/form views --- src/wuttafarm/web/views/animals.py | 6 ++++++ src/wuttafarm/web/views/groups.py | 7 +++++++ src/wuttafarm/web/views/land_assets.py | 7 +++++++ src/wuttafarm/web/views/structures.py | 7 +++++++ 4 files changed, 27 insertions(+) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 0c53770..73b64ff 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -103,6 +103,12 @@ class AnimalView(WuttaFarmMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) + def grid_row_class(self, animal, data, i): + """ """ + if animal.archived: + return "has-background-warning" + return None + def configure_form(self, form): f = form super().configure_form(f) diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 2524bff..899addc 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -49,6 +49,7 @@ class GroupView(WuttaFarmMasterView): filter_defaults = { "name": {"active": True, "verb": "contains"}, + "archived": {"active": True, "verb": "is_false"}, } form_fields = [ @@ -68,6 +69,12 @@ class GroupView(WuttaFarmMasterView): # name g.set_link("name") + def grid_row_class(self, group, data, i): + """ """ + if group.archived: + return "has-background-warning" + return None + def configure_form(self, form): f = form super().configure_form(f) diff --git a/src/wuttafarm/web/views/land_assets.py b/src/wuttafarm/web/views/land_assets.py index 1cad870..48d44d7 100644 --- a/src/wuttafarm/web/views/land_assets.py +++ b/src/wuttafarm/web/views/land_assets.py @@ -52,6 +52,7 @@ class LandAssetView(WuttaFarmMasterView): filter_defaults = { "name": {"active": True, "verb": "contains"}, + "archived": {"active": True, "verb": "is_false"}, } form_fields = [ @@ -78,6 +79,12 @@ class LandAssetView(WuttaFarmMasterView): g.set_sorter("land_type", model.LandType.name) g.set_filter("land_type", model.LandType.name, label="Land Type Name") + def grid_row_class(self, land, data, i): + """ """ + if land.archived: + return "has-background-warning" + return None + def configure_form(self, form): f = form super().configure_form(f) diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index b9da13e..34c6b96 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -52,6 +52,7 @@ class StructureView(WuttaFarmMasterView): filter_defaults = { "name": {"active": True, "verb": "contains"}, + "archived": {"active": True, "verb": "is_false"}, } form_fields = [ @@ -82,6 +83,12 @@ class StructureView(WuttaFarmMasterView): "structure_type", model.StructureType.name, label="Structure Type Name" ) + def grid_row_class(self, structure, data, i): + """ """ + if structure.archived: + return "has-background-warning" + return None + def configure_form(self, form): f = form super().configure_form(f) From 5e4cd8978dee6f925909d2db7329936555238346 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 20:00:39 -0600 Subject: [PATCH 067/166] fix: add / display thumbnail image for animals --- .../2a49127e974b_add_animal_thumbnail_url.py | 41 +++++++++++++++++++ src/wuttafarm/db/model/animals.py | 8 ++++ src/wuttafarm/importing/farmos.py | 4 ++ src/wuttafarm/web/views/animals.py | 26 ++++++++++++ src/wuttafarm/web/views/master.py | 2 + 5 files changed, 81 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/2a49127e974b_add_animal_thumbnail_url.py diff --git a/src/wuttafarm/db/alembic/versions/2a49127e974b_add_animal_thumbnail_url.py b/src/wuttafarm/db/alembic/versions/2a49127e974b_add_animal_thumbnail_url.py new file mode 100644 index 0000000..7c32b29 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/2a49127e974b_add_animal_thumbnail_url.py @@ -0,0 +1,41 @@ +"""add animal thumbnail url + +Revision ID: 2a49127e974b +Revises: 8898184c5c75 +Create Date: 2026-02-14 19:41:22.039343 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "2a49127e974b" +down_revision: Union[str, None] = "8898184c5c75" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # animal + op.add_column( + "animal", sa.Column("thumbnail_url", sa.String(length=255), nullable=True) + ) + op.add_column( + "animal_version", + sa.Column( + "thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True + ), + ) + + +def downgrade() -> None: + + # animal + op.drop_column("animal_version", "thumbnail_url") + op.drop_column("animal", "thumbnail_url") diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py index 4bc1f8a..1aec805 100644 --- a/src/wuttafarm/db/model/animals.py +++ b/src/wuttafarm/db/model/animals.py @@ -173,6 +173,14 @@ class Animal(model.Base): """, ) + thumbnail_url = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional thumbnail URL for the animal. + """, + ) + farmos_uuid = sa.Column( model.UUID(), nullable=True, diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index f337226..e9167dc 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -194,6 +194,7 @@ class AnimalImporter(FromFarmOS, ToWutta): "notes", "archived", "image_url", + "thumbnail_url", ] def setup(self): @@ -214,6 +215,7 @@ class AnimalImporter(FromFarmOS, ToWutta): """ """ animal_type_uuid = None image_url = None + thumbnail_url = None if relationships := animal.get("relationships"): if animal_type := relationships.get("animal_type"): @@ -232,6 +234,7 @@ class AnimalImporter(FromFarmOS, ToWutta): "image_style_uri" ): image_url = image_style["large"] + thumbnail_url = image_style["thumbnail"] if not animal_type_uuid: log.warning("missing/invalid animal_type for farmOS Animal: %s", animal) @@ -267,6 +270,7 @@ class AnimalImporter(FromFarmOS, ToWutta): "archived": archived, "notes": notes, "image_url": image_url, + "thumbnail_url": thumbnail_url, } diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 73b64ff..884c185 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -23,6 +23,8 @@ Master view for Animals """ +from webhelpers2.html import tags + from wuttaweb.forms.schema import WuttaDictEnum from wuttafarm.db.model.animals import Animal @@ -49,6 +51,7 @@ class AnimalView(WuttaFarmMasterView): } grid_columns = [ + "thumbnail", "drupal_id", "name", "animal_type", @@ -76,7 +79,9 @@ class AnimalView(WuttaFarmMasterView): "archived", "farmos_uuid", "drupal_id", + "thumbnail_url", "image_url", + "thumbnail", "image", ] @@ -86,6 +91,11 @@ class AnimalView(WuttaFarmMasterView): model = self.app.model enum = self.app.enum + # thumbnail + g.set_renderer("thumbnail", self.render_grid_thumbnail) + g.set_label("thumbnail", "", column_only=True) + g.set_centered("thumbnail") + # drupal_id g.set_label("drupal_id", "ID", column_only=True) @@ -103,6 +113,11 @@ class AnimalView(WuttaFarmMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) + def render_grid_thumbnail(self, animal, field, value): + if animal.thumbnail_url: + return tags.image(animal.thumbnail_url, "animal thumbnail") + return None + def grid_row_class(self, animal, data, i): """ """ if animal.archived: @@ -131,10 +146,21 @@ class AnimalView(WuttaFarmMasterView): f.set_default("asset_type", "Animal") f.set_readonly("asset_type") + # thumbnail_url + if self.creating or self.editing: + f.remove("thumbnail_url") + # image_url if self.creating or self.editing: f.remove("image_url") + # thumbnail + if self.creating or self.editing: + f.remove("thumbnail") + elif animal.thumbnail_url: + f.set_widget("thumbnail", ImageWidget("animal thumbnail")) + f.set_default("thumbnail", animal.thumbnail_url) + # image if self.creating or self.editing: f.remove("image") diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 482bdf2..9498db4 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -37,12 +37,14 @@ class WuttaFarmMasterView(MasterView): "farmos_uuid": "farmOS UUID", "drupal_id": "Drupal ID", "image_url": "Image URL", + "thumbnail_url": "Thumbnail URL", } row_labels = { "farmos_uuid": "farmOS UUID", "drupal_id": "Drupal ID", "image_url": "Image URL", + "thumbnail_url": "Thumbnail URL", } def get_farmos_url(self, obj): From aae01c010bc12727f01eba495991b2625933798c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 20:06:09 -0600 Subject: [PATCH 068/166] fix: cleanup Group views to better match farmOS --- src/wuttafarm/web/views/groups.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 899addc..e3ae0ad 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -38,10 +38,13 @@ class GroupView(WuttaFarmMasterView): farmos_refurl_path = "/assets/group" + labels = { + "name": "Asset Name", + } + grid_columns = [ + "drupal_id", "name", - "is_location", - "is_fixed", "archived", ] @@ -54,10 +57,9 @@ class GroupView(WuttaFarmMasterView): form_fields = [ "name", - "is_location", - "is_fixed", - "archived", "notes", + "asset_type", + "archived", "farmos_uuid", "drupal_id", ] @@ -66,6 +68,9 @@ class GroupView(WuttaFarmMasterView): g = grid super().configure_grid(g) + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + # name g.set_link("name") @@ -82,6 +87,13 @@ class GroupView(WuttaFarmMasterView): # notes f.set_widget("notes", "notes") + # asset_type + if self.creating: + f.remove("asset_type") + else: + f.set_default("asset_type", "Group") + f.set_readonly("asset_type") + def get_farmos_url(self, group): return self.app.get_farmos_url(f"/asset/{group.drupal_id}") From e60b91fd452d0ac88d3f8fcc2f056f5487e9a84b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 20:18:35 -0600 Subject: [PATCH 069/166] fix: cleanup Structure views to better match farmOS --- ...cc1565d38e7_add_structure_thumbnail_url.py | 41 +++++++++++++++ src/wuttafarm/db/model/structures.py | 8 +++ src/wuttafarm/importing/farmos.py | 4 ++ src/wuttafarm/web/views/animals.py | 7 --- src/wuttafarm/web/views/master.py | 9 ++++ src/wuttafarm/web/views/structures.py | 50 +++++++++++++++++-- 6 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/8cc1565d38e7_add_structure_thumbnail_url.py diff --git a/src/wuttafarm/db/alembic/versions/8cc1565d38e7_add_structure_thumbnail_url.py b/src/wuttafarm/db/alembic/versions/8cc1565d38e7_add_structure_thumbnail_url.py new file mode 100644 index 0000000..6bcd51b --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/8cc1565d38e7_add_structure_thumbnail_url.py @@ -0,0 +1,41 @@ +"""add structure thumbnail url + +Revision ID: 8cc1565d38e7 +Revises: 2a49127e974b +Create Date: 2026-02-14 20:07:33.913573 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "8cc1565d38e7" +down_revision: Union[str, None] = "2a49127e974b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # structure + op.add_column( + "structure", sa.Column("thumbnail_url", sa.String(length=255), nullable=True) + ) + op.add_column( + "structure_version", + sa.Column( + "thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True + ), + ) + + +def downgrade() -> None: + + # structure + op.drop_column("structure_version", "thumbnail_url") + op.drop_column("structure", "thumbnail_url") diff --git a/src/wuttafarm/db/model/structures.py b/src/wuttafarm/db/model/structures.py index 546c9a1..aad50d0 100644 --- a/src/wuttafarm/db/model/structures.py +++ b/src/wuttafarm/db/model/structures.py @@ -146,6 +146,14 @@ class Structure(model.Base): """, ) + thumbnail_url = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional thumbnail URL for the structure. + """, + ) + farmos_uuid = sa.Column( model.UUID(), nullable=True, diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index e9167dc..d8c67b4 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -511,6 +511,7 @@ class StructureImporter(FromFarmOS, ToWutta): "notes", "archived", "image_url", + "thumbnail_url", ] def setup(self): @@ -542,6 +543,7 @@ class StructureImporter(FromFarmOS, ToWutta): notes = notes["value"] image_url = None + thumbnail_url = None if relationships := structure.get("relationships"): if image := relationships.get("image"): if image["data"]: @@ -552,6 +554,7 @@ class StructureImporter(FromFarmOS, ToWutta): "image_style_uri" ): image_url = image_style["large"] + thumbnail_url = image_style["thumbnail"] if self.farmos_4x: archived = structure["attributes"]["archived"] @@ -568,6 +571,7 @@ class StructureImporter(FromFarmOS, ToWutta): "archived": archived, "notes": notes, "image_url": image_url, + "thumbnail_url": thumbnail_url, } diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 884c185..5758d68 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -23,8 +23,6 @@ Master view for Animals """ -from webhelpers2.html import tags - from wuttaweb.forms.schema import WuttaDictEnum from wuttafarm.db.model.animals import Animal @@ -113,11 +111,6 @@ class AnimalView(WuttaFarmMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) - def render_grid_thumbnail(self, animal, field, value): - if animal.thumbnail_url: - return tags.image(animal.thumbnail_url, "animal thumbnail") - return None - def grid_row_class(self, animal, data, i): """ """ if animal.archived: diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 9498db4..98856f6 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -23,6 +23,8 @@ Base class for WuttaFarm master views """ +from webhelpers2.html import tags + from wuttaweb.views import MasterView @@ -57,6 +59,13 @@ class WuttaFarmMasterView(MasterView): return context + def render_grid_thumbnail(self, obj, field, value): + if obj.thumbnail_url: + return tags.image( + obj.thumbnail_url, f"thumbnail for {self.get_model_title()}" + ) + return None + def get_xref_buttons(self, obj): url = self.get_farmos_url(obj) if url: diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 34c6b96..018911d 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -40,11 +40,15 @@ class StructureView(WuttaFarmMasterView): farmos_refurl_path = "/assets/structure" + labels = { + "name": "Asset Name", + } + grid_columns = [ + "thumbnail", + "drupal_id", "name", "structure_type", - "is_location", - "is_fixed", "archived", ] @@ -57,14 +61,17 @@ class StructureView(WuttaFarmMasterView): form_fields = [ "name", + "notes", + "asset_type", "structure_type", "is_location", "is_fixed", - "notes", "archived", "farmos_uuid", "drupal_id", + "thumbnail_url", "image_url", + "thumbnail", "image", ] @@ -73,6 +80,14 @@ class StructureView(WuttaFarmMasterView): super().configure_grid(g) model = self.app.model + # thumbnail + g.set_renderer("thumbnail", self.render_grid_thumbnail) + g.set_label("thumbnail", "", column_only=True) + g.set_centered("thumbnail") + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + # name g.set_link("name") @@ -94,11 +109,38 @@ class StructureView(WuttaFarmMasterView): super().configure_form(f) structure = form.model_instance + # notes + f.set_widget("notes", "notes") + + # asset_type + if self.creating: + f.remove("asset_type") + else: + f.set_default("asset_type", "Structure") + f.set_readonly("asset_type") + # structure_type f.set_node("structure_type", StructureTypeRef(self.request)) + # thumbnail_url + if self.creating or self.editing: + f.remove("thumbnail_url") + + # image_url + if self.creating or self.editing: + f.remove("image_url") + + # thumbnail + if self.creating or self.editing: + f.remove("thumbnail") + elif structure.thumbnail_url: + f.set_widget("thumbnail", ImageWidget("structure thumbnail")) + f.set_default("thumbnail", structure.thumbnail_url) + # image - if structure.image_url: + if self.creating or self.editing: + f.remove("image") + elif structure.image_url: f.set_widget("image", ImageWidget("structure image")) f.set_default("image", structure.image_url) From 71592e883a9baa8a9412ae1f29df5ff122a39af6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 20:21:42 -0600 Subject: [PATCH 070/166] fix: cleanup Land views to better match farmOS --- src/wuttafarm/web/views/land_assets.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/wuttafarm/web/views/land_assets.py b/src/wuttafarm/web/views/land_assets.py index 48d44d7..45659f3 100644 --- a/src/wuttafarm/web/views/land_assets.py +++ b/src/wuttafarm/web/views/land_assets.py @@ -39,12 +39,14 @@ class LandAssetView(WuttaFarmMasterView): farmos_refurl_path = "/assets/land" + labels = { + "name": "Asset Name", + } + grid_columns = [ + "drupal_id", "name", "land_type", - "is_location", - "is_fixed", - "notes", "archived", ] @@ -57,10 +59,11 @@ class LandAssetView(WuttaFarmMasterView): form_fields = [ "name", + "notes", + "asset_type", "land_type", "is_location", "is_fixed", - "notes", "archived", "farmos_uuid", "drupal_id", @@ -71,6 +74,9 @@ class LandAssetView(WuttaFarmMasterView): super().configure_grid(g) model = self.app.model + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + # name g.set_link("name") @@ -89,6 +95,16 @@ class LandAssetView(WuttaFarmMasterView): f = form super().configure_form(f) + # notes + f.set_widget("notes", "notes") + + # asset_type + if self.creating: + f.remove("asset_type") + else: + f.set_default("asset_type", "Land") + f.set_readonly("asset_type") + # land_type f.set_node("land_type", LandTypeRef(self.request)) From ac084c4e79e63fbb9bfc4832c77e21848ff080de Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Feb 2026 22:50:34 -0600 Subject: [PATCH 071/166] fix: add parent relationships support for land assets this may not be complete yet, we'll see. works for the simple case afaik --- .../554e6168c339_add_landassetparent_model.py | 125 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 2 +- src/wuttafarm/db/model/land.py | 34 +++++ src/wuttafarm/importing/farmos.py | 66 ++++++++- src/wuttafarm/web/forms/schema.py | 19 ++- src/wuttafarm/web/forms/widgets.py | 34 +++++ src/wuttafarm/web/views/land_assets.py | 18 ++- 7 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py diff --git a/src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py b/src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py new file mode 100644 index 0000000..e943f77 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py @@ -0,0 +1,125 @@ +"""add LandAssetParent model + +Revision ID: 554e6168c339 +Revises: 8cc1565d38e7 +Create Date: 2026-02-14 20:41:24.859064 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "554e6168c339" +down_revision: Union[str, None] = "8cc1565d38e7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # land_asset_parent + op.create_table( + "land_asset_parent", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("land_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("parent_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["land_asset_uuid"], + ["land_asset.uuid"], + name=op.f("fk_land_asset_parent_land_asset_uuid_land_asset"), + ), + sa.ForeignKeyConstraint( + ["parent_asset_uuid"], + ["land_asset.uuid"], + name=op.f("fk_land_asset_parent_parent_asset_uuid_land_asset"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset_parent")), + ) + op.create_table( + "land_asset_parent_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "land_asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "parent_asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_land_asset_parent_version") + ), + ) + op.create_index( + op.f("ix_land_asset_parent_version_end_transaction_id"), + "land_asset_parent_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_parent_version_operation_type"), + "land_asset_parent_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_land_asset_parent_version_pk_transaction_id", + "land_asset_parent_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_land_asset_parent_version_pk_validity", + "land_asset_parent_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_parent_version_transaction_id"), + "land_asset_parent_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # land_asset_parent + op.drop_index( + op.f("ix_land_asset_parent_version_transaction_id"), + table_name="land_asset_parent_version", + ) + op.drop_index( + "ix_land_asset_parent_version_pk_validity", + table_name="land_asset_parent_version", + ) + op.drop_index( + "ix_land_asset_parent_version_pk_transaction_id", + table_name="land_asset_parent_version", + ) + op.drop_index( + op.f("ix_land_asset_parent_version_operation_type"), + table_name="land_asset_parent_version", + ) + op.drop_index( + op.f("ix_land_asset_parent_version_end_transaction_id"), + table_name="land_asset_parent_version", + ) + op.drop_table("land_asset_parent_version") + op.drop_table("land_asset_parent") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 27d0070..e8bce3f 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -31,7 +31,7 @@ from .users import WuttaFarmUser # wuttafarm proper models from .assets import AssetType -from .land import LandType, LandAsset +from .land import LandType, LandAsset, LandAssetParent from .structures import StructureType, Structure from .animals import AnimalType, Animal from .groups import Group diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py index 17e22c1..da94cf1 100644 --- a/src/wuttafarm/db/model/land.py +++ b/src/wuttafarm/db/model/land.py @@ -153,5 +153,39 @@ class LandAsset(model.Base): """, ) + _parents = orm.relationship( + "LandAssetParent", + foreign_keys="LandAssetParent.land_asset_uuid", + back_populates="land_asset", + cascade="all, delete-orphan", + cascade_backrefs=False, + ) + def __str__(self): return self.name or "" + + +class LandAssetParent(model.Base): + """ + Represents a "land asset's parent relationship" from farmOS. + """ + + __tablename__ = "land_asset_parent" + __versioned__ = {} + + uuid = model.uuid_column() + + land_asset_uuid = model.uuid_fk_column("land_asset.uuid", nullable=False) + + land_asset = orm.relationship( + LandAsset, + foreign_keys=land_asset_uuid, + back_populates="_parents", + ) + + parent_asset_uuid = model.uuid_fk_column("land_asset.uuid", nullable=False) + + parent_asset = orm.relationship( + LandAsset, + foreign_keys=parent_asset_uuid, + ) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index d8c67b4..adc63d0 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -393,9 +393,11 @@ class LandAssetImporter(FromFarmOS, ToWutta): "is_fixed", "notes", "archived", + "parents", ] def setup(self): + """ """ super().setup() model = self.app.model @@ -408,6 +410,17 @@ class LandAssetImporter(FromFarmOS, ToWutta): land_assets = self.farmos_client.asset.get("land") return land_assets["data"] + def normalize_source_data(self, **kwargs): + """ """ + data = super().normalize_source_data(**kwargs) + + if "parents" in self.fields: + # nb. make sure parent-less (root) assets come first, so they + # exist when child assets need to reference them + data.sort(key=lambda l: len(l["parents"])) + + return data + def normalize_source_object(self, land): """ """ land_type_id = land["attributes"]["land_type"] @@ -426,7 +439,7 @@ class LandAssetImporter(FromFarmOS, ToWutta): else: archived = land["attributes"]["status"] == "archived" - return { + data = { "farmos_uuid": UUID(land["id"]), "drupal_id": land["attributes"]["drupal_internal__id"], "name": land["attributes"]["name"], @@ -437,6 +450,57 @@ class LandAssetImporter(FromFarmOS, ToWutta): "notes": notes, } + if "parents" in self.fields: + data["parents"] = [] + for parent in land["relationships"]["parent"]["data"]: + assert parent["type"] == "asset--land" + data["parents"].append(UUID(parent["id"])) + + return data + + def normalize_target_object(self, land): + data = super().normalize_target_object(land) + + if "parents" in self.fields: + data["parents"] = [p.parent_asset.farmos_uuid for p in land._parents] + + return data + + def update_target_object(self, land, source_data, target_data=None): + model = self.app.model + land = super().update_target_object(land, source_data, target_data) + + if "parents" in self.fields: + if not target_data or target_data["parents"] != source_data["parents"]: + + for farmos_uuid in source_data["parents"]: + if not target_data or farmos_uuid not in target_data["parents"]: + self.target_session.flush() + parent = ( + self.target_session.query(model.LandAsset) + .filter(model.LandAsset.farmos_uuid == farmos_uuid) + .one() + ) + land._parents.append(model.LandAssetParent(parent_asset=parent)) + + if target_data: + for farmos_uuid in target_data["parents"]: + if farmos_uuid not in source_data["parents"]: + parent = ( + self.target_session.query(model.LandAsset) + .filter(model.LandAsset.farmos_uuid == farmos_uuid) + .one() + ) + parent = ( + self.target_session.query(model.LandAssetParent) + .filter(model.LandAssetParent.land_asset == land) + .filter(model.LandAssetParent.parent_asset == parent) + .one() + ) + self.target_session.delete(parent) + + return land + class LandTypeImporter(FromFarmOS, ToWutta): """ diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index f646a96..7c048ee 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -27,7 +27,7 @@ import json import colander -from wuttaweb.forms.schema import ObjectRef +from wuttaweb.forms.schema import ObjectRef, WuttaSet class AnimalTypeRef(ObjectRef): @@ -160,3 +160,20 @@ class UsersType(colander.SchemaType): from wuttafarm.web.forms.widgets import UsersWidget return UsersWidget(self.request, **kwargs) + + +class LandParentRefs(WuttaSet): + """ + Schema type for Parents field which references land assets. + """ + + def serialize(self, node, appstruct): + if not appstruct: + appstruct = [] + uuids = [u.hex for u in appstruct] + return json.dumps(uuids) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import LandParentRefsWidget + + return LandParentRefsWidget(self.request, **kwargs) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index f6a99fc..45519b9 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -29,6 +29,9 @@ import colander from deform.widget import Widget from webhelpers2.html import HTML, tags +from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget +from wuttaweb.db import Session + class ImageWidget(Widget): """ @@ -132,3 +135,34 @@ class UsersWidget(Widget): return HTML.tag("ul", c=items) return super().serialize(field, cstruct, **kw) + + +class LandParentRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for Parents field which references land assets. + """ + + def serialize(self, field, cstruct, **kw): + """ """ + model = self.app.model + session = Session() + + readonly = kw.get("readonly", self.readonly) + if readonly: + parents = [] + for uuid in json.loads(cstruct): + parent = session.get(model.LandAsset, uuid) + parents.append( + HTML.tag( + "li", + c=tags.link_to( + str(parent), + self.request.route_url( + "land_assets.view", uuid=parent.uuid + ), + ), + ) + ) + return HTML.tag("ul", c=parents) + + return super().serialize(field, cstruct, **kw) diff --git a/src/wuttafarm/web/views/land_assets.py b/src/wuttafarm/web/views/land_assets.py index 45659f3..7105465 100644 --- a/src/wuttafarm/web/views/land_assets.py +++ b/src/wuttafarm/web/views/land_assets.py @@ -23,9 +23,11 @@ Master view for Land Assets """ +from webhelpers2.html import HTML, tags + from wuttafarm.db.model.land import LandAsset from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.web.forms.schema import LandTypeRef +from wuttafarm.web.forms.schema import LandTypeRef, LandParentRefs class LandAssetView(WuttaFarmMasterView): @@ -47,6 +49,7 @@ class LandAssetView(WuttaFarmMasterView): "drupal_id", "name", "land_type", + "parents", "archived", ] @@ -59,6 +62,7 @@ class LandAssetView(WuttaFarmMasterView): form_fields = [ "name", + "parents", "notes", "asset_type", "land_type", @@ -85,6 +89,13 @@ class LandAssetView(WuttaFarmMasterView): g.set_sorter("land_type", model.LandType.name) g.set_filter("land_type", model.LandType.name, label="Land Type Name") + # parents + g.set_renderer("parents", self.render_parents_for_grid) + + def render_parents_for_grid(self, land, field, value): + parents = [str(p.parent_asset) for p in land._parents] + return ", ".join(parents) + def grid_row_class(self, land, data, i): """ """ if land.archived: @@ -94,6 +105,11 @@ class LandAssetView(WuttaFarmMasterView): def configure_form(self, form): f = form super().configure_form(f) + land = f.model_instance + + # parents + f.set_node("parents", LandParentRefs(self.request)) + f.set_default("parents", [p.parent_asset_uuid for p in land._parents]) # notes f.set_widget("notes", "notes") From 140f3cbdbae8f052097080fdb905d7626267e7ce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Feb 2026 10:42:50 -0600 Subject: [PATCH 072/166] feat: add "generic" assets, new animal assets based on that --- .../d6e8d16d6854_add_generic_animal_assets.py | 333 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 4 +- src/wuttafarm/db/model/animals.py | 76 +--- src/wuttafarm/db/model/assets.py | 125 +++++++ src/wuttafarm/importing/farmos.py | 121 ++++--- src/wuttafarm/web/menus.py | 12 +- src/wuttafarm/web/views/__init__.py | 2 +- src/wuttafarm/web/views/animal_types.py | 128 ------- src/wuttafarm/web/views/animals.py | 208 ++++++----- src/wuttafarm/web/views/assets.py | 236 +++++++++++++ src/wuttafarm/web/views/farmos/animals.py | 8 +- 11 files changed, 920 insertions(+), 333 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/d6e8d16d6854_add_generic_animal_assets.py delete mode 100644 src/wuttafarm/web/views/animal_types.py create mode 100644 src/wuttafarm/web/views/assets.py diff --git a/src/wuttafarm/db/alembic/versions/d6e8d16d6854_add_generic_animal_assets.py b/src/wuttafarm/db/alembic/versions/d6e8d16d6854_add_generic_animal_assets.py new file mode 100644 index 0000000..cd0a34a --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/d6e8d16d6854_add_generic_animal_assets.py @@ -0,0 +1,333 @@ +"""add generic, animal assets + +Revision ID: d6e8d16d6854 +Revises: 554e6168c339 +Create Date: 2026-02-15 09:11:04.886362 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "d6e8d16d6854" +down_revision: Union[str, None] = "554e6168c339" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # animal + op.drop_table("animal") + op.drop_index( + op.f("ix_animal_version_end_transaction_id"), table_name="animal_version" + ) + op.drop_index(op.f("ix_animal_version_operation_type"), table_name="animal_version") + op.drop_index( + op.f("ix_animal_version_pk_transaction_id"), table_name="animal_version" + ) + op.drop_index(op.f("ix_animal_version_pk_validity"), table_name="animal_version") + op.drop_index(op.f("ix_animal_version_transaction_id"), table_name="animal_version") + op.drop_table("animal_version") + + # asset + op.create_table( + "asset", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.Column("asset_type", sa.String(length=100), nullable=False), + sa.Column("asset_name", sa.String(length=100), nullable=False), + sa.Column("is_location", sa.Boolean(), nullable=False), + sa.Column("is_fixed", sa.Boolean(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("thumbnail_url", sa.String(length=255), nullable=True), + sa.Column("image_url", sa.String(length=255), nullable=True), + sa.Column("archived", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_type"], + ["asset_type.drupal_id"], + name=op.f("fk_asset_asset_type_asset_type"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_asset_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_asset_farmos_uuid")), + ) + op.create_table( + "asset_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "asset_type", sa.String(length=100), autoincrement=False, nullable=True + ), + sa.Column( + "asset_name", sa.String(length=100), autoincrement=False, nullable=True + ), + sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("notes", sa.Text(), autoincrement=False, nullable=True), + sa.Column( + "thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "image_url", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column("archived", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_version") + ), + ) + op.create_index( + op.f("ix_asset_version_end_transaction_id"), + "asset_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_version_operation_type"), + "asset_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_version_pk_transaction_id", + "asset_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_version_pk_validity", + "asset_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_version_transaction_id"), + "asset_version", + ["transaction_id"], + unique=False, + ) + + # asset_animal + op.create_table( + "asset_animal", + sa.Column("animal_type_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("birthdate", sa.DateTime(), nullable=True), + sa.Column("sex", sa.String(length=1), nullable=True), + sa.Column("is_sterile", sa.Boolean(), nullable=True), + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["animal_type_uuid"], + ["animal_type.uuid"], + name=op.f("fk_asset_animal_animal_type_uuid_animal_type"), + ), + sa.ForeignKeyConstraint( + ["uuid"], ["asset.uuid"], name=op.f("fk_asset_animal_uuid_asset") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_animal")), + ) + op.create_table( + "asset_animal_version", + sa.Column( + "animal_type_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("sex", sa.String(length=1), autoincrement=False, nullable=True), + sa.Column("is_sterile", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_animal_version") + ), + ) + op.create_index( + op.f("ix_asset_animal_version_end_transaction_id"), + "asset_animal_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_animal_version_operation_type"), + "asset_animal_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_animal_version_pk_transaction_id", + "asset_animal_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_animal_version_pk_validity", + "asset_animal_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_animal_version_transaction_id"), + "asset_animal_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # asset_animal + op.drop_index( + op.f("ix_asset_animal_version_transaction_id"), + table_name="asset_animal_version", + ) + op.drop_index( + "ix_asset_animal_version_pk_validity", table_name="asset_animal_version" + ) + op.drop_index( + "ix_asset_animal_version_pk_transaction_id", table_name="asset_animal_version" + ) + op.drop_index( + op.f("ix_asset_animal_version_operation_type"), + table_name="asset_animal_version", + ) + op.drop_index( + op.f("ix_asset_animal_version_end_transaction_id"), + table_name="asset_animal_version", + ) + op.drop_table("asset_animal_version") + op.drop_table("asset_animal") + + # asset + op.drop_index(op.f("ix_asset_version_transaction_id"), table_name="asset_version") + op.drop_index("ix_asset_version_pk_validity", table_name="asset_version") + op.drop_index("ix_asset_version_pk_transaction_id", table_name="asset_version") + op.drop_index(op.f("ix_asset_version_operation_type"), table_name="asset_version") + op.drop_index( + op.f("ix_asset_version_end_transaction_id"), table_name="asset_version" + ) + op.drop_table("asset_version") + op.drop_table("asset") + + # animal + op.create_table( + "animal", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column("animal_type_uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("sex", sa.VARCHAR(length=1), autoincrement=False, nullable=True), + sa.Column("is_sterile", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column( + "image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column( + "thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.ForeignKeyConstraint( + ["animal_type_uuid"], + ["animal_type.uuid"], + name=op.f("fk_animal_animal_type_uuid_animal_type"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal")), + sa.UniqueConstraint( + "drupal_id", + name=op.f("uq_animal_drupal_id"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + sa.UniqueConstraint( + "farmos_uuid", + name=op.f("uq_animal_farmos_uuid"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + ) + op.create_table( + "animal_version", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True), + sa.Column("animal_type_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column( + "birthdate", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + sa.Column("sex", sa.VARCHAR(length=1), autoincrement=False, nullable=True), + sa.Column("is_sterile", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column( + "image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column( + "end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True + ), + sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False), + sa.Column( + "thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_animal_version") + ), + ) + op.create_index( + op.f("ix_animal_version_transaction_id"), + "animal_version", + ["transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_animal_version_pk_validity"), + "animal_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_animal_version_pk_transaction_id"), + "animal_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + op.f("ix_animal_version_operation_type"), + "animal_version", + ["operation_type"], + unique=False, + ) + op.create_index( + op.f("ix_animal_version_end_transaction_id"), + "animal_version", + ["end_transaction_id"], + unique=False, + ) diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index e8bce3f..a549879 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,9 +30,9 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models -from .assets import AssetType +from .assets import AssetType, Asset from .land import LandType, LandAsset, LandAssetParent from .structures import StructureType, Structure -from .animals import AnimalType, Animal +from .animals import AnimalType, AnimalAsset from .groups import Group from .logs import LogType, ActivityLog diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py index 1aec805..548be86 100644 --- a/src/wuttafarm/db/model/animals.py +++ b/src/wuttafarm/db/model/animals.py @@ -28,6 +28,8 @@ from sqlalchemy import orm from wuttjamaican.db import model +from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies + class AnimalType(model.Base): """ @@ -94,28 +96,19 @@ class AnimalType(model.Base): return self.name or "" -class Animal(model.Base): +class AnimalAsset(AssetMixin, model.Base): """ - Represents an animal from farmOS + Represents an animal asset from farmOS """ - __tablename__ = "animal" + __tablename__ = "asset_animal" __versioned__ = {} __wutta_hint__ = { - "model_title": "Animal", - "model_title_plural": "Animals", + "model_title": "Animal Asset", + "model_title_plural": "Animal Assets", + "farmos_asset_type": "animal", } - uuid = model.uuid_column() - - name = sa.Column( - sa.String(length=100), - nullable=False, - doc=""" - Name for the animal. - """, - ) - animal_type_uuid = model.uuid_fk_column("animal_type.uuid", nullable=False) animal_type = orm.relationship( "AnimalType", @@ -148,56 +141,5 @@ class Animal(model.Base): """, ) - archived = sa.Column( - sa.Boolean(), - nullable=False, - default=False, - doc=""" - Whether the animal is archived. - """, - ) - notes = sa.Column( - sa.Text(), - nullable=True, - doc=""" - Arbitrary notes for the animal. - """, - ) - - image_url = sa.Column( - sa.String(length=255), - nullable=True, - doc=""" - Optional image URL for the animal. - """, - ) - - thumbnail_url = sa.Column( - sa.String(length=255), - nullable=True, - doc=""" - Optional thumbnail URL for the animal. - """, - ) - - farmos_uuid = sa.Column( - model.UUID(), - nullable=True, - unique=True, - doc=""" - UUID for the animal within farmOS. - """, - ) - - drupal_id = sa.Column( - sa.Integer(), - nullable=True, - unique=True, - doc=""" - Drupal internal ID for the animal. - """, - ) - - def __str__(self): - return self.name or "" +add_asset_proxies(AnimalAsset) diff --git a/src/wuttafarm/db/model/assets.py b/src/wuttafarm/db/model/assets.py index 581be62..85c7eb4 100644 --- a/src/wuttafarm/db/model/assets.py +++ b/src/wuttafarm/db/model/assets.py @@ -25,6 +25,7 @@ Model definition for Asset Types import sqlalchemy as sa from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr from wuttjamaican.db import model @@ -80,3 +81,127 @@ class AssetType(model.Base): def __str__(self): return self.name or "" + + +class Asset(model.Base): + """ + Represents an asset (of any kind) from farmOS. + """ + + __tablename__ = "asset" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Asset", + "model_title_plural": "All Assets", + } + + uuid = model.uuid_column() + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the asset within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the asset. + """, + ) + + asset_type = sa.Column( + sa.String(length=100), sa.ForeignKey("asset_type.drupal_id"), nullable=False + ) + + asset_name = sa.Column( + sa.String(length=100), + nullable=False, + doc=""" + Name of the asset. + """, + ) + + is_location = sa.Column( + sa.Boolean(), + nullable=False, + default=False, + doc=""" + Whether the asset should be considered a location. + """, + ) + + is_fixed = sa.Column( + sa.Boolean(), + nullable=False, + default=False, + doc=""" + Whether the asset's location is fixed. + """, + ) + + notes = sa.Column( + sa.Text(), + nullable=True, + doc=""" + Notes for the asset. + """, + ) + + thumbnail_url = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional thumbnail URL for the asset. + """, + ) + + image_url = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional image URL for the asset. + """, + ) + + archived = sa.Column( + sa.Boolean(), + nullable=False, + default=False, + doc=""" + Whether the asset is archived. + """, + ) + + def __str__(self): + return self.asset_name or "" + + +class AssetMixin: + + uuid = model.uuid_fk_column("asset.uuid", nullable=False, primary_key=True) + + @declared_attr + def asset(cls): + return orm.relationship(Asset) + + def __str__(self): + return self.asset_name or "" + + +def add_asset_proxies(subclass): + Asset.make_proxy(subclass, "asset", "farmos_uuid") + Asset.make_proxy(subclass, "asset", "drupal_id") + Asset.make_proxy(subclass, "asset", "asset_type") + Asset.make_proxy(subclass, "asset", "asset_name") + Asset.make_proxy(subclass, "asset", "is_location") + Asset.make_proxy(subclass, "asset", "is_fixed") + Asset.make_proxy(subclass, "asset", "notes") + Asset.make_proxy(subclass, "asset", "thumbnail_url") + Asset.make_proxy(subclass, "asset", "image_url") + Asset.make_proxy(subclass, "asset", "archived") diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index adc63d0..6d8c573 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -102,7 +102,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["StructureType"] = StructureTypeImporter importers["Structure"] = StructureImporter importers["AnimalType"] = AnimalTypeImporter - importers["Animal"] = AnimalImporter + importers["AnimalAsset"] = AnimalAssetImporter importers["Group"] = GroupImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter @@ -176,17 +176,77 @@ class ActivityLogImporter(FromFarmOS, ToWutta): } -class AnimalImporter(FromFarmOS, ToWutta): +class AssetImporterBase(FromFarmOS, ToWutta): + """ + Base class for farmOS API → WuttaFarm asset importers + """ + + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare proxy fields + fields.extend( + [ + "farmos_uuid", + "drupal_id", + "asset_type", + "asset_name", + "notes", + "archived", + "image_url", + "thumbnail_url", + ] + ) + return fields + + def normalize_asset(self, asset): + """ """ + image_url = None + thumbnail_url = None + if relationships := asset.get("relationships"): + + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + if image_style := image["data"]["attributes"].get( + "image_style_uri" + ): + image_url = image_style["large"] + thumbnail_url = image_style["thumbnail"] + + if notes := asset["attributes"]["notes"]: + notes = notes["value"] + + if self.farmos_4x: + archived = asset["attributes"]["archived"] + else: + archived = asset["attributes"]["status"] == "archived" + + return { + "farmos_uuid": UUID(asset["id"]), + "drupal_id": asset["attributes"]["drupal_internal__id"], + "asset_name": asset["attributes"]["name"], + "archived": archived, + "notes": notes, + "image_url": image_url, + "thumbnail_url": thumbnail_url, + } + + +class AnimalAssetImporter(AssetImporterBase): """ farmOS API → WuttaFarm importer for Animals """ - model_class = model.Animal + model_class = model.AnimalAsset supported_fields = [ "farmos_uuid", "drupal_id", - "name", + "asset_type", + "asset_name", "animal_type_uuid", "sex", "is_sterile", @@ -214,27 +274,18 @@ class AnimalImporter(FromFarmOS, ToWutta): def normalize_source_object(self, animal): """ """ animal_type_uuid = None - image_url = None - thumbnail_url = None if relationships := animal.get("relationships"): if animal_type := relationships.get("animal_type"): if animal_type["data"]: - if animal_type := self.animal_types_by_farmos_uuid.get( + if wf_animal_type := self.animal_types_by_farmos_uuid.get( UUID(animal_type["data"]["id"]) ): - animal_type_uuid = animal_type.uuid - - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - if image_style := image["data"]["attributes"].get( - "image_style_uri" - ): - image_url = image_style["large"] - thumbnail_url = image_style["thumbnail"] + animal_type_uuid = wf_animal_type.uuid + else: + log.warning( + "animal type not found: %s", animal_type["data"]["id"] + ) if not animal_type_uuid: log.warning("missing/invalid animal_type for farmOS Animal: %s", animal) @@ -251,27 +302,17 @@ class AnimalImporter(FromFarmOS, ToWutta): else: sterile = animal["attributes"]["is_castrated"] - if notes := animal["attributes"]["notes"]: - notes = notes["value"] - - if self.farmos_4x: - archived = animal["attributes"]["archived"] - else: - archived = animal["attributes"]["status"] == "archived" - - return { - "farmos_uuid": UUID(animal["id"]), - "drupal_id": animal["attributes"]["drupal_internal__id"], - "name": animal["attributes"]["name"], - "animal_type_uuid": animal_type.uuid, - "sex": animal["attributes"]["sex"], - "is_sterile": sterile, - "birthdate": birthdate, - "archived": archived, - "notes": notes, - "image_url": image_url, - "thumbnail_url": thumbnail_url, - } + data = self.normalize_asset(animal) + data.update( + { + "asset_type": "animal", + "animal_type_uuid": animal_type_uuid, + "sex": animal["attributes"]["sex"], + "is_sterile": sterile, + "birthdate": birthdate, + } + ) + return data class AnimalTypeImporter(FromFarmOS, ToWutta): diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 3e5bb46..071e5b6 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -45,10 +45,16 @@ class WuttaFarmMenuHandler(base.MenuHandler): "type": "menu", "items": [ { - "title": "Animals", - "route": "animals", - "perm": "animals.list", + "title": "All Assets", + "route": "assets", + "perm": "assets.list", }, + { + "title": "Animal", + "route": "animal_assets", + "perm": "animal_assets.list", + }, + {"type": "sep"}, { "title": "Groups", "route": "groups", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index a4d12dd..d27e6d9 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -42,9 +42,9 @@ def includeme(config): # native table views config.include("wuttafarm.web.views.asset_types") + config.include("wuttafarm.web.views.assets") config.include("wuttafarm.web.views.land_types") config.include("wuttafarm.web.views.structure_types") - config.include("wuttafarm.web.views.animal_types") config.include("wuttafarm.web.views.land_assets") config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.animals") diff --git a/src/wuttafarm/web/views/animal_types.py b/src/wuttafarm/web/views/animal_types.py deleted file mode 100644 index acb1bd9..0000000 --- a/src/wuttafarm/web/views/animal_types.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaFarm --Web app to integrate with and extend farmOS -# Copyright © 2026 Lance Edgar -# -# This file is part of WuttaFarm. -# -# WuttaFarm is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# WuttaFarm. If not, see . -# -################################################################################ -""" -Master view for Animal Types -""" - -from wuttafarm.db.model.animals import AnimalType, Animal -from wuttafarm.web.views import WuttaFarmMasterView - - -class AnimalTypeView(WuttaFarmMasterView): - """ - Master view for Animal Types - """ - - model_class = AnimalType - route_prefix = "animal_types" - url_prefix = "/animal-types" - - farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview" - - grid_columns = [ - "name", - "description", - "changed", - ] - - sort_defaults = "name" - - filter_defaults = { - "name": {"active": True, "verb": "contains"}, - } - - form_fields = [ - "name", - "description", - "changed", - "farmos_uuid", - "drupal_id", - ] - - has_rows = True - row_model_class = Animal - rows_viewable = True - - row_grid_columns = [ - "name", - "sex", - "is_sterile", - "birthdate", - "archived", - ] - - rows_sort_defaults = "name" - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - - def get_farmos_url(self, animal_type): - return self.app.get_farmos_url(f"/taxonomy/term/{animal_type.drupal_id}") - - def get_xref_buttons(self, animal_type): - buttons = super().get_xref_buttons(animal_type) - - if animal_type.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_animal_types.view", uuid=animal_type.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - - def get_row_grid_data(self, animal_type): - model = self.app.model - session = self.Session() - return session.query(model.Animal).filter( - model.Animal.animal_type == animal_type - ) - - def configure_row_grid(self, grid): - g = grid - super().configure_row_grid(g) - - # name - g.set_link("name") - - def get_row_action_url_view(self, animal, i): - return self.request.route_url("animals.view", uuid=animal.uuid) - - -def defaults(config, **kwargs): - base = globals() - - AnimalTypeView = kwargs.get("AnimalTypeView", base["AnimalTypeView"]) - AnimalTypeView.defaults(config) - - -def includeme(config): - defaults(config) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 5758d68..b415cd6 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -25,25 +25,129 @@ Master view for Animals from wuttaweb.forms.schema import WuttaDictEnum -from wuttafarm.db.model.animals import Animal +from wuttafarm.db.model import AnimalType, AnimalAsset from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.views.assets import AssetMasterView from wuttafarm.web.forms.schema import AnimalTypeRef from wuttafarm.web.forms.widgets import ImageWidget -class AnimalView(WuttaFarmMasterView): +class AnimalTypeView(WuttaFarmMasterView): """ - Master view for Animals + Master view for Animal Types """ - model_class = Animal - route_prefix = "animals" - url_prefix = "/animals" + model_class = AnimalType + route_prefix = "animal_types" + url_prefix = "/animal-types" + + farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview" + + grid_columns = [ + "name", + "description", + "changed", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "changed", + "farmos_uuid", + "drupal_id", + ] + + has_rows = True + row_model_class = AnimalAsset + rows_viewable = True + + row_grid_columns = [ + "asset_name", + "sex", + "is_sterile", + "birthdate", + "archived", + ] + + rows_sort_defaults = "asset_name" + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_farmos_url(self, animal_type): + return self.app.get_farmos_url(f"/taxonomy/term/{animal_type.drupal_id}") + + def get_xref_buttons(self, animal_type): + buttons = super().get_xref_buttons(animal_type) + + if animal_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_animal_types.view", uuid=animal_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + def get_row_grid_data(self, animal_type): + model = self.app.model + session = self.Session() + return ( + session.query(model.AnimalAsset) + .join(model.Asset) + .filter(model.AnimalAsset.animal_type == animal_type) + ) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + model = self.app.model + enum = self.app.enum + + # asset_name + g.set_link("asset_name") + g.set_sorter("asset_name", model.Asset.asset_name) + g.set_filter("asset_name", model.Asset.asset_name) + + # sex + g.set_enum("sex", enum.ANIMAL_SEX) + + # archived + g.set_renderer("archived", "boolean") + g.set_sorter("archived", model.Asset.archived) + g.set_filter("archived", model.Asset.archived) + + def get_row_action_url_view(self, animal, i): + return self.request.route_url("animal_assets.view", uuid=animal.uuid) + + +class AnimalAssetView(AssetMasterView): + """ + Master view for Animal Assets + """ + + model_class = AnimalAsset + route_prefix = "animal_assets" + url_prefix = "/assets/animal" farmos_refurl_path = "/assets/animal" labels = { - "name": "Asset Name", "animal_type": "Species/Breed", "is_sterile": "Sterile", } @@ -51,7 +155,7 @@ class AnimalView(WuttaFarmMasterView): grid_columns = [ "thumbnail", "drupal_id", - "name", + "asset_name", "animal_type", "birthdate", "is_sterile", @@ -59,15 +163,8 @@ class AnimalView(WuttaFarmMasterView): "archived", ] - sort_defaults = "name" - - filter_defaults = { - "name": {"active": True, "verb": "contains"}, - "archived": {"active": True, "verb": "is_false"}, - } - form_fields = [ - "name", + "asset_name", "animal_type", "birthdate", "sex", @@ -89,21 +186,10 @@ class AnimalView(WuttaFarmMasterView): model = self.app.model enum = self.app.enum - # thumbnail - g.set_renderer("thumbnail", self.render_grid_thumbnail) - g.set_label("thumbnail", "", column_only=True) - g.set_centered("thumbnail") - - # drupal_id - g.set_label("drupal_id", "ID", column_only=True) - - # name - g.set_link("name") - # animal_type g.set_joiner("animal_type", lambda q: q.join(model.AnimalType)) g.set_sorter("animal_type", model.AnimalType.name) - g.set_filter("animal_type", model.AnimalType.name, label="Animal Type Name") + g.set_filter("animal_type", model.AnimalType.name) # birthdate g.set_renderer("birthdate", "date") @@ -111,17 +197,10 @@ class AnimalView(WuttaFarmMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) - def grid_row_class(self, animal, data, i): - """ """ - if animal.archived: - return "has-background-warning" - return None - def configure_form(self, form): f = form super().configure_form(f) enum = self.app.enum - animal = form.model_instance # animal_type f.set_node("animal_type", AnimalTypeRef(self.request)) @@ -129,64 +208,15 @@ class AnimalView(WuttaFarmMasterView): # sex f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) - # notes - f.set_widget("notes", "notes") - - # asset_type - if self.creating: - f.remove("asset_type") - else: - f.set_default("asset_type", "Animal") - f.set_readonly("asset_type") - - # thumbnail_url - if self.creating or self.editing: - f.remove("thumbnail_url") - - # image_url - if self.creating or self.editing: - f.remove("image_url") - - # thumbnail - if self.creating or self.editing: - f.remove("thumbnail") - elif animal.thumbnail_url: - f.set_widget("thumbnail", ImageWidget("animal thumbnail")) - f.set_default("thumbnail", animal.thumbnail_url) - - # image - if self.creating or self.editing: - f.remove("image") - elif animal.image_url: - f.set_widget("image", ImageWidget("animal image")) - f.set_default("image", animal.image_url) - - def get_farmos_url(self, animal): - return self.app.get_farmos_url(f"/asset/{animal.drupal_id}") - - def get_xref_buttons(self, animal): - buttons = super().get_xref_buttons(animal) - - if animal.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_animals.view", uuid=animal.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - def defaults(config, **kwargs): base = globals() - AnimalView = kwargs.get("AnimalView", base["AnimalView"]) - AnimalView.defaults(config) + AnimalTypeView = kwargs.get("AnimalTypeView", base["AnimalTypeView"]) + AnimalTypeView.defaults(config) + + AnimalAssetView = kwargs.get("AnimalAssetView", base["AnimalAssetView"]) + AnimalAssetView.defaults(config) def includeme(config): diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py new file mode 100644 index 0000000..b8b1dfc --- /dev/null +++ b/src/wuttafarm/web/views/assets.py @@ -0,0 +1,236 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Assets +""" + +from collections import OrderedDict + +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.db.model import Asset +from wuttafarm.web.forms.widgets import ImageWidget + + +class AssetView(WuttaFarmMasterView): + """ + Master view for Assets + """ + + model_class = Asset + route_prefix = "assets" + url_prefix = "/assets" + + farmos_refurl_path = "/assets" + + viewable = False + creatable = False + editable = False + deletable = False + + grid_columns = [ + "thumbnail", + "drupal_id", + "asset_name", + "asset_type", + "archived", + ] + + sort_defaults = "asset_name" + + filter_defaults = { + "asset_name": {"active": True, "verb": "contains"}, + "archived": {"active": True, "verb": "is_false"}, + } + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # thumbnail + g.set_renderer("thumbnail", self.render_grid_thumbnail) + g.set_label("thumbnail", "", column_only=True) + g.set_centered("thumbnail") + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + + # asset_name + g.set_link("asset_name") + + # asset_type + g.set_enum("asset_type", self.get_asset_type_enum()) + + # view action links to final asset record + def asset_url(asset, i): + return self.request.route_url( + f"{asset.asset_type}_assets.view", uuid=asset.uuid + ) + + g.add_action("view", icon="eye", url=asset_url) + + def get_asset_type_enum(self): + model = self.app.model + session = self.Session() + + asset_types = OrderedDict() + query = session.query(model.AssetType).order_by(model.AssetType.name) + for asset_type in query: + asset_types[asset_type.drupal_id] = asset_type.name + return asset_types + + def grid_row_class(self, asset, data, i): + """ """ + if asset.archived: + return "has-background-warning" + return None + + +class AssetMasterView(WuttaFarmMasterView): + """ + Base class for Asset master views + """ + + sort_defaults = "asset_name" + + filter_defaults = { + "asset_name": {"active": True, "verb": "contains"}, + "archived": {"active": True, "verb": "is_false"}, + } + + def get_query(self, session=None): + """ """ + model = self.app.model + model_class = self.get_model_class() + session = session or self.Session() + return session.query(model_class).join(model.Asset) + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + + # thumbnail + g.set_renderer("thumbnail", self.render_grid_thumbnail) + g.set_label("thumbnail", "", column_only=True) + g.set_centered("thumbnail") + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", model.Asset.drupal_id) + g.set_filter("drupal_id", model.Asset.drupal_id) + + # asset_name + g.set_link("asset_name") + g.set_sorter("asset_name", model.Asset.asset_name) + g.set_filter("asset_name", model.Asset.asset_name) + + # archived + g.set_renderer("archived", "boolean") + g.set_sorter("archived", model.Asset.archived) + g.set_filter("archived", model.Asset.archived) + + def grid_row_class(self, asset, data, i): + """ """ + if asset.archived: + return "has-background-warning" + return None + + def configure_form(self, form): + f = form + super().configure_form(f) + asset = form.model_instance + + # asset_type + if self.creating: + f.remove("asset_type") + else: + f.set_readonly("asset_type") + + # notes + f.set_widget("notes", "notes") + + # thumbnail_url + if self.creating or self.editing: + f.remove("thumbnail_url") + + # image_url + if self.creating or self.editing: + f.remove("image_url") + + # thumbnail + if self.creating or self.editing: + f.remove("thumbnail") + elif asset.thumbnail_url: + f.set_widget("thumbnail", ImageWidget("animal thumbnail")) + f.set_default("thumbnail", asset.thumbnail_url) + + # image + if self.creating or self.editing: + f.remove("image") + elif asset.image_url: + f.set_widget("image", ImageWidget("animal image")) + f.set_default("image", asset.image_url) + + def objectify(self, form): + asset = super().objectify(form) + + if self.creating: + model_class = self.get_model_class() + asset.asset_type = model_class.__wutta_hint__["farmos_asset_type"] + + return asset + + def get_farmos_url(self, asset): + return self.app.get_farmos_url(f"/asset/{asset.drupal_id}") + + def get_xref_buttons(self, asset): + buttons = super().get_xref_buttons(asset) + + if asset.farmos_uuid: + + # TODO + route = None + if asset.asset_type == "animal": + route = "farmos_animals.view" + + if route: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url(route, uuid=asset.farmos_uuid), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + AssetView = kwargs.get("AssetView", base["AssetView"]) + AssetView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index d181a02..c9c2887 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -251,15 +251,17 @@ class AnimalView(FarmOSMasterView): ] if wf_animal := ( - session.query(model.Animal) - .filter(model.Animal.farmos_uuid == animal["uuid"]) + session.query(model.Asset) + .filter(model.Asset.farmos_uuid == animal["uuid"]) .first() ): buttons.append( self.make_button( f"View {self.app.get_title()} record", primary=True, - url=self.request.route_url("animals.view", uuid=wf_animal.uuid), + url=self.request.route_url( + "animal_assets.view", uuid=wf_animal.uuid + ), icon_left="eye", ) ) From 7b6280b6dc24d2c919645f851d9bbd0ff51c703c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Feb 2026 12:14:35 -0600 Subject: [PATCH 073/166] feat: convert land assets to use common base/mixin --- ...82c82f9_use_shared_base_for_land_assets.py | 411 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 4 +- src/wuttafarm/db/model/assets.py | 33 ++ src/wuttafarm/db/model/land.py | 105 +---- src/wuttafarm/importing/farmos.py | 176 ++++---- src/wuttafarm/web/forms/schema.py | 8 +- src/wuttafarm/web/forms/widgets.py | 8 +- src/wuttafarm/web/menus.py | 20 +- src/wuttafarm/web/views/__init__.py | 3 +- src/wuttafarm/web/views/assets.py | 50 ++- .../web/views/{land_types.py => land.py} | 91 +++- src/wuttafarm/web/views/land_assets.py | 156 ------- 12 files changed, 691 insertions(+), 374 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/d882682c82f9_use_shared_base_for_land_assets.py rename src/wuttafarm/web/views/{land_types.py => land.py} (55%) delete mode 100644 src/wuttafarm/web/views/land_assets.py diff --git a/src/wuttafarm/db/alembic/versions/d882682c82f9_use_shared_base_for_land_assets.py b/src/wuttafarm/db/alembic/versions/d882682c82f9_use_shared_base_for_land_assets.py new file mode 100644 index 0000000..7c9b5f7 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/d882682c82f9_use_shared_base_for_land_assets.py @@ -0,0 +1,411 @@ +"""use shared base for Land Assets + +Revision ID: d882682c82f9 +Revises: d6e8d16d6854 +Create Date: 2026-02-15 12:00:27.036011 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "d882682c82f9" +down_revision: Union[str, None] = "d6e8d16d6854" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_parent + op.create_table( + "asset_parent", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("parent_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_uuid"], + ["asset.uuid"], + name=op.f("fk_asset_parent_asset_uuid_asset"), + ), + sa.ForeignKeyConstraint( + ["parent_uuid"], + ["asset.uuid"], + name=op.f("fk_asset_parent_parent_uuid_asset"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_parent")), + ) + op.create_table( + "asset_parent_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "parent_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_parent_version") + ), + ) + op.create_index( + op.f("ix_asset_parent_version_end_transaction_id"), + "asset_parent_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_parent_version_operation_type"), + "asset_parent_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_parent_version_pk_transaction_id", + "asset_parent_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_parent_version_pk_validity", + "asset_parent_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_parent_version_transaction_id"), + "asset_parent_version", + ["transaction_id"], + unique=False, + ) + + # asset_land + op.create_table( + "asset_land", + sa.Column("land_type_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["land_type_uuid"], + ["land_type.uuid"], + name=op.f("fk_asset_land_land_type_uuid_land_type"), + ), + sa.ForeignKeyConstraint( + ["uuid"], ["asset.uuid"], name=op.f("fk_asset_land_uuid_asset") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_land")), + sa.UniqueConstraint( + "land_type_uuid", name=op.f("uq_asset_land_land_type_uuid") + ), + ) + op.create_table( + "asset_land_version", + sa.Column( + "land_type_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_land_version") + ), + ) + op.create_index( + op.f("ix_asset_land_version_end_transaction_id"), + "asset_land_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_land_version_operation_type"), + "asset_land_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_land_version_pk_transaction_id", + "asset_land_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_land_version_pk_validity", + "asset_land_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_land_version_transaction_id"), + "asset_land_version", + ["transaction_id"], + unique=False, + ) + + # land_asset_parent + op.drop_index( + op.f("ix_land_asset_parent_version_end_transaction_id"), + table_name="land_asset_parent_version", + ) + op.drop_index( + op.f("ix_land_asset_parent_version_operation_type"), + table_name="land_asset_parent_version", + ) + op.drop_index( + op.f("ix_land_asset_parent_version_pk_transaction_id"), + table_name="land_asset_parent_version", + ) + op.drop_index( + op.f("ix_land_asset_parent_version_pk_validity"), + table_name="land_asset_parent_version", + ) + op.drop_index( + op.f("ix_land_asset_parent_version_transaction_id"), + table_name="land_asset_parent_version", + ) + op.drop_table("land_asset_parent_version") + op.drop_table("land_asset_parent") + + # land_asset + op.drop_index( + op.f("ix_land_asset_version_end_transaction_id"), + table_name="land_asset_version", + ) + op.drop_index( + op.f("ix_land_asset_version_operation_type"), table_name="land_asset_version" + ) + op.drop_index( + op.f("ix_land_asset_version_pk_transaction_id"), table_name="land_asset_version" + ) + op.drop_index( + op.f("ix_land_asset_version_pk_validity"), table_name="land_asset_version" + ) + op.drop_index( + op.f("ix_land_asset_version_transaction_id"), table_name="land_asset_version" + ) + op.drop_table("land_asset_version") + op.drop_table("land_asset") + + +def downgrade() -> None: + + # land_asset + op.create_table( + "land_asset", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column("land_type_uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint( + ["land_type_uuid"], + ["land_type.uuid"], + name=op.f("fk_land_asset_land_type_uuid_land_type"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset")), + sa.UniqueConstraint( + "drupal_id", + name=op.f("uq_land_asset_drupal_id"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + sa.UniqueConstraint( + "farmos_uuid", + name=op.f("uq_land_asset_farmos_uuid"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + sa.UniqueConstraint( + "land_type_uuid", + name=op.f("uq_land_asset_land_type_uuid"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + sa.UniqueConstraint( + "name", + name=op.f("uq_land_asset_name"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + ) + op.create_table( + "land_asset_version", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True), + sa.Column("land_type_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column( + "end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True + ), + sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_land_asset_version") + ), + ) + op.create_index( + op.f("ix_land_asset_version_transaction_id"), + "land_asset_version", + ["transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_version_pk_validity"), + "land_asset_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_version_pk_transaction_id"), + "land_asset_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_version_operation_type"), + "land_asset_version", + ["operation_type"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_version_end_transaction_id"), + "land_asset_version", + ["end_transaction_id"], + unique=False, + ) + + # land_asset_parent + op.create_table( + "land_asset_parent", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("land_asset_uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("parent_asset_uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint( + ["land_asset_uuid"], + ["land_asset.uuid"], + name=op.f("fk_land_asset_parent_land_asset_uuid_land_asset"), + ), + sa.ForeignKeyConstraint( + ["parent_asset_uuid"], + ["land_asset.uuid"], + name=op.f("fk_land_asset_parent_parent_asset_uuid_land_asset"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset_parent")), + ) + op.create_table( + "land_asset_parent_version", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("land_asset_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("parent_asset_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column( + "end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True + ), + sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_land_asset_parent_version") + ), + ) + op.create_index( + op.f("ix_land_asset_parent_version_transaction_id"), + "land_asset_parent_version", + ["transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_parent_version_pk_validity"), + "land_asset_parent_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_parent_version_pk_transaction_id"), + "land_asset_parent_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_parent_version_operation_type"), + "land_asset_parent_version", + ["operation_type"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_parent_version_end_transaction_id"), + "land_asset_parent_version", + ["end_transaction_id"], + unique=False, + ) + + # asset_land + op.drop_table("asset_land") + op.drop_index( + op.f("ix_asset_land_version_transaction_id"), table_name="asset_land_version" + ) + op.drop_index("ix_asset_land_version_pk_validity", table_name="asset_land_version") + op.drop_index( + "ix_asset_land_version_pk_transaction_id", table_name="asset_land_version" + ) + op.drop_index( + op.f("ix_asset_land_version_operation_type"), table_name="asset_land_version" + ) + op.drop_index( + op.f("ix_asset_land_version_end_transaction_id"), + table_name="asset_land_version", + ) + op.drop_table("asset_land_version") + + # asset_parent + op.drop_index( + op.f("ix_asset_parent_version_transaction_id"), + table_name="asset_parent_version", + ) + op.drop_index( + "ix_asset_parent_version_pk_validity", table_name="asset_parent_version" + ) + op.drop_index( + "ix_asset_parent_version_pk_transaction_id", table_name="asset_parent_version" + ) + op.drop_index( + op.f("ix_asset_parent_version_operation_type"), + table_name="asset_parent_version", + ) + op.drop_index( + op.f("ix_asset_parent_version_end_transaction_id"), + table_name="asset_parent_version", + ) + op.drop_table("asset_parent_version") + op.drop_table("asset_parent") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index a549879..94592f2 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,8 +30,8 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models -from .assets import AssetType, Asset -from .land import LandType, LandAsset, LandAssetParent +from .assets import AssetType, Asset, AssetParent +from .land import LandType, LandAsset from .structures import StructureType, Structure from .animals import AnimalType, AnimalAsset from .groups import Group diff --git a/src/wuttafarm/db/model/assets.py b/src/wuttafarm/db/model/assets.py index 85c7eb4..531fd62 100644 --- a/src/wuttafarm/db/model/assets.py +++ b/src/wuttafarm/db/model/assets.py @@ -178,6 +178,14 @@ class Asset(model.Base): """, ) + _parents = orm.relationship( + "AssetParent", + foreign_keys="AssetParent.asset_uuid", + back_populates="asset", + cascade="all, delete-orphan", + cascade_backrefs=False, + ) + def __str__(self): return self.asset_name or "" @@ -205,3 +213,28 @@ def add_asset_proxies(subclass): Asset.make_proxy(subclass, "asset", "thumbnail_url") Asset.make_proxy(subclass, "asset", "image_url") Asset.make_proxy(subclass, "asset", "archived") + + +class AssetParent(model.Base): + """ + Represents an "asset's parent relationship" from farmOS. + """ + + __tablename__ = "asset_parent" + __versioned__ = {} + + uuid = model.uuid_column() + + asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + + asset = orm.relationship( + Asset, + foreign_keys=asset_uuid, + ) + + parent_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + + parent = orm.relationship( + Asset, + foreign_keys=parent_uuid, + ) diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py index da94cf1..1221c63 100644 --- a/src/wuttafarm/db/model/land.py +++ b/src/wuttafarm/db/model/land.py @@ -28,6 +28,8 @@ from sqlalchemy import orm from wuttjamaican.db import model +from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies + class LandType(model.Base): """ @@ -76,116 +78,21 @@ class LandType(model.Base): return self.name or "" -class LandAsset(model.Base): +class LandAsset(AssetMixin, model.Base): """ Represents a "land asset" from farmOS """ - __tablename__ = "land_asset" + __tablename__ = "asset_land" __versioned__ = {} __wutta_hint__ = { "model_title": "Land Asset", "model_title_plural": "Land Assets", + "farmos_asset_type": "animal", } - uuid = model.uuid_column() - - name = sa.Column( - sa.String(length=100), - nullable=False, - unique=True, - doc=""" - Name of the land asset. - """, - ) - land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False, unique=True) land_type = orm.relationship(LandType, back_populates="land_assets") - is_location = sa.Column( - sa.Boolean(), - nullable=False, - doc=""" - Whether the land asset should be considered a location. - """, - ) - is_fixed = sa.Column( - sa.Boolean(), - nullable=False, - doc=""" - Whether the land asset's location is fixed. - """, - ) - - notes = sa.Column( - sa.Text(), - nullable=True, - doc=""" - Notes for the land asset. - """, - ) - - archived = sa.Column( - sa.Boolean(), - nullable=False, - default=False, - doc=""" - Whether the land asset is archived. - """, - ) - - farmos_uuid = sa.Column( - model.UUID(), - nullable=True, - unique=True, - doc=""" - UUID for the land asset within farmOS. - """, - ) - - drupal_id = sa.Column( - sa.Integer(), - nullable=True, - unique=True, - doc=""" - Drupal internal ID for the land asset. - """, - ) - - _parents = orm.relationship( - "LandAssetParent", - foreign_keys="LandAssetParent.land_asset_uuid", - back_populates="land_asset", - cascade="all, delete-orphan", - cascade_backrefs=False, - ) - - def __str__(self): - return self.name or "" - - -class LandAssetParent(model.Base): - """ - Represents a "land asset's parent relationship" from farmOS. - """ - - __tablename__ = "land_asset_parent" - __versioned__ = {} - - uuid = model.uuid_column() - - land_asset_uuid = model.uuid_fk_column("land_asset.uuid", nullable=False) - - land_asset = orm.relationship( - LandAsset, - foreign_keys=land_asset_uuid, - back_populates="_parents", - ) - - parent_asset_uuid = model.uuid_fk_column("land_asset.uuid", nullable=False) - - parent_asset = orm.relationship( - LandAsset, - foreign_keys=parent_asset_uuid, - ) +add_asset_proxies(LandAsset) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 6d8c573..fadb43e 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -191,6 +191,8 @@ class AssetImporterBase(FromFarmOS, ToWutta): "drupal_id", "asset_type", "asset_name", + "is_location", + "is_fixed", "notes", "archived", "image_url", @@ -199,6 +201,27 @@ class AssetImporterBase(FromFarmOS, ToWutta): ) return fields + def get_supported_fields(self): + """ """ + fields = list(super().get_supported_fields()) + fields.extend( + [ + "parents", + ] + ) + return fields + + def normalize_source_data(self, **kwargs): + """ """ + data = super().normalize_source_data(**kwargs) + + if "parents" in self.fields: + # nb. make sure parent-less (root) assets come first, so they + # exist when child assets need to reference them + data.sort(key=lambda l: len(l["parents"])) + + return data + def normalize_asset(self, asset): """ """ image_url = None @@ -224,16 +247,78 @@ class AssetImporterBase(FromFarmOS, ToWutta): else: archived = asset["attributes"]["status"] == "archived" + parents = None + if "parents" in self.fields: + parents = [] + for parent in asset["relationships"]["parent"]["data"]: + parents.append((self.get_asset_type(parent), UUID(parent["id"]))) + return { "farmos_uuid": UUID(asset["id"]), "drupal_id": asset["attributes"]["drupal_internal__id"], "asset_name": asset["attributes"]["name"], + "is_location": asset["attributes"]["is_location"], + "is_fixed": asset["attributes"]["is_fixed"], "archived": archived, "notes": notes, "image_url": image_url, "thumbnail_url": thumbnail_url, + "parents": parents, } + def get_asset_type(self, asset): + return asset["type"].split("--")[1] + + def normalize_target_object(self, asset): + data = super().normalize_target_object(asset) + + if "parents" in self.fields: + data["parents"] = [ + (p.parent.asset_type, p.parent.farmos_uuid) + for p in asset.asset._parents + ] + + return data + + def update_target_object(self, asset, source_data, target_data=None): + model = self.app.model + asset = super().update_target_object(asset, source_data, target_data) + + if "parents" in self.fields: + if not target_data or target_data["parents"] != source_data["parents"]: + + for key in source_data["parents"]: + asset_type, farmos_uuid = key + if not target_data or key not in target_data["parents"]: + self.target_session.flush() + parent = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + asset.asset._parents.append(model.AssetParent(parent=parent)) + + if target_data: + for key in target_data["parents"]: + asset_type, farmos_uuid = key + if key not in source_data["parents"]: + parent = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + parent = ( + self.target_session.query(model.AssetParent) + .filter(model.AssetParent.asset == asset) + .filter(model.AssetParent.parent == parent) + .one() + ) + self.target_session.delete(parent) + + return asset + class AnimalAssetImporter(AssetImporterBase): """ @@ -418,7 +503,7 @@ class GroupImporter(FromFarmOS, ToWutta): } -class LandAssetImporter(FromFarmOS, ToWutta): +class LandAssetImporter(AssetImporterBase): """ farmOS API → WuttaFarm importer for Land Assets """ @@ -428,7 +513,8 @@ class LandAssetImporter(FromFarmOS, ToWutta): supported_fields = [ "farmos_uuid", "drupal_id", - "name", + "asset_type", + "asset_name", "land_type_uuid", "is_location", "is_fixed", @@ -451,17 +537,6 @@ class LandAssetImporter(FromFarmOS, ToWutta): land_assets = self.farmos_client.asset.get("land") return land_assets["data"] - def normalize_source_data(self, **kwargs): - """ """ - data = super().normalize_source_data(**kwargs) - - if "parents" in self.fields: - # nb. make sure parent-less (root) assets come first, so they - # exist when child assets need to reference them - data.sort(key=lambda l: len(l["parents"])) - - return data - def normalize_source_object(self, land): """ """ land_type_id = land["attributes"]["land_type"] @@ -472,76 +547,15 @@ class LandAssetImporter(FromFarmOS, ToWutta): ) return None - if notes := land["attributes"]["notes"]: - notes = notes["value"] - - if self.farmos_4x: - archived = land["attributes"]["archived"] - else: - archived = land["attributes"]["status"] == "archived" - - data = { - "farmos_uuid": UUID(land["id"]), - "drupal_id": land["attributes"]["drupal_internal__id"], - "name": land["attributes"]["name"], - "land_type_uuid": land_type.uuid, - "is_location": land["attributes"]["is_location"], - "is_fixed": land["attributes"]["is_fixed"], - "archived": archived, - "notes": notes, - } - - if "parents" in self.fields: - data["parents"] = [] - for parent in land["relationships"]["parent"]["data"]: - assert parent["type"] == "asset--land" - data["parents"].append(UUID(parent["id"])) - + data = self.normalize_asset(land) + data.update( + { + "asset_type": "land", + "land_type_uuid": land_type.uuid, + } + ) return data - def normalize_target_object(self, land): - data = super().normalize_target_object(land) - - if "parents" in self.fields: - data["parents"] = [p.parent_asset.farmos_uuid for p in land._parents] - - return data - - def update_target_object(self, land, source_data, target_data=None): - model = self.app.model - land = super().update_target_object(land, source_data, target_data) - - if "parents" in self.fields: - if not target_data or target_data["parents"] != source_data["parents"]: - - for farmos_uuid in source_data["parents"]: - if not target_data or farmos_uuid not in target_data["parents"]: - self.target_session.flush() - parent = ( - self.target_session.query(model.LandAsset) - .filter(model.LandAsset.farmos_uuid == farmos_uuid) - .one() - ) - land._parents.append(model.LandAssetParent(parent_asset=parent)) - - if target_data: - for farmos_uuid in target_data["parents"]: - if farmos_uuid not in source_data["parents"]: - parent = ( - self.target_session.query(model.LandAsset) - .filter(model.LandAsset.farmos_uuid == farmos_uuid) - .one() - ) - parent = ( - self.target_session.query(model.LandAssetParent) - .filter(model.LandAssetParent.land_asset == land) - .filter(model.LandAssetParent.parent_asset == parent) - .one() - ) - self.target_session.delete(parent) - - return land - class LandTypeImporter(FromFarmOS, ToWutta): """ diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 7c048ee..95b3e9d 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -162,9 +162,9 @@ class UsersType(colander.SchemaType): return UsersWidget(self.request, **kwargs) -class LandParentRefs(WuttaSet): +class AssetParentRefs(WuttaSet): """ - Schema type for Parents field which references land assets. + Schema type for Parents field which references assets. """ def serialize(self, node, appstruct): @@ -174,6 +174,6 @@ class LandParentRefs(WuttaSet): return json.dumps(uuids) def widget_maker(self, **kwargs): - from wuttafarm.web.forms.widgets import LandParentRefsWidget + from wuttafarm.web.forms.widgets import AssetParentRefsWidget - return LandParentRefsWidget(self.request, **kwargs) + return AssetParentRefsWidget(self.request, **kwargs) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 45519b9..f812ccf 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -137,9 +137,9 @@ class UsersWidget(Widget): return super().serialize(field, cstruct, **kw) -class LandParentRefsWidget(WuttaCheckboxChoiceWidget): +class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): """ - Widget for Parents field which references land assets. + Widget for Parents field which references assets. """ def serialize(self, field, cstruct, **kw): @@ -151,14 +151,14 @@ class LandParentRefsWidget(WuttaCheckboxChoiceWidget): if readonly: parents = [] for uuid in json.loads(cstruct): - parent = session.get(model.LandAsset, uuid) + parent = session.get(model.Asset, uuid) parents.append( HTML.tag( "li", c=tags.link_to( str(parent), self.request.route_url( - "land_assets.view", uuid=parent.uuid + f"{parent.asset_type}_assets.view", uuid=parent.uuid ), ), ) diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 071e5b6..9625af0 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -54,6 +54,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "animal_assets", "perm": "animal_assets.list", }, + { + "title": "Land", + "route": "land_assets", + "perm": "land_assets.list", + }, {"type": "sep"}, { "title": "Groups", @@ -65,27 +70,22 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "structures", "perm": "structures.list", }, - { - "title": "Land", - "route": "land_assets", - "perm": "land_assets.list", - }, {"type": "sep"}, { "title": "Animal Types", "route": "animal_types", "perm": "animal_types.list", }, - { - "title": "Structure Types", - "route": "structure_types", - "perm": "structure_types.list", - }, { "title": "Land Types", "route": "land_types", "perm": "land_types.list", }, + { + "title": "Structure Types", + "route": "structure_types", + "perm": "structure_types.list", + }, { "title": "Asset Types", "route": "asset_types", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index d27e6d9..3963014 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -43,9 +43,8 @@ def includeme(config): # native table views config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.assets") - config.include("wuttafarm.web.views.land_types") + config.include("wuttafarm.web.views.land") config.include("wuttafarm.web.views.structure_types") - config.include("wuttafarm.web.views.land_assets") config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.groups") diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b8b1dfc..81fd093 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -25,11 +25,26 @@ Master view for Assets from collections import OrderedDict +from wuttaweb.forms.schema import WuttaDictEnum +from wuttaweb.db import Session + from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import Asset +from wuttafarm.web.forms.schema import AssetParentRefs from wuttafarm.web.forms.widgets import ImageWidget +def get_asset_type_enum(config): + app = config.get_app() + model = app.model + session = Session() + asset_types = OrderedDict() + query = session.query(model.AssetType).order_by(model.AssetType.name) + for asset_type in query: + asset_types[asset_type.drupal_id] = asset_type.name + return asset_types + + class AssetView(WuttaFarmMasterView): """ Master view for Assets @@ -51,6 +66,7 @@ class AssetView(WuttaFarmMasterView): "drupal_id", "asset_name", "asset_type", + "parents", "archived", ] @@ -77,7 +93,10 @@ class AssetView(WuttaFarmMasterView): g.set_link("asset_name") # asset_type - g.set_enum("asset_type", self.get_asset_type_enum()) + g.set_enum("asset_type", get_asset_type_enum(self.config)) + + # parents + g.set_renderer("parents", self.render_parents_for_grid) # view action links to final asset record def asset_url(asset, i): @@ -87,15 +106,9 @@ class AssetView(WuttaFarmMasterView): g.add_action("view", icon="eye", url=asset_url) - def get_asset_type_enum(self): - model = self.app.model - session = self.Session() - - asset_types = OrderedDict() - query = session.query(model.AssetType).order_by(model.AssetType.name) - for asset_type in query: - asset_types[asset_type.drupal_id] = asset_type.name - return asset_types + def render_parents_for_grid(self, asset, field, value): + parents = [str(p.parent) for p in asset._parents] + return ", ".join(parents) def grid_row_class(self, asset, data, i): """ """ @@ -143,11 +156,18 @@ class AssetMasterView(WuttaFarmMasterView): g.set_sorter("asset_name", model.Asset.asset_name) g.set_filter("asset_name", model.Asset.asset_name) + # parents + g.set_renderer("parents", self.render_parents_for_grid) + # archived g.set_renderer("archived", "boolean") g.set_sorter("archived", model.Asset.archived) g.set_filter("archived", model.Asset.archived) + def render_parents_for_grid(self, asset, field, value): + parents = [str(p.parent) for p in asset.asset._parents] + return ", ".join(parents) + def grid_row_class(self, asset, data, i): """ """ if asset.archived: @@ -163,8 +183,16 @@ class AssetMasterView(WuttaFarmMasterView): if self.creating: f.remove("asset_type") else: + f.set_node( + "asset_type", + WuttaDictEnum(self.request, get_asset_type_enum(self.config)), + ) f.set_readonly("asset_type") + # parents + f.set_node("parents", AssetParentRefs(self.request)) + f.set_default("parents", [p.parent_uuid for p in asset.asset._parents]) + # notes f.set_widget("notes", "notes") @@ -211,6 +239,8 @@ class AssetMasterView(WuttaFarmMasterView): route = None if asset.asset_type == "animal": route = "farmos_animals.view" + elif asset.asset_type == "land": + route = "farmos_land_assets.view" if route: buttons.append( diff --git a/src/wuttafarm/web/views/land_types.py b/src/wuttafarm/web/views/land.py similarity index 55% rename from src/wuttafarm/web/views/land_types.py rename to src/wuttafarm/web/views/land.py index 20d8a21..ce577c9 100644 --- a/src/wuttafarm/web/views/land_types.py +++ b/src/wuttafarm/web/views/land.py @@ -23,8 +23,12 @@ Master view for Land Types """ +from webhelpers2.html import HTML, tags + from wuttafarm.db.model.land import LandType, LandAsset from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.views.assets import AssetMasterView +from wuttafarm.web.forms.schema import LandTypeRef class LandTypeView(WuttaFarmMasterView): @@ -57,13 +61,13 @@ class LandTypeView(WuttaFarmMasterView): rows_viewable = True row_grid_columns = [ - "name", + "asset_name", "is_location", "is_fixed", "archived", ] - rows_sort_defaults = "name" + rows_sort_defaults = "asset_name" def configure_grid(self, grid): g = grid @@ -92,27 +96,102 @@ class LandTypeView(WuttaFarmMasterView): def get_row_grid_data(self, land_type): model = self.app.model session = self.Session() - return session.query(model.LandAsset).filter( - model.LandAsset.land_type == land_type + return ( + session.query(model.LandAsset) + .join(model.Asset) + .filter(model.LandAsset.land_type == land_type) ) def configure_row_grid(self, grid): g = grid super().configure_row_grid(g) + model = self.app.model - # name - g.set_link("name") + # asset_name + g.set_link("asset_name") + g.set_sorter("asset_name", model.Asset.asset_name) + g.set_filter("asset_name", model.Asset.asset_name) + + # is_location + g.set_renderer("is_location", "boolean") + g.set_sorter("is_location", model.Asset.is_location) + g.set_filter("is_location", model.Asset.is_location) + + # is_fixed + g.set_renderer("is_fixed", "boolean") + g.set_sorter("is_fixed", model.Asset.is_fixed) + g.set_filter("is_fixed", model.Asset.is_fixed) + + # archived + g.set_renderer("archived", "boolean") + g.set_sorter("archived", model.Asset.archived) + g.set_filter("archived", model.Asset.archived) def get_row_action_url_view(self, land_asset, i): return self.request.route_url("land_assets.view", uuid=land_asset.uuid) +class LandAssetView(AssetMasterView): + """ + Master view for Land Assets + """ + + model_class = LandAsset + route_prefix = "land_assets" + url_prefix = "/assets/land" + + farmos_refurl_path = "/assets/land" + + grid_columns = [ + "thumbnail", + "drupal_id", + "asset_name", + "land_type", + "parents", + "archived", + ] + + form_fields = [ + "asset_name", + "parents", + "notes", + "asset_type", + "land_type", + "is_location", + "is_fixed", + "archived", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + + # land_type + g.set_joiner("land_type", lambda q: q.join(model.LandType)) + g.set_sorter("land_type", model.LandType.name) + g.set_filter("land_type", model.LandType.name, label="Land Type Name") + + def configure_form(self, form): + f = form + super().configure_form(f) + land = f.model_instance + + # land_type + f.set_node("land_type", LandTypeRef(self.request)) + + def defaults(config, **kwargs): base = globals() LandTypeView = kwargs.get("LandTypeView", base["LandTypeView"]) LandTypeView.defaults(config) + LandAssetView = kwargs.get("LandAssetView", base["LandAssetView"]) + LandAssetView.defaults(config) + def includeme(config): defaults(config) diff --git a/src/wuttafarm/web/views/land_assets.py b/src/wuttafarm/web/views/land_assets.py deleted file mode 100644 index 7105465..0000000 --- a/src/wuttafarm/web/views/land_assets.py +++ /dev/null @@ -1,156 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaFarm --Web app to integrate with and extend farmOS -# Copyright © 2026 Lance Edgar -# -# This file is part of WuttaFarm. -# -# WuttaFarm is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# WuttaFarm. If not, see . -# -################################################################################ -""" -Master view for Land Assets -""" - -from webhelpers2.html import HTML, tags - -from wuttafarm.db.model.land import LandAsset -from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.web.forms.schema import LandTypeRef, LandParentRefs - - -class LandAssetView(WuttaFarmMasterView): - """ - Master view for Land Assets - """ - - model_class = LandAsset - route_prefix = "land_assets" - url_prefix = "/land-assets" - - farmos_refurl_path = "/assets/land" - - labels = { - "name": "Asset Name", - } - - grid_columns = [ - "drupal_id", - "name", - "land_type", - "parents", - "archived", - ] - - sort_defaults = "name" - - filter_defaults = { - "name": {"active": True, "verb": "contains"}, - "archived": {"active": True, "verb": "is_false"}, - } - - form_fields = [ - "name", - "parents", - "notes", - "asset_type", - "land_type", - "is_location", - "is_fixed", - "archived", - "farmos_uuid", - "drupal_id", - ] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - model = self.app.model - - # drupal_id - g.set_label("drupal_id", "ID", column_only=True) - - # name - g.set_link("name") - - # land_type - g.set_joiner("land_type", lambda q: q.join(model.LandType)) - g.set_sorter("land_type", model.LandType.name) - g.set_filter("land_type", model.LandType.name, label="Land Type Name") - - # parents - g.set_renderer("parents", self.render_parents_for_grid) - - def render_parents_for_grid(self, land, field, value): - parents = [str(p.parent_asset) for p in land._parents] - return ", ".join(parents) - - def grid_row_class(self, land, data, i): - """ """ - if land.archived: - return "has-background-warning" - return None - - def configure_form(self, form): - f = form - super().configure_form(f) - land = f.model_instance - - # parents - f.set_node("parents", LandParentRefs(self.request)) - f.set_default("parents", [p.parent_asset_uuid for p in land._parents]) - - # notes - f.set_widget("notes", "notes") - - # asset_type - if self.creating: - f.remove("asset_type") - else: - f.set_default("asset_type", "Land") - f.set_readonly("asset_type") - - # land_type - f.set_node("land_type", LandTypeRef(self.request)) - - def get_farmos_url(self, land): - return self.app.get_farmos_url(f"/asset/{land.drupal_id}") - - def get_xref_buttons(self, land_asset): - buttons = super().get_xref_buttons(land_asset) - - if land_asset.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_land_assets.view", uuid=land_asset.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - - -def defaults(config, **kwargs): - base = globals() - - LandAssetView = kwargs.get("LandAssetView", base["LandAssetView"]) - LandAssetView.defaults(config) - - -def includeme(config): - defaults(config) From 3435b4714e594588d1f9c1e14a9a75dc20efd1f4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Feb 2026 13:38:11 -0600 Subject: [PATCH 074/166] feat: convert structure assets to use common base/mixin --- ...52_use_shared_base_for_structure_assets.py | 236 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 2 +- src/wuttafarm/db/model/structures.py | 91 +------ src/wuttafarm/importing/farmos.py | 56 ++--- src/wuttafarm/web/menus.py | 10 +- src/wuttafarm/web/views/__init__.py | 1 - src/wuttafarm/web/views/assets.py | 2 + src/wuttafarm/web/views/farmos/structures.py | 6 +- src/wuttafarm/web/views/structure_types.py | 118 --------- src/wuttafarm/web/views/structures.py | 202 ++++++++------- 10 files changed, 385 insertions(+), 339 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/34ec51d80f52_use_shared_base_for_structure_assets.py delete mode 100644 src/wuttafarm/web/views/structure_types.py diff --git a/src/wuttafarm/db/alembic/versions/34ec51d80f52_use_shared_base_for_structure_assets.py b/src/wuttafarm/db/alembic/versions/34ec51d80f52_use_shared_base_for_structure_assets.py new file mode 100644 index 0000000..4be383f --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/34ec51d80f52_use_shared_base_for_structure_assets.py @@ -0,0 +1,236 @@ +"""use shared base for Structure Assets + +Revision ID: 34ec51d80f52 +Revises: d882682c82f9 +Create Date: 2026-02-15 13:19:18.814523 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "34ec51d80f52" +down_revision: Union[str, None] = "d882682c82f9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_structure + op.create_table( + "asset_structure", + sa.Column("structure_type_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["structure_type_uuid"], + ["structure_type.uuid"], + name=op.f("fk_asset_structure_structure_type_uuid_structure_type"), + ), + sa.ForeignKeyConstraint( + ["uuid"], ["asset.uuid"], name=op.f("fk_asset_structure_uuid_asset") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_structure")), + ) + op.create_table( + "asset_structure_version", + sa.Column( + "structure_type_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_structure_version") + ), + ) + op.create_index( + op.f("ix_asset_structure_version_end_transaction_id"), + "asset_structure_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_structure_version_operation_type"), + "asset_structure_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_structure_version_pk_transaction_id", + "asset_structure_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_structure_version_pk_validity", + "asset_structure_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_structure_version_transaction_id"), + "asset_structure_version", + ["transaction_id"], + unique=False, + ) + + # structure + op.drop_index( + op.f("ix_structure_version_end_transaction_id"), table_name="structure_version" + ) + op.drop_index( + op.f("ix_structure_version_operation_type"), table_name="structure_version" + ) + op.drop_index( + op.f("ix_structure_version_pk_transaction_id"), table_name="structure_version" + ) + op.drop_index( + op.f("ix_structure_version_pk_validity"), table_name="structure_version" + ) + op.drop_index( + op.f("ix_structure_version_transaction_id"), table_name="structure_version" + ) + op.drop_table("structure_version") + op.drop_table("structure") + + +def downgrade() -> None: + + # structure + op.create_table( + "structure", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column( + "structure_type_uuid", sa.UUID(), autoincrement=False, nullable=False + ), + sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column( + "image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column( + "thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.ForeignKeyConstraint( + ["structure_type_uuid"], + ["structure_type.uuid"], + name=op.f("fk_structure_structure_type_uuid_structure_type"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure")), + sa.UniqueConstraint( + "drupal_id", + name=op.f("uq_structure_drupal_id"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + sa.UniqueConstraint( + "farmos_uuid", + name=op.f("uq_structure_farmos_uuid"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + sa.UniqueConstraint( + "name", + name=op.f("uq_structure_name"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + ) + op.create_table( + "structure_version", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True), + sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("structure_type_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column( + "image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column( + "end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True + ), + sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False), + sa.Column( + "thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_structure_version") + ), + ) + op.create_index( + op.f("ix_structure_version_transaction_id"), + "structure_version", + ["transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_structure_version_pk_validity"), + "structure_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_structure_version_pk_transaction_id"), + "structure_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + op.f("ix_structure_version_operation_type"), + "structure_version", + ["operation_type"], + unique=False, + ) + op.create_index( + op.f("ix_structure_version_end_transaction_id"), + "structure_version", + ["end_transaction_id"], + unique=False, + ) + + # asset_structure + op.drop_index( + op.f("ix_asset_structure_version_transaction_id"), + table_name="asset_structure_version", + ) + op.drop_index( + "ix_asset_structure_version_pk_validity", table_name="asset_structure_version" + ) + op.drop_index( + "ix_asset_structure_version_pk_transaction_id", + table_name="asset_structure_version", + ) + op.drop_index( + op.f("ix_asset_structure_version_operation_type"), + table_name="asset_structure_version", + ) + op.drop_index( + op.f("ix_asset_structure_version_end_transaction_id"), + table_name="asset_structure_version", + ) + op.drop_table("asset_structure_version") + op.drop_table("asset_structure") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 94592f2..5b1625a 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -32,7 +32,7 @@ from .users import WuttaFarmUser # wuttafarm proper models from .assets import AssetType, Asset, AssetParent from .land import LandType, LandAsset -from .structures import StructureType, Structure +from .structures import StructureType, StructureAsset from .animals import AnimalType, AnimalAsset from .groups import Group from .logs import LogType, ActivityLog diff --git a/src/wuttafarm/db/model/structures.py b/src/wuttafarm/db/model/structures.py index aad50d0..8c5371c 100644 --- a/src/wuttafarm/db/model/structures.py +++ b/src/wuttafarm/db/model/structures.py @@ -28,6 +28,8 @@ from sqlalchemy import orm from wuttjamaican.db import model +from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies + class StructureType(model.Base): """ @@ -74,38 +76,19 @@ class StructureType(model.Base): return self.name or "" -class Structure(model.Base): +class StructureAsset(AssetMixin, model.Base): """ Represents a structure from farmOS """ - __tablename__ = "structure" + __tablename__ = "asset_structure" __versioned__ = {} __wutta_hint__ = { - "model_title": "Structure", - "model_title_plural": "Structures", + "model_title": "Structure Asset", + "model_title_plural": "Structure Assets", + "farmos_asset_type": "structure", } - uuid = model.uuid_column() - - name = sa.Column( - sa.String(length=100), - nullable=False, - unique=True, - doc=""" - Name for the structure. - """, - ) - - archived = sa.Column( - sa.Boolean(), - nullable=False, - default=False, - doc=""" - Whether the structure is archived. - """, - ) - structure_type_uuid = model.uuid_fk_column("structure_type.uuid", nullable=False) structure_type = orm.relationship( "StructureType", @@ -114,63 +97,5 @@ class Structure(model.Base): """, ) - is_location = sa.Column( - sa.Boolean(), - nullable=False, - doc=""" - Whether the structure is considered a location. - """, - ) - is_fixed = sa.Column( - sa.Boolean(), - nullable=False, - doc=""" - Whether the structure location is fixed. - """, - ) - - notes = sa.Column( - sa.Text(), - nullable=True, - doc=""" - Arbitrary notes for the structure. - """, - ) - - image_url = sa.Column( - sa.String(length=255), - nullable=True, - doc=""" - Optional image URL for the structure. - """, - ) - - thumbnail_url = sa.Column( - sa.String(length=255), - nullable=True, - doc=""" - Optional thumbnail URL for the structure. - """, - ) - - farmos_uuid = sa.Column( - model.UUID(), - nullable=True, - unique=True, - doc=""" - UUID for the structure within farmOS. - """, - ) - - drupal_id = sa.Column( - sa.Integer(), - nullable=True, - unique=True, - doc=""" - Drupal internal ID for the structure. - """, - ) - - def __str__(self): - return self.name or "" +add_asset_proxies(StructureAsset) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index fadb43e..4410023 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -100,7 +100,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["LandType"] = LandTypeImporter importers["LandAsset"] = LandAssetImporter importers["StructureType"] = StructureTypeImporter - importers["Structure"] = StructureImporter + importers["StructureAsset"] = StructureAssetImporter importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["Group"] = GroupImporter @@ -613,17 +613,18 @@ class LogTypeImporter(FromFarmOS, ToWutta): } -class StructureImporter(FromFarmOS, ToWutta): +class StructureAssetImporter(AssetImporterBase): """ - farmOS API → WuttaFarm importer for Structures + farmOS API → WuttaFarm importer for Structure Assets """ - model_class = model.Structure + model_class = model.StructureAsset supported_fields = [ "farmos_uuid", "drupal_id", - "name", + "asset_type", + "asset_name", "structure_type_uuid", "is_location", "is_fixed", @@ -631,6 +632,7 @@ class StructureImporter(FromFarmOS, ToWutta): "archived", "image_url", "thumbnail_url", + "parents", ] def setup(self): @@ -652,46 +654,20 @@ class StructureImporter(FromFarmOS, ToWutta): structure_type = self.structure_types_by_id.get(structure_type_id) if not structure_type: log.warning( - "invalid structure_type '%s' for farmOS Structure: %s", + "invalid structure_type '%s' for farmOS Structure Asset: %s", structure_type_id, structure, ) return None - if notes := structure["attributes"]["notes"]: - notes = notes["value"] - - image_url = None - thumbnail_url = None - if relationships := structure.get("relationships"): - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - if image_style := image["data"]["attributes"].get( - "image_style_uri" - ): - image_url = image_style["large"] - thumbnail_url = image_style["thumbnail"] - - if self.farmos_4x: - archived = structure["attributes"]["archived"] - else: - archived = structure["attributes"]["status"] == "archived" - - return { - "farmos_uuid": UUID(structure["id"]), - "drupal_id": structure["attributes"]["drupal_internal__id"], - "name": structure["attributes"]["name"], - "structure_type_uuid": structure_type.uuid, - "is_location": structure["attributes"]["is_location"], - "is_fixed": structure["attributes"]["is_fixed"], - "archived": archived, - "notes": notes, - "image_url": image_url, - "thumbnail_url": thumbnail_url, - } + data = self.normalize_asset(structure) + data.update( + { + "asset_type": "structure", + "structure_type_uuid": structure_type.uuid, + } + ) + return data class StructureTypeImporter(FromFarmOS, ToWutta): diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 9625af0..a7aa4b2 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -59,17 +59,17 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "land_assets", "perm": "land_assets.list", }, + { + "title": "Structure", + "route": "structure_assets", + "perm": "structure_assets.list", + }, {"type": "sep"}, { "title": "Groups", "route": "groups", "perm": "groups.list", }, - { - "title": "Structures", - "route": "structures", - "perm": "structures.list", - }, {"type": "sep"}, { "title": "Animal Types", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 3963014..e44c16e 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -44,7 +44,6 @@ def includeme(config): config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.assets") config.include("wuttafarm.web.views.land") - config.include("wuttafarm.web.views.structure_types") config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.groups") diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 81fd093..c4bb792 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -241,6 +241,8 @@ class AssetMasterView(WuttaFarmMasterView): route = "farmos_animals.view" elif asset.asset_type == "land": route = "farmos_land_assets.view" + elif asset.asset_type == "structure": + route = "farmos_structures.view" if route: buttons.append( diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py index 618c2fa..550f432 100644 --- a/src/wuttafarm/web/views/farmos/structures.py +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -211,8 +211,8 @@ class StructureView(FarmOSMasterView): ] if wf_structure := ( - session.query(model.Structure) - .filter(model.Structure.farmos_uuid == structure["uuid"]) + session.query(model.StructureAsset) + .filter(model.StructureAsset.farmos_uuid == structure["uuid"]) .first() ): buttons.append( @@ -220,7 +220,7 @@ class StructureView(FarmOSMasterView): f"View {self.app.get_title()} record", primary=True, url=self.request.route_url( - "structures.view", uuid=wf_structure.uuid + "structure_assets.view", uuid=wf_structure.uuid ), icon_left="eye", ) diff --git a/src/wuttafarm/web/views/structure_types.py b/src/wuttafarm/web/views/structure_types.py deleted file mode 100644 index f9d9bf0..0000000 --- a/src/wuttafarm/web/views/structure_types.py +++ /dev/null @@ -1,118 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaFarm --Web app to integrate with and extend farmOS -# Copyright © 2026 Lance Edgar -# -# This file is part of WuttaFarm. -# -# WuttaFarm is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# WuttaFarm. If not, see . -# -################################################################################ -""" -Master view for Structure Types -""" - -from wuttafarm.db.model.structures import StructureType, Structure -from wuttafarm.web.views import WuttaFarmMasterView - - -class StructureTypeView(WuttaFarmMasterView): - """ - Master view for Structure Types - """ - - model_class = StructureType - route_prefix = "structure_types" - url_prefix = "/structure-types" - - grid_columns = [ - "name", - ] - - sort_defaults = "name" - - filter_defaults = { - "name": {"active": True, "verb": "contains"}, - } - - form_fields = [ - "name", - "farmos_uuid", - "drupal_id", - ] - - has_rows = True - row_model_class = Structure - rows_viewable = True - - row_grid_columns = [ - "name", - "is_location", - "is_fixed", - "archived", - ] - - rows_sort_defaults = "name" - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - - def get_xref_buttons(self, structure_type): - buttons = super().get_xref_buttons(structure_type) - - if structure_type.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_structure_types.view", uuid=structure_type.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - - def get_row_grid_data(self, structure_type): - model = self.app.model - session = self.Session() - return session.query(model.Structure).filter( - model.Structure.structure_type == structure_type - ) - - def configure_row_grid(self, grid): - g = grid - super().configure_row_grid(g) - - # name - g.set_link("name") - - def get_row_action_url_view(self, structure, i): - return self.request.route_url("structures.view", uuid=structure.uuid) - - -def defaults(config, **kwargs): - base = globals() - - StructureTypeView = kwargs.get("StructureTypeView", base["StructureTypeView"]) - StructureTypeView.defaults(config) - - -def includeme(config): - defaults(config) diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 018911d..5745658 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -23,44 +23,136 @@ Master view for Structures """ -from wuttafarm.db.model.structures import Structure from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.views.assets import AssetMasterView +from wuttafarm.db.model import StructureType, StructureAsset from wuttafarm.web.forms.schema import StructureTypeRef from wuttafarm.web.forms.widgets import ImageWidget -class StructureView(WuttaFarmMasterView): +class StructureTypeView(WuttaFarmMasterView): """ - Master view for Structures + Master view for Structure Types """ - model_class = Structure - route_prefix = "structures" - url_prefix = "/structures" - - farmos_refurl_path = "/assets/structure" - - labels = { - "name": "Asset Name", - } + model_class = StructureType + route_prefix = "structure_types" + url_prefix = "/structure-types" grid_columns = [ - "thumbnail", - "drupal_id", "name", - "structure_type", - "archived", ] sort_defaults = "name" filter_defaults = { "name": {"active": True, "verb": "contains"}, - "archived": {"active": True, "verb": "is_false"}, } form_fields = [ "name", + "farmos_uuid", + "drupal_id", + ] + + has_rows = True + row_model_class = StructureAsset + rows_viewable = True + + row_grid_columns = [ + "asset_name", + "is_location", + "is_fixed", + "archived", + ] + + rows_sort_defaults = "asset_name" + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, structure_type): + buttons = super().get_xref_buttons(structure_type) + + if structure_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_structure_types.view", uuid=structure_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + def get_row_grid_data(self, structure_type): + model = self.app.model + session = self.Session() + return ( + session.query(model.StructureAsset) + .join(model.Asset) + .filter(model.StructureAsset.structure_type == structure_type) + ) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + model = self.app.model + + # asset_name + g.set_link("asset_name") + g.set_sorter("asset_name", model.Asset.asset_name) + g.set_filter("asset_name", model.Asset.asset_name) + + # is_location + g.set_renderer("is_location", "boolean") + g.set_sorter("is_location", model.Asset.is_location) + g.set_filter("is_location", model.Asset.is_location) + + # is_fixed + g.set_renderer("is_fixed", "boolean") + g.set_sorter("is_fixed", model.Asset.is_fixed) + g.set_filter("is_fixed", model.Asset.is_fixed) + + # archived + g.set_renderer("archived", "boolean") + g.set_sorter("archived", model.Asset.archived) + g.set_filter("archived", model.Asset.archived) + + def get_row_action_url_view(self, structure, i): + return self.request.route_url("structure_assets.view", uuid=structure.uuid) + + +class StructureAssetView(AssetMasterView): + """ + Master view for Structures + """ + + model_class = StructureAsset + route_prefix = "structure_assets" + url_prefix = "/asset/structures" + + farmos_refurl_path = "/assets/structure" + + grid_columns = [ + "thumbnail", + "drupal_id", + "asset_name", + "structure_type", + "parents", + "archived", + ] + + form_fields = [ + "asset_name", + "parents", "notes", "asset_type", "structure_type", @@ -80,17 +172,6 @@ class StructureView(WuttaFarmMasterView): super().configure_grid(g) model = self.app.model - # thumbnail - g.set_renderer("thumbnail", self.render_grid_thumbnail) - g.set_label("thumbnail", "", column_only=True) - g.set_centered("thumbnail") - - # drupal_id - g.set_label("drupal_id", "ID", column_only=True) - - # name - g.set_link("name") - # structure_type g.set_joiner("structure_type", lambda q: q.join(model.StructureType)) g.set_sorter("structure_type", model.StructureType.name) @@ -98,78 +179,23 @@ class StructureView(WuttaFarmMasterView): "structure_type", model.StructureType.name, label="Structure Type Name" ) - def grid_row_class(self, structure, data, i): - """ """ - if structure.archived: - return "has-background-warning" - return None - def configure_form(self, form): f = form super().configure_form(f) structure = form.model_instance - # notes - f.set_widget("notes", "notes") - - # asset_type - if self.creating: - f.remove("asset_type") - else: - f.set_default("asset_type", "Structure") - f.set_readonly("asset_type") - # structure_type f.set_node("structure_type", StructureTypeRef(self.request)) - # thumbnail_url - if self.creating or self.editing: - f.remove("thumbnail_url") - - # image_url - if self.creating or self.editing: - f.remove("image_url") - - # thumbnail - if self.creating or self.editing: - f.remove("thumbnail") - elif structure.thumbnail_url: - f.set_widget("thumbnail", ImageWidget("structure thumbnail")) - f.set_default("thumbnail", structure.thumbnail_url) - - # image - if self.creating or self.editing: - f.remove("image") - elif structure.image_url: - f.set_widget("image", ImageWidget("structure image")) - f.set_default("image", structure.image_url) - - def get_farmos_url(self, structure): - return self.app.get_farmos_url(f"/asset/{structure.drupal_id}") - - def get_xref_buttons(self, structure): - buttons = super().get_xref_buttons(structure) - - if structure.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_structures.view", uuid=structure.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - def defaults(config, **kwargs): base = globals() - StructureView = kwargs.get("StructureView", base["StructureView"]) - StructureView.defaults(config) + StructureTypeView = kwargs.get("StructureTypeView", base["StructureTypeView"]) + StructureTypeView.defaults(config) + + StructureAssetView = kwargs.get("StructureAssetView", base["StructureAssetView"]) + StructureAssetView.defaults(config) def includeme(config): From 2fc9c88cd50359755cd448b84e7e81057d4cc732 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Feb 2026 14:07:03 -0600 Subject: [PATCH 075/166] feat: convert group assets to use common base/mixin --- ...175624_use_shared_base_for_group_assets.py | 194 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 2 +- src/wuttafarm/db/model/groups.py | 80 +------- src/wuttafarm/importing/farmos.py | 38 ++-- src/wuttafarm/web/menus.py | 11 +- src/wuttafarm/web/views/assets.py | 2 + src/wuttafarm/web/views/farmos/groups.py | 6 +- src/wuttafarm/web/views/groups.py | 78 +------ 8 files changed, 239 insertions(+), 172 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/aecfd9175624_use_shared_base_for_group_assets.py diff --git a/src/wuttafarm/db/alembic/versions/aecfd9175624_use_shared_base_for_group_assets.py b/src/wuttafarm/db/alembic/versions/aecfd9175624_use_shared_base_for_group_assets.py new file mode 100644 index 0000000..1295d40 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/aecfd9175624_use_shared_base_for_group_assets.py @@ -0,0 +1,194 @@ +"""use shared base for Group Assets + +Revision ID: aecfd9175624 +Revises: 34ec51d80f52 +Create Date: 2026-02-15 13:57:01.055304 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "aecfd9175624" +down_revision: Union[str, None] = "34ec51d80f52" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_group + op.create_table( + "asset_group", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["uuid"], ["asset.uuid"], name=op.f("fk_asset_group_uuid_asset") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_group")), + ) + op.create_table( + "asset_group_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_group_version") + ), + ) + op.create_index( + op.f("ix_asset_group_version_end_transaction_id"), + "asset_group_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_group_version_operation_type"), + "asset_group_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_group_version_pk_transaction_id", + "asset_group_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_group_version_pk_validity", + "asset_group_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_group_version_transaction_id"), + "asset_group_version", + ["transaction_id"], + unique=False, + ) + + # group + op.drop_index( + op.f("ix_group_version_end_transaction_id"), table_name="group_version" + ) + op.drop_index(op.f("ix_group_version_operation_type"), table_name="group_version") + op.drop_index( + op.f("ix_group_version_pk_transaction_id"), table_name="group_version" + ) + op.drop_index(op.f("ix_group_version_pk_validity"), table_name="group_version") + op.drop_index(op.f("ix_group_version_transaction_id"), table_name="group_version") + op.drop_table("group_version") + op.drop_table("group") + + +def downgrade() -> None: + + # group + op.create_table( + "group", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_group")), + sa.UniqueConstraint( + "drupal_id", + name=op.f("uq_group_drupal_id"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + sa.UniqueConstraint( + "farmos_uuid", + name=op.f("uq_group_farmos_uuid"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + sa.UniqueConstraint( + "name", + name=op.f("uq_group_name"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + ) + op.create_table( + "group_version", + sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True), + sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column( + "end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True + ), + sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_group_version") + ), + ) + op.create_index( + op.f("ix_group_version_transaction_id"), + "group_version", + ["transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_group_version_pk_validity"), + "group_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_group_version_pk_transaction_id"), + "group_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + op.f("ix_group_version_operation_type"), + "group_version", + ["operation_type"], + unique=False, + ) + op.create_index( + op.f("ix_group_version_end_transaction_id"), + "group_version", + ["end_transaction_id"], + unique=False, + ) + + # asset_group + op.drop_index( + op.f("ix_asset_group_version_transaction_id"), table_name="asset_group_version" + ) + op.drop_index( + "ix_asset_group_version_pk_validity", table_name="asset_group_version" + ) + op.drop_index( + "ix_asset_group_version_pk_transaction_id", table_name="asset_group_version" + ) + op.drop_index( + op.f("ix_asset_group_version_operation_type"), table_name="asset_group_version" + ) + op.drop_index( + op.f("ix_asset_group_version_end_transaction_id"), + table_name="asset_group_version", + ) + op.drop_table("asset_group_version") + op.drop_table("asset_group") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 5b1625a..367bc1c 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -34,5 +34,5 @@ from .assets import AssetType, Asset, AssetParent from .land import LandType, LandAsset from .structures import StructureType, StructureAsset from .animals import AnimalType, AnimalAsset -from .groups import Group +from .groups import GroupAsset from .logs import LogType, ActivityLog diff --git a/src/wuttafarm/db/model/groups.py b/src/wuttafarm/db/model/groups.py index 2cec3eb..84453a7 100644 --- a/src/wuttafarm/db/model/groups.py +++ b/src/wuttafarm/db/model/groups.py @@ -23,85 +23,23 @@ Model definition for Groups """ -import sqlalchemy as sa -from sqlalchemy import orm - from wuttjamaican.db import model +from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies -class Group(model.Base): + +class GroupAsset(AssetMixin, model.Base): """ - Represents a "group" from farmOS + Represents a group asset from farmOS """ - __tablename__ = "group" + __tablename__ = "asset_group" __versioned__ = {} __wutta_hint__ = { - "model_title": "Group", - "model_title_plural": "Groups", + "model_title": "Group Asset", + "model_title_plural": "Group Assets", + "farmos_asset_type": "group", } - uuid = model.uuid_column() - name = sa.Column( - sa.String(length=100), - nullable=False, - unique=True, - doc=""" - Name for the group. - """, - ) - - is_location = sa.Column( - sa.Boolean(), - nullable=False, - doc=""" - Whether the group is considered to be a location. - """, - ) - - is_fixed = sa.Column( - sa.Boolean(), - nullable=False, - doc=""" - Whether the group location is fixed. - """, - ) - - archived = sa.Column( - sa.Boolean(), - nullable=False, - default=False, - doc=""" - Whether the group is archived. - """, - ) - - notes = sa.Column( - sa.Text(), - nullable=True, - doc=""" - Arbitrary notes for the group. - """, - ) - - farmos_uuid = sa.Column( - model.UUID(), - nullable=True, - unique=True, - doc=""" - UUID for the group within farmOS. - """, - ) - - drupal_id = sa.Column( - sa.Integer(), - nullable=True, - unique=True, - doc=""" - Drupal internal ID for the group. - """, - ) - - def __str__(self): - return self.name or "" +add_asset_proxies(GroupAsset) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 4410023..4f9db20 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -103,7 +103,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["StructureAsset"] = StructureAssetImporter importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter - importers["Group"] = GroupImporter + importers["GroupAsset"] = GroupAssetImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter return importers @@ -460,21 +460,25 @@ class AssetTypeImporter(FromFarmOS, ToWutta): } -class GroupImporter(FromFarmOS, ToWutta): +class GroupAssetImporter(AssetImporterBase): """ - farmOS API → WuttaFarm importer for Groups + farmOS API → WuttaFarm importer for Group Assets """ - model_class = model.Group + model_class = model.GroupAsset supported_fields = [ "farmos_uuid", "drupal_id", - "name", + "asset_type", + "asset_name", "is_location", "is_fixed", "notes", "archived", + "image_url", + "thumbnail_url", + "parents", ] def get_source_objects(self): @@ -484,23 +488,13 @@ class GroupImporter(FromFarmOS, ToWutta): def normalize_source_object(self, group): """ """ - if notes := group["attributes"]["notes"]: - notes = notes["value"] - - if self.farmos_4x: - archived = group["attributes"]["archived"] - else: - archived = group["attributes"]["status"] == "archived" - - return { - "farmos_uuid": UUID(group["id"]), - "drupal_id": group["attributes"]["drupal_internal__id"], - "name": group["attributes"]["name"], - "is_location": group["attributes"]["is_location"], - "is_fixed": group["attributes"]["is_fixed"], - "archived": archived, - "notes": notes, - } + data = self.normalize_asset(group) + data.update( + { + "asset_type": "group", + } + ) + return data class LandAssetImporter(AssetImporterBase): diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index a7aa4b2..bdd2fbf 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -54,6 +54,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "animal_assets", "perm": "animal_assets.list", }, + { + "title": "Group", + "route": "group_assets", + "perm": "group_assets.list", + }, { "title": "Land", "route": "land_assets", @@ -65,12 +70,6 @@ class WuttaFarmMenuHandler(base.MenuHandler): "perm": "structure_assets.list", }, {"type": "sep"}, - { - "title": "Groups", - "route": "groups", - "perm": "groups.list", - }, - {"type": "sep"}, { "title": "Animal Types", "route": "animal_types", diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index c4bb792..4798a64 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -239,6 +239,8 @@ class AssetMasterView(WuttaFarmMasterView): route = None if asset.asset_type == "animal": route = "farmos_animals.view" + elif asset.asset_type == "group": + route = "farmos_groups.view" elif asset.asset_type == "land": route = "farmos_land_assets.view" elif asset.asset_type == "structure": diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index df54b04..c6748c4 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -166,15 +166,15 @@ class GroupView(FarmOSMasterView): ] if wf_group := ( - session.query(model.Group) - .filter(model.Group.farmos_uuid == group["uuid"]) + session.query(model.GroupAsset) + .filter(model.GroupAsset.farmos_uuid == group["uuid"]) .first() ): buttons.append( self.make_button( f"View {self.app.get_title()} record", primary=True, - url=self.request.route_url("groups.view", uuid=wf_group.uuid), + url=self.request.route_url("group_assets.view", uuid=wf_group.uuid), icon_left="eye", ) ) diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index e3ae0ad..21d7ed4 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -23,40 +23,30 @@ Master view for Groups """ -from wuttafarm.db.model.groups import Group -from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.views.assets import AssetMasterView +from wuttafarm.db.model.groups import GroupAsset -class GroupView(WuttaFarmMasterView): +class GroupView(AssetMasterView): """ Master view for Groups """ - model_class = Group - route_prefix = "groups" - url_prefix = "/groups" + model_class = GroupAsset + route_prefix = "group_assets" + url_prefix = "/assets/group" farmos_refurl_path = "/assets/group" - labels = { - "name": "Asset Name", - } - grid_columns = [ + "thumbnail", "drupal_id", - "name", + "asset_name", "archived", ] - sort_defaults = "name" - - filter_defaults = { - "name": {"active": True, "verb": "contains"}, - "archived": {"active": True, "verb": "is_false"}, - } - form_fields = [ - "name", + "asset_name", "notes", "asset_type", "archived", @@ -64,56 +54,6 @@ class GroupView(WuttaFarmMasterView): "drupal_id", ] - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # drupal_id - g.set_label("drupal_id", "ID", column_only=True) - - # name - g.set_link("name") - - def grid_row_class(self, group, data, i): - """ """ - if group.archived: - return "has-background-warning" - return None - - def configure_form(self, form): - f = form - super().configure_form(f) - - # notes - f.set_widget("notes", "notes") - - # asset_type - if self.creating: - f.remove("asset_type") - else: - f.set_default("asset_type", "Group") - f.set_readonly("asset_type") - - def get_farmos_url(self, group): - return self.app.get_farmos_url(f"/asset/{group.drupal_id}") - - def get_xref_buttons(self, group): - buttons = super().get_xref_buttons(group) - - if group.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_groups.view", uuid=group.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - def defaults(config, **kwargs): base = globals() From ec89230893b00653762a4feaa247d6fd9d791547 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Feb 2026 14:08:01 -0600 Subject: [PATCH 076/166] fix: fix initial admin perms per route renaming --- src/wuttafarm/web/views/common.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index cd68b78..1e74d0a 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -57,9 +57,9 @@ class CommonView(base.CommonView): "animal_types.list", "animal_types.view", "animal_types.versions", - "animals.list", - "animals.view", - "animals.versions", + "animal_assets.list", + "animal_assets.view", + "animal_assets.versions", "asset_types.list", "asset_types.view", "asset_types.versions", @@ -85,9 +85,9 @@ class CommonView(base.CommonView): "farmos_structures.view", "farmos_users.list", "farmos_users.view", - "groups.list", - "groups.view", - "groups.versions", + "group_asests.list", + "group_asests.view", + "group_asests.versions", "land_assets.list", "land_assets.view", "land_assets.versions", @@ -100,9 +100,9 @@ class CommonView(base.CommonView): "structure_types.list", "structure_types.view", "structure_types.versions", - "structures.list", - "structures.view", - "structures.versions", + "structure_assets.list", + "structure_assets.view", + "structure_assets.versions", ] for perm in site_admin_perms: auth.grant_permission(site_admin, perm) From bb21d6a36411fbcf92c785abd466d66e030cbc76 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Feb 2026 14:11:14 -0600 Subject: [PATCH 077/166] fix: fix some perms for all assets view --- src/wuttafarm/web/views/assets.py | 1 + src/wuttafarm/web/views/common.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 4798a64..702687d 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -60,6 +60,7 @@ class AssetView(WuttaFarmMasterView): creatable = False editable = False deletable = False + model_is_versioned = False grid_columns = [ "thumbnail", diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 1e74d0a..8b030f5 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -60,6 +60,7 @@ class CommonView(base.CommonView): "animal_assets.list", "animal_assets.view", "animal_assets.versions", + "assets.list", "asset_types.list", "asset_types.view", "asset_types.versions", From b85259c01343469aeff2243bbf4b6cf4b9bc5ace Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Feb 2026 14:54:55 -0600 Subject: [PATCH 078/166] fix: show warning when viewing an archived asset --- .../web/templates/assets/master/view.mako | 14 ++++++++++++++ src/wuttafarm/web/views/assets.py | 8 ++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/wuttafarm/web/templates/assets/master/view.mako diff --git a/src/wuttafarm/web/templates/assets/master/view.mako b/src/wuttafarm/web/templates/assets/master/view.mako new file mode 100644 index 0000000..dac5a1c --- /dev/null +++ b/src/wuttafarm/web/templates/assets/master/view.mako @@ -0,0 +1,14 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="page_content()"> + + % if instance.archived: + + This asset is archived. + Archived assets should only be edited if they need corrections. + + % endif + + ${parent.page_content()} + diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 702687d..682e258 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -130,6 +130,14 @@ class AssetMasterView(WuttaFarmMasterView): "archived": {"active": True, "verb": "is_false"}, } + def get_fallback_templates(self, template): + templates = super().get_fallback_templates(template) + + if self.viewing: + templates.insert(0, "/assets/master/view.mako") + + return templates + def get_query(self, session=None): """ """ model = self.app.model From 6677fe1e23f289b3876b691b06323a27ed996ea2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Feb 2026 14:55:15 -0600 Subject: [PATCH 079/166] fix: misc. field tweaks for asset forms --- src/wuttafarm/web/views/animals.py | 7 ++++++- src/wuttafarm/web/views/assets.py | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index b415cd6..fbad4ce 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -201,12 +201,17 @@ class AnimalAssetView(AssetMasterView): f = form super().configure_form(f) enum = self.app.enum + animal = f.model_instance # animal_type f.set_node("animal_type", AnimalTypeRef(self.request)) # sex - f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) + if self.viewing and animal.sex is None: + pass # TODO: dict enum widget does not handle null values well + else: + f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) + f.set_required("sex", False) def defaults(config, **kwargs): diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 682e258..f0ebefe 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -199,8 +199,11 @@ class AssetMasterView(WuttaFarmMasterView): f.set_readonly("asset_type") # parents - f.set_node("parents", AssetParentRefs(self.request)) - f.set_default("parents", [p.parent_uuid for p in asset.asset._parents]) + if self.creating or self.editing: + f.remove("parents") # TODO: add support for this + else: + f.set_node("parents", AssetParentRefs(self.request)) + f.set_default("parents", [p.parent_uuid for p in asset.asset._parents]) # notes f.set_widget("notes", "notes") From da9b559752bee70a157a66f973f9da8ed3284f79 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Feb 2026 14:53:36 -0600 Subject: [PATCH 080/166] =?UTF-8?q?feat:=20add=20basic=20support=20for=20W?= =?UTF-8?q?uttaFarm=20=E2=86=92=20farmOS=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typical CLI export tool, but also the export happens automatically when create or edit of record happens in wuttafarm supported models: - AnimalType - AnimalAsset - GroupAsset - LandAsset - StructureAsset --- pyproject.toml | 1 + src/wuttafarm/app.py | 32 ++ src/wuttafarm/cli/__init__.py | 1 + src/wuttafarm/cli/export_farmos.py | 41 +++ src/wuttafarm/emails.py | 6 + src/wuttafarm/farmos/importing/__init__.py | 26 ++ src/wuttafarm/farmos/importing/model.py | 365 ++++++++++++++++++++ src/wuttafarm/farmos/importing/wuttafarm.py | 263 ++++++++++++++ src/wuttafarm/web/views/animals.py | 5 +- src/wuttafarm/web/views/assets.py | 10 + src/wuttafarm/web/views/common.py | 10 + src/wuttafarm/web/views/land.py | 5 +- src/wuttafarm/web/views/master.py | 4 + src/wuttafarm/web/views/structures.py | 5 +- 14 files changed, 765 insertions(+), 9 deletions(-) create mode 100644 src/wuttafarm/cli/export_farmos.py create mode 100644 src/wuttafarm/farmos/importing/__init__.py create mode 100644 src/wuttafarm/farmos/importing/model.py create mode 100644 src/wuttafarm/farmos/importing/wuttafarm.py diff --git a/pyproject.toml b/pyproject.toml index 073879b..51dcb61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ wuttafarm = "wuttafarm.app:WuttaFarmAppProvider" "wuttafarm" = "wuttafarm.web.menus:WuttaFarmMenuHandler" [project.entry-points."wuttasync.importing"] +"export.to_farmos.from_wuttafarm" = "wuttafarm.farmos.importing.wuttafarm:FromWuttaFarmToFarmOS" "import.to_wuttafarm.from_farmos" = "wuttafarm.importing.farmos:FromFarmOSToWuttaFarm" diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 72e9f00..087c48a 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -85,6 +85,38 @@ class WuttaFarmAppHandler(base.AppHandler): handler = self.get_farmos_handler() return handler.is_farmos_4x(*args, **kwargs) + def export_to_farmos(self, obj, require=True): + """ + Export the given object to farmOS, using configured handler. + + This should ensure the given object is also *updated* with the + farmOS UUID and Drupal ID, when new record is created in + farmOS. + + :param obj: Any data object in WuttaFarm, e.g. AnimalAsset + instance. + + :param require: If true, this will *require* the export + handler to support objects of the given type. If false, + then nothing will happen / export is silently skipped when + there is no such exporter. + """ + handler = self.app.get_import_handler("export.to_farmos.from_wuttafarm") + + model_name = type(obj).__name__ + if model_name not in handler.importers: + if require: + raise ValueError(f"no exporter found for {model_name}") + return + + # nb. begin txn to establish the API client + # TODO: should probably use current user oauth2 token instead + # of always making a new one here, which is what happens IIUC + handler.begin_target_transaction() + importer = handler.get_importer(model_name, caches_target=False) + normal = importer.normalize_source_object(obj) + importer.process_data(source_data=[normal]) + class WuttaFarmAppProvider(base.AppProvider): """ diff --git a/src/wuttafarm/cli/__init__.py b/src/wuttafarm/cli/__init__.py index 7f6c2bb..cd06344 100644 --- a/src/wuttafarm/cli/__init__.py +++ b/src/wuttafarm/cli/__init__.py @@ -26,5 +26,6 @@ WuttaFarm CLI from .base import wuttafarm_typer # nb. must bring in all modules for discovery to work +from . import export_farmos from . import import_farmos from . import install diff --git a/src/wuttafarm/cli/export_farmos.py b/src/wuttafarm/cli/export_farmos.py new file mode 100644 index 0000000..18a21dd --- /dev/null +++ b/src/wuttafarm/cli/export_farmos.py @@ -0,0 +1,41 @@ +# -*- 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 . +# +################################################################################ +""" +See also: :ref:`wuttafarm-export-farmos` +""" + +import typer + +from wuttasync.cli import import_command, ImportCommandHandler + +from wuttafarm.cli import wuttafarm_typer + + +@wuttafarm_typer.command() +@import_command +def export_farmos(ctx: typer.Context, **kwargs): + """ + Export data from WuttaFarm to farmOS API + """ + config = ctx.parent.wutta_config + handler = ImportCommandHandler(config, key="export.to_farmos.from_wuttafarm") + handler.run(ctx) diff --git a/src/wuttafarm/emails.py b/src/wuttafarm/emails.py index 55b1612..05416ab 100644 --- a/src/wuttafarm/emails.py +++ b/src/wuttafarm/emails.py @@ -26,6 +26,12 @@ Email sending config for WuttaFarm from wuttasync.emails import ImportExportWarning +class export_to_farmos_from_wuttafarm_warning(ImportExportWarning): + """ + Diff warning for WuttaFarm → farmOS export. + """ + + class import_to_wuttafarm_from_farmos_warning(ImportExportWarning): """ Diff warning for farmOS → WuttaFarm import. diff --git a/src/wuttafarm/farmos/importing/__init__.py b/src/wuttafarm/farmos/importing/__init__.py new file mode 100644 index 0000000..a4b17eb --- /dev/null +++ b/src/wuttafarm/farmos/importing/__init__.py @@ -0,0 +1,26 @@ +# -*- 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 . +# +################################################################################ +""" +Importing data *into* farmOS +""" + +from . import model diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py new file mode 100644 index 0000000..6c3f5a0 --- /dev/null +++ b/src/wuttafarm/farmos/importing/model.py @@ -0,0 +1,365 @@ +# -*- 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 . +# +################################################################################ +""" +Importer models targeting farmOS +""" + +import datetime +from uuid import UUID + +import requests + +from wuttasync.importing import Importer + + +class ToFarmOS(Importer): + """ + Base class for data importer targeting the farmOS API. + """ + + key = "uuid" + caches_target = True + + def format_datetime(self, dt): + """ + Convert a WuttaFarm datetime object to the format required for + pushing to the farmOS API. + """ + if dt is None: + return None + dt = self.app.localtime(dt) + return dt.timestamp() + + def normalize_datetime(self, dt): + """ + Convert a farmOS datetime value to naive UTC used by + WuttaFarm. + + :param dt: Date/time string value "as-is" from the farmOS API. + + :returns: Equivalent naive UTC ``datetime`` + """ + if dt is None: + return None + dt = datetime.datetime.fromisoformat(dt) + return self.app.make_utc(dt) + + +class ToFarmOSAsset(ToFarmOS): + """ + Base class for asset data importer targeting the farmOS API. + """ + + farmos_asset_type = None + + def get_target_objects(self, **kwargs): + assets = self.farmos_client.asset.get(self.farmos_asset_type) + return assets["data"] + + def get_target_object(self, key): + + # fetch from cache, if applicable + if self.caches_target: + return super().get_target_object(key) + + # okay now must fetch via API + if self.get_keys() != ["uuid"]: + raise ValueError("must use uuid key for this to work") + uuid = key[0] + + try: + asset = self.farmos_client.asset.get_id(self.farmos_asset_type, str(uuid)) + except requests.HTTPError as exc: + if exc.response.status_code == 404: + return None + raise + return asset["data"] + + def create_target_object(self, key, source_data): + if source_data.get("__ignoreme__"): + return None + if self.dry_run: + return source_data + + payload = self.get_asset_payload(source_data) + result = self.farmos_client.asset.send(self.farmos_asset_type, payload) + normal = self.normalize_target_object(result["data"]) + normal["_new_object"] = result["data"] + return normal + + def update_target_object(self, asset, source_data, target_data=None): + if self.dry_run: + return asset + + payload = self.get_asset_payload(source_data) + payload["id"] = str(source_data["uuid"]) + result = self.farmos_client.asset.send(self.farmos_asset_type, payload) + return self.normalize_target_object(result["data"]) + + def normalize_target_object(self, asset): + + if notes := asset["attributes"]["notes"]: + notes = notes["value"] + + return { + "uuid": UUID(asset["id"]), + "asset_name": asset["attributes"]["name"], + "is_location": asset["attributes"]["is_location"], + "is_fixed": asset["attributes"]["is_fixed"], + "notes": notes, + "archived": asset["attributes"]["archived"], + } + + def get_asset_payload(self, source_data): + + attrs = {} + if "asset_name" in self.fields: + attrs["name"] = source_data["asset_name"] + if "is_location" in self.fields: + attrs["is_location"] = source_data["is_location"] + if "is_fixed" in self.fields: + attrs["is_fixed"] = source_data["is_fixed"] + if "notes" in self.fields: + attrs["notes"] = {"value": source_data["notes"]} + if "archived" in self.fields: + attrs["archived"] = source_data["archived"] + + payload = {"attributes": attrs} + + return payload + + +class AnimalAssetImporter(ToFarmOSAsset): + + model_title = "AnimalAsset" + farmos_asset_type = "animal" + + supported_fields = [ + "uuid", + "asset_name", + "animal_type_uuid", + "sex", + "is_sterile", + "birthdate", + "notes", + "archived", + ] + + def normalize_target_object(self, animal): + data = super().normalize_target_object(animal) + data.update( + { + "animal_type_uuid": UUID( + animal["relationships"]["animal_type"]["data"]["id"] + ), + "sex": animal["attributes"]["sex"], + "is_sterile": animal["attributes"]["is_sterile"], + "birthdate": self.normalize_datetime(animal["attributes"]["birthdate"]), + } + ) + return data + + def get_asset_payload(self, source_data): + payload = super().get_asset_payload(source_data) + + attrs = {} + if "sex" in self.fields: + attrs["sex"] = source_data["sex"] + if "is_sterile" in self.fields: + attrs["is_sterile"] = source_data["is_sterile"] + if "birthdate" in self.fields: + attrs["birthdate"] = self.format_datetime(source_data["birthdate"]) + + rels = {} + if "animal_type_uuid" in self.fields: + rels["animal_type"] = { + "data": { + "id": str(source_data["animal_type_uuid"]), + "type": "taxonomy_term--animal_type", + } + } + + payload["attributes"].update(attrs) + if rels: + payload.setdefault("relationships", {}).update(rels) + + return payload + + +class AnimalTypeImporter(ToFarmOS): + + model_title = "AnimalType" + + supported_fields = [ + "uuid", + "name", + ] + + def get_target_objects(self, **kwargs): + result = self.farmos_client.resource.get("taxonomy_term", "animal_type") + return result["data"] + + def get_target_object(self, key): + + # fetch from cache, if applicable + if self.caches_target: + return super().get_target_object(key) + + # okay now must fetch via API + if self.get_keys() != ["uuid"]: + raise ValueError("must use uuid key for this to work") + uuid = key[0] + + try: + result = self.farmos_client.resource.get_id( + "taxonomy_term", "animal_type", str(uuid) + ) + except requests.HTTPError as exc: + if exc.response.status_code == 404: + return None + raise + return result["data"] + + def normalize_target_object(self, obj): + return { + "uuid": UUID(obj["id"]), + "name": obj["attributes"]["name"], + } + + def get_type_payload(self, source_data): + return { + "attributes": { + "name": source_data["name"], + } + } + + def create_target_object(self, key, source_data): + if source_data.get("__ignoreme__"): + return None + if self.dry_run: + return source_data + + payload = self.get_type_payload(source_data) + result = self.farmos_client.resource.send( + "taxonomy_term", "animal_type", payload + ) + normal = self.normalize_target_object(result["data"]) + normal["_new_object"] = result["data"] + return normal + + def update_target_object(self, asset, source_data, target_data=None): + if self.dry_run: + return asset + + payload = self.get_type_payload(source_data) + payload["id"] = str(source_data["uuid"]) + result = self.farmos_client.resource.send( + "taxonomy_term", "animal_type", payload + ) + return self.normalize_target_object(result["data"]) + + +class GroupAssetImporter(ToFarmOSAsset): + + model_title = "GroupAsset" + farmos_asset_type = "group" + + supported_fields = [ + "uuid", + "asset_name", + "notes", + "archived", + ] + + +class LandAssetImporter(ToFarmOSAsset): + + model_title = "LandAsset" + farmos_asset_type = "land" + + supported_fields = [ + "uuid", + "asset_name", + "land_type_id", + "is_location", + "is_fixed", + "notes", + "archived", + ] + + def normalize_target_object(self, land): + data = super().normalize_target_object(land) + data.update( + { + "land_type_id": land["attributes"]["land_type"], + } + ) + return data + + def get_asset_payload(self, source_data): + payload = super().get_asset_payload(source_data) + + attrs = {} + if "land_type_id" in self.fields: + attrs["land_type"] = source_data["land_type_id"] + + if attrs: + payload["attributes"].update(attrs) + + return payload + + +class StructureAssetImporter(ToFarmOSAsset): + + model_title = "StructureAsset" + farmos_asset_type = "structure" + + supported_fields = [ + "uuid", + "asset_name", + "structure_type_id", + "is_location", + "is_fixed", + "notes", + "archived", + ] + + def normalize_target_object(self, structure): + data = super().normalize_target_object(structure) + data.update( + { + "structure_type_id": structure["attributes"]["structure_type"], + } + ) + return data + + def get_asset_payload(self, source_data): + payload = super().get_asset_payload(source_data) + + attrs = {} + if "structure_type_id" in self.fields: + attrs["structure_type"] = source_data["structure_type_id"] + + if attrs: + payload["attributes"].update(attrs) + + return payload diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py new file mode 100644 index 0000000..8ef8a77 --- /dev/null +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -0,0 +1,263 @@ +# -*- 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 . +# +################################################################################ +""" +WuttaFarm → farmOS data export +""" + +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2Session + +from wuttasync.importing import ImportHandler, FromWuttaHandler, FromWutta, Orientation + +from wuttafarm.db import model +from wuttafarm.farmos import importing as farmos_importing + + +class FromWuttaFarmHandler(FromWuttaHandler): + """ + Base class for import handler targeting WuttaFarm + """ + + source_key = "wuttafarm" + + +class ToFarmOSHandler(ImportHandler): + """ + Base class for export handlers using CSV file(s) as data target. + """ + + target_key = "farmos" + generic_target_title = "farmOS" + + # TODO: a lot of duplication to cleanup here; see FromFarmOSHandler + + def begin_target_transaction(self): + """ + Establish the farmOS API client. + """ + token = self.get_farmos_oauth2_token() + self.farmos_client = self.app.get_farmos_client(token=token) + self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + + def get_farmos_oauth2_token(self): + + client_id = self.config.get( + "farmos.oauth2.importing.client_id", default="wuttafarm" + ) + client_secret = self.config.require("farmos.oauth2.importing.client_secret") + scope = self.config.get("farmos.oauth2.importing.scope", default="farm_manager") + + client = BackendApplicationClient(client_id=client_id) + oauth = OAuth2Session(client=client) + + return oauth.fetch_token( + token_url=self.app.get_farmos_url("/oauth/token"), + include_client_id=True, + client_secret=client_secret, + scope=scope, + ) + + def get_importer_kwargs(self, key, **kwargs): + kwargs = super().get_importer_kwargs(key, **kwargs) + kwargs["farmos_client"] = self.farmos_client + kwargs["farmos_4x"] = self.farmos_4x + return kwargs + + +class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): + """ + Handler for WuttaFarm → farmOS API export. + """ + + orientation = Orientation.EXPORT + + def define_importers(self): + """ """ + importers = super().define_importers() + importers["LandAsset"] = LandAssetImporter + importers["StructureAsset"] = StructureAssetImporter + importers["AnimalType"] = AnimalTypeImporter + importers["AnimalAsset"] = AnimalAssetImporter + importers["GroupAsset"] = GroupAssetImporter + return importers + + +class FromWuttaFarm(FromWutta): + + drupal_internal_id_field = "drupal_internal__id" + + def create_target_object(self, key, source_data): + obj = super().create_target_object(key, source_data) + if obj is None: + return None + + if not self.dry_run: + + # set farmOS, Drupal key fields in WuttaFarm + api_object = obj["_new_object"] + wf_object = source_data["_src_object"] + wf_object.farmos_uuid = obj["uuid"] + wf_object.drupal_id = api_object["attributes"][ + self.drupal_internal_id_field + ] + + return obj + + +class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImporter): + """ + WuttaFarm → farmOS API exporter for Animal Assets + """ + + source_model_class = model.AnimalAsset + + supported_fields = [ + "uuid", + "asset_name", + "animal_type_uuid", + "sex", + "is_sterile", + "birthdate", + "notes", + "archived", + ] + + def normalize_source_object(self, animal): + return { + "uuid": animal.farmos_uuid or self.app.make_true_uuid(), + "asset_name": animal.asset_name, + "animal_type_uuid": animal.animal_type.farmos_uuid, + "sex": animal.sex, + "is_sterile": animal.is_sterile, + "birthdate": animal.birthdate, + "notes": animal.notes, + "archived": animal.archived, + "_src_object": animal, + } + + +class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporter): + """ + WuttaFarm → farmOS API exporter for Animal Types + """ + + source_model_class = model.AnimalType + + supported_fields = [ + "uuid", + "name", + ] + + drupal_internal_id_field = "drupal_internal__tid" + + def normalize_source_object(self, animal_type): + return { + "uuid": animal_type.farmos_uuid or self.app.make_true_uuid(), + "name": animal_type.name, + "_src_object": animal_type, + } + + +class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter): + """ + WuttaFarm → farmOS API exporter for Group Assets + """ + + source_model_class = model.GroupAsset + + supported_fields = [ + "uuid", + "asset_name", + "notes", + "archived", + ] + + def normalize_source_object(self, group): + return { + "uuid": group.farmos_uuid or self.app.make_true_uuid(), + "asset_name": group.asset_name, + "notes": group.notes, + "archived": group.archived, + "_src_object": group, + } + + +class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter): + """ + WuttaFarm → farmOS API exporter for Land Assets + """ + + source_model_class = model.LandAsset + + supported_fields = [ + "uuid", + "asset_name", + "land_type_id", + "is_location", + "is_fixed", + "notes", + "archived", + ] + + def normalize_source_object(self, land): + return { + "uuid": land.farmos_uuid or self.app.make_true_uuid(), + "asset_name": land.asset_name, + "land_type_id": land.land_type.drupal_id, + "is_location": land.is_location, + "is_fixed": land.is_fixed, + "notes": land.notes, + "archived": land.archived, + "_src_object": land, + } + + +class StructureAssetImporter( + FromWuttaFarm, farmos_importing.model.StructureAssetImporter +): + """ + WuttaFarm → farmOS API exporter for Structure Assets + """ + + source_model_class = model.StructureAsset + + supported_fields = [ + "uuid", + "asset_name", + "structure_type_id", + "is_location", + "is_fixed", + "notes", + "archived", + ] + + def normalize_source_object(self, structure): + return { + "uuid": structure.farmos_uuid or self.app.make_true_uuid(), + "asset_name": structure.asset_name, + "structure_type_id": structure.structure_type.drupal_id, + "is_location": structure.is_location, + "is_fixed": structure.is_fixed, + "notes": structure.notes, + "archived": structure.archived, + "_src_object": structure, + } diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index fbad4ce..bae7dde 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -26,13 +26,12 @@ Master view for Animals from wuttaweb.forms.schema import WuttaDictEnum from wuttafarm.db.model import AnimalType, AnimalAsset -from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.web.views.assets import AssetMasterView +from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.forms.schema import AnimalTypeRef from wuttafarm.web.forms.widgets import ImageWidget -class AnimalTypeView(WuttaFarmMasterView): +class AnimalTypeView(AssetTypeMasterView): """ Master view for Animal Types """ diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index f0ebefe..dffaae7 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -118,6 +118,16 @@ class AssetView(WuttaFarmMasterView): return None +class AssetTypeMasterView(WuttaFarmMasterView): + """ + Base class for "Asset Type" master views. + + A bit of a misnmer perhaps, this is *not* for the actual AssetType + model, but rather the "secondary" types, e.g. AnimalType, + LandType etc. + """ + + class AssetMasterView(WuttaFarmMasterView): """ Base class for Asset master views diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 8b030f5..121e631 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -54,9 +54,13 @@ class CommonView(base.CommonView): "activity_logs.list", "activity_logs.view", "activity_logs.versions", + "animal_types.create", + "animal_types.edit", "animal_types.list", "animal_types.view", "animal_types.versions", + "animal_assets.create", + "animal_assets.edit", "animal_assets.list", "animal_assets.view", "animal_assets.versions", @@ -86,9 +90,13 @@ class CommonView(base.CommonView): "farmos_structures.view", "farmos_users.list", "farmos_users.view", + "group_asests.create", + "group_asests.edit", "group_asests.list", "group_asests.view", "group_asests.versions", + "land_assets.create", + "land_assets.edit", "land_assets.list", "land_assets.view", "land_assets.versions", @@ -101,6 +109,8 @@ class CommonView(base.CommonView): "structure_types.list", "structure_types.view", "structure_types.versions", + "structure_assets.create", + "structure_assets.edit", "structure_assets.list", "structure_assets.view", "structure_assets.versions", diff --git a/src/wuttafarm/web/views/land.py b/src/wuttafarm/web/views/land.py index ce577c9..aad15e7 100644 --- a/src/wuttafarm/web/views/land.py +++ b/src/wuttafarm/web/views/land.py @@ -26,12 +26,11 @@ Master view for Land Types from webhelpers2.html import HTML, tags from wuttafarm.db.model.land import LandType, LandAsset -from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.web.views.assets import AssetMasterView +from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.forms.schema import LandTypeRef -class LandTypeView(WuttaFarmMasterView): +class LandTypeView(AssetTypeMasterView): """ Master view for Land Types """ diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 98856f6..0e25a30 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -96,3 +96,7 @@ class WuttaFarmMasterView(MasterView): f.remove("drupal_id") else: f.set_readonly("drupal_id") + + def persist(self, obj, session=None): + super().persist(obj, session) + self.app.export_to_farmos(obj, require=False) diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 5745658..aa9bf31 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -23,14 +23,13 @@ Master view for Structures """ -from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.web.views.assets import AssetMasterView +from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.db.model import StructureType, StructureAsset from wuttafarm.web.forms.schema import StructureTypeRef from wuttafarm.web.forms.widgets import ImageWidget -class StructureTypeView(WuttaFarmMasterView): +class StructureTypeView(AssetTypeMasterView): """ Master view for Structure Types """ From 36eca088953c26060c5fef655c754ec98c030fba Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 17 Feb 2026 15:40:03 -0600 Subject: [PATCH 081/166] =?UTF-8?q?bump:=20version=200.3.1=20=E2=86=92=200?= =?UTF-8?q?.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2111345..1e7712a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.4.0 (2026-02-17) + +### Feat + +- add basic support for WuttaFarm → farmOS export +- convert group assets to use common base/mixin +- convert structure assets to use common base/mixin +- convert land assets to use common base/mixin +- add "generic" assets, new animal assets based on that + +### Fix + +- misc. field tweaks for asset forms +- show warning when viewing an archived asset +- fix some perms for all assets view +- fix initial admin perms per route renaming +- add parent relationships support for land assets +- cleanup Land views to better match farmOS +- cleanup Structure views to better match farmOS +- cleanup Group views to better match farmOS +- add / display thumbnail image for animals +- improve handling of 'archived' records for grid/form views +- use Male/Female dict enum for animal sex field +- prevent direct edit of `farmos_uuid` and `drupal_id` fields +- use same datetime display format as farmOS +- convert `active` flag to `archived` +- suppress output when user farmos/drupal keys are empty +- customize page footer to mention farmOS + ## v0.3.1 (2026-02-14) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 51dcb61..12bce62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.3.1" +version = "0.4.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ @@ -34,7 +34,7 @@ dependencies = [ "pyramid_exclog", "uvicorn[standard]", "WuttaSync", - "WuttaWeb[continuum]>=0.27.4", + "WuttaWeb[continuum]>=0.28.1", ] From d741a882997652e22ee4d546af96e8f6cf35131b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 17 Feb 2026 16:54:43 -0600 Subject: [PATCH 082/166] docs: update feature list, roadmap, screenshots --- docs/narr/features.rst | 99 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/docs/narr/features.rst b/docs/narr/features.rst index 60a9120..f072b41 100644 --- a/docs/narr/features.rst +++ b/docs/narr/features.rst @@ -18,8 +18,105 @@ Here is the list of features currently supported: * limited data is imported from farmOS API into native app tables * this data is exposed in views, similar to direct farmOS views (above) +* export some data back to farmOS + * limited data is exported back via farmOS API, from native tables + * supported tables are auto-synced when a record is created/updated + * AnimalType + * AnimalAsset + * GroupAsset + * LandAsset + * StructureAsset + + +How I Use This App +------------------ + +My production farmOS instance is deployed via Podman container, which +I prefer over Docker. (Not that I know much about any of that +really.) It has a PostgreSQL database which runs in a separate +container. + +My production WuttaFarm instance is installed directly on the same +host machine, in a Python virtual environment. PostgreSQL is also +installed on the host machine; the app uses that for DB. + +I ran the initial "special" import to establish the user accounts; +then I ran the "full" import (farmOS → WuttaFarm). See also +:doc:`/narr/install`. + +I configured a cron job to run the full import every night, but in +dry-run mode with warnings. This means I will get an email if +WuttaFarm is ever out of sync with farmOS. + +With all that in place, I can use WuttaFarm as my "daily driver" to +add/edit assets (and soon, logs). Changes I make are immediately +synced to farmOS, so as long as the overnight check does not send me +an email, I know everything is good. + + +Roadmap +------- + +Here are some things I still have planned so far: + +* finish support for auto-sync, in current asset models + * must make "asset parents" editable + +* add more asset models? + * i may only add those i need for now, but others can add more + +* flesh out the log model support + * add more tables, fields to schema + * add/improve import and export + * basically this should be as good as the asset model support + * although again i may only add those i need for now + +* add custom "quick forms" for assets and logs + * again i probably will just add a few (e.g. egg collection) + * but this could be an interesting path to go down, we'll see + +* add custom "CSV/file importers" + * the framework has some pretty neat tools around this, so.. + * ..even if i don't need CSV import i'd like to show what's possible + +Notably **off the table** for now are: + +* anything involving maps +* file/image attachments + +I will just import "thumbnail" and "large" image URLs from farmOS for +each asset for now. Will have to think more on the image/attachment +stuff before I'll know if/how to add support in WuttaFarm. + +Maps will wait mostly because I have never done anything involving +those (or GIS etc. - if that's even the right term). And anyway the +main "use" for this app is probably around data entry, so it may never +"need" maps support. + Screenshots ----------- -.. image:: https://wuttaproject.org/images/screenshot.png + +Login Screen +~~~~~~~~~~~~ + +.. image:: https://wuttaproject.org/images/wuttafarm/screenshot001.png + + +List All Assets +~~~~~~~~~~~~~~~ + +.. image:: https://wuttaproject.org/images/wuttafarm/screenshot002.png + + +View Animal Asset +~~~~~~~~~~~~~~~~~ + +.. image:: https://wuttaproject.org/images/wuttafarm/screenshot003.png + + +Edit Animal Asset +~~~~~~~~~~~~~~~~~ + +.. image:: https://wuttaproject.org/images/wuttafarm/screenshot004.png From e520a34fa5b52304ad180c9082aad4b710e94281 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 17 Feb 2026 18:13:16 -0600 Subject: [PATCH 083/166] fix: remove `AnimalType.changed` column that was just confusing things. WuttaFarm model should have its own notion of when things changed, and farmOS can have its own. --- .../b8cd4a8f981f_remove_animaltype_changed.py | 37 +++++++++++++++++++ src/wuttafarm/db/model/animals.py | 14 +------ src/wuttafarm/importing/farmos.py | 2 - src/wuttafarm/web/views/animals.py | 2 - 4 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/b8cd4a8f981f_remove_animaltype_changed.py diff --git a/src/wuttafarm/db/alembic/versions/b8cd4a8f981f_remove_animaltype_changed.py b/src/wuttafarm/db/alembic/versions/b8cd4a8f981f_remove_animaltype_changed.py new file mode 100644 index 0000000..a43a6d4 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/b8cd4a8f981f_remove_animaltype_changed.py @@ -0,0 +1,37 @@ +"""remove AnimalType.changed + +Revision ID: b8cd4a8f981f +Revises: aecfd9175624 +Create Date: 2026-02-17 18:11:06.110003 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "b8cd4a8f981f" +down_revision: Union[str, None] = "aecfd9175624" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # animal_type + op.drop_column("animal_type", "changed") + + +def downgrade() -> None: + + # animal_type + op.add_column( + "animal_type", + sa.Column( + "changed", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + ) diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/animals.py index 548be86..8c0df35 100644 --- a/src/wuttafarm/db/model/animals.py +++ b/src/wuttafarm/db/model/animals.py @@ -37,11 +37,7 @@ class AnimalType(model.Base): """ __tablename__ = "animal_type" - __versioned__ = { - "exclude": [ - "changed", - ], - } + __versioned__ = {} __wutta_hint__ = { "model_title": "Animal Type", "model_title_plural": "Animal Types", @@ -66,14 +62,6 @@ class AnimalType(model.Base): """, ) - changed = sa.Column( - sa.DateTime(), - nullable=True, - doc=""" - When the animal type was last changed, according to farmOS. - """, - ) - farmos_uuid = sa.Column( model.UUID(), nullable=True, diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 4f9db20..b07d06d 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -412,7 +412,6 @@ class AnimalTypeImporter(FromFarmOS, ToWutta): "drupal_id", "name", "description", - "changed", ] def get_source_objects(self): @@ -427,7 +426,6 @@ class AnimalTypeImporter(FromFarmOS, ToWutta): "drupal_id": animal_type["attributes"]["drupal_internal__tid"], "name": animal_type["attributes"]["name"], "description": animal_type["attributes"]["description"], - "changed": self.normalize_datetime(animal_type["attributes"]["changed"]), } diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index bae7dde..7016e36 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -45,7 +45,6 @@ class AnimalTypeView(AssetTypeMasterView): grid_columns = [ "name", "description", - "changed", ] sort_defaults = "name" @@ -57,7 +56,6 @@ class AnimalTypeView(AssetTypeMasterView): form_fields = [ "name", "description", - "changed", "farmos_uuid", "drupal_id", ] From 4bc556aec55d4dd8eddc13187ff9ed4b2c06f460 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 17 Feb 2026 20:12:38 -0600 Subject: [PATCH 084/166] =?UTF-8?q?bump:=20version=200.4.0=20=E2=86=92=200?= =?UTF-8?q?.4.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7712a..d096239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.4.1 (2026-02-17) + +### Fix + +- remove `AnimalType.changed` column + ## v0.4.0 (2026-02-17) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 12bce62..44bea43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.4.0" +version = "0.4.1" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 4ec79231640435f53e7219b5437029b521124d4a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 11:29:38 -0600 Subject: [PATCH 085/166] fix: add override for requests cert validation for use in local dev with self-signed certs --- src/wuttafarm/config.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/wuttafarm/config.py b/src/wuttafarm/config.py index 5828299..831698f 100644 --- a/src/wuttafarm/config.py +++ b/src/wuttafarm/config.py @@ -23,6 +23,8 @@ WuttaFarm config extensions """ +import os + from wuttjamaican.conf import WuttaConfigExtension @@ -56,3 +58,8 @@ class WuttaFarmConfig(WuttaConfigExtension): # web app libcache # config.setdefault('wuttaweb.static_libcache.module', 'wuttafarm.web.static') + + # maybe override cert validation for requests lib. + # nb. this is "global" and not "specific" to the farmos API requests! + if bundle := config.get(f"{config.appname}.requests_ca_bundle"): + os.environ.setdefault("REQUESTS_CA_BUNDLE", bundle) From 982da89861beb0e46ea4efe609b7f9bd626258e8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 11:43:26 -0600 Subject: [PATCH 086/166] fix: rename db model modules, for better convention --- src/wuttafarm/db/model/__init__.py | 10 +++++----- src/wuttafarm/db/model/{assets.py => asset.py} | 0 src/wuttafarm/db/model/{animals.py => asset_animal.py} | 2 +- src/wuttafarm/db/model/{groups.py => asset_group.py} | 2 +- src/wuttafarm/db/model/{land.py => asset_land.py} | 2 +- .../db/model/{structures.py => asset_structure.py} | 2 +- src/wuttafarm/web/views/asset_types.py | 2 +- src/wuttafarm/web/views/groups.py | 2 +- src/wuttafarm/web/views/land.py | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) rename src/wuttafarm/db/model/{assets.py => asset.py} (100%) rename src/wuttafarm/db/model/{animals.py => asset_animal.py} (97%) rename src/wuttafarm/db/model/{groups.py => asset_group.py} (95%) rename src/wuttafarm/db/model/{land.py => asset_land.py} (97%) rename src/wuttafarm/db/model/{structures.py => asset_structure.py} (97%) diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 367bc1c..277a92c 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,9 +30,9 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models -from .assets import AssetType, Asset, AssetParent -from .land import LandType, LandAsset -from .structures import StructureType, StructureAsset -from .animals import AnimalType, AnimalAsset -from .groups import GroupAsset +from .asset import AssetType, Asset, AssetParent +from .asset_land import LandType, LandAsset +from .asset_structure import StructureType, StructureAsset +from .asset_animal import AnimalType, AnimalAsset +from .asset_group import GroupAsset from .logs import LogType, ActivityLog diff --git a/src/wuttafarm/db/model/assets.py b/src/wuttafarm/db/model/asset.py similarity index 100% rename from src/wuttafarm/db/model/assets.py rename to src/wuttafarm/db/model/asset.py diff --git a/src/wuttafarm/db/model/animals.py b/src/wuttafarm/db/model/asset_animal.py similarity index 97% rename from src/wuttafarm/db/model/animals.py rename to src/wuttafarm/db/model/asset_animal.py index 8c0df35..b50c41b 100644 --- a/src/wuttafarm/db/model/animals.py +++ b/src/wuttafarm/db/model/asset_animal.py @@ -28,7 +28,7 @@ from sqlalchemy import orm from wuttjamaican.db import model -from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies class AnimalType(model.Base): diff --git a/src/wuttafarm/db/model/groups.py b/src/wuttafarm/db/model/asset_group.py similarity index 95% rename from src/wuttafarm/db/model/groups.py rename to src/wuttafarm/db/model/asset_group.py index 84453a7..fe82bdd 100644 --- a/src/wuttafarm/db/model/groups.py +++ b/src/wuttafarm/db/model/asset_group.py @@ -25,7 +25,7 @@ Model definition for Groups from wuttjamaican.db import model -from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies class GroupAsset(AssetMixin, model.Base): diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/asset_land.py similarity index 97% rename from src/wuttafarm/db/model/land.py rename to src/wuttafarm/db/model/asset_land.py index 1221c63..bbd7bf0 100644 --- a/src/wuttafarm/db/model/land.py +++ b/src/wuttafarm/db/model/asset_land.py @@ -28,7 +28,7 @@ from sqlalchemy import orm from wuttjamaican.db import model -from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies class LandType(model.Base): diff --git a/src/wuttafarm/db/model/structures.py b/src/wuttafarm/db/model/asset_structure.py similarity index 97% rename from src/wuttafarm/db/model/structures.py rename to src/wuttafarm/db/model/asset_structure.py index 8c5371c..7f4fc23 100644 --- a/src/wuttafarm/db/model/structures.py +++ b/src/wuttafarm/db/model/asset_structure.py @@ -28,7 +28,7 @@ from sqlalchemy import orm from wuttjamaican.db import model -from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies class StructureType(model.Base): diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py index 775fa3a..b9f560a 100644 --- a/src/wuttafarm/web/views/asset_types.py +++ b/src/wuttafarm/web/views/asset_types.py @@ -23,7 +23,7 @@ Master view for Asset Types """ -from wuttafarm.db.model.assets import AssetType +from wuttafarm.db.model import AssetType from wuttafarm.web.views import WuttaFarmMasterView diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 21d7ed4..ae50b89 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -24,7 +24,7 @@ Master view for Groups """ from wuttafarm.web.views.assets import AssetMasterView -from wuttafarm.db.model.groups import GroupAsset +from wuttafarm.db.model import GroupAsset class GroupView(AssetMasterView): diff --git a/src/wuttafarm/web/views/land.py b/src/wuttafarm/web/views/land.py index aad15e7..22827a0 100644 --- a/src/wuttafarm/web/views/land.py +++ b/src/wuttafarm/web/views/land.py @@ -25,7 +25,7 @@ Master view for Land Types from webhelpers2.html import HTML, tags -from wuttafarm.db.model.land import LandType, LandAsset +from wuttafarm.db.model import LandType, LandAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.forms.schema import LandTypeRef From b061959b183abd5d9a70bb8fb86ecf0d070f7c3a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 13:21:38 -0600 Subject: [PATCH 087/166] feat: refactor log models, views to use generic/common base --- .../dd6351e69233_add_generic_log_base.py | 206 ++++++++++++++++ src/wuttafarm/db/model/__init__.py | 3 +- src/wuttafarm/db/model/{logs.py => log.py} | 41 +++- src/wuttafarm/db/model/log_activity.py | 45 ++++ src/wuttafarm/importing/farmos.py | 102 +++++--- src/wuttafarm/web/views/__init__.py | 2 +- src/wuttafarm/web/views/log_types.py | 90 ------- src/wuttafarm/web/views/logs.py | 223 ++++++++++++++++++ src/wuttafarm/web/views/logs_activity.py | 61 +---- 9 files changed, 580 insertions(+), 193 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py rename src/wuttafarm/db/model/{logs.py => log.py} (76%) create mode 100644 src/wuttafarm/db/model/log_activity.py delete mode 100644 src/wuttafarm/web/views/log_types.py create mode 100644 src/wuttafarm/web/views/logs.py diff --git a/src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py b/src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py new file mode 100644 index 0000000..0b82da9 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/dd6351e69233_add_generic_log_base.py @@ -0,0 +1,206 @@ +"""add generic log base + +Revision ID: dd6351e69233 +Revises: b8cd4a8f981f +Create Date: 2026-02-18 12:09:05.200134 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "dd6351e69233" +down_revision: Union[str, None] = "b8cd4a8f981f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log + op.create_table( + "log", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_type", sa.String(length=100), nullable=False), + sa.Column("message", sa.String(length=255), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["log_type"], ["log_type.drupal_id"], name=op.f("fk_log_log_type_log_type") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_log_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_log_farmos_uuid")), + ) + op.create_table( + "log_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_type", sa.String(length=100), autoincrement=False, nullable=True + ), + sa.Column("message", sa.String(length=255), autoincrement=False, nullable=True), + sa.Column("timestamp", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("status", sa.String(length=20), autoincrement=False, nullable=True), + sa.Column("notes", sa.Text(), autoincrement=False, nullable=True), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint("uuid", "transaction_id", name=op.f("pk_log_version")), + ) + op.create_index( + op.f("ix_log_version_end_transaction_id"), + "log_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_version_operation_type"), + "log_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_version_pk_transaction_id", + "log_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_version_pk_validity", + "log_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_version_transaction_id"), + "log_version", + ["transaction_id"], + unique=False, + ) + + # log_activity + op.drop_column("log_activity_version", "status") + op.drop_column("log_activity_version", "farmos_uuid") + op.drop_column("log_activity_version", "timestamp") + op.drop_column("log_activity_version", "message") + op.drop_column("log_activity_version", "drupal_id") + op.drop_column("log_activity_version", "notes") + op.drop_constraint( + op.f("uq_log_activity_drupal_id"), "log_activity", type_="unique" + ) + op.drop_constraint( + op.f("uq_log_activity_farmos_uuid"), "log_activity", type_="unique" + ) + op.create_foreign_key( + op.f("fk_log_activity_uuid_log"), "log_activity", "log", ["uuid"], ["uuid"] + ) + op.drop_column("log_activity", "status") + op.drop_column("log_activity", "farmos_uuid") + op.drop_column("log_activity", "timestamp") + op.drop_column("log_activity", "message") + op.drop_column("log_activity", "drupal_id") + op.drop_column("log_activity", "notes") + + +def downgrade() -> None: + + # log_activity + op.add_column( + "log_activity", + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity", + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity", + sa.Column( + "message", sa.VARCHAR(length=255), autoincrement=False, nullable=False + ), + ) + op.add_column( + "log_activity", + sa.Column( + "timestamp", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + ) + op.add_column( + "log_activity", + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity", + sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=False), + ) + op.drop_constraint( + op.f("fk_log_activity_uuid_log"), "log_activity", type_="foreignkey" + ) + op.create_unique_constraint( + op.f("uq_log_activity_farmos_uuid"), + "log_activity", + ["farmos_uuid"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("uq_log_activity_drupal_id"), + "log_activity", + ["drupal_id"], + postgresql_nulls_not_distinct=False, + ) + op.add_column( + "log_activity_version", + sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity_version", + sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity_version", + sa.Column( + "message", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + ) + op.add_column( + "log_activity_version", + sa.Column( + "timestamp", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "log_activity_version", + sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True), + ) + op.add_column( + "log_activity_version", + sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=True), + ) + + # log + op.drop_index(op.f("ix_log_version_transaction_id"), table_name="log_version") + op.drop_index("ix_log_version_pk_validity", table_name="log_version") + op.drop_index("ix_log_version_pk_transaction_id", table_name="log_version") + op.drop_index(op.f("ix_log_version_operation_type"), table_name="log_version") + op.drop_index(op.f("ix_log_version_end_transaction_id"), table_name="log_version") + op.drop_table("log_version") + op.drop_table("log") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 277a92c..978ed5d 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -35,4 +35,5 @@ from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset from .asset_animal import AnimalType, AnimalAsset from .asset_group import GroupAsset -from .logs import LogType, ActivityLog +from .log import LogType, Log +from .log_activity import ActivityLog diff --git a/src/wuttafarm/db/model/logs.py b/src/wuttafarm/db/model/log.py similarity index 76% rename from src/wuttafarm/db/model/logs.py rename to src/wuttafarm/db/model/log.py index 76f7715..14afe3e 100644 --- a/src/wuttafarm/db/model/logs.py +++ b/src/wuttafarm/db/model/log.py @@ -20,11 +20,12 @@ # ################################################################################ """ -Model definition for Log Types +Model definition for Logs """ import sqlalchemy as sa from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr from wuttjamaican.db import model @@ -82,20 +83,26 @@ class LogType(model.Base): return self.name or "" -class ActivityLog(model.Base): +class Log(model.Base): """ - Represents an activity log from farmOS + Represents a base log record from farmOS """ - __tablename__ = "log_activity" + __tablename__ = "log" __versioned__ = {} __wutta_hint__ = { - "model_title": "Activity Log", - "model_title_plural": "Activity Logs", + "model_title": "Log", + "model_title_plural": "Logs", } uuid = model.uuid_column() + log_type = sa.Column( + sa.String(length=100), + sa.ForeignKey("log_type.drupal_id"), + nullable=False, + ) + message = sa.Column( sa.String(length=255), nullable=False, @@ -148,3 +155,25 @@ class ActivityLog(model.Base): def __str__(self): return self.message or "" + + +class LogMixin: + + uuid = model.uuid_fk_column("log.uuid", nullable=False, primary_key=True) + + @declared_attr + def log(cls): + return orm.relationship(Log) + + def __str__(self): + return self.message or "" + + +def add_log_proxies(subclass): + Log.make_proxy(subclass, "log", "farmos_uuid") + Log.make_proxy(subclass, "log", "drupal_id") + Log.make_proxy(subclass, "log", "log_type") + Log.make_proxy(subclass, "log", "message") + Log.make_proxy(subclass, "log", "timestamp") + Log.make_proxy(subclass, "log", "status") + Log.make_proxy(subclass, "log", "notes") diff --git a/src/wuttafarm/db/model/log_activity.py b/src/wuttafarm/db/model/log_activity.py new file mode 100644 index 0000000..bbf8154 --- /dev/null +++ b/src/wuttafarm/db/model/log_activity.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaFarm --Web app to integrate with and extend farmOS +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaFarm. +# +# WuttaFarm is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaFarm. If not, see . +# +################################################################################ +""" +Model definition for Activity Logs +""" + +from wuttjamaican.db import model + +from wuttafarm.db.model.log import LogMixin, add_log_proxies + + +class ActivityLog(LogMixin, model.Base): + """ + Represents an activity log from farmOS + """ + + __tablename__ = "log_activity" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Activity Log", + "model_title_plural": "Activity Logs", + "farmos_log_type": "activity", + } + + +add_log_proxies(ActivityLog) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index b07d06d..e421f26 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -139,43 +139,6 @@ class FromFarmOS(Importer): return self.app.make_utc(dt) -class ActivityLogImporter(FromFarmOS, ToWutta): - """ - farmOS API → WuttaFarm importer for Activity Logs - """ - - model_class = model.ActivityLog - - supported_fields = [ - "farmos_uuid", - "drupal_id", - "message", - "timestamp", - "notes", - "status", - ] - - def get_source_objects(self): - """ """ - logs = self.farmos_client.log.get("activity") - return logs["data"] - - def normalize_source_object(self, log): - """ """ - - if notes := log["attributes"]["notes"]: - notes = notes["value"] - - return { - "farmos_uuid": UUID(log["id"]), - "drupal_id": log["attributes"]["drupal_internal__id"], - "message": log["attributes"]["name"], - "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), - "notes": notes, - "status": log["attributes"]["status"], - } - - class AssetImporterBase(FromFarmOS, ToWutta): """ Base class for farmOS API → WuttaFarm asset importers @@ -320,6 +283,71 @@ class AssetImporterBase(FromFarmOS, ToWutta): return asset +class LogImporterBase(FromFarmOS, ToWutta): + """ + Base class for farmOS API → WuttaFarm log importers + """ + + def get_farmos_log_type(self): + return self.model_class.__wutta_hint__["farmos_log_type"] + + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare proxy fields + fields.extend( + [ + "farmos_uuid", + "drupal_id", + "log_type", + "message", + "timestamp", + "notes", + "status", + ] + ) + return fields + + def get_source_objects(self): + """ """ + log_type = self.get_farmos_log_type() + result = self.farmos_client.log.get(log_type) + return result["data"] + + def normalize_source_object(self, log): + """ """ + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + return { + "farmos_uuid": UUID(log["id"]), + "drupal_id": log["attributes"]["drupal_internal__id"], + "log_type": self.get_farmos_log_type(), + "message": log["attributes"]["name"], + "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), + "notes": notes, + "status": log["attributes"]["status"], + } + + +class ActivityLogImporter(LogImporterBase): + """ + farmOS API → WuttaFarm importer for Activity Logs + """ + + model_class = model.ActivityLog + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "log_type", + "message", + "timestamp", + "notes", + "status", + ] + + class AnimalAssetImporter(AssetImporterBase): """ farmOS API → WuttaFarm importer for Animals diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index e44c16e..fe42703 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -47,7 +47,7 @@ def includeme(config): config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.groups") - config.include("wuttafarm.web.views.log_types") + config.include("wuttafarm.web.views.logs") config.include("wuttafarm.web.views.logs_activity") # views for farmOS diff --git a/src/wuttafarm/web/views/log_types.py b/src/wuttafarm/web/views/log_types.py deleted file mode 100644 index 13ea35f..0000000 --- a/src/wuttafarm/web/views/log_types.py +++ /dev/null @@ -1,90 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaFarm --Web app to integrate with and extend farmOS -# Copyright © 2026 Lance Edgar -# -# This file is part of WuttaFarm. -# -# WuttaFarm is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# WuttaFarm. If not, see . -# -################################################################################ -""" -Master view for Log Types -""" - -from wuttafarm.db.model.logs import LogType -from wuttafarm.web.views import WuttaFarmMasterView - - -class LogTypeView(WuttaFarmMasterView): - """ - Master view for Log Types - """ - - model_class = LogType - route_prefix = "log_types" - url_prefix = "/log-types" - - grid_columns = [ - "name", - "description", - ] - - sort_defaults = "name" - - filter_defaults = { - "name": {"active": True, "verb": "contains"}, - } - - form_fields = [ - "name", - "description", - "farmos_uuid", - "drupal_id", - ] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - - def get_xref_buttons(self, log_type): - buttons = super().get_xref_buttons(log_type) - - if log_type.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_log_types.view", uuid=log_type.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - - -def defaults(config, **kwargs): - base = globals() - - LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) - LogTypeView.defaults(config) - - -def includeme(config): - defaults(config) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py new file mode 100644 index 0000000..fc05613 --- /dev/null +++ b/src/wuttafarm/web/views/logs.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaFarm --Web app to integrate with and extend farmOS +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaFarm. +# +# WuttaFarm is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaFarm. If not, see . +# +################################################################################ +""" +Base views for Logs +""" + +from collections import OrderedDict + +from wuttaweb.forms.schema import WuttaDictEnum +from wuttaweb.db import Session +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.db.model import LogType + + +def get_log_type_enum(config): + app = config.get_app() + model = app.model + session = Session() + log_types = OrderedDict() + query = session.query(model.LogType).order_by(model.LogType.name) + for log_type in query: + log_types[log_type.drupal_id] = log_type.name + return log_types + + +class LogTypeView(WuttaFarmMasterView): + """ + Master view for Log Types + """ + + model_class = LogType + route_prefix = "log_types" + url_prefix = "/log-types" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, log_type): + buttons = super().get_xref_buttons(log_type) + + if log_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_log_types.view", uuid=log_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +class LogMasterView(WuttaFarmMasterView): + """ + Base class for Asset master views + """ + + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "assets", + "location", + "quantity", + "is_group_assignment", + ] + + sort_defaults = ("timestamp", "desc") + + filter_defaults = { + "message": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "message", + "timestamp", + "assets", + "location", + "quantity", + "notes", + "status", + "log_type", + "owners", + "is_group_assignment", + "farmos_uuid", + "drupal_id", + ] + + def get_query(self, session=None): + """ """ + model = self.app.model + model_class = self.get_model_class() + session = session or self.Session() + return session.query(model_class).join(model.Log) + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + + # status + g.set_sorter("status", model.Log.status) + g.set_filter("status", model.Log.status) + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", model.Log.drupal_id) + g.set_filter("drupal_id", model.Log.drupal_id) + + # timestamp + g.set_renderer("timestamp", "date") + g.set_link("timestamp") + g.set_sorter("timestamp", model.Log.timestamp) + g.set_filter("timestamp", model.Log.timestamp) + + # message + g.set_link("message") + g.set_sorter("message", model.Log.message) + g.set_filter("message", model.Log.message) + + def configure_form(self, form): + f = form + super().configure_form(f) + + # timestamp + # TODO: the widget should be automatic (assn proxy field) + f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) + + # log_type + if self.creating: + f.remove("log_type") + else: + f.set_node( + "log_type", + WuttaDictEnum(self.request, get_log_type_enum(self.config)), + ) + f.set_readonly("log_type") + + # notes + f.set_widget("notes", "notes") + + def get_farmos_url(self, log): + return self.app.get_farmos_url(f"/log/{log.drupal_id}") + + def get_xref_buttons(self, log): + buttons = super().get_xref_buttons(log) + + if log.farmos_uuid: + + # TODO + route = None + if log.log_type == "activity": + route = "farmos_logs_activity.view" + + if route: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url(route, uuid=log.farmos_uuid), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) + LogTypeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/logs_activity.py b/src/wuttafarm/web/views/logs_activity.py index a2b2154..d4333f5 100644 --- a/src/wuttafarm/web/views/logs_activity.py +++ b/src/wuttafarm/web/views/logs_activity.py @@ -23,11 +23,11 @@ Master view for Activity Logs """ -from wuttafarm.db.model.logs import ActivityLog -from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.web.views.logs import LogMasterView +from wuttafarm.db.model import ActivityLog -class ActivityLogView(WuttaFarmMasterView): +class ActivityLogView(LogMasterView): """ Master view for Activity Logs """ @@ -38,61 +38,6 @@ class ActivityLogView(WuttaFarmMasterView): farmos_refurl_path = "/logs/activity" - grid_columns = [ - "message", - "timestamp", - "status", - ] - - sort_defaults = ("timestamp", "desc") - - filter_defaults = { - "message": {"active": True, "verb": "contains"}, - } - - form_fields = [ - "message", - "timestamp", - "status", - "notes", - "farmos_uuid", - "drupal_id", - ] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # message - g.set_link("message") - - def configure_form(self, form): - f = form - super().configure_form(f) - - # notes - f.set_widget("notes", "notes") - - def get_farmos_url(self, log): - return self.app.get_farmos_url(f"/log/{log.drupal_id}") - - def get_xref_buttons(self, log): - buttons = super().get_xref_buttons(log) - - if log.farmos_uuid: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url( - "farmos_logs_activity.view", uuid=log.farmos_uuid - ), - icon_left="eye", - ) - ) - - return buttons - def defaults(config, **kwargs): base = globals() From 2e0ec733178d9e4a72a4298bc15f39da52a592a8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 18:36:12 -0600 Subject: [PATCH 088/166] feat: add more assets (plant) and logs (harvest, medical, observation) --- ...e46f48a6_add_plant_assets_and_more_logs.py | 596 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 6 +- src/wuttafarm/db/model/asset_plant.py | 132 ++++ src/wuttafarm/db/model/log.py | 28 +- src/wuttafarm/db/model/log_activity.py | 2 +- src/wuttafarm/db/model/log_harvest.py | 45 ++ src/wuttafarm/db/model/log_medical.py | 45 ++ src/wuttafarm/db/model/log_observation.py | 45 ++ src/wuttafarm/enum.py | 9 + src/wuttafarm/farmos/importing/model.py | 110 ++++ src/wuttafarm/farmos/importing/wuttafarm.py | 63 ++ src/wuttafarm/importing/farmos.py | 418 +++++++++--- src/wuttafarm/web/forms/schema.py | 53 ++ src/wuttafarm/web/forms/widgets.py | 92 +++ src/wuttafarm/web/menus.py | 61 +- src/wuttafarm/web/views/__init__.py | 4 + src/wuttafarm/web/views/assets.py | 59 +- src/wuttafarm/web/views/farmos/__init__.py | 4 + src/wuttafarm/web/views/farmos/groups.py | 5 +- src/wuttafarm/web/views/farmos/logs.py | 142 +++++ .../web/views/farmos/logs_activity.py | 111 +--- .../web/views/farmos/logs_harvest.py | 53 ++ .../web/views/farmos/logs_medical.py | 53 ++ .../web/views/farmos/logs_observation.py | 53 ++ src/wuttafarm/web/views/farmos/plants.py | 362 +++++++++++ src/wuttafarm/web/views/logs.py | 149 ++++- src/wuttafarm/web/views/logs_activity.py | 2 +- src/wuttafarm/web/views/logs_harvest.py | 50 ++ src/wuttafarm/web/views/logs_medical.py | 50 ++ src/wuttafarm/web/views/logs_observation.py | 50 ++ src/wuttafarm/web/views/plants.py | 201 ++++++ 31 files changed, 2847 insertions(+), 206 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/11e0e46f48a6_add_plant_assets_and_more_logs.py create mode 100644 src/wuttafarm/db/model/asset_plant.py create mode 100644 src/wuttafarm/db/model/log_harvest.py create mode 100644 src/wuttafarm/db/model/log_medical.py create mode 100644 src/wuttafarm/db/model/log_observation.py create mode 100644 src/wuttafarm/web/views/farmos/logs.py create mode 100644 src/wuttafarm/web/views/farmos/logs_harvest.py create mode 100644 src/wuttafarm/web/views/farmos/logs_medical.py create mode 100644 src/wuttafarm/web/views/farmos/logs_observation.py create mode 100644 src/wuttafarm/web/views/farmos/plants.py create mode 100644 src/wuttafarm/web/views/logs_harvest.py create mode 100644 src/wuttafarm/web/views/logs_medical.py create mode 100644 src/wuttafarm/web/views/logs_observation.py create mode 100644 src/wuttafarm/web/views/plants.py diff --git a/src/wuttafarm/db/alembic/versions/11e0e46f48a6_add_plant_assets_and_more_logs.py b/src/wuttafarm/db/alembic/versions/11e0e46f48a6_add_plant_assets_and_more_logs.py new file mode 100644 index 0000000..7df55c4 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/11e0e46f48a6_add_plant_assets_and_more_logs.py @@ -0,0 +1,596 @@ +"""add Plant Assets and more Logs + +Revision ID: 11e0e46f48a6 +Revises: dd6351e69233 +Create Date: 2026-02-18 18:11:46.536930 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "11e0e46f48a6" +down_revision: Union[str, None] = "dd6351e69233" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # plant_type + op.create_table( + "plant_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_plant_type")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_plant_type_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_plant_type_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_plant_type_name")), + ) + op.create_table( + "plant_type_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_plant_type_version") + ), + ) + op.create_index( + op.f("ix_plant_type_version_end_transaction_id"), + "plant_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_plant_type_version_operation_type"), + "plant_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_plant_type_version_pk_transaction_id", + "plant_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_plant_type_version_pk_validity", + "plant_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_plant_type_version_transaction_id"), + "plant_type_version", + ["transaction_id"], + unique=False, + ) + + # asset_plant + op.create_table( + "asset_plant", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["uuid"], ["asset.uuid"], name=op.f("fk_asset_plant_uuid_asset") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_plant")), + ) + op.create_table( + "asset_plant_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_plant_version") + ), + ) + op.create_index( + op.f("ix_asset_plant_version_end_transaction_id"), + "asset_plant_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_plant_version_operation_type"), + "asset_plant_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_plant_version_pk_transaction_id", + "asset_plant_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_plant_version_pk_validity", + "asset_plant_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_plant_version_transaction_id"), + "asset_plant_version", + ["transaction_id"], + unique=False, + ) + + # asset_plant_plant_type + op.create_table( + "asset_plant_plant_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("plant_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("plant_type_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["plant_asset_uuid"], + ["asset_plant.uuid"], + name=op.f("fk_asset_plant_plant_type_plant_asset_uuid_asset_plant"), + ), + sa.ForeignKeyConstraint( + ["plant_type_uuid"], + ["plant_type.uuid"], + name=op.f("fk_asset_plant_plant_type_plant_type_uuid_plant_type"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_plant_plant_type")), + ) + op.create_table( + "asset_plant_plant_type_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "plant_asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "plant_type_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_plant_plant_type_version") + ), + ) + op.create_index( + op.f("ix_asset_plant_plant_type_version_end_transaction_id"), + "asset_plant_plant_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_plant_plant_type_version_operation_type"), + "asset_plant_plant_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_plant_plant_type_version_pk_transaction_id", + "asset_plant_plant_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_plant_plant_type_version_pk_validity", + "asset_plant_plant_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_plant_plant_type_version_transaction_id"), + "asset_plant_plant_type_version", + ["transaction_id"], + unique=False, + ) + + # log_asset + op.create_table( + "log_asset", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_uuid"], ["asset.uuid"], name=op.f("fk_log_asset_asset_uuid_asset") + ), + sa.ForeignKeyConstraint( + ["log_uuid"], ["log.uuid"], name=op.f("fk_log_asset_log_uuid_log") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_asset")), + ) + op.create_table( + "log_asset_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_asset_version") + ), + ) + op.create_index( + op.f("ix_log_asset_version_end_transaction_id"), + "log_asset_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_asset_version_operation_type"), + "log_asset_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_asset_version_pk_transaction_id", + "log_asset_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_asset_version_pk_validity", + "log_asset_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_asset_version_transaction_id"), + "log_asset_version", + ["transaction_id"], + unique=False, + ) + + # log_harvest + op.create_table( + "log_harvest", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["uuid"], ["log.uuid"], name=op.f("fk_log_harvest_uuid_log") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_harvest")), + ) + op.create_table( + "log_harvest_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_harvest_version") + ), + ) + op.create_index( + op.f("ix_log_harvest_version_end_transaction_id"), + "log_harvest_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_harvest_version_operation_type"), + "log_harvest_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_harvest_version_pk_transaction_id", + "log_harvest_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_harvest_version_pk_validity", + "log_harvest_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_harvest_version_transaction_id"), + "log_harvest_version", + ["transaction_id"], + unique=False, + ) + + # log_medical + op.create_table( + "log_medical", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["uuid"], ["log.uuid"], name=op.f("fk_log_medical_uuid_log") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_medical")), + ) + op.create_table( + "log_medical_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_medical_version") + ), + ) + op.create_index( + op.f("ix_log_medical_version_end_transaction_id"), + "log_medical_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_medical_version_operation_type"), + "log_medical_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_medical_version_pk_transaction_id", + "log_medical_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_medical_version_pk_validity", + "log_medical_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_medical_version_transaction_id"), + "log_medical_version", + ["transaction_id"], + unique=False, + ) + + # log_observation + op.create_table( + "log_observation", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["uuid"], ["log.uuid"], name=op.f("fk_log_observation_uuid_log") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_observation")), + ) + op.create_table( + "log_observation_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_observation_version") + ), + ) + op.create_index( + op.f("ix_log_observation_version_end_transaction_id"), + "log_observation_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_observation_version_operation_type"), + "log_observation_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_observation_version_pk_transaction_id", + "log_observation_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_observation_version_pk_validity", + "log_observation_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_observation_version_transaction_id"), + "log_observation_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_observation + op.drop_index( + op.f("ix_log_observation_version_transaction_id"), + table_name="log_observation_version", + ) + op.drop_index( + "ix_log_observation_version_pk_validity", table_name="log_observation_version" + ) + op.drop_index( + "ix_log_observation_version_pk_transaction_id", + table_name="log_observation_version", + ) + op.drop_index( + op.f("ix_log_observation_version_operation_type"), + table_name="log_observation_version", + ) + op.drop_index( + op.f("ix_log_observation_version_end_transaction_id"), + table_name="log_observation_version", + ) + op.drop_table("log_observation_version") + op.drop_table("log_observation") + + # log_medical + op.drop_index( + op.f("ix_log_medical_version_transaction_id"), table_name="log_medical_version" + ) + op.drop_index( + "ix_log_medical_version_pk_validity", table_name="log_medical_version" + ) + op.drop_index( + "ix_log_medical_version_pk_transaction_id", table_name="log_medical_version" + ) + op.drop_index( + op.f("ix_log_medical_version_operation_type"), table_name="log_medical_version" + ) + op.drop_index( + op.f("ix_log_medical_version_end_transaction_id"), + table_name="log_medical_version", + ) + op.drop_table("log_medical_version") + op.drop_table("log_medical") + + # log_harvest + op.drop_index( + op.f("ix_log_harvest_version_transaction_id"), table_name="log_harvest_version" + ) + op.drop_index( + "ix_log_harvest_version_pk_validity", table_name="log_harvest_version" + ) + op.drop_index( + "ix_log_harvest_version_pk_transaction_id", table_name="log_harvest_version" + ) + op.drop_index( + op.f("ix_log_harvest_version_operation_type"), table_name="log_harvest_version" + ) + op.drop_index( + op.f("ix_log_harvest_version_end_transaction_id"), + table_name="log_harvest_version", + ) + op.drop_table("log_harvest_version") + op.drop_table("log_harvest") + + # log_asset + op.drop_index( + op.f("ix_log_asset_version_transaction_id"), table_name="log_asset_version" + ) + op.drop_index("ix_log_asset_version_pk_validity", table_name="log_asset_version") + op.drop_index( + "ix_log_asset_version_pk_transaction_id", table_name="log_asset_version" + ) + op.drop_index( + op.f("ix_log_asset_version_operation_type"), table_name="log_asset_version" + ) + op.drop_index( + op.f("ix_log_asset_version_end_transaction_id"), table_name="log_asset_version" + ) + op.drop_table("log_asset_version") + op.drop_table("log_asset") + + # asset_plant_plant_type + op.drop_index( + op.f("ix_asset_plant_plant_type_version_transaction_id"), + table_name="asset_plant_plant_type_version", + ) + op.drop_index( + "ix_asset_plant_plant_type_version_pk_validity", + table_name="asset_plant_plant_type_version", + ) + op.drop_index( + "ix_asset_plant_plant_type_version_pk_transaction_id", + table_name="asset_plant_plant_type_version", + ) + op.drop_index( + op.f("ix_asset_plant_plant_type_version_operation_type"), + table_name="asset_plant_plant_type_version", + ) + op.drop_index( + op.f("ix_asset_plant_plant_type_version_end_transaction_id"), + table_name="asset_plant_plant_type_version", + ) + op.drop_table("asset_plant_plant_type_version") + op.drop_table("asset_plant_plant_type") + + # asset_plant + op.drop_index( + op.f("ix_asset_plant_version_transaction_id"), table_name="asset_plant_version" + ) + op.drop_index( + "ix_asset_plant_version_pk_validity", table_name="asset_plant_version" + ) + op.drop_index( + "ix_asset_plant_version_pk_transaction_id", table_name="asset_plant_version" + ) + op.drop_index( + op.f("ix_asset_plant_version_operation_type"), table_name="asset_plant_version" + ) + op.drop_index( + op.f("ix_asset_plant_version_end_transaction_id"), + table_name="asset_plant_version", + ) + op.drop_table("asset_plant_version") + op.drop_table("asset_plant") + + # plant_type + op.drop_index( + op.f("ix_plant_type_version_transaction_id"), table_name="plant_type_version" + ) + op.drop_index("ix_plant_type_version_pk_validity", table_name="plant_type_version") + op.drop_index( + "ix_plant_type_version_pk_transaction_id", table_name="plant_type_version" + ) + op.drop_index( + op.f("ix_plant_type_version_operation_type"), table_name="plant_type_version" + ) + op.drop_index( + op.f("ix_plant_type_version_end_transaction_id"), + table_name="plant_type_version", + ) + op.drop_table("plant_type_version") + op.drop_table("plant_type") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 978ed5d..f9eb790 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -35,5 +35,9 @@ from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset from .asset_animal import AnimalType, AnimalAsset from .asset_group import GroupAsset -from .log import LogType, Log +from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType +from .log import LogType, Log, LogAsset from .log_activity import ActivityLog +from .log_harvest import HarvestLog +from .log_medical import MedicalLog +from .log_observation import ObservationLog diff --git a/src/wuttafarm/db/model/asset_plant.py b/src/wuttafarm/db/model/asset_plant.py new file mode 100644 index 0000000..5f10e7c --- /dev/null +++ b/src/wuttafarm/db/model/asset_plant.py @@ -0,0 +1,132 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Plant Assets +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies + + +class PlantType(model.Base): + """ + Represents a "plant type" (taxonomy term) from farmOS + """ + + __tablename__ = "plant_type" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Plant Type", + "model_title_plural": "Plant Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the plant type. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional description for the plant type. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the plant type within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the plant type. + """, + ) + + def __str__(self): + return self.name or "" + + +class PlantAsset(AssetMixin, model.Base): + """ + Represents a plant asset from farmOS + """ + + __tablename__ = "asset_plant" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Plant Asset", + "model_title_plural": "Plant Assets", + "farmos_asset_type": "plant", + } + + _plant_types = orm.relationship( + "PlantAssetPlantType", + back_populates="plant_asset", + ) + + +add_asset_proxies(PlantAsset) + + +class PlantAssetPlantType(model.Base): + """ + Associates one or more plant types with a plant asset. + """ + + __tablename__ = "asset_plant_plant_type" + __versioned__ = {} + + uuid = model.uuid_column() + + plant_asset_uuid = model.uuid_fk_column("asset_plant.uuid", nullable=False) + plant_asset = orm.relationship( + PlantAsset, + foreign_keys=plant_asset_uuid, + back_populates="_plant_types", + ) + + plant_type_uuid = model.uuid_fk_column("plant_type.uuid", nullable=False) + plant_type = orm.relationship( + PlantType, + doc=""" + Reference to the plant type. + """, + ) diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 14afe3e..a86c447 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -92,7 +92,7 @@ class Log(model.Base): __versioned__ = {} __wutta_hint__ = { "model_title": "Log", - "model_title_plural": "Logs", + "model_title_plural": "All Logs", } uuid = model.uuid_column() @@ -153,6 +153,8 @@ class Log(model.Base): """, ) + _assets = orm.relationship("LogAsset", back_populates="log") + def __str__(self): return self.message or "" @@ -177,3 +179,27 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "timestamp") Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") + + +class LogAsset(model.Base): + """ + Represents a "log's asset relationship" from farmOS. + """ + + __tablename__ = "log_asset" + __versioned__ = {} + + uuid = model.uuid_column() + + log_uuid = model.uuid_fk_column("log.uuid", nullable=False) + log = orm.relationship( + Log, + foreign_keys=log_uuid, + back_populates="_assets", + ) + + asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + asset = orm.relationship( + "Asset", + foreign_keys=asset_uuid, + ) diff --git a/src/wuttafarm/db/model/log_activity.py b/src/wuttafarm/db/model/log_activity.py index bbf8154..2f5f6e5 100644 --- a/src/wuttafarm/db/model/log_activity.py +++ b/src/wuttafarm/db/model/log_activity.py @@ -30,7 +30,7 @@ from wuttafarm.db.model.log import LogMixin, add_log_proxies class ActivityLog(LogMixin, model.Base): """ - Represents an activity log from farmOS + Represents an Activity Log from farmOS """ __tablename__ = "log_activity" diff --git a/src/wuttafarm/db/model/log_harvest.py b/src/wuttafarm/db/model/log_harvest.py new file mode 100644 index 0000000..35c3105 --- /dev/null +++ b/src/wuttafarm/db/model/log_harvest.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaFarm --Web app to integrate with and extend farmOS +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaFarm. +# +# WuttaFarm is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaFarm. If not, see . +# +################################################################################ +""" +Model definition for Harvest Logs +""" + +from wuttjamaican.db import model + +from wuttafarm.db.model.log import LogMixin, add_log_proxies + + +class HarvestLog(LogMixin, model.Base): + """ + Represents a Harvest Log from farmOS + """ + + __tablename__ = "log_harvest" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Harvest Log", + "model_title_plural": "Harvest Logs", + "farmos_log_type": "harvest", + } + + +add_log_proxies(HarvestLog) diff --git a/src/wuttafarm/db/model/log_medical.py b/src/wuttafarm/db/model/log_medical.py new file mode 100644 index 0000000..439ee3b --- /dev/null +++ b/src/wuttafarm/db/model/log_medical.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaFarm --Web app to integrate with and extend farmOS +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaFarm. +# +# WuttaFarm is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaFarm. If not, see . +# +################################################################################ +""" +Model definition for Medical Logs +""" + +from wuttjamaican.db import model + +from wuttafarm.db.model.log import LogMixin, add_log_proxies + + +class MedicalLog(LogMixin, model.Base): + """ + Represents a Medical Log from farmOS + """ + + __tablename__ = "log_medical" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Medical Log", + "model_title_plural": "Medical Logs", + "farmos_log_type": "medical", + } + + +add_log_proxies(MedicalLog) diff --git a/src/wuttafarm/db/model/log_observation.py b/src/wuttafarm/db/model/log_observation.py new file mode 100644 index 0000000..ab89c3f --- /dev/null +++ b/src/wuttafarm/db/model/log_observation.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaFarm --Web app to integrate with and extend farmOS +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaFarm. +# +# WuttaFarm is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaFarm. If not, see . +# +################################################################################ +""" +Model definition for Observation Logs +""" + +from wuttjamaican.db import model + +from wuttafarm.db.model.log import LogMixin, add_log_proxies + + +class ObservationLog(LogMixin, model.Base): + """ + Represents a Observation Log from farmOS + """ + + __tablename__ = "log_observation" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Observation Log", + "model_title_plural": "Observation Logs", + "farmos_log_type": "observation", + } + + +add_log_proxies(ObservationLog) diff --git a/src/wuttafarm/enum.py b/src/wuttafarm/enum.py index 41bf597..03181b9 100644 --- a/src/wuttafarm/enum.py +++ b/src/wuttafarm/enum.py @@ -34,3 +34,12 @@ ANIMAL_SEX = OrderedDict( ("F", "Female"), ] ) + + +LOG_STATUS = OrderedDict( + [ + ("pending", "Pending"), + ("done", "Done"), + ("abandoned", "Abandoned"), + ] +) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 6c3f5a0..e114746 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -363,3 +363,113 @@ class StructureAssetImporter(ToFarmOSAsset): payload["attributes"].update(attrs) return payload + + +############################## +# log importers +############################## + + +class ToFarmOSLog(ToFarmOS): + """ + Base class for log data importer targeting the farmOS API. + """ + + farmos_log_type = None + + supported_fields = [ + "uuid", + "name", + "notes", + ] + + def get_target_objects(self, **kwargs): + result = self.farmos_client.log.get(self.farmos_log_type) + return result["data"] + + def get_target_object(self, key): + + # fetch from cache, if applicable + if self.caches_target: + return super().get_target_object(key) + + # okay now must fetch via API + if self.get_keys() != ["uuid"]: + raise ValueError("must use uuid key for this to work") + uuid = key[0] + + try: + log = self.farmos_client.log.get_id(self.farmos_log_type, str(uuid)) + except requests.HTTPError as exc: + if exc.response.status_code == 404: + return None + raise + return log["data"] + + def create_target_object(self, key, source_data): + if source_data.get("__ignoreme__"): + return None + if self.dry_run: + return source_data + + payload = self.get_log_payload(source_data) + result = self.farmos_client.log.send(self.farmos_log_type, payload) + normal = self.normalize_target_object(result["data"]) + normal["_new_object"] = result["data"] + return normal + + def update_target_object(self, asset, source_data, target_data=None): + if self.dry_run: + return asset + + payload = self.get_log_payload(source_data) + payload["id"] = str(source_data["uuid"]) + result = self.farmos_client.log.send(self.farmos_log_type, payload) + return self.normalize_target_object(result["data"]) + + def normalize_target_object(self, log): + + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + return { + "uuid": UUID(log["id"]), + "name": log["attributes"]["name"], + "notes": notes, + } + + def get_log_payload(self, source_data): + + attrs = {} + if "name" in self.fields: + attrs["name"] = source_data["name"] + if "notes" in self.fields: + attrs["notes"] = {"value": source_data["notes"]} + + payload = {"attributes": attrs} + + return payload + + +class ActivityLogImporter(ToFarmOSLog): + + model_title = "ActivityLog" + farmos_log_type = "activity" + + +class HarvestLogImporter(ToFarmOSLog): + + model_title = "HarvestLog" + farmos_log_type = "harvest" + + +class MedicalLogImporter(ToFarmOSLog): + + model_title = "MedicalLog" + farmos_log_type = "medical" + + +class ObservationLogImporter(ToFarmOSLog): + + model_title = "ObservationLog" + farmos_log_type = "observation" diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 8ef8a77..0718614 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -98,6 +98,10 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter + importers["ActivityLog"] = ActivityLogImporter + importers["HarvestLog"] = HarvestLogImporter + importers["MedicalLog"] = MedicalLogImporter + importers["ObservationLog"] = ObservationLogImporter return importers @@ -261,3 +265,62 @@ class StructureAssetImporter( "archived": structure.archived, "_src_object": structure, } + + +############################## +# log importers +############################## + + +class FromWuttaFarmLog(FromWuttaFarm): + """ + Base class for WuttaFarm -> farmOS log importers + """ + + supported_fields = [ + "uuid", + "name", + "notes", + ] + + def normalize_source_object(self, log): + return { + "uuid": log.farmos_uuid or self.app.make_true_uuid(), + "name": log.message, + "notes": log.notes, + "_src_object": log, + } + + +class ActivityLogImporter(FromWuttaFarmLog, farmos_importing.model.ActivityLogImporter): + """ + WuttaFarm → farmOS API exporter for Activity Logs + """ + + source_model_class = model.ActivityLog + + +class HarvestLogImporter(FromWuttaFarmLog, farmos_importing.model.HarvestLogImporter): + """ + WuttaFarm → farmOS API exporter for Harvest Logs + """ + + source_model_class = model.HarvestLog + + +class MedicalLogImporter(FromWuttaFarmLog, farmos_importing.model.MedicalLogImporter): + """ + WuttaFarm → farmOS API exporter for Medical Logs + """ + + source_model_class = model.MedicalLog + + +class ObservationLogImporter( + FromWuttaFarmLog, farmos_importing.model.ObservationLogImporter +): + """ + WuttaFarm → farmOS API exporter for Observation Logs + """ + + source_model_class = model.ObservationLog diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index e421f26..bde283f 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -104,8 +104,13 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter + importers["PlantType"] = PlantTypeImporter + importers["PlantAsset"] = PlantAssetImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter + importers["HarvestLog"] = HarvestLogImporter + importers["MedicalLog"] = MedicalLogImporter + importers["ObservationLog"] = ObservationLogImporter return importers @@ -144,6 +149,9 @@ class AssetImporterBase(FromFarmOS, ToWutta): Base class for farmOS API → WuttaFarm asset importers """ + def get_farmos_asset_type(self): + return self.model_class.__wutta_hint__["farmos_asset_type"] + def get_simple_fields(self): """ """ fields = list(super().get_simple_fields()) @@ -174,6 +182,12 @@ class AssetImporterBase(FromFarmOS, ToWutta): ) return fields + def get_source_objects(self): + """ """ + asset_type = self.get_farmos_asset_type() + result = self.farmos_client.asset.get(asset_type) + return result["data"] + def normalize_source_data(self, **kwargs): """ """ data = super().normalize_source_data(**kwargs) @@ -283,71 +297,6 @@ class AssetImporterBase(FromFarmOS, ToWutta): return asset -class LogImporterBase(FromFarmOS, ToWutta): - """ - Base class for farmOS API → WuttaFarm log importers - """ - - def get_farmos_log_type(self): - return self.model_class.__wutta_hint__["farmos_log_type"] - - def get_simple_fields(self): - """ """ - fields = list(super().get_simple_fields()) - # nb. must explicitly declare proxy fields - fields.extend( - [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "notes", - "status", - ] - ) - return fields - - def get_source_objects(self): - """ """ - log_type = self.get_farmos_log_type() - result = self.farmos_client.log.get(log_type) - return result["data"] - - def normalize_source_object(self, log): - """ """ - if notes := log["attributes"]["notes"]: - notes = notes["value"] - - return { - "farmos_uuid": UUID(log["id"]), - "drupal_id": log["attributes"]["drupal_internal__id"], - "log_type": self.get_farmos_log_type(), - "message": log["attributes"]["name"], - "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), - "notes": notes, - "status": log["attributes"]["status"], - } - - -class ActivityLogImporter(LogImporterBase): - """ - farmOS API → WuttaFarm importer for Activity Logs - """ - - model_class = model.ActivityLog - - supported_fields = [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "notes", - "status", - ] - - class AnimalAssetImporter(AssetImporterBase): """ farmOS API → WuttaFarm importer for Animals @@ -604,12 +553,12 @@ class LandTypeImporter(FromFarmOS, ToWutta): } -class LogTypeImporter(FromFarmOS, ToWutta): +class PlantTypeImporter(FromFarmOS, ToWutta): """ - farmOS API → WuttaFarm importer for Log Types + farmOS API → WuttaFarm importer for Plant Types """ - model_class = model.LogType + model_class = model.PlantType supported_fields = [ "farmos_uuid", @@ -620,19 +569,112 @@ class LogTypeImporter(FromFarmOS, ToWutta): def get_source_objects(self): """ """ - log_types = self.farmos_client.resource.get("log_type") - return log_types["data"] + result = self.farmos_client.resource.get("taxonomy_term", "plant_type") + return result["data"] - def normalize_source_object(self, log_type): + def normalize_source_object(self, plant_type): """ """ return { - "farmos_uuid": UUID(log_type["id"]), - "drupal_id": log_type["attributes"]["drupal_internal__id"], - "name": log_type["attributes"]["label"], - "description": log_type["attributes"]["description"], + "farmos_uuid": UUID(plant_type["id"]), + "drupal_id": plant_type["attributes"]["drupal_internal__tid"], + "name": plant_type["attributes"]["name"], + "description": plant_type["attributes"]["description"], } +class PlantAssetImporter(AssetImporterBase): + """ + farmOS API → WuttaFarm importer for Plant Assets + """ + + model_class = model.PlantAsset + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "asset_type", + "asset_name", + "plant_types", + "notes", + "archived", + "image_url", + "thumbnail_url", + ] + + def setup(self): + super().setup() + model = self.app.model + + self.plant_types_by_farmos_uuid = {} + for plant_type in self.target_session.query(model.PlantType): + if plant_type.farmos_uuid: + self.plant_types_by_farmos_uuid[plant_type.farmos_uuid] = plant_type + + def normalize_source_object(self, plant): + """ """ + plant_types = None + if relationships := plant.get("relationships"): + + if plant_type := relationships.get("plant_type"): + plant_types = [] + for plant_type in plant_type["data"]: + if wf_plant_type := self.plant_types_by_farmos_uuid.get( + UUID(plant_type["id"]) + ): + plant_types.append(wf_plant_type.uuid) + else: + log.warning("plant type not found: %s", plant_type["id"]) + + data = self.normalize_asset(plant) + data.update( + { + "asset_type": "plant", + "plant_types": plant_types, + } + ) + return data + + def normalize_target_object(self, plant): + data = super().normalize_target_object(plant) + + if "plant_types" in self.fields: + data["plant_types"] = [t.plant_type_uuid for t in plant._plant_types] + + return data + + def update_target_object(self, plant, source_data, target_data=None): + model = self.app.model + plant = super().update_target_object(plant, source_data, target_data) + + if "plant_types" in self.fields: + if ( + not target_data + or target_data["plant_types"] != source_data["plant_types"] + ): + + for uuid in source_data["plant_types"]: + if not target_data or uuid not in target_data["plant_types"]: + self.target_session.flush() + plant._plant_types.append( + model.PlantAssetPlantType(plant_type_uuid=uuid) + ) + + if target_data: + for uuid in target_data["plant_types"]: + if uuid not in source_data["plant_types"]: + plant_type = ( + self.target_session.query(model.PlantAssetPlantType) + .filter(model.PlantAssetPlantType.plant_asset == plant) + .filter( + model.PlantAssetPlantType.plant_type_uuid == uuid + ) + .one() + ) + self.target_session.delete(plant_type) + + return plant + + class StructureAssetImporter(AssetImporterBase): """ farmOS API → WuttaFarm importer for Structure Assets @@ -768,3 +810,229 @@ class UserImporter(FromFarmOS, ToWutta): if not user.farmos_uuid: return False return True + + +############################## +# log importers +############################## + + +class LogTypeImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Log Types + """ + + model_class = model.LogType + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + log_types = self.farmos_client.resource.get("log_type") + return log_types["data"] + + def normalize_source_object(self, log_type): + """ """ + return { + "farmos_uuid": UUID(log_type["id"]), + "drupal_id": log_type["attributes"]["drupal_internal__id"], + "name": log_type["attributes"]["label"], + "description": log_type["attributes"]["description"], + } + + +class LogImporterBase(FromFarmOS, ToWutta): + """ + Base class for farmOS API → WuttaFarm log importers + """ + + def get_farmos_log_type(self): + return self.model_class.__wutta_hint__["farmos_log_type"] + + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare proxy fields + fields.extend( + [ + "farmos_uuid", + "drupal_id", + "log_type", + "message", + "timestamp", + "notes", + "status", + ] + ) + return fields + + def get_supported_fields(self): + """ """ + fields = list(super().get_supported_fields()) + fields.extend( + [ + "assets", + ] + ) + return fields + + def get_source_objects(self): + """ """ + log_type = self.get_farmos_log_type() + result = self.farmos_client.log.get(log_type) + return result["data"] + + def get_asset_type(self, asset): + return asset["type"].split("--")[1] + + def normalize_source_object(self, log): + """ """ + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + assets = None + if "assets" in self.fields: + assets = [] + for asset in log["relationships"]["asset"]["data"]: + assets.append((self.get_asset_type(asset), UUID(asset["id"]))) + + return { + "farmos_uuid": UUID(log["id"]), + "drupal_id": log["attributes"]["drupal_internal__id"], + "log_type": self.get_farmos_log_type(), + "message": log["attributes"]["name"], + "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), + "notes": notes, + "status": log["attributes"]["status"], + "assets": assets, + } + + def normalize_target_object(self, log): + data = super().normalize_target_object(log) + + if "assets" in self.fields: + data["assets"] = [ + (a.asset.asset_type, a.asset.farmos_uuid) for a in log.log._assets + ] + + return data + + def update_target_object(self, log, source_data, target_data=None): + model = self.app.model + log = super().update_target_object(log, source_data, target_data) + + if "assets" in self.fields: + if not target_data or target_data["assets"] != source_data["assets"]: + + for key in source_data["assets"]: + asset_type, farmos_uuid = key + if not target_data or key not in target_data["assets"]: + self.target_session.flush() + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + log.log._assets.append(model.LogAsset(asset=asset)) + + if target_data: + for key in target_data["assets"]: + asset_type, farmos_uuid = key + if key not in source_data["assets"]: + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + asset = ( + self.target_session.query(model.LogAsset) + .filter(model.LogAsset.log == log) + .filter(model.LogAsset.asset == asset) + .one() + ) + self.target_session.delete(asset) + + return log + + +class ActivityLogImporter(LogImporterBase): + """ + farmOS API → WuttaFarm importer for Activity Logs + """ + + model_class = model.ActivityLog + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "log_type", + "message", + "timestamp", + "notes", + "status", + "assets", + ] + + +class HarvestLogImporter(LogImporterBase): + """ + farmOS API → WuttaFarm importer for Harvest Logs + """ + + model_class = model.HarvestLog + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "log_type", + "message", + "timestamp", + "notes", + "status", + "assets", + ] + + +class MedicalLogImporter(LogImporterBase): + """ + farmOS API → WuttaFarm importer for Medical Logs + """ + + model_class = model.MedicalLog + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "log_type", + "message", + "timestamp", + "notes", + "status", + "assets", + ] + + +class ObservationLogImporter(LogImporterBase): + """ + farmOS API → WuttaFarm importer for Observation Logs + """ + + model_class = model.ObservationLog + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "log_type", + "message", + "timestamp", + "notes", + "status", + "assets", + ] diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 95b3e9d..123f662 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -74,6 +74,25 @@ class AnimalTypeType(colander.SchemaType): return AnimalTypeWidget(self.request, **kwargs) +class FarmOSPlantTypes(colander.SchemaType): + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return json.dumps(appstruct) + + def widget_maker(self, **kwargs): # pylint: disable=empty-docstring + """ """ + from wuttafarm.web.forms.widgets import FarmOSPlantTypesWidget + + return FarmOSPlantTypesWidget(self.request, **kwargs) + + class LandTypeRef(ObjectRef): """ Custom schema type for a @@ -99,6 +118,23 @@ class LandTypeRef(ObjectRef): return self.request.route_url("land_types.view", uuid=land_type.uuid) +class PlantTypeRefs(WuttaSet): + """ + Schema type for Plant Types field (on a Plant Asset). + """ + + def serialize(self, node, appstruct): + if not appstruct: + appstruct = [] + uuids = [u.hex for u in appstruct] + return json.dumps(uuids) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import PlantTypeRefsWidget + + return PlantTypeRefsWidget(self.request, **kwargs) + + class StructureType(colander.SchemaType): def __init__(self, request, *args, **kwargs): @@ -177,3 +213,20 @@ class AssetParentRefs(WuttaSet): from wuttafarm.web.forms.widgets import AssetParentRefsWidget return AssetParentRefsWidget(self.request, **kwargs) + + +class LogAssetRefs(WuttaSet): + """ + Schema type for Assets field (on a Log record) + """ + + def serialize(self, node, appstruct): + if not appstruct: + appstruct = [] + uuids = [u.hex for u in appstruct] + return json.dumps(uuids) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import LogAssetRefsWidget + + return LogAssetRefsWidget(self.request, **kwargs) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index f812ccf..d5bf5c2 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -81,6 +81,67 @@ class AnimalTypeWidget(Widget): return super().serialize(field, cstruct, **kw) +class FarmOSPlantTypesWidget(Widget): + """ + Widget to display a farmOS "plant types" field. + """ + + 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") + + links = [] + for plant_type in json.loads(cstruct): + link = tags.link_to( + plant_type["name"], + self.request.route_url( + "farmos_plant_types.view", uuid=plant_type["uuid"] + ), + ) + links.append(HTML.tag("li", c=link)) + return HTML.tag("ul", c=links) + + return super().serialize(field, cstruct, **kw) + + +class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for Plant Types field (on a Plant Asset). + """ + + def serialize(self, field, cstruct, **kw): + """ """ + model = self.app.model + session = Session() + + readonly = kw.get("readonly", self.readonly) + if readonly: + plant_types = [] + for uuid in json.loads(cstruct): + plant_type = session.get(model.PlantType, uuid) + plant_types.append( + HTML.tag( + "li", + c=tags.link_to( + str(plant_type), + self.request.route_url( + "plant_types.view", uuid=plant_type.uuid + ), + ), + ) + ) + return HTML.tag("ul", c=plant_types) + + return super().serialize(field, cstruct, **kw) + + class StructureWidget(Widget): """ Widget to display a "structure" field. @@ -166,3 +227,34 @@ class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): return HTML.tag("ul", c=parents) return super().serialize(field, cstruct, **kw) + + +class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for Assets field (on a Log record) + """ + + def serialize(self, field, cstruct, **kw): + """ """ + model = self.app.model + session = Session() + + readonly = kw.get("readonly", self.readonly) + if readonly: + assets = [] + for uuid in json.loads(cstruct): + asset = session.get(model.Asset, uuid) + assets.append( + HTML.tag( + "li", + c=tags.link_to( + str(asset), + self.request.route_url( + f"{asset.asset_type}_assets.view", uuid=asset.uuid + ), + ), + ) + ) + return HTML.tag("ul", c=assets) + + return super().serialize(field, cstruct, **kw) diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index bdd2fbf..d52a6ca 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -64,6 +64,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "land_assets", "perm": "land_assets.list", }, + { + "title": "Plant", + "route": "plant_assets", + "perm": "plant_assets.list", + }, { "title": "Structure", "route": "structure_assets", @@ -80,6 +85,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "land_types", "perm": "land_types.list", }, + { + "title": "Plant Types", + "route": "plant_types", + "perm": "plant_types.list", + }, { "title": "Structure Types", "route": "structure_types", @@ -99,9 +109,29 @@ class WuttaFarmMenuHandler(base.MenuHandler): "type": "menu", "items": [ { - "title": "Activity Logs", - "route": "activity_logs", - "perm": "activity_logs.list", + "title": "All Logs", + "route": "log", + "perm": "log.list", + }, + { + "title": "Activity", + "route": "logs_activity", + "perm": "logs_activity.list", + }, + { + "title": "Harvest", + "route": "logs_harvest", + "perm": "logs_harvest.list", + }, + { + "title": "Medical", + "route": "logs_medical", + "perm": "logs_medical.list", + }, + { + "title": "Observation", + "route": "logs_observation", + "perm": "logs_observation.list", }, {"type": "sep"}, { @@ -135,6 +165,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_groups", "perm": "farmos_groups.list", }, + { + "title": "Plants", + "route": "farmos_asset_plant", + "perm": "farmos_asset_plant.list", + }, { "title": "Structures", "route": "farmos_structures", @@ -151,12 +186,32 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_logs_activity", "perm": "farmos_logs_activity.list", }, + { + "title": "Harvest Logs", + "route": "farmos_logs_harvest", + "perm": "farmos_logs_harvest.list", + }, + { + "title": "Medical Logs", + "route": "farmos_logs_medical", + "perm": "farmos_logs_medical.list", + }, + { + "title": "Observation Logs", + "route": "farmos_logs_observation", + "perm": "farmos_logs_observation.list", + }, {"type": "sep"}, { "title": "Animal Types", "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Plant Types", + "route": "farmos_plant_types", + "perm": "farmos_plant_types.list", + }, { "title": "Structure Types", "route": "farmos_structure_types", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index fe42703..bb710a2 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -47,8 +47,12 @@ def includeme(config): config.include("wuttafarm.web.views.structures") config.include("wuttafarm.web.views.animals") config.include("wuttafarm.web.views.groups") + config.include("wuttafarm.web.views.plants") config.include("wuttafarm.web.views.logs") config.include("wuttafarm.web.views.logs_activity") + config.include("wuttafarm.web.views.logs_harvest") + config.include("wuttafarm.web.views.logs_medical") + config.include("wuttafarm.web.views.logs_observation") # views for farmOS config.include("wuttafarm.web.views.farmos") diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index dffaae7..b918839 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -29,7 +29,7 @@ from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.db import Session from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.db.model import Asset +from wuttafarm.db.model import Asset, Log from wuttafarm.web.forms.schema import AssetParentRefs from wuttafarm.web.forms.widgets import ImageWidget @@ -140,6 +140,28 @@ class AssetMasterView(WuttaFarmMasterView): "archived": {"active": True, "verb": "is_false"}, } + has_rows = True + row_model_class = Log + rows_viewable = True + + row_labels = { + "message": "Log Name", + } + + row_grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "log_type", + "assets", + "location", + "quantity", + "is_group_assignment", + ] + + rows_sort_defaults = ("timestamp", "desc") + def get_fallback_templates(self, template): templates = super().get_fallback_templates(template) @@ -265,6 +287,8 @@ class AssetMasterView(WuttaFarmMasterView): route = "farmos_groups.view" elif asset.asset_type == "land": route = "farmos_land_assets.view" + elif asset.asset_type == "plant": + route = "farmos_asset_plant.view" elif asset.asset_type == "structure": route = "farmos_structures.view" @@ -280,6 +304,39 @@ class AssetMasterView(WuttaFarmMasterView): return buttons + def get_row_grid_data(self, asset): + model = self.app.model + session = self.Session() + return ( + session.query(model.Log) + .outerjoin(model.LogAsset) + .filter(model.LogAsset.asset_uuid == asset.uuid) + ) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + model = self.app.model + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + + # message + g.set_link("message") + g.set_sorter("message", model.Log.message) + g.set_filter("message", model.Log.message) + + # timestamp + g.set_sorter("timestamp", model.Log.timestamp) + g.set_filter("timestamp", model.Log.timestamp) + + # log_type + g.set_sorter("log_type", model.Log.log_type) + g.set_filter("log_type", model.Log.log_type) + + def get_row_action_url_view(self, log, i): + return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index deacd7d..bda5d03 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -36,5 +36,9 @@ def includeme(config): config.include("wuttafarm.web.views.farmos.animal_types") config.include("wuttafarm.web.views.farmos.animals") config.include("wuttafarm.web.views.farmos.groups") + config.include("wuttafarm.web.views.farmos.plants") config.include("wuttafarm.web.views.farmos.log_types") config.include("wuttafarm.web.views.farmos.logs_activity") + config.include("wuttafarm.web.views.farmos.logs_harvest") + config.include("wuttafarm.web.views.farmos.logs_medical") + config.include("wuttafarm.web.views.farmos.logs_observation") diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index c6748c4..ddb7278 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -115,6 +115,9 @@ class GroupView(FarmOSMasterView): else: archived = group["attributes"]["status"] == "archived" + if notes := group["attributes"]["notes"]: + notes = notes["value"] + return { "uuid": group["id"], "drupal_id": group["attributes"]["drupal_internal__id"], @@ -124,7 +127,7 @@ class GroupView(FarmOSMasterView): "is_fixed": group["attributes"]["is_fixed"], "is_location": group["attributes"]["is_location"], "archived": archived, - "notes": group["attributes"]["notes"]["value"], + "notes": notes or colander.null, } def configure_form(self, form): diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py new file mode 100644 index 0000000..a3e804f --- /dev/null +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -0,0 +1,142 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Harvest Logs +""" + +import datetime + +import colander + +from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class LogMasterView(FarmOSMasterView): + """ + Base class for farmOS Log master views + """ + + farmos_log_type = None + + grid_columns = [ + "name", + "timestamp", + "status", + ] + + sort_defaults = ("timestamp", "desc") + + form_fields = [ + "name", + "timestamp", + "status", + "notes", + ] + + 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 configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # timestamp + g.set_renderer("timestamp", "datetime") + + def get_instance(self): + log = self.farmos_client.log.get_id( + self.farmos_log_type, self.request.matchdict["uuid"] + ) + self.raw_json = log + return self.normalize_log(log["data"]) + + def get_instance_title(self, log): + return log["name"] + + def normalize_log(self, log): + + if timestamp := log["attributes"]["timestamp"]: + timestamp = datetime.datetime.fromisoformat(timestamp) + timestamp = self.app.localtime(timestamp) + + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + return { + "uuid": log["id"], + "drupal_id": log["attributes"]["drupal_internal__id"], + "name": log["attributes"]["name"], + "timestamp": timestamp, + "status": log["attributes"]["status"], + "notes": notes or colander.null, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # timestamp + f.set_node("timestamp", WuttaDateTime()) + f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) + + # notes + f.set_widget("notes", "notes") + + def get_xref_buttons(self, log): + model = self.app.model + session = self.Session() + + buttons = [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/log/{log['drupal_id']}"), + target="_blank", + icon_left="external-link-alt", + ), + ] + + if wf_log := ( + session.query(model.Log) + .filter(model.Log.farmos_uuid == log["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + f"logs_{self.farmos_log_type}.view", uuid=wf_log.uuid + ), + icon_left="eye", + ) + ) + + return buttons diff --git a/src/wuttafarm/web/views/farmos/logs_activity.py b/src/wuttafarm/web/views/farmos/logs_activity.py index e966810..972ca31 100644 --- a/src/wuttafarm/web/views/farmos/logs_activity.py +++ b/src/wuttafarm/web/views/farmos/logs_activity.py @@ -20,20 +20,13 @@ # ################################################################################ """ -View for farmOS activity logs +View for farmOS Activity Logs """ -import datetime - -import colander - -from wuttaweb.forms.schema import WuttaDateTime -from wuttaweb.forms.widgets import WuttaDateTimeWidget - -from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.views.farmos.logs import LogMasterView -class ActivityLogView(FarmOSMasterView): +class ActivityLogView(LogMasterView): """ View for farmOS activity logs """ @@ -45,105 +38,9 @@ class ActivityLogView(FarmOSMasterView): route_prefix = "farmos_logs_activity" url_prefix = "/farmOS/logs/activity" + farmos_log_type = "activity" farmos_refurl_path = "/logs/activity" - grid_columns = [ - "name", - "timestamp", - "status", - ] - - sort_defaults = ("timestamp", "desc") - - form_fields = [ - "name", - "timestamp", - "status", - "notes", - ] - - def get_grid_data(self, columns=None, session=None): - logs = self.farmos_client.log.get("activity") - return [self.normalize_log(t) for t in logs["data"]] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - g.set_searchable("name") - - # timestamp - g.set_renderer("timestamp", "datetime") - - def get_instance(self): - log = self.farmos_client.log.get_id("activity", self.request.matchdict["uuid"]) - self.raw_json = log - return self.normalize_log(log["data"]) - - def get_instance_title(self, log): - return log["name"] - - def normalize_log(self, log): - - if timestamp := log["attributes"]["timestamp"]: - timestamp = datetime.datetime.fromisoformat(timestamp) - timestamp = self.app.localtime(timestamp) - - if notes := log["attributes"]["notes"]: - notes = notes["value"] - - return { - "uuid": log["id"], - "drupal_id": log["attributes"]["drupal_internal__id"], - "name": log["attributes"]["name"], - "timestamp": timestamp, - "status": log["attributes"]["status"], - "notes": notes or colander.null, - } - - def configure_form(self, form): - f = form - super().configure_form(f) - - # timestamp - f.set_node("timestamp", WuttaDateTime()) - f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) - - # notes - f.set_widget("notes", "notes") - - def get_xref_buttons(self, log): - model = self.app.model - session = self.Session() - - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url(f"/log/{log['drupal_id']}"), - target="_blank", - icon_left="external-link-alt", - ), - ] - - if wf_log := ( - session.query(model.ActivityLog) - .filter(model.ActivityLog.farmos_uuid == log["uuid"]) - .first() - ): - buttons.append( - self.make_button( - f"View {self.app.get_title()} record", - primary=True, - url=self.request.route_url("activity_logs.view", uuid=wf_log.uuid), - icon_left="eye", - ) - ) - - return buttons - def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/farmos/logs_harvest.py b/src/wuttafarm/web/views/farmos/logs_harvest.py new file mode 100644 index 0000000..0f39a5a --- /dev/null +++ b/src/wuttafarm/web/views/farmos/logs_harvest.py @@ -0,0 +1,53 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Harvest Logs +""" + +from wuttafarm.web.views.farmos.logs import LogMasterView + + +class HarvestLogView(LogMasterView): + """ + View for farmOS harvest logs + """ + + model_name = "farmos_harvest_log" + model_title = "farmOS Harvest Log" + model_title_plural = "farmOS Harvest Logs" + + route_prefix = "farmos_logs_harvest" + url_prefix = "/farmOS/logs/harvest" + + farmos_log_type = "harvest" + farmos_refurl_path = "/logs/harvest" + + +def defaults(config, **kwargs): + base = globals() + + HarvestLogView = kwargs.get("HarvestLogView", base["HarvestLogView"]) + HarvestLogView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/logs_medical.py b/src/wuttafarm/web/views/farmos/logs_medical.py new file mode 100644 index 0000000..95a88c5 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/logs_medical.py @@ -0,0 +1,53 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Medical Logs +""" + +from wuttafarm.web.views.farmos.logs import LogMasterView + + +class MedicalLogView(LogMasterView): + """ + View for farmOS medical logs + """ + + model_name = "farmos_medical_log" + model_title = "farmOS Medical Log" + model_title_plural = "farmOS Medical Logs" + + route_prefix = "farmos_logs_medical" + url_prefix = "/farmOS/logs/medical" + + farmos_log_type = "medical" + farmos_refurl_path = "/logs/medical" + + +def defaults(config, **kwargs): + base = globals() + + MedicalLogView = kwargs.get("MedicalLogView", base["MedicalLogView"]) + MedicalLogView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/logs_observation.py b/src/wuttafarm/web/views/farmos/logs_observation.py new file mode 100644 index 0000000..ab27b5a --- /dev/null +++ b/src/wuttafarm/web/views/farmos/logs_observation.py @@ -0,0 +1,53 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Observation Logs +""" + +from wuttafarm.web.views.farmos.logs import LogMasterView + + +class ObservationLogView(LogMasterView): + """ + View for farmOS observation logs + """ + + model_name = "farmos_observation_log" + model_title = "farmOS Observation Log" + model_title_plural = "farmOS Observation Logs" + + route_prefix = "farmos_logs_observation" + url_prefix = "/farmOS/logs/observation" + + farmos_log_type = "observation" + farmos_refurl_path = "/logs/observation" + + +def defaults(config, **kwargs): + base = globals() + + ObservationLogView = kwargs.get("ObservationLogView", base["ObservationLogView"]) + ObservationLogView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py new file mode 100644 index 0000000..f02801f --- /dev/null +++ b/src/wuttafarm/web/views/farmos/plants.py @@ -0,0 +1,362 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Farm Plants +""" + +import datetime + +import colander + +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 UsersType, StructureType, FarmOSPlantTypes +from wuttafarm.web.forms.widgets import ImageWidget + + +class PlantTypeView(FarmOSMasterView): + """ + Master view for Plant Types in farmOS. + """ + + model_name = "farmos_plant_type" + model_title = "farmOS Plant Type" + model_title_plural = "farmOS Plant Types" + + route_prefix = "farmos_plant_types" + url_prefix = "/farmOS/plant-types" + + farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" + + grid_columns = [ + "name", + "description", + "changed", + ] + + sort_defaults = "name" + + form_fields = [ + "name", + "description", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get("taxonomy_term", "plant_type") + return [self.normalize_plant_type(t) for t in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + plant_type = self.farmos_client.resource.get_id( + "taxonomy_term", "plant_type", self.request.matchdict["uuid"] + ) + self.raw_json = plant_type + return self.normalize_plant_type(plant_type["data"]) + + def get_instance_title(self, plant_type): + return plant_type["name"] + + def normalize_plant_type(self, plant_type): + + if changed := plant_type["attributes"]["changed"]: + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + if description := plant_type["attributes"]["description"]: + description = description["value"] + + return { + "uuid": plant_type["id"], + "drupal_id": plant_type["attributes"]["drupal_internal__tid"], + "name": plant_type["attributes"]["name"], + "description": description or colander.null, + "changed": changed, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + # changed + f.set_node("changed", WuttaDateTime()) + + def get_xref_buttons(self, plant_type): + model = self.app.model + session = self.Session() + + buttons = [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url( + f"/taxonomy/term/{plant_type['drupal_id']}" + ), + target="_blank", + icon_left="external-link-alt", + ) + ] + + if wf_plant_type := ( + session.query(model.PlantType) + .filter(model.PlantType.farmos_uuid == plant_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "plant_types.view", uuid=wf_plant_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +class PlantAssetView(FarmOSMasterView): + """ + Master view for farmOS Plant Assets + """ + + model_name = "farmos_asset_plant" + model_title = "farmOS Plant Asset" + model_title_plural = "farmOS Plant Assets" + + route_prefix = "farmos_asset_plant" + url_prefix = "/farmOS/assets/plant" + + farmos_refurl_path = "/assets/plant" + + grid_columns = [ + "name", + "archived", + ] + + sort_defaults = "name" + + form_fields = [ + "name", + "plant_types", + "archived", + "owners", + "location", + "notes", + "raw_image_url", + "large_image_url", + "thumbnail_image_url", + "image", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.asset.get("plant") + return [self.normalize_plant(a) for a in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # archived + g.set_renderer("archived", "boolean") + + def get_instance(self): + + plant = self.farmos_client.resource.get_id( + "asset", "plant", self.request.matchdict["uuid"] + ) + self.raw_json = plant + + # instance data + data = self.normalize_plant(plant["data"]) + + if relationships := plant["data"].get("relationships"): + + # add plant types + if plant_type := relationships.get("plant_type"): + if plant_type["data"]: + data["plant_types"] = [] + for plant_type in plant_type["data"]: + plant_type = self.farmos_client.resource.get_id( + "taxonomy_term", "plant_type", plant_type["id"] + ) + data["plant_types"].append( + { + "uuid": plant_type["data"]["id"], + "name": plant_type["data"]["attributes"]["name"], + } + ) + + # add location + if location := relationships.get("location"): + if location["data"]: + location = self.farmos_client.resource.get_id( + "asset", "structure", location["data"][0]["id"] + ) + data["location"] = { + "uuid": location["data"]["id"], + "name": location["data"]["attributes"]["name"], + } + + # add owners + if owner := relationships.get("owner"): + data["owners"] = [] + for owner_data in owner["data"]: + owner = self.farmos_client.resource.get_id( + "user", "user", owner_data["id"] + ) + data["owners"].append( + { + "uuid": owner["data"]["id"], + "display_name": owner["data"]["attributes"]["display_name"], + } + ) + + # add image urls + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + data["raw_image_url"] = self.app.get_farmos_url( + image["data"]["attributes"]["uri"]["url"] + ) + # nb. other styles available: medium, wide + data["large_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["large"] + data["thumbnail_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["thumbnail"] + + return data + + def get_instance_title(self, plant): + return plant["name"] + + def normalize_plant(self, plant): + + if notes := plant["attributes"]["notes"]: + notes = notes["value"] + + if self.farmos_4x: + archived = plant["attributes"]["archived"] + else: + archived = plant["attributes"]["status"] == "archived" + + return { + "uuid": plant["id"], + "drupal_id": plant["attributes"]["drupal_internal__id"], + "name": plant["attributes"]["name"], + "location": colander.null, # TODO + "archived": archived, + "notes": notes or colander.null, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + plant = f.model_instance + + # plant_types + f.set_node("plant_types", FarmOSPlantTypes(self.request)) + + # location + f.set_node("location", StructureType(self.request)) + + # owners + f.set_node("owners", UsersType(self.request)) + + # notes + f.set_widget("notes", "notes") + + # archived + f.set_node("archived", colander.Boolean()) + + # image + if url := plant.get("large_image_url"): + f.set_widget("image", ImageWidget("plant image")) + f.set_default("image", url) + + def get_xref_buttons(self, plant): + model = self.app.model + session = self.Session() + + buttons = [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/asset/{plant['drupal_id']}"), + target="_blank", + icon_left="external-link-alt", + ), + ] + + if wf_plant := ( + session.query(model.Asset) + .filter(model.Asset.farmos_uuid == plant["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("plant_assets.view", uuid=wf_plant.uuid), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) + PlantTypeView.defaults(config) + + PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) + PlantAssetView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index fc05613..cf77967 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -25,12 +25,15 @@ Base views for Logs from collections import OrderedDict +import colander + from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.db import Session from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.db.model import LogType +from wuttafarm.db.model import LogType, Log +from wuttafarm.web.forms.schema import LogAssetRefs def get_log_type_enum(config): @@ -96,6 +99,77 @@ class LogTypeView(WuttaFarmMasterView): return buttons +class LogView(WuttaFarmMasterView): + """ + Master view for All Logs + """ + + model_class = Log + route_prefix = "log" + url_prefix = "/logs" + + farmos_refurl_path = "/logs" + + viewable = False + creatable = False + editable = False + deletable = False + model_is_versioned = False + + labels = { + "message": "Log Name", + } + + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "log_type", + "assets", + "location", + "quantity", + "groups", + "is_group_assignment", + ] + + sort_defaults = ("timestamp", "desc") + + filter_defaults = { + "message": {"active": True, "verb": "contains"}, + } + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + + # timestamp + g.set_renderer("timestamp", "date") + g.set_link("timestamp") + + # message + g.set_link("message") + + # log_type + g.set_enum("log_type", get_log_type_enum(self.config)) + + # assets + g.set_renderer("assets", self.render_assets_for_grid) + + # view action links to final log record + def log_url(log, i): + return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) + + g.add_action("view", icon="eye", url=log_url) + + def render_assets_for_grid(self, log, field, value): + assets = [str(a.asset) for a in log._assets] + return ", ".join(assets) + + class LogMasterView(WuttaFarmMasterView): """ Base class for Asset master views @@ -165,13 +239,34 @@ class LogMasterView(WuttaFarmMasterView): g.set_sorter("message", model.Log.message) g.set_filter("message", model.Log.message) + # assets + g.set_renderer("assets", self.render_assets_for_grid) + + def render_assets_for_grid(self, log, field, value): + return ", ".join([a.asset.asset_name for a in log.log._assets]) + def configure_form(self, form): f = form super().configure_form(f) + enum = self.app.enum + log = f.model_instance # timestamp # TODO: the widget should be automatic (assn proxy field) f.set_widget("timestamp", WuttaDateTimeWidget(self.request)) + if self.creating: + f.set_default("timestamp", self.app.make_utc()) + + # assets + if self.creating or self.editing: + f.remove("assets") # TODO: need to support this + else: + f.set_node("assets", LogAssetRefs(self.request)) + f.set_default("assets", [a.asset_uuid for a in log.log._assets]) + + # location + if self.creating or self.editing: + f.remove("location") # TODO: need to support this # log_type if self.creating: @@ -183,31 +278,52 @@ class LogMasterView(WuttaFarmMasterView): ) f.set_readonly("log_type") + # quantity + if self.creating or self.editing: + f.remove("quantity") # TODO: need to support this + # notes f.set_widget("notes", "notes") + # owners + if self.creating or self.editing: + f.remove("owners") # TODO: need to support this + + # status + f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) + + # is_group_assignment + f.set_node("is_group_assignment", colander.Boolean()) + + def objectify(self, form): + log = super().objectify(form) + + if self.creating: + model_class = self.get_model_class() + log.log_type = self.get_farmos_log_type() + + return log + def get_farmos_url(self, log): return self.app.get_farmos_url(f"/log/{log.drupal_id}") + def get_farmos_log_type(self): + return self.model_class.__wutta_hint__["farmos_log_type"] + def get_xref_buttons(self, log): buttons = super().get_xref_buttons(log) if log.farmos_uuid: - - # TODO - route = None - if log.log_type == "activity": - route = "farmos_logs_activity.view" - - if route: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url(route, uuid=log.farmos_uuid), - icon_left="eye", - ) + log_type = self.get_farmos_log_type() + route = f"farmos_logs_{log_type}.view" + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url(route, uuid=log.farmos_uuid), + icon_left="eye", ) + ) return buttons @@ -218,6 +334,9 @@ def defaults(config, **kwargs): LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) LogTypeView.defaults(config) + LogView = kwargs.get("LogView", base["LogView"]) + LogView.defaults(config) + def includeme(config): defaults(config) diff --git a/src/wuttafarm/web/views/logs_activity.py b/src/wuttafarm/web/views/logs_activity.py index d4333f5..dda3ca7 100644 --- a/src/wuttafarm/web/views/logs_activity.py +++ b/src/wuttafarm/web/views/logs_activity.py @@ -33,7 +33,7 @@ class ActivityLogView(LogMasterView): """ model_class = ActivityLog - route_prefix = "activity_logs" + route_prefix = "logs_activity" url_prefix = "/logs/activity" farmos_refurl_path = "/logs/activity" diff --git a/src/wuttafarm/web/views/logs_harvest.py b/src/wuttafarm/web/views/logs_harvest.py new file mode 100644 index 0000000..825c864 --- /dev/null +++ b/src/wuttafarm/web/views/logs_harvest.py @@ -0,0 +1,50 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Harvest Logs +""" + +from wuttafarm.web.views.logs import LogMasterView +from wuttafarm.db.model import HarvestLog + + +class HarvestLogView(LogMasterView): + """ + Master view for Harvest Logs + """ + + model_class = HarvestLog + route_prefix = "logs_harvest" + url_prefix = "/logs/harvest" + + farmos_refurl_path = "/logs/harvest" + + +def defaults(config, **kwargs): + base = globals() + + HarvestLogView = kwargs.get("HarvestLogView", base["HarvestLogView"]) + HarvestLogView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/logs_medical.py b/src/wuttafarm/web/views/logs_medical.py new file mode 100644 index 0000000..d582db9 --- /dev/null +++ b/src/wuttafarm/web/views/logs_medical.py @@ -0,0 +1,50 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Medical Logs +""" + +from wuttafarm.web.views.logs import LogMasterView +from wuttafarm.db.model import MedicalLog + + +class MedicalLogView(LogMasterView): + """ + Master view for Medical Logs + """ + + model_class = MedicalLog + route_prefix = "logs_medical" + url_prefix = "/logs/medical" + + farmos_refurl_path = "/logs/medical" + + +def defaults(config, **kwargs): + base = globals() + + MedicalLogView = kwargs.get("MedicalLogView", base["MedicalLogView"]) + MedicalLogView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/logs_observation.py b/src/wuttafarm/web/views/logs_observation.py new file mode 100644 index 0000000..a4b9e8e --- /dev/null +++ b/src/wuttafarm/web/views/logs_observation.py @@ -0,0 +1,50 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Observation Logs +""" + +from wuttafarm.web.views.logs import LogMasterView +from wuttafarm.db.model import ObservationLog + + +class ObservationLogView(LogMasterView): + """ + Master view for Observation Logs + """ + + model_class = ObservationLog + route_prefix = "logs_observation" + url_prefix = "/logs/observation" + + farmos_refurl_path = "/logs/observation" + + +def defaults(config, **kwargs): + base = globals() + + ObservationLogView = kwargs.get("ObservationLogView", base["ObservationLogView"]) + ObservationLogView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py new file mode 100644 index 0000000..d92949a --- /dev/null +++ b/src/wuttafarm/web/views/plants.py @@ -0,0 +1,201 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Plants +""" + +from wuttaweb.forms.schema import WuttaDictEnum + +from wuttafarm.db.model import PlantType, PlantAsset +from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView +from wuttafarm.web.forms.schema import PlantTypeRefs +from wuttafarm.web.forms.widgets import ImageWidget + + +class PlantTypeView(AssetTypeMasterView): + """ + Master view for Plant Types + """ + + model_class = PlantType + route_prefix = "plant_types" + url_prefix = "/plant-types" + + farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + has_rows = True + row_model_class = PlantAsset + rows_viewable = True + + row_grid_columns = [ + "asset_name", + "archived", + ] + + rows_sort_defaults = "asset_name" + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_farmos_url(self, plant_type): + return self.app.get_farmos_url(f"/taxonomy/term/{plant_type.drupal_id}") + + def get_xref_buttons(self, plant_type): + buttons = super().get_xref_buttons(plant_type) + + if plant_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_plant_types.view", uuid=plant_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + def get_row_grid_data(self, plant_type): + model = self.app.model + session = self.Session() + return ( + session.query(model.PlantAsset) + .join(model.Asset) + .outerjoin(model.PlantAssetPlantType) + .filter(model.PlantAssetPlantType.plant_type == plant_type) + ) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + model = self.app.model + + # asset_name + g.set_link("asset_name") + g.set_sorter("asset_name", model.Asset.asset_name) + g.set_filter("asset_name", model.Asset.asset_name) + + # archived + g.set_renderer("archived", "boolean") + g.set_sorter("archived", model.Asset.archived) + g.set_filter("archived", model.Asset.archived) + + def get_row_action_url_view(self, plant, i): + return self.request.route_url("plant_assets.view", uuid=plant.uuid) + + +class PlantAssetView(AssetMasterView): + """ + Master view for Plant Assets + """ + + model_class = PlantAsset + route_prefix = "plant_assets" + url_prefix = "/assets/plant" + + farmos_refurl_path = "/assets/plant" + + labels = { + "plant_types": "Crop/Variety", + } + + grid_columns = [ + "thumbnail", + "drupal_id", + "asset_name", + "plant_types", + "season", + "archived", + ] + + form_fields = [ + "asset_name", + "plant_types", + "season", + "notes", + "asset_type", + "archived", + "farmos_uuid", + "drupal_id", + "thumbnail_url", + "image_url", + "thumbnail", + "image", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # plant_types + g.set_renderer("plant_types", self.render_grid_plant_types) + + def render_grid_plant_types(self, plant, field, value): + return ", ".join([t.plant_type.name for t in plant._plant_types]) + + def configure_form(self, form): + f = form + super().configure_form(f) + enum = self.app.enum + plant = f.model_instance + + # plant_types + f.set_node("plant_types", PlantTypeRefs(self.request)) + f.set_default("plant_types", [t.plant_type_uuid for t in plant._plant_types]) + + +def defaults(config, **kwargs): + base = globals() + + PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) + PlantTypeView.defaults(config) + + PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) + PlantAssetView.defaults(config) + + +def includeme(config): + defaults(config) From 26a47468986ae2eed6e25007e803746f3955054b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 18:56:40 -0600 Subject: [PATCH 089/166] feat: add `produces_eggs` flag for animal, group assets even if the farmOS instance does not have `farm_eggs` module installed, we should support the schema --- ...3f4ef1a4_add_produces_eggs_via_eggmixin.py | 52 +++++++++++++++++++ src/wuttafarm/db/model/asset.py | 12 +++++ src/wuttafarm/db/model/asset_animal.py | 4 +- src/wuttafarm/db/model/asset_group.py | 4 +- src/wuttafarm/farmos/importing/model.py | 5 ++ src/wuttafarm/farmos/importing/wuttafarm.py | 4 ++ src/wuttafarm/importing/farmos.py | 4 ++ src/wuttafarm/web/views/animals.py | 2 + src/wuttafarm/web/views/groups.py | 2 + 9 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/82a03f4ef1a4_add_produces_eggs_via_eggmixin.py diff --git a/src/wuttafarm/db/alembic/versions/82a03f4ef1a4_add_produces_eggs_via_eggmixin.py b/src/wuttafarm/db/alembic/versions/82a03f4ef1a4_add_produces_eggs_via_eggmixin.py new file mode 100644 index 0000000..9bed92c --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/82a03f4ef1a4_add_produces_eggs_via_eggmixin.py @@ -0,0 +1,52 @@ +"""add produces_eggs via EggMixin + +Revision ID: 82a03f4ef1a4 +Revises: 11e0e46f48a6 +Create Date: 2026-02-18 18:45:36.015144 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "82a03f4ef1a4" +down_revision: Union[str, None] = "11e0e46f48a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_animal + op.add_column( + "asset_animal", sa.Column("produces_eggs", sa.Boolean(), nullable=True) + ) + op.add_column( + "asset_animal_version", + sa.Column("produces_eggs", sa.Boolean(), autoincrement=False, nullable=True), + ) + + # asset_group + op.add_column( + "asset_group", sa.Column("produces_eggs", sa.Boolean(), nullable=True) + ) + op.add_column( + "asset_group_version", + sa.Column("produces_eggs", sa.Boolean(), autoincrement=False, nullable=True), + ) + + +def downgrade() -> None: + + # asset_group + op.drop_column("asset_group_version", "produces_eggs") + op.drop_column("asset_group", "produces_eggs") + + # asset_animal + op.drop_column("asset_animal_version", "produces_eggs") + op.drop_column("asset_animal", "produces_eggs") diff --git a/src/wuttafarm/db/model/asset.py b/src/wuttafarm/db/model/asset.py index 531fd62..90372e2 100644 --- a/src/wuttafarm/db/model/asset.py +++ b/src/wuttafarm/db/model/asset.py @@ -215,6 +215,18 @@ def add_asset_proxies(subclass): Asset.make_proxy(subclass, "asset", "archived") +class EggMixin: + + produces_eggs = sa.Column( + sa.Boolean(), + nullable=True, + doc=""" + Whether the group asset produces eggs (i.e. it should be + available in the egg harvest form). + """, + ) + + class AssetParent(model.Base): """ Represents an "asset's parent relationship" from farmOS. diff --git a/src/wuttafarm/db/model/asset_animal.py b/src/wuttafarm/db/model/asset_animal.py index b50c41b..768b0f9 100644 --- a/src/wuttafarm/db/model/asset_animal.py +++ b/src/wuttafarm/db/model/asset_animal.py @@ -28,7 +28,7 @@ from sqlalchemy import orm from wuttjamaican.db import model -from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies, EggMixin class AnimalType(model.Base): @@ -84,7 +84,7 @@ class AnimalType(model.Base): return self.name or "" -class AnimalAsset(AssetMixin, model.Base): +class AnimalAsset(AssetMixin, EggMixin, model.Base): """ Represents an animal asset from farmOS """ diff --git a/src/wuttafarm/db/model/asset_group.py b/src/wuttafarm/db/model/asset_group.py index fe82bdd..ad4d184 100644 --- a/src/wuttafarm/db/model/asset_group.py +++ b/src/wuttafarm/db/model/asset_group.py @@ -25,10 +25,10 @@ Model definition for Groups from wuttjamaican.db import model -from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies +from wuttafarm.db.model.asset import AssetMixin, add_asset_proxies, EggMixin -class GroupAsset(AssetMixin, model.Base): +class GroupAsset(AssetMixin, EggMixin, model.Base): """ Represents a group asset from farmOS """ diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index e114746..d20c068 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -125,6 +125,7 @@ class ToFarmOSAsset(ToFarmOS): "asset_name": asset["attributes"]["name"], "is_location": asset["attributes"]["is_location"], "is_fixed": asset["attributes"]["is_fixed"], + "produces_eggs": asset["attributes"].get("produces_eggs"), "notes": notes, "archived": asset["attributes"]["archived"], } @@ -138,6 +139,8 @@ class ToFarmOSAsset(ToFarmOS): attrs["is_location"] = source_data["is_location"] if "is_fixed" in self.fields: attrs["is_fixed"] = source_data["is_fixed"] + if "produces_eggs" in self.fields: + attrs["produces_eggs"] = source_data["produces_eggs"] if "notes" in self.fields: attrs["notes"] = {"value": source_data["notes"]} if "archived" in self.fields: @@ -159,6 +162,7 @@ class AnimalAssetImporter(ToFarmOSAsset): "animal_type_uuid", "sex", "is_sterile", + "produces_eggs", "birthdate", "notes", "archived", @@ -286,6 +290,7 @@ class GroupAssetImporter(ToFarmOSAsset): supported_fields = [ "uuid", "asset_name", + "produces_eggs", "notes", "archived", ] diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 0718614..ffd78b7 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -140,6 +140,7 @@ class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImpor "animal_type_uuid", "sex", "is_sterile", + "produces_eggs", "birthdate", "notes", "archived", @@ -152,6 +153,7 @@ class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImpor "animal_type_uuid": animal.animal_type.farmos_uuid, "sex": animal.sex, "is_sterile": animal.is_sterile, + "produces_eggs": animal.produces_eggs, "birthdate": animal.birthdate, "notes": animal.notes, "archived": animal.archived, @@ -191,6 +193,7 @@ class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporte supported_fields = [ "uuid", "asset_name", + "produces_eggs", "notes", "archived", ] @@ -199,6 +202,7 @@ class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporte return { "uuid": group.farmos_uuid or self.app.make_true_uuid(), "asset_name": group.asset_name, + "produces_eggs": group.produces_eggs, "notes": group.notes, "archived": group.archived, "_src_object": group, diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index bde283f..d1cac19 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -312,6 +312,7 @@ class AnimalAssetImporter(AssetImporterBase): "animal_type_uuid", "sex", "is_sterile", + "produces_eggs", "birthdate", "notes", "archived", @@ -371,6 +372,7 @@ class AnimalAssetImporter(AssetImporterBase): "animal_type_uuid": animal_type_uuid, "sex": animal["attributes"]["sex"], "is_sterile": sterile, + "produces_eggs": animal["attributes"]["produces_eggs"], "birthdate": birthdate, } ) @@ -449,6 +451,7 @@ class GroupAssetImporter(AssetImporterBase): "asset_name", "is_location", "is_fixed", + "produces_eggs", "notes", "archived", "image_url", @@ -467,6 +470,7 @@ class GroupAssetImporter(AssetImporterBase): data.update( { "asset_type": "group", + "produces_eggs": group["attributes"]["produces_eggs"], } ) return data diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 7016e36..72a05ee 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -157,6 +157,7 @@ class AnimalAssetView(AssetMasterView): "birthdate", "is_sterile", "sex", + "produces_eggs", "archived", ] @@ -166,6 +167,7 @@ class AnimalAssetView(AssetMasterView): "birthdate", "sex", "is_sterile", + "produces_eggs", "notes", "asset_type", "archived", diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index ae50b89..4b26463 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -42,6 +42,7 @@ class GroupView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", + "produces_eggs", "archived", ] @@ -49,6 +50,7 @@ class GroupView(AssetMasterView): "asset_name", "notes", "asset_type", + "produces_eggs", "archived", "farmos_uuid", "drupal_id", From 5ee2db267a678a4374ebb511b79ded408b0c25d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 19:03:45 -0600 Subject: [PATCH 090/166] =?UTF-8?q?bump:=20version=200.4.1=20=E2=86=92=200?= =?UTF-8?q?.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d096239..8cf9487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.5.0 (2026-02-18) + +### Feat + +- add `produces_eggs` flag for animal, group assets +- add more assets (plant) and logs (harvest, medical, observation) +- refactor log models, views to use generic/common base + +### Fix + +- rename db model modules, for better convention +- add override for requests cert validation + ## v0.4.1 (2026-02-17) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 44bea43..a474302 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.4.1" +version = "0.5.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 185cd86efb4fdbab0a1d06c953916d0acd9ff84a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 19:09:39 -0600 Subject: [PATCH 091/166] fix: fix default admin user perms, per new log schema --- src/wuttafarm/web/views/common.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 121e631..ce5fba2 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -51,9 +51,6 @@ class CommonView(base.CommonView): site_admin = session.query(model.Role).filter_by(name="Site Admin").first() if site_admin: site_admin_perms = [ - "activity_logs.list", - "activity_logs.view", - "activity_logs.versions", "animal_types.create", "animal_types.edit", "animal_types.list", @@ -84,17 +81,23 @@ class CommonView(base.CommonView): "farmos_log_types.view", "farmos_logs_activity.list", "farmos_logs_activity.view", + "farmos_logs_harvest.list", + "farmos_logs_harvest.view", + "farmos_logs_medical.list", + "farmos_logs_medical.view", + "farmos_logs_observation.list", + "farmos_logs_observation.view", "farmos_structure_types.list", "farmos_structure_types.view", "farmos_structures.list", "farmos_structures.view", "farmos_users.list", "farmos_users.view", - "group_asests.create", - "group_asests.edit", - "group_asests.list", - "group_asests.view", - "group_asests.versions", + "group_assets.create", + "group_assets.edit", + "group_assets.list", + "group_assets.view", + "group_assets.versions", "land_assets.create", "land_assets.edit", "land_assets.list", @@ -106,6 +109,18 @@ class CommonView(base.CommonView): "log_types.list", "log_types.view", "log_types.versions", + "logs_activity.list", + "logs_activity.view", + "logs_activity.versions", + "logs_harvest.list", + "logs_harvest.view", + "logs_harvest.versions", + "logs_medical.list", + "logs_medical.view", + "logs_medical.versions", + "logs_observation.list", + "logs_observation.view", + "logs_observation.versions", "structure_types.list", "structure_types.view", "structure_types.versions", From e7b493d7c993088c32ab352973d024969c1f14b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 19:31:41 -0600 Subject: [PATCH 092/166] fix: add WuttaFarm -> farmOS export for Plant Assets --- src/wuttafarm/farmos/importing/model.py | 53 +++++++++++++++++++++ src/wuttafarm/farmos/importing/wuttafarm.py | 27 +++++++++++ src/wuttafarm/web/views/plants.py | 13 ++++- 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index d20c068..9bb0bf5 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -333,6 +333,59 @@ class LandAssetImporter(ToFarmOSAsset): return payload +class PlantAssetImporter(ToFarmOSAsset): + + model_title = "PlantAsset" + farmos_asset_type = "plant" + + supported_fields = [ + "uuid", + "asset_name", + "plant_type_uuids", + "notes", + "archived", + ] + + def normalize_target_object(self, plant): + data = super().normalize_target_object(plant) + data.update( + { + "plant_type_uuids": [ + UUID(p["id"]) for p in plant["relationships"]["plant_type"]["data"] + ], + } + ) + return data + + def get_asset_payload(self, source_data): + payload = super().get_asset_payload(source_data) + + attrs = {} + if "sex" in self.fields: + attrs["sex"] = source_data["sex"] + if "is_sterile" in self.fields: + attrs["is_sterile"] = source_data["is_sterile"] + if "birthdate" in self.fields: + attrs["birthdate"] = self.format_datetime(source_data["birthdate"]) + + rels = {} + if "plant_type_uuids" in self.fields: + rels["plant_type"] = {"data": []} + for uuid in source_data["plant_type_uuids"]: + rels["plant_type"]["data"].append( + { + "id": str(uuid), + "type": "taxonomy_term--plant_type", + } + ) + + payload["attributes"].update(attrs) + if rels: + payload.setdefault("relationships", {}).update(rels) + + return payload + + class StructureAssetImporter(ToFarmOSAsset): model_title = "StructureAsset" diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index ffd78b7..5b3a25e 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -98,6 +98,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter + importers["PlantAsset"] = PlantAssetImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter @@ -239,6 +240,32 @@ class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter) } +class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter): + """ + WuttaFarm → farmOS API exporter for Plant Assets + """ + + source_model_class = model.PlantAsset + + supported_fields = [ + "uuid", + "asset_name", + "plant_type_uuids", + "notes", + "archived", + ] + + def normalize_source_object(self, plant): + return { + "uuid": plant.farmos_uuid or self.app.make_true_uuid(), + "asset_name": plant.asset_name, + "plant_type_uuids": [t.plant_type.farmos_uuid for t in plant._plant_types], + "notes": plant.notes, + "archived": plant.archived, + "_src_object": plant, + } + + class StructureAssetImporter( FromWuttaFarm, farmos_importing.model.StructureAssetImporter ): diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index d92949a..4bd32c6 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -183,8 +183,17 @@ class PlantAssetView(AssetMasterView): plant = f.model_instance # plant_types - f.set_node("plant_types", PlantTypeRefs(self.request)) - f.set_default("plant_types", [t.plant_type_uuid for t in plant._plant_types]) + if self.creating or self.editing: + f.remove("plant_types") # TODO: add support for this + else: + f.set_node("plant_types", PlantTypeRefs(self.request)) + f.set_default( + "plant_types", [t.plant_type_uuid for t in plant._plant_types] + ) + + # season + if self.creating or self.editing: + f.remove("season") # TODO: add support for this def defaults(config, **kwargs): From bc0836fc3ccc0133ed26d9feb317ea50e7ed743d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 19:31:58 -0600 Subject: [PATCH 093/166] fix: reword some menu entries --- src/wuttafarm/web/menus.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index d52a6ca..d56977a 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -156,30 +156,30 @@ class WuttaFarmMenuHandler(base.MenuHandler): }, {"type": "sep"}, { - "title": "Animals", + "title": "Animal Assets", "route": "farmos_animals", "perm": "farmos_animals.list", }, { - "title": "Groups", + "title": "Group Assets", "route": "farmos_groups", "perm": "farmos_groups.list", }, { - "title": "Plants", + "title": "Land Assets", + "route": "farmos_land_assets", + "perm": "farmos_land_assets.list", + }, + { + "title": "Plant Assets", "route": "farmos_asset_plant", "perm": "farmos_asset_plant.list", }, { - "title": "Structures", + "title": "Structure Assets", "route": "farmos_structures", "perm": "farmos_structures.list", }, - { - "title": "Land", - "route": "farmos_land_assets", - "perm": "farmos_land_assets.list", - }, {"type": "sep"}, { "title": "Activity Logs", @@ -207,6 +207,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_animal_types", "perm": "farmos_animal_types.list", }, + { + "title": "Land Types", + "route": "farmos_land_types", + "perm": "farmos_land_types.list", + }, { "title": "Plant Types", "route": "farmos_plant_types", @@ -217,11 +222,7 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_structure_types", "perm": "farmos_structure_types.list", }, - { - "title": "Land Types", - "route": "farmos_land_types", - "perm": "farmos_land_types.list", - }, + {"type": "sep"}, { "title": "Asset Types", "route": "farmos_asset_types", From 0a0d43aa9f86c47c81c110fc48eca91f7def52f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 20:13:58 -0600 Subject: [PATCH 094/166] feat: add Units table, views, import/export --- .../versions/ea88e72a5fa5_add_units.py | 102 ++++++++++++ src/wuttafarm/db/model/__init__.py | 1 + src/wuttafarm/db/model/unit.py | 81 ++++++++++ src/wuttafarm/farmos/importing/model.py | 152 ++++++++++-------- src/wuttafarm/farmos/importing/wuttafarm.py | 23 +++ src/wuttafarm/importing/farmos.py | 30 ++++ src/wuttafarm/web/menus.py | 10 ++ src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/common.py | 5 + src/wuttafarm/web/views/farmos/__init__.py | 1 + .../web/views/farmos/animal_types.py | 90 +---------- src/wuttafarm/web/views/farmos/master.py | 90 +++++++++++ src/wuttafarm/web/views/farmos/plants.py | 83 +--------- src/wuttafarm/web/views/farmos/units.py | 74 +++++++++ src/wuttafarm/web/views/units.py | 95 +++++++++++ 15 files changed, 604 insertions(+), 234 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py create mode 100644 src/wuttafarm/db/model/unit.py create mode 100644 src/wuttafarm/web/views/farmos/units.py create mode 100644 src/wuttafarm/web/views/units.py diff --git a/src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py b/src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py new file mode 100644 index 0000000..e85afed --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/ea88e72a5fa5_add_units.py @@ -0,0 +1,102 @@ +"""add Units + +Revision ID: ea88e72a5fa5 +Revises: 82a03f4ef1a4 +Create Date: 2026-02-18 20:01:40.720138 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "ea88e72a5fa5" +down_revision: Union[str, None] = "82a03f4ef1a4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # unit + op.create_table( + "unit", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_unit")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_unit_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_unit_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_unit_name")), + ) + op.create_table( + "unit_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint("uuid", "transaction_id", name=op.f("pk_unit_version")), + ) + op.create_index( + op.f("ix_unit_version_end_transaction_id"), + "unit_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_unit_version_operation_type"), + "unit_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_unit_version_pk_transaction_id", + "unit_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_unit_version_pk_validity", + "unit_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_unit_version_transaction_id"), + "unit_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # unit + op.drop_index(op.f("ix_unit_version_transaction_id"), table_name="unit_version") + op.drop_index("ix_unit_version_pk_validity", table_name="unit_version") + op.drop_index("ix_unit_version_pk_transaction_id", table_name="unit_version") + op.drop_index(op.f("ix_unit_version_operation_type"), table_name="unit_version") + op.drop_index(op.f("ix_unit_version_end_transaction_id"), table_name="unit_version") + op.drop_table("unit_version") + op.drop_table("unit") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index f9eb790..a0b856d 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,6 +30,7 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models +from .unit import Unit from .asset import AssetType, Asset, AssetParent from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset diff --git a/src/wuttafarm/db/model/unit.py b/src/wuttafarm/db/model/unit.py new file mode 100644 index 0000000..8cbdd5a --- /dev/null +++ b/src/wuttafarm/db/model/unit.py @@ -0,0 +1,81 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Units +""" + +import sqlalchemy as sa + +from wuttjamaican.db import model + + +class Unit(model.Base): + """ + Represents an "unit" (taxonomy term) from farmOS + """ + + __tablename__ = "unit" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Unit", + "model_title_plural": "Units", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the unit. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional description for the unit. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the unit within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the unit. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 9bb0bf5..337649c 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -64,6 +64,81 @@ class ToFarmOS(Importer): return self.app.make_utc(dt) +class ToFarmOSTaxonomy(ToFarmOS): + + farmos_taxonomy_type = None + + supported_fields = [ + "uuid", + "name", + ] + + def get_target_objects(self, **kwargs): + result = self.farmos_client.resource.get( + "taxonomy_term", self.farmos_taxonomy_type + ) + return result["data"] + + def get_target_object(self, key): + + # fetch from cache, if applicable + if self.caches_target: + return super().get_target_object(key) + + # okay now must fetch via API + if self.get_keys() != ["uuid"]: + raise ValueError("must use uuid key for this to work") + uuid = key[0] + + try: + result = self.farmos_client.resource.get_id( + "taxonomy_term", self.farmos_taxonomy_type, str(uuid) + ) + except requests.HTTPError as exc: + if exc.response.status_code == 404: + return None + raise + return result["data"] + + def normalize_target_object(self, obj): + return { + "uuid": UUID(obj["id"]), + "name": obj["attributes"]["name"], + } + + def get_term_payload(self, source_data): + return { + "attributes": { + "name": source_data["name"], + } + } + + def create_target_object(self, key, source_data): + if source_data.get("__ignoreme__"): + return None + if self.dry_run: + return source_data + + payload = self.get_term_payload(source_data) + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + normal = self.normalize_target_object(result["data"]) + normal["_new_object"] = result["data"] + return normal + + def update_target_object(self, asset, source_data, target_data=None): + if self.dry_run: + return asset + + payload = self.get_term_payload(source_data) + payload["id"] = str(source_data["uuid"]) + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + return self.normalize_target_object(result["data"]) + + class ToFarmOSAsset(ToFarmOS): """ Base class for asset data importer targeting the farmOS API. @@ -151,6 +226,12 @@ class ToFarmOSAsset(ToFarmOS): return payload +class UnitImporter(ToFarmOSTaxonomy): + + model_title = "Unit" + farmos_taxonomy_type = "unit" + + class AnimalAssetImporter(ToFarmOSAsset): model_title = "AnimalAsset" @@ -209,77 +290,10 @@ class AnimalAssetImporter(ToFarmOSAsset): return payload -class AnimalTypeImporter(ToFarmOS): +class AnimalTypeImporter(ToFarmOSTaxonomy): model_title = "AnimalType" - - supported_fields = [ - "uuid", - "name", - ] - - def get_target_objects(self, **kwargs): - result = self.farmos_client.resource.get("taxonomy_term", "animal_type") - return result["data"] - - def get_target_object(self, key): - - # fetch from cache, if applicable - if self.caches_target: - return super().get_target_object(key) - - # okay now must fetch via API - if self.get_keys() != ["uuid"]: - raise ValueError("must use uuid key for this to work") - uuid = key[0] - - try: - result = self.farmos_client.resource.get_id( - "taxonomy_term", "animal_type", str(uuid) - ) - except requests.HTTPError as exc: - if exc.response.status_code == 404: - return None - raise - return result["data"] - - def normalize_target_object(self, obj): - return { - "uuid": UUID(obj["id"]), - "name": obj["attributes"]["name"], - } - - def get_type_payload(self, source_data): - return { - "attributes": { - "name": source_data["name"], - } - } - - def create_target_object(self, key, source_data): - if source_data.get("__ignoreme__"): - return None - if self.dry_run: - return source_data - - payload = self.get_type_payload(source_data) - result = self.farmos_client.resource.send( - "taxonomy_term", "animal_type", payload - ) - normal = self.normalize_target_object(result["data"]) - normal["_new_object"] = result["data"] - return normal - - def update_target_object(self, asset, source_data, target_data=None): - if self.dry_run: - return asset - - payload = self.get_type_payload(source_data) - payload["id"] = str(source_data["uuid"]) - result = self.farmos_client.resource.send( - "taxonomy_term", "animal_type", payload - ) - return self.normalize_target_object(result["data"]) + farmos_taxonomy_type = "animal_type" class GroupAssetImporter(ToFarmOSAsset): diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 5b3a25e..e11663f 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -99,6 +99,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter importers["PlantAsset"] = PlantAssetImporter + importers["Unit"] = UnitImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter @@ -184,6 +185,28 @@ class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporte } +class UnitImporter(FromWuttaFarm, farmos_importing.model.UnitImporter): + """ + WuttaFarm → farmOS API exporter for Units + """ + + source_model_class = model.Unit + + supported_fields = [ + "uuid", + "name", + ] + + drupal_internal_id_field = "drupal_internal__tid" + + def normalize_source_object(self, unit): + return { + "uuid": unit.farmos_uuid or self.app.make_true_uuid(), + "name": unit.name, + "_src_object": unit, + } + + class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter): """ WuttaFarm → farmOS API exporter for Group Assets diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index d1cac19..fc759f5 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -106,6 +106,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["GroupAsset"] = GroupAssetImporter importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter + importers["Unit"] = UnitImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter @@ -821,6 +822,35 @@ class UserImporter(FromFarmOS, ToWutta): ############################## +class UnitImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Units + """ + + model_class = model.Unit + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + result = self.farmos_client.resource.get("taxonomy_term", "unit") + return result["data"] + + def normalize_source_object(self, unit): + """ """ + return { + "farmos_uuid": UUID(unit["id"]), + "drupal_id": unit["attributes"]["drupal_internal__tid"], + "name": unit["attributes"]["name"], + "description": unit["attributes"]["description"], + } + + class LogTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Log Types diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index d56977a..1e62d09 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -139,6 +139,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "log_types", "perm": "log_types.list", }, + { + "title": "Units", + "route": "units", + "perm": "units.list", + }, ], } @@ -233,6 +238,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_log_types", "perm": "farmos_log_types.list", }, + { + "title": "Units", + "route": "farmos_units", + "perm": "farmos_units.list", + }, {"type": "sep"}, { "title": "Users", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index bb710a2..fa335f5 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -41,6 +41,7 @@ def includeme(config): ) # native table views + config.include("wuttafarm.web.views.units") config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.assets") config.include("wuttafarm.web.views.land") diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index ce5fba2..44a9598 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -129,6 +129,11 @@ class CommonView(base.CommonView): "structure_assets.list", "structure_assets.view", "structure_assets.versions", + "units.create", + "units.edit", + "units.list", + "units.view", + "units.versions", ] for perm in site_admin_perms: auth.grant_permission(site_admin, perm) diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index bda5d03..c0f28a8 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -29,6 +29,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") config.include("wuttafarm.web.views.farmos.asset_types") + config.include("wuttafarm.web.views.farmos.units") config.include("wuttafarm.web.views.farmos.land_types") config.include("wuttafarm.web.views.farmos.land_assets") config.include("wuttafarm.web.views.farmos.structure_types") diff --git a/src/wuttafarm/web/views/farmos/animal_types.py b/src/wuttafarm/web/views/farmos/animal_types.py index 94d02d8..03bd42c 100644 --- a/src/wuttafarm/web/views/farmos/animal_types.py +++ b/src/wuttafarm/web/views/farmos/animal_types.py @@ -23,16 +23,10 @@ View for farmOS animal types """ -import datetime - -import colander - -from wuttaweb.forms.schema import WuttaDateTime - -from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.views.farmos.master import TaxonomyMasterView -class AnimalTypeView(FarmOSMasterView): +class AnimalTypeView(TaxonomyMasterView): """ Master view for Animal Types in farmOS. """ @@ -44,90 +38,14 @@ class AnimalTypeView(FarmOSMasterView): route_prefix = "farmos_animal_types" url_prefix = "/farmOS/animal-types" + farmos_taxonomy_type = "animal_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview" - grid_columns = [ - "name", - "description", - "changed", - ] - - sort_defaults = "name" - - form_fields = [ - "name", - "description", - "changed", - ] - - def get_grid_data(self, columns=None, session=None): - animal_types = self.farmos_client.resource.get("taxonomy_term", "animal_type") - return [self.normalize_animal_type(t) for t in animal_types["data"]] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - g.set_searchable("name") - - # changed - g.set_renderer("changed", "datetime") - - def get_instance(self): - animal_type = self.farmos_client.resource.get_id( - "taxonomy_term", "animal_type", self.request.matchdict["uuid"] - ) - self.raw_json = animal_type - return self.normalize_animal_type(animal_type["data"]) - - def get_instance_title(self, animal_type): - return animal_type["name"] - - def normalize_animal_type(self, animal_type): - - if changed := animal_type["attributes"]["changed"]: - changed = datetime.datetime.fromisoformat(changed) - changed = self.app.localtime(changed) - - if description := animal_type["attributes"]["description"]: - description = description["value"] - - return { - "uuid": animal_type["id"], - "drupal_id": animal_type["attributes"]["drupal_internal__tid"], - "name": animal_type["attributes"]["name"], - "description": description or colander.null, - "changed": changed, - } - - def configure_form(self, form): - f = form - super().configure_form(f) - - # description - f.set_widget("description", "notes") - - # changed - f.set_node("changed", WuttaDateTime()) - def get_xref_buttons(self, animal_type): + buttons = super().get_xref_buttons(animal_type) model = self.app.model session = self.Session() - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url( - f"/taxonomy/term/{animal_type['drupal_id']}" - ), - target="_blank", - icon_left="external-link-alt", - ) - ] - if wf_animal_type := ( session.query(model.AnimalType) .filter(model.AnimalType.farmos_uuid == animal_type["uuid"]) diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index fff3d2c..56d70b6 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -23,11 +23,14 @@ Base class for farmOS master views """ +import datetime import json +import colander import markdown from wuttaweb.views import MasterView +from wuttaweb.forms.schema import WuttaDateTime from wuttafarm.web.util import save_farmos_oauth2_token @@ -100,3 +103,90 @@ class FarmOSMasterView(MasterView): ) return context + + +class TaxonomyMasterView(FarmOSMasterView): + """ + Base class for farmOS "taxonomy term" views + """ + + farmos_taxonomy_type = None + + grid_columns = [ + "name", + "description", + "changed", + ] + + sort_defaults = "name" + + form_fields = [ + "name", + "description", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get( + "taxonomy_term", self.farmos_taxonomy_type + ) + return [self.normalize_taxonomy_term(t) for t in result["data"]] + + def normalize_taxonomy_term(self, term): + + if changed := term["attributes"]["changed"]: + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + if description := term["attributes"]["description"]: + description = description["value"] + + return { + "uuid": term["id"], + "drupal_id": term["attributes"]["drupal_internal__tid"], + "name": term["attributes"]["name"], + "description": description or colander.null, + "changed": changed, + } + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + result = self.farmos_client.resource.get_id( + "taxonomy_term", self.farmos_taxonomy_type, self.request.matchdict["uuid"] + ) + self.raw_json = result + return self.normalize_taxonomy_term(result["data"]) + + def get_instance_title(self, term): + return term["name"] + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + # changed + f.set_node("changed", WuttaDateTime()) + + def get_xref_buttons(self, term): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/taxonomy/term/{term['drupal_id']}"), + target="_blank", + icon_left="external-link-alt", + ) + ] diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py index f02801f..95a2dab 100644 --- a/src/wuttafarm/web/views/farmos/plants.py +++ b/src/wuttafarm/web/views/farmos/plants.py @@ -30,12 +30,13 @@ import colander from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget +from wuttafarm.web.views.farmos.master import TaxonomyMasterView from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.schema import UsersType, StructureType, FarmOSPlantTypes from wuttafarm.web.forms.widgets import ImageWidget -class PlantTypeView(FarmOSMasterView): +class PlantTypeView(TaxonomyMasterView): """ Master view for Plant Types in farmOS. """ @@ -47,90 +48,14 @@ class PlantTypeView(FarmOSMasterView): route_prefix = "farmos_plant_types" url_prefix = "/farmOS/plant-types" + farmos_taxonomy_type = "plant_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" - grid_columns = [ - "name", - "description", - "changed", - ] - - sort_defaults = "name" - - form_fields = [ - "name", - "description", - "changed", - ] - - def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.resource.get("taxonomy_term", "plant_type") - return [self.normalize_plant_type(t) for t in result["data"]] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # name - g.set_link("name") - g.set_searchable("name") - - # changed - g.set_renderer("changed", "datetime") - - def get_instance(self): - plant_type = self.farmos_client.resource.get_id( - "taxonomy_term", "plant_type", self.request.matchdict["uuid"] - ) - self.raw_json = plant_type - return self.normalize_plant_type(plant_type["data"]) - - def get_instance_title(self, plant_type): - return plant_type["name"] - - def normalize_plant_type(self, plant_type): - - if changed := plant_type["attributes"]["changed"]: - changed = datetime.datetime.fromisoformat(changed) - changed = self.app.localtime(changed) - - if description := plant_type["attributes"]["description"]: - description = description["value"] - - return { - "uuid": plant_type["id"], - "drupal_id": plant_type["attributes"]["drupal_internal__tid"], - "name": plant_type["attributes"]["name"], - "description": description or colander.null, - "changed": changed, - } - - def configure_form(self, form): - f = form - super().configure_form(f) - - # description - f.set_widget("description", "notes") - - # changed - f.set_node("changed", WuttaDateTime()) - def get_xref_buttons(self, plant_type): + buttons = super().get_xref_buttons(plant_type) model = self.app.model session = self.Session() - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url( - f"/taxonomy/term/{plant_type['drupal_id']}" - ), - target="_blank", - icon_left="external-link-alt", - ) - ] - if wf_plant_type := ( session.query(model.PlantType) .filter(model.PlantType.farmos_uuid == plant_type["uuid"]) diff --git a/src/wuttafarm/web/views/farmos/units.py b/src/wuttafarm/web/views/farmos/units.py new file mode 100644 index 0000000..397614d --- /dev/null +++ b/src/wuttafarm/web/views/farmos/units.py @@ -0,0 +1,74 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS units +""" + +from wuttafarm.web.views.farmos.master import TaxonomyMasterView + + +class UnitView(TaxonomyMasterView): + """ + Master view for Units in farmOS. + """ + + model_name = "farmos_unit" + model_title = "farmOS Unit" + model_title_plural = "farmOS Units" + + route_prefix = "farmos_units" + url_prefix = "/farmOS/units" + + farmos_taxonomy_type = "unit" + farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" + + def get_xref_buttons(self, unit): + buttons = super().get_xref_buttons(unit) + model = self.app.model + session = self.Session() + + if wf_unit := ( + session.query(model.Unit) + .filter(model.Unit.farmos_uuid == unit["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("units.view", uuid=wf_unit.uuid), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + UnitView = kwargs.get("UnitView", base["UnitView"]) + UnitView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py new file mode 100644 index 0000000..28570d8 --- /dev/null +++ b/src/wuttafarm/web/views/units.py @@ -0,0 +1,95 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Units +""" + +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.db.model import Unit + + +class UnitView(WuttaFarmMasterView): + """ + Master view for Units + """ + + model_class = Unit + route_prefix = "units" + url_prefix = "/animal-types" + + farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_farmos_url(self, unit): + return self.app.get_farmos_url(f"/taxonomy/term/{unit.drupal_id}") + + def get_xref_buttons(self, unit): + buttons = super().get_xref_buttons(unit) + + if unit.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_units.view", uuid=unit.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + UnitView = kwargs.get("UnitView", base["UnitView"]) + UnitView.defaults(config) + + +def includeme(config): + defaults(config) From c93660ec4ab7979753ac905022dc62fa6b43faf5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Feb 2026 21:15:18 -0600 Subject: [PATCH 095/166] feat: add Quantity Types table, views, import --- .../1f98d27cabeb_add_quantity_types.py | 119 +++++++++++++++++ src/wuttafarm/db/model/__init__.py | 1 + src/wuttafarm/db/model/quantities.py | 81 ++++++++++++ src/wuttafarm/importing/farmos.py | 30 +++++ src/wuttafarm/web/menus.py | 10 ++ src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/farmos/__init__.py | 1 + .../web/views/farmos/quantity_types.py | 125 ++++++++++++++++++ src/wuttafarm/web/views/quantities.py | 90 +++++++++++++ 9 files changed, 458 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py create mode 100644 src/wuttafarm/db/model/quantities.py create mode 100644 src/wuttafarm/web/views/farmos/quantity_types.py create mode 100644 src/wuttafarm/web/views/quantities.py diff --git a/src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py b/src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py new file mode 100644 index 0000000..816f05c --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/1f98d27cabeb_add_quantity_types.py @@ -0,0 +1,119 @@ +"""add Quantity Types + +Revision ID: 1f98d27cabeb +Revises: ea88e72a5fa5 +Create Date: 2026-02-18 21:03:52.245619 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "1f98d27cabeb" +down_revision: Union[str, None] = "ea88e72a5fa5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # quantity_type + op.create_table( + "quantity_type", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_type")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_quantity_type_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_quantity_type_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_quantity_type_name")), + ) + op.create_table( + "quantity_type_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "drupal_id", sa.String(length=50), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_quantity_type_version") + ), + ) + op.create_index( + op.f("ix_quantity_type_version_end_transaction_id"), + "quantity_type_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_type_version_operation_type"), + "quantity_type_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_quantity_type_version_pk_transaction_id", + "quantity_type_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_quantity_type_version_pk_validity", + "quantity_type_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_type_version_transaction_id"), + "quantity_type_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # quantity_type + op.drop_index( + op.f("ix_quantity_type_version_transaction_id"), + table_name="quantity_type_version", + ) + op.drop_index( + "ix_quantity_type_version_pk_validity", table_name="quantity_type_version" + ) + op.drop_index( + "ix_quantity_type_version_pk_transaction_id", table_name="quantity_type_version" + ) + op.drop_index( + op.f("ix_quantity_type_version_operation_type"), + table_name="quantity_type_version", + ) + op.drop_index( + op.f("ix_quantity_type_version_end_transaction_id"), + table_name="quantity_type_version", + ) + op.drop_table("quantity_type_version") + op.drop_table("quantity_type") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index a0b856d..827fc70 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -31,6 +31,7 @@ from .users import WuttaFarmUser # wuttafarm proper models from .unit import Unit +from .quantities import QuantityType from .asset import AssetType, Asset, AssetParent from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py new file mode 100644 index 0000000..b66f9bb --- /dev/null +++ b/src/wuttafarm/db/model/quantities.py @@ -0,0 +1,81 @@ +# -*- 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 . +# +################################################################################ +""" +Model definition for Quantities +""" + +import sqlalchemy as sa + +from wuttjamaican.db import model + + +class QuantityType(model.Base): + """ + Represents an "quantity type" from farmOS + """ + + __tablename__ = "quantity_type" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Quantity Type", + "model_title_plural": "Quantity Types", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the quantity type. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Description for the quantity type. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the quantity type within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.String(length=50), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the quantity type. + """, + ) + + def __str__(self): + return self.name or "" diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index fc759f5..90a4a7c 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -107,6 +107,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter + importers["QuantityType"] = QuantityTypeImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter @@ -851,6 +852,35 @@ class UnitImporter(FromFarmOS, ToWutta): } +class QuantityTypeImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Quantity Types + """ + + model_class = model.QuantityType + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + result = self.farmos_client.resource.get("quantity_type") + return result["data"] + + def normalize_source_object(self, quantity_type): + """ """ + return { + "farmos_uuid": UUID(quantity_type["id"]), + "drupal_id": quantity_type["attributes"]["drupal_internal__id"], + "name": quantity_type["attributes"]["label"], + "description": quantity_type["attributes"]["description"], + } + + class LogTypeImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Log Types diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 1e62d09..01e0f07 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -139,6 +139,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "log_types", "perm": "log_types.list", }, + { + "title": "Quantity Types", + "route": "quantity_types", + "perm": "quantity_types.list", + }, { "title": "Units", "route": "units", @@ -238,6 +243,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_log_types", "perm": "farmos_log_types.list", }, + { + "title": "Quantity Types", + "route": "farmos_quantity_types", + "perm": "farmos_quantity_types.list", + }, { "title": "Units", "route": "farmos_units", diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index fa335f5..21dcbad 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -42,6 +42,7 @@ def includeme(config): # native table views config.include("wuttafarm.web.views.units") + config.include("wuttafarm.web.views.quantities") config.include("wuttafarm.web.views.asset_types") config.include("wuttafarm.web.views.assets") config.include("wuttafarm.web.views.land") diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index c0f28a8..cfedfb1 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,6 +28,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") + config.include("wuttafarm.web.views.farmos.quantity_types") config.include("wuttafarm.web.views.farmos.asset_types") config.include("wuttafarm.web.views.farmos.units") config.include("wuttafarm.web.views.farmos.land_types") diff --git a/src/wuttafarm/web/views/farmos/quantity_types.py b/src/wuttafarm/web/views/farmos/quantity_types.py new file mode 100644 index 0000000..2b10a0a --- /dev/null +++ b/src/wuttafarm/web/views/farmos/quantity_types.py @@ -0,0 +1,125 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Quantity Types +""" + +from wuttafarm.web.views.farmos import FarmOSMasterView + + +class QuantityTypeView(FarmOSMasterView): + """ + View for farmOS Quantity Types + """ + + model_name = "farmos_quantity_type" + model_title = "farmOS Quantity Type" + model_title_plural = "farmOS Quantity Types" + + route_prefix = "farmos_quantity_types" + url_prefix = "/farmOS/quantity-types" + + grid_columns = [ + "label", + "description", + ] + + sort_defaults = "label" + + form_fields = [ + "label", + "description", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get("quantity_type") + return [self.normalize_quantity_type(t) for t in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # label + g.set_link("label") + g.set_searchable("label") + + # description + g.set_searchable("description") + + def get_instance(self): + result = self.farmos_client.resource.get_id( + "quantity_type", "quantity_type", self.request.matchdict["uuid"] + ) + self.raw_json = result + return self.normalize_quantity_type(result["data"]) + + def get_instance_title(self, quantity_type): + return quantity_type["label"] + + def normalize_quantity_type(self, quantity_type): + return { + "uuid": quantity_type["id"], + "drupal_id": quantity_type["attributes"]["drupal_internal__id"], + "label": quantity_type["attributes"]["label"], + "description": quantity_type["attributes"]["description"], + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + def get_xref_buttons(self, quantity_type): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_quantity_type := ( + session.query(model.QuantityType) + .filter(model.QuantityType.farmos_uuid == quantity_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "quantity_types.view", uuid=wf_quantity_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) + QuantityTypeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py new file mode 100644 index 0000000..1291791 --- /dev/null +++ b/src/wuttafarm/web/views/quantities.py @@ -0,0 +1,90 @@ +# -*- 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 . +# +################################################################################ +""" +Master view for Quantities +""" + +from wuttafarm.web.views import WuttaFarmMasterView +from wuttafarm.db.model import QuantityType + + +class QuantityTypeView(WuttaFarmMasterView): + """ + Master view for Quantity Types + """ + + model_class = QuantityType + route_prefix = "quantity_types" + url_prefix = "/quantity-types" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "farmos_uuid", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_xref_buttons(self, quantity_type): + buttons = super().get_xref_buttons(quantity_type) + + if quantity_type.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_quantity_types.view", uuid=quantity_type.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) + QuantityTypeView.defaults(config) + + +def includeme(config): + defaults(config) From cfe2e4b7b4d64d875836a37e1068ec36de4351c9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 17:48:33 -0600 Subject: [PATCH 096/166] feat: add Standard Quantities table, views, import --- .../5b6c87d8cddf_add_standard_quantities.py | 293 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 4 +- src/wuttafarm/db/model/quantities.py | 140 +++++++++ src/wuttafarm/db/model/unit.py | 36 +++ src/wuttafarm/importing/farmos.py | 144 +++++++++ src/wuttafarm/web/forms/schema.py | 41 +++ src/wuttafarm/web/forms/widgets.py | 27 ++ src/wuttafarm/web/menus.py | 21 ++ src/wuttafarm/web/views/farmos/__init__.py | 2 +- src/wuttafarm/web/views/farmos/quantities.py | 278 +++++++++++++++++ .../web/views/farmos/quantity_types.py | 125 -------- src/wuttafarm/web/views/quantities.py | 205 +++++++++++- src/wuttafarm/web/views/units.py | 40 ++- 13 files changed, 1225 insertions(+), 131 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py create mode 100644 src/wuttafarm/web/views/farmos/quantities.py delete mode 100644 src/wuttafarm/web/views/farmos/quantity_types.py diff --git a/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py b/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py new file mode 100644 index 0000000..a6aab9d --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/5b6c87d8cddf_add_standard_quantities.py @@ -0,0 +1,293 @@ +"""add Standard Quantities + +Revision ID: 5b6c87d8cddf +Revises: 1f98d27cabeb +Create Date: 2026-02-19 15:42:19.691148 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "5b6c87d8cddf" +down_revision: Union[str, None] = "1f98d27cabeb" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # measure + op.create_table( + "measure", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("drupal_id", sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_measure")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_measure_drupal_id")), + sa.UniqueConstraint("name", name=op.f("uq_measure_name")), + ) + op.create_table( + "measure_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "drupal_id", sa.String(length=20), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_measure_version") + ), + ) + op.create_index( + op.f("ix_measure_version_end_transaction_id"), + "measure_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_measure_version_operation_type"), + "measure_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_measure_version_pk_transaction_id", + "measure_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_measure_version_pk_validity", + "measure_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_measure_version_transaction_id"), + "measure_version", + ["transaction_id"], + unique=False, + ) + + # quantity + op.create_table( + "quantity", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("quantity_type_id", sa.String(length=50), nullable=False), + sa.Column("measure_id", sa.String(length=20), nullable=False), + sa.Column("value_numerator", sa.Integer(), nullable=False), + sa.Column("value_denominator", sa.Integer(), nullable=False), + sa.Column("units_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("label", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["measure_id"], + ["measure.drupal_id"], + name=op.f("fk_quantity_measure_id_measure"), + ), + sa.ForeignKeyConstraint( + ["quantity_type_id"], + ["quantity_type.drupal_id"], + name=op.f("fk_quantity_quantity_type_id_quantity_type"), + ), + sa.ForeignKeyConstraint( + ["units_uuid"], ["unit.uuid"], name=op.f("fk_quantity_units_uuid_unit") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_quantity_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_quantity_farmos_uuid")), + ) + op.create_table( + "quantity_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "quantity_type_id", sa.String(length=50), autoincrement=False, nullable=True + ), + sa.Column( + "measure_id", sa.String(length=20), autoincrement=False, nullable=True + ), + sa.Column("value_numerator", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "value_denominator", sa.Integer(), autoincrement=False, nullable=True + ), + sa.Column( + "units_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("label", sa.String(length=255), autoincrement=False, nullable=True), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_quantity_version") + ), + ) + op.create_index( + op.f("ix_quantity_version_end_transaction_id"), + "quantity_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_version_operation_type"), + "quantity_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_quantity_version_pk_transaction_id", + "quantity_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_quantity_version_pk_validity", + "quantity_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_version_transaction_id"), + "quantity_version", + ["transaction_id"], + unique=False, + ) + + # quantity_standard + op.create_table( + "quantity_standard", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["uuid"], ["quantity.uuid"], name=op.f("fk_quantity_standard_uuid_quantity") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_quantity_standard")), + ) + op.create_table( + "quantity_standard_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_quantity_standard_version") + ), + ) + op.create_index( + op.f("ix_quantity_standard_version_end_transaction_id"), + "quantity_standard_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_standard_version_operation_type"), + "quantity_standard_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_quantity_standard_version_pk_transaction_id", + "quantity_standard_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_quantity_standard_version_pk_validity", + "quantity_standard_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_quantity_standard_version_transaction_id"), + "quantity_standard_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # quantity_standard + op.drop_index( + op.f("ix_quantity_standard_version_transaction_id"), + table_name="quantity_standard_version", + ) + op.drop_index( + "ix_quantity_standard_version_pk_validity", + table_name="quantity_standard_version", + ) + op.drop_index( + "ix_quantity_standard_version_pk_transaction_id", + table_name="quantity_standard_version", + ) + op.drop_index( + op.f("ix_quantity_standard_version_operation_type"), + table_name="quantity_standard_version", + ) + op.drop_index( + op.f("ix_quantity_standard_version_end_transaction_id"), + table_name="quantity_standard_version", + ) + op.drop_table("quantity_standard_version") + op.drop_table("quantity_standard") + + # quantity + op.drop_index( + op.f("ix_quantity_version_transaction_id"), table_name="quantity_version" + ) + op.drop_index("ix_quantity_version_pk_validity", table_name="quantity_version") + op.drop_index( + "ix_quantity_version_pk_transaction_id", table_name="quantity_version" + ) + op.drop_index( + op.f("ix_quantity_version_operation_type"), table_name="quantity_version" + ) + op.drop_index( + op.f("ix_quantity_version_end_transaction_id"), table_name="quantity_version" + ) + op.drop_table("quantity_version") + op.drop_table("quantity") + + # measure + op.drop_index( + op.f("ix_measure_version_transaction_id"), table_name="measure_version" + ) + op.drop_index("ix_measure_version_pk_validity", table_name="measure_version") + op.drop_index("ix_measure_version_pk_transaction_id", table_name="measure_version") + op.drop_index( + op.f("ix_measure_version_operation_type"), table_name="measure_version" + ) + op.drop_index( + op.f("ix_measure_version_end_transaction_id"), table_name="measure_version" + ) + op.drop_table("measure_version") + op.drop_table("measure") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 827fc70..68695e5 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -30,8 +30,8 @@ from wuttjamaican.db.model import * from .users import WuttaFarmUser # wuttafarm proper models -from .unit import Unit -from .quantities import QuantityType +from .unit import Unit, Measure +from .quantities import QuantityType, Quantity, StandardQuantity from .asset import AssetType, Asset, AssetParent from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py index b66f9bb..4f537b9 100644 --- a/src/wuttafarm/db/model/quantities.py +++ b/src/wuttafarm/db/model/quantities.py @@ -24,6 +24,8 @@ Model definition for Quantities """ import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr from wuttjamaican.db import model @@ -79,3 +81,141 @@ class QuantityType(model.Base): def __str__(self): return self.name or "" + + +class Quantity(model.Base): + """ + Represents a base quantity record from farmOS + """ + + __tablename__ = "quantity" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Quantity", + "model_title_plural": "All Quantities", + } + + uuid = model.uuid_column() + + quantity_type_id = sa.Column( + sa.String(length=50), + sa.ForeignKey("quantity_type.drupal_id"), + nullable=False, + ) + + quantity_type = orm.relationship(QuantityType) + + measure_id = sa.Column( + sa.String(length=20), + sa.ForeignKey("measure.drupal_id"), + nullable=False, + doc=""" + Measure for the quantity. + """, + ) + + measure = orm.relationship("Measure") + + value_numerator = sa.Column( + sa.Integer(), + nullable=False, + doc=""" + Numerator for the quantity value. + """, + ) + + value_denominator = sa.Column( + sa.Integer(), + nullable=False, + doc=""" + Denominator for the quantity value. + """, + ) + + units_uuid = model.uuid_fk_column("unit.uuid", nullable=False) + units = orm.relationship("Unit") + + label = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional label for the quantity. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the quantity within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the quantity. + """, + ) + + def render_as_text(self, config=None): + measure = str(self.measure or self.measure_id or "") + value = self.value_numerator / self.value_denominator + if config: + app = config.get_app() + value = app.render_quantity(value) + units = str(self.units or "") + return f"( {measure} ) {value} {units}" + + def __str__(self): + return self.render_as_text() + + +class QuantityMixin: + + uuid = model.uuid_fk_column("quantity.uuid", nullable=False, primary_key=True) + + @declared_attr + def quantity(cls): + return orm.relationship(Quantity) + + def render_as_text(self, config=None): + return self.quantity.render_as_text(config) + + def __str__(self): + return self.render_as_text() + + +def add_quantity_proxies(subclass): + Quantity.make_proxy(subclass, "quantity", "farmos_uuid") + Quantity.make_proxy(subclass, "quantity", "drupal_id") + Quantity.make_proxy(subclass, "quantity", "quantity_type") + Quantity.make_proxy(subclass, "quantity", "quantity_type_id") + Quantity.make_proxy(subclass, "quantity", "measure") + Quantity.make_proxy(subclass, "quantity", "measure_id") + Quantity.make_proxy(subclass, "quantity", "value_numerator") + Quantity.make_proxy(subclass, "quantity", "value_denominator") + Quantity.make_proxy(subclass, "quantity", "value_decimal") + Quantity.make_proxy(subclass, "quantity", "units_uuid") + Quantity.make_proxy(subclass, "quantity", "units") + Quantity.make_proxy(subclass, "quantity", "label") + + +class StandardQuantity(QuantityMixin, model.Base): + """ + Represents a Standard Quantity from farmOS + """ + + __tablename__ = "quantity_standard" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Standard Quantity", + "model_title_plural": "Standard Quantities", + "farmos_quantity_type": "standard", + } + + +add_quantity_proxies(StandardQuantity) diff --git a/src/wuttafarm/db/model/unit.py b/src/wuttafarm/db/model/unit.py index 8cbdd5a..e9c6e70 100644 --- a/src/wuttafarm/db/model/unit.py +++ b/src/wuttafarm/db/model/unit.py @@ -28,6 +28,42 @@ import sqlalchemy as sa from wuttjamaican.db import model +class Measure(model.Base): + """ + Represents a "measure" option (for quantities) from farmOS + """ + + __tablename__ = "measure" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Measure", + "model_title_plural": "Measures", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the measure. + """, + ) + + drupal_id = sa.Column( + sa.String(length=20), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the measure. + """, + ) + + def __str__(self): + return self.name or "" + + class Unit(model.Base): """ Represents an "unit" (taxonomy term) from farmOS diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 90a4a7c..5cf2242 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -106,8 +106,10 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["GroupAsset"] = GroupAssetImporter importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter + importers["Measure"] = MeasureImporter importers["Unit"] = UnitImporter importers["QuantityType"] = QuantityTypeImporter + importers["StandardQuantity"] = StandardQuantityImporter importers["LogType"] = LogTypeImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter @@ -823,6 +825,37 @@ class UserImporter(FromFarmOS, ToWutta): ############################## +class MeasureImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Measures + """ + + model_class = model.Measure + + key = "drupal_id" + + supported_fields = [ + "drupal_id", + "name", + ] + + def get_source_objects(self): + """ """ + response = self.farmos_client.session.get( + self.app.get_farmos_url("/api/quantity/standard/resource/schema") + ) + response.raise_for_status() + data = response.json() + return data["definitions"]["attributes"]["properties"]["measure"]["oneOf"] + + def normalize_source_object(self, measure): + """ """ + return { + "drupal_id": measure["const"], + "name": measure["title"], + } + + class UnitImporter(FromFarmOS, ToWutta): """ farmOS API → WuttaFarm importer for Units @@ -1100,3 +1133,114 @@ class ObservationLogImporter(LogImporterBase): "status", "assets", ] + + +class QuantityImporterBase(FromFarmOS, ToWutta): + """ + Base class for farmOS API → WuttaFarm quantity importers + """ + + def get_farmos_quantity_type(self): + return self.model_class.__wutta_hint__["farmos_quantity_type"] + + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare proxy fields + fields.extend( + [ + "farmos_uuid", + "drupal_id", + "quantity_type_id", + "measure_id", + "value_numerator", + "value_denominator", + "units_uuid", + "label", + ] + ) + return fields + + def setup(self): + super().setup() + model = self.app.model + + self.quantity_types_by_farmos_uuid = {} + for quantity_type in self.target_session.query(model.QuantityType): + if quantity_type.farmos_uuid: + self.quantity_types_by_farmos_uuid[quantity_type.farmos_uuid] = ( + quantity_type + ) + + self.units_by_farmos_uuid = {} + for unit in self.target_session.query(model.Unit): + if unit.farmos_uuid: + self.units_by_farmos_uuid[unit.farmos_uuid] = unit + + def get_source_objects(self): + """ """ + quantity_type = self.get_farmos_quantity_type() + result = self.farmos_client.resource.get("quantity", quantity_type) + return result["data"] + + def normalize_source_object(self, quantity): + """ """ + quantity_type_id = None + units_uuid = None + if relationships := quantity.get("relationships"): + + if quantity_type := relationships.get("quantity_type"): + if quantity_type["data"]: + if wf_quantity_type := self.quantity_types_by_farmos_uuid.get( + UUID(quantity_type["data"]["id"]) + ): + quantity_type_id = wf_quantity_type.drupal_id + + if units := relationships.get("units"): + if units["data"]: + if wf_unit := self.units_by_farmos_uuid.get( + UUID(units["data"]["id"]) + ): + units_uuid = wf_unit.uuid + + if not quantity_type_id: + log.warning( + "missing/invalid quantity_type for farmOS Quantity: %s", quantity + ) + return None + + if not units_uuid: + log.warning("missing/invalid units for farmOS Quantity: %s", quantity) + return None + + value = quantity["attributes"]["value"] + + return { + "farmos_uuid": UUID(quantity["id"]), + "drupal_id": quantity["attributes"]["drupal_internal__id"], + "quantity_type_id": quantity_type_id, + "measure_id": quantity["attributes"]["measure"], + "value_numerator": value["numerator"], + "value_denominator": value["denominator"], + "units_uuid": units_uuid, + "label": quantity["attributes"]["label"], + } + + +class StandardQuantityImporter(QuantityImporterBase): + """ + farmOS API → WuttaFarm importer for Standard Quantities + """ + + model_class = model.StandardQuantity + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "quantity_type_id", + "measure_id", + "value_numerator", + "value_denominator", + "units_uuid", + "label", + ] diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 123f662..df2a45c 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -55,6 +55,26 @@ class AnimalTypeRef(ObjectRef): return self.request.route_url("animal_types.view", uuid=animal_type.uuid) +class FarmOSRef(colander.SchemaType): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + 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 FarmOSRefWidget + + return FarmOSRefWidget(self.request, self.route_prefix, **kwargs) + + class AnimalTypeType(colander.SchemaType): def __init__(self, request, *args, **kwargs): @@ -179,6 +199,27 @@ class StructureTypeRef(ObjectRef): return self.request.route_url("structure_types.view", uuid=structure_type.uuid) +class UnitRef(ObjectRef): + """ + Custom schema type for a :class:`~wuttafarm.db.model.units.Unit` + reference field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): + model = self.app.model + return model.Unit + + def sort_query(self, query): + return query.order_by(self.model_class.name) + + def get_object_url(self, unit): + return self.request.route_url("units.view", uuid=unit.uuid) + + class UsersType(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 d5bf5c2..24c33eb 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -54,6 +54,33 @@ class ImageWidget(Widget): return super().serialize(field, cstruct, **kw) +class FarmOSRefWidget(Widget): + """ + Generic widget to display "any reference field" - as a link to + view the farmOS record it references. Only used by the farmOS + direct API views. + """ + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + obj = json.loads(cstruct) + return tags.link_to( + obj["name"], + self.request.route_url(f"{self.route_prefix}.view", uuid=obj["uuid"]), + ) + + 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 01e0f07..448fb8d 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -134,11 +134,27 @@ class WuttaFarmMenuHandler(base.MenuHandler): "perm": "logs_observation.list", }, {"type": "sep"}, + { + "title": "All Quantities", + "route": "quantities", + "perm": "quantities.list", + }, + { + "title": "Standard Quantities", + "route": "quantities_standard", + "perm": "quantities_standard.list", + }, + {"type": "sep"}, { "title": "Log Types", "route": "log_types", "perm": "log_types.list", }, + { + "title": "Measures", + "route": "measures", + "perm": "measures.list", + }, { "title": "Quantity Types", "route": "quantity_types", @@ -248,6 +264,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_quantity_types", "perm": "farmos_quantity_types.list", }, + { + "title": "Standard Quantities", + "route": "farmos_quantities_standard", + "perm": "farmos_quantities_standard.list", + }, { "title": "Units", "route": "farmos_units", diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py index cfedfb1..e59ac1f 100644 --- a/src/wuttafarm/web/views/farmos/__init__.py +++ b/src/wuttafarm/web/views/farmos/__init__.py @@ -28,7 +28,7 @@ from .master import FarmOSMasterView def includeme(config): config.include("wuttafarm.web.views.farmos.users") - config.include("wuttafarm.web.views.farmos.quantity_types") + config.include("wuttafarm.web.views.farmos.quantities") config.include("wuttafarm.web.views.farmos.asset_types") config.include("wuttafarm.web.views.farmos.units") config.include("wuttafarm.web.views.farmos.land_types") diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py new file mode 100644 index 0000000..414474b --- /dev/null +++ b/src/wuttafarm/web/views/farmos/quantities.py @@ -0,0 +1,278 @@ +# -*- 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 . +# +################################################################################ +""" +View for farmOS Quantity Types +""" + +import datetime + +import colander + +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 + + +class QuantityTypeView(FarmOSMasterView): + """ + View for farmOS Quantity Types + """ + + model_name = "farmos_quantity_type" + model_title = "farmOS Quantity Type" + model_title_plural = "farmOS Quantity Types" + + route_prefix = "farmos_quantity_types" + url_prefix = "/farmOS/quantity-types" + + grid_columns = [ + "label", + "description", + ] + + sort_defaults = "label" + + form_fields = [ + "label", + "description", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get("quantity_type") + return [self.normalize_quantity_type(t) for t in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # label + g.set_link("label") + g.set_searchable("label") + + # description + g.set_searchable("description") + + def get_instance(self): + result = self.farmos_client.resource.get_id( + "quantity_type", "quantity_type", self.request.matchdict["uuid"] + ) + self.raw_json = result + return self.normalize_quantity_type(result["data"]) + + def get_instance_title(self, quantity_type): + return quantity_type["label"] + + def normalize_quantity_type(self, quantity_type): + return { + "uuid": quantity_type["id"], + "drupal_id": quantity_type["attributes"]["drupal_internal__id"], + "label": quantity_type["attributes"]["label"], + "description": quantity_type["attributes"]["description"], + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + def get_xref_buttons(self, quantity_type): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_quantity_type := ( + session.query(model.QuantityType) + .filter(model.QuantityType.farmos_uuid == quantity_type["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "quantity_types.view", uuid=wf_quantity_type.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +class QuantityMasterView(FarmOSMasterView): + """ + Base class for Quantity views + """ + + farmos_quantity_type = None + + grid_columns = [ + "measure", + "value", + "label", + "changed", + ] + + sort_defaults = ("changed", "desc") + + form_fields = [ + "measure", + "value", + "units", + "label", + "created", + "changed", + ] + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.resource.get("quantity", self.farmos_quantity_type) + return [self.normalize_quantity(t) for t in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # value + g.set_link("value") + + # changed + g.set_renderer("changed", "datetime") + + def get_instance(self): + quantity = self.farmos_client.resource.get_id( + "quantity", self.farmos_quantity_type, self.request.matchdict["uuid"] + ) + self.raw_json = quantity + + data = self.normalize_quantity(quantity["data"]) + + if relationships := quantity["data"].get("relationships"): + + # add units + if units := relationships.get("units"): + if units["data"]: + unit = self.farmos_client.resource.get_id( + "taxonomy_term", "unit", units["data"]["id"] + ) + data["units"] = { + "uuid": unit["data"]["id"], + "name": unit["data"]["attributes"]["name"], + } + + return data + + def get_instance_title(self, quantity): + return quantity["value"] + + def normalize_quantity(self, quantity): + + if created := quantity["attributes"]["created"]: + created = datetime.datetime.fromisoformat(created) + created = self.app.localtime(created) + + if changed := quantity["attributes"]["changed"]: + changed = datetime.datetime.fromisoformat(changed) + changed = self.app.localtime(changed) + + return { + "uuid": quantity["id"], + "drupal_id": quantity["attributes"]["drupal_internal__id"], + "measure": quantity["attributes"]["measure"], + "value": quantity["attributes"]["value"], + "label": quantity["attributes"]["label"] or colander.null, + "created": created, + "changed": changed, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + + # created + f.set_node("created", WuttaDateTime(self.request)) + f.set_widget("created", WuttaDateTimeWidget(self.request)) + + # changed + f.set_node("changed", WuttaDateTime(self.request)) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + # units + f.set_node("units", FarmOSRef(self.request, "farmos_units")) + + +class StandardQuantityView(QuantityMasterView): + """ + View for farmOS Standard Quantities + """ + + model_name = "farmos_standard_quantity" + model_title = "farmOS Standard Quantity" + model_title_plural = "farmOS Standard Quantities" + + route_prefix = "farmos_quantities_standard" + url_prefix = "/farmOS/quantities/standard" + + farmos_quantity_type = "standard" + + def get_xref_buttons(self, standard_quantity): + model = self.app.model + session = self.Session() + buttons = [] + + if wf_standard_quantity := ( + session.query(model.StandardQuantity) + .join(model.Quantity) + .filter(model.Quantity.farmos_uuid == standard_quantity["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "quantities_standard.view", uuid=wf_standard_quantity.uuid + ), + icon_left="eye", + ) + ) + + return buttons + + +def defaults(config, **kwargs): + base = globals() + + QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) + QuantityTypeView.defaults(config) + + StandardQuantityView = kwargs.get( + "StandardQuantityView", base["StandardQuantityView"] + ) + StandardQuantityView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttafarm/web/views/farmos/quantity_types.py b/src/wuttafarm/web/views/farmos/quantity_types.py deleted file mode 100644 index 2b10a0a..0000000 --- a/src/wuttafarm/web/views/farmos/quantity_types.py +++ /dev/null @@ -1,125 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaFarm --Web app to integrate with and extend farmOS -# Copyright © 2026 Lance Edgar -# -# This file is part of WuttaFarm. -# -# WuttaFarm is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# WuttaFarm. If not, see . -# -################################################################################ -""" -View for farmOS Quantity Types -""" - -from wuttafarm.web.views.farmos import FarmOSMasterView - - -class QuantityTypeView(FarmOSMasterView): - """ - View for farmOS Quantity Types - """ - - model_name = "farmos_quantity_type" - model_title = "farmOS Quantity Type" - model_title_plural = "farmOS Quantity Types" - - route_prefix = "farmos_quantity_types" - url_prefix = "/farmOS/quantity-types" - - grid_columns = [ - "label", - "description", - ] - - sort_defaults = "label" - - form_fields = [ - "label", - "description", - ] - - def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.resource.get("quantity_type") - return [self.normalize_quantity_type(t) for t in result["data"]] - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # label - g.set_link("label") - g.set_searchable("label") - - # description - g.set_searchable("description") - - def get_instance(self): - result = self.farmos_client.resource.get_id( - "quantity_type", "quantity_type", self.request.matchdict["uuid"] - ) - self.raw_json = result - return self.normalize_quantity_type(result["data"]) - - def get_instance_title(self, quantity_type): - return quantity_type["label"] - - def normalize_quantity_type(self, quantity_type): - return { - "uuid": quantity_type["id"], - "drupal_id": quantity_type["attributes"]["drupal_internal__id"], - "label": quantity_type["attributes"]["label"], - "description": quantity_type["attributes"]["description"], - } - - def configure_form(self, form): - f = form - super().configure_form(f) - - # description - f.set_widget("description", "notes") - - def get_xref_buttons(self, quantity_type): - model = self.app.model - session = self.Session() - buttons = [] - - if wf_quantity_type := ( - session.query(model.QuantityType) - .filter(model.QuantityType.farmos_uuid == quantity_type["uuid"]) - .first() - ): - buttons.append( - self.make_button( - f"View {self.app.get_title()} record", - primary=True, - url=self.request.route_url( - "quantity_types.view", uuid=wf_quantity_type.uuid - ), - icon_left="eye", - ) - ) - - return buttons - - -def defaults(config, **kwargs): - base = globals() - - QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) - QuantityTypeView.defaults(config) - - -def includeme(config): - defaults(config) diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py index 1291791..7d75290 100644 --- a/src/wuttafarm/web/views/quantities.py +++ b/src/wuttafarm/web/views/quantities.py @@ -23,8 +23,24 @@ Master view for Quantities """ +from collections import OrderedDict + +from wuttaweb.db import Session + from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.db.model import QuantityType +from wuttafarm.db.model import QuantityType, Quantity, StandardQuantity +from wuttafarm.web.forms.schema import UnitRef + + +def get_quantity_type_enum(config): + app = config.get_app() + model = app.model + session = Session() + quantity_types = OrderedDict() + query = session.query(model.QuantityType).order_by(model.QuantityType.name) + for quantity_type in query: + quantity_types[quantity_type.drupal_id] = quantity_type.name + return quantity_types class QuantityTypeView(WuttaFarmMasterView): @@ -79,12 +95,199 @@ class QuantityTypeView(WuttaFarmMasterView): return buttons +class QuantityMasterView(WuttaFarmMasterView): + """ + Base class for Quantity master views + """ + + grid_columns = [ + "drupal_id", + "as_text", + "quantity_type", + "measure", + "value", + "units", + "label", + ] + + sort_defaults = ("drupal_id", "desc") + + form_fields = [ + "quantity_type", + "as_text", + "measure", + "value", + "units", + "label", + "farmos_uuid", + "drupal_id", + ] + + def get_query(self, session=None): + """ """ + model = self.app.model + model_class = self.get_model_class() + session = session or self.Session() + query = session.query(model_class) + if model_class is not model.Quantity: + query = query.join(model.Quantity) + query = query.join(model.Measure).join(model.Unit) + return query + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + model = self.app.model + model_class = self.get_model_class() + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + g.set_sorter("drupal_id", model.Quantity.drupal_id) + + # as_text + g.set_renderer("as_text", self.render_as_text_for_grid) + g.set_link("as_text") + + # quantity_type + if model_class is not model.Quantity: + g.remove("quantity_type") + else: + g.set_enum("quantity_type", get_quantity_type_enum(self.config)) + + # measure + g.set_sorter("measure", model.Measure.name) + + # value + g.set_renderer("value", self.render_value_for_grid) + + # units + g.set_sorter("units", model.Unit.name) + + # label + g.set_sorter("label", model.Quantity.label) + + # view action links to final quantity record + if model_class is model.Quantity: + + def quantity_url(quantity, i): + return self.request.route_url( + f"quantities_{quantity.quantity_type_id}.view", uuid=quantity.uuid + ) + + g.add_action("view", icon="eye", url=quantity_url) + + def render_as_text_for_grid(self, quantity, field, value): + return quantity.render_as_text(self.config) + + def render_value_for_grid(self, quantity, field, value): + value = quantity.value_numerator / quantity.value_denominator + return self.app.render_quantity(value) + + def get_instance_title(self, quantity): + return quantity.render_as_text(self.config) + + def configure_form(self, form): + f = form + super().configure_form(f) + quantity = form.model_instance + + # as_text + if self.creating or self.editing: + f.remove("as_text") + else: + f.set_default("as_text", quantity.render_as_text(self.config)) + + # quantity_type + if self.creating: + f.remove("quantity_type") + else: + f.set_readonly("quantity_type") + f.set_default("quantity_type", quantity.quantity_type.name) + + # measure + if self.creating: + f.remove("measure") + else: + f.set_readonly("measure") + f.set_default("measure", quantity.measure.name) + + # value + if self.creating: + f.remove("value") + else: + value = quantity.value_numerator / quantity.value_denominator + value = self.app.render_quantity(value) + f.set_default( + "value", + f"{value} ({quantity.value_numerator} / {quantity.value_denominator})", + ) + + # units + if self.creating: + f.remove("units") + else: + f.set_readonly("units") + f.set_node("units", UnitRef(self.request)) + # TODO: ugh + f.set_default("units", quantity.quantity.units) + + def get_xref_buttons(self, quantity): + buttons = super().get_xref_buttons(quantity) + + if quantity.farmos_uuid: + url = self.request.route_url( + f"farmos_quantities_{quantity.quantity_type_id}.view", + uuid=quantity.farmos_uuid, + ) + buttons.append( + self.make_button( + "View farmOS record", primary=True, url=url, icon_left="eye" + ) + ) + + return buttons + + +class QuantityView(QuantityMasterView): + """ + Master view for All Quantities + """ + + model_class = Quantity + route_prefix = "quantities" + url_prefix = "/quantities" + + viewable = False + creatable = False + editable = False + deletable = False + model_is_versioned = False + + +class StandardQuantityView(QuantityMasterView): + """ + Master view for Standard Quantities + """ + + model_class = StandardQuantity + route_prefix = "quantities_standard" + url_prefix = "/quantities/standard" + + def defaults(config, **kwargs): base = globals() QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) QuantityTypeView.defaults(config) + QuantityView = kwargs.get("QuantityView", base["QuantityView"]) + QuantityView.defaults(config) + + StandardQuantityView = kwargs.get( + "StandardQuantityView", base["StandardQuantityView"] + ) + StandardQuantityView.defaults(config) + def includeme(config): defaults(config) diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index 28570d8..3b86426 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -24,7 +24,40 @@ Master view for Units """ from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.db.model import Unit +from wuttafarm.db.model import Measure, Unit + + +class MeasureView(WuttaFarmMasterView): + """ + Master view for Measures + """ + + model_class = Measure + route_prefix = "measures" + url_prefix = "/measures" + + grid_columns = [ + "name", + "drupal_id", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "drupal_id", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") class UnitView(WuttaFarmMasterView): @@ -34,7 +67,7 @@ class UnitView(WuttaFarmMasterView): model_class = Unit route_prefix = "units" - url_prefix = "/animal-types" + url_prefix = "/units" farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" @@ -87,6 +120,9 @@ class UnitView(WuttaFarmMasterView): def defaults(config, **kwargs): base = globals() + MeasureView = kwargs.get("MeasureView", base["MeasureView"]) + MeasureView.defaults(config) + UnitView = kwargs.get("UnitView", base["UnitView"]) UnitView.defaults(config) From d884a761ad0048430361fbfad680b141a2d1f700 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 18:56:38 -0600 Subject: [PATCH 097/166] fix: expose farmOS integration mode, URL in app settings although as of now changing the integration mode setting will not actually change any behavior.. but it will refs: #3 --- src/wuttafarm/enum.py | 13 ++++ .../web/templates/appinfo/configure.mako | 28 +++++++ src/wuttafarm/web/views/__init__.py | 1 + src/wuttafarm/web/views/settings.py | 78 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 src/wuttafarm/web/templates/appinfo/configure.mako create mode 100644 src/wuttafarm/web/views/settings.py diff --git a/src/wuttafarm/enum.py b/src/wuttafarm/enum.py index 03181b9..870e4cd 100644 --- a/src/wuttafarm/enum.py +++ b/src/wuttafarm/enum.py @@ -28,6 +28,19 @@ from collections import OrderedDict from wuttjamaican.enum import * +FARMOS_INTEGRATION_MODE_WRAPPER = "wrapper" +FARMOS_INTEGRATION_MODE_MIRROR = "mirror" +FARMOS_INTEGRATION_MODE_NONE = "none" + +FARMOS_INTEGRATION_MODE = OrderedDict( + [ + (FARMOS_INTEGRATION_MODE_WRAPPER, "wrapper (API only)"), + (FARMOS_INTEGRATION_MODE_MIRROR, "mirror (2-way sync)"), + (FARMOS_INTEGRATION_MODE_NONE, "none (standalone)"), + ] +) + + ANIMAL_SEX = OrderedDict( [ ("M", "Male"), diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako new file mode 100644 index 0000000..d9e448f --- /dev/null +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -0,0 +1,28 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> + +<%def name="form_content()"> + ${parent.form_content()} + +

farmOS

+
+ + + + % for value, label in enum.FARMOS_INTEGRATION_MODE.items(): + + % endfor + + + + + + + + +
+ diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 21dcbad..5e31d84 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -36,6 +36,7 @@ def includeme(config): **{ "wuttaweb.views.auth": "wuttafarm.web.views.auth", "wuttaweb.views.common": "wuttafarm.web.views.common", + "wuttaweb.views.settings": "wuttafarm.web.views.settings", "wuttaweb.views.users": "wuttafarm.web.views.users", } ) diff --git a/src/wuttafarm/web/views/settings.py b/src/wuttafarm/web/views/settings.py new file mode 100644 index 0000000..3b2c858 --- /dev/null +++ b/src/wuttafarm/web/views/settings.py @@ -0,0 +1,78 @@ +# -*- 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 . +# +################################################################################ +""" +Custom views for Settings +""" + +from webhelpers2.html import tags + +from wuttaweb.views import settings as base + + +class AppInfoView(base.AppInfoView): + """ + Custom appinfo view + """ + + def get_appinfo_dict(self): + info = super().get_appinfo_dict() + enum = self.app.enum + + mode = self.config.get( + f"{self.app.appname}.farmos_integration_mode", default="wrapper" + ) + + info["farmos_integration"] = { + "label": "farmOS Integration", + "value": enum.FARMOS_INTEGRATION_MODE.get(mode, mode), + } + + url = self.app.get_farmos_url() + info["farmos_url"] = { + "label": "farmOS URL", + "value": tags.link_to(url, url, target="_blank"), + } + + return info + + def configure_get_simple_settings(self): # pylint: disable=empty-docstring + simple_settings = super().configure_get_simple_settings() + simple_settings.extend( + [ + {"name": "farmos.url.base"}, + { + "name": f"{self.app.appname}.farmos_integration_mode", + "default": "wrapper", + }, + ] + ) + return simple_settings + + +def defaults(config, **kwargs): + local = globals() + AppInfoView = kwargs.get("AppInfoView", local["AppInfoView"]) + base.defaults(config, **{"AppInfoView": AppInfoView}) + + +def includeme(config): + defaults(config) From 1f254ca77557333e266334f9804a08be17840b4d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 19:08:15 -0600 Subject: [PATCH 098/166] fix: set *default* instead of configured menu handler --- src/wuttafarm/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttafarm/config.py b/src/wuttafarm/config.py index 831698f..16a7578 100644 --- a/src/wuttafarm/config.py +++ b/src/wuttafarm/config.py @@ -52,7 +52,7 @@ class WuttaFarmConfig(WuttaConfigExtension): # web app menu config.setdefault( - f"{config.appname}.web.menus.handler.spec", + f"{config.appname}.web.menus.handler.default_spec", "wuttafarm.web.menus:WuttaFarmMenuHandler", ) From 87101d6b0451e84638518f61f774ee417231ea33 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 19:21:23 -0600 Subject: [PATCH 099/166] feat: include/exclude certain views, menus based on integration mode refs: #3 --- src/wuttafarm/app.py | 24 ++++ src/wuttafarm/farmos/handler.py | 29 +++++ src/wuttafarm/web/menus.py | 167 ++++++++++++++++++++++++++-- src/wuttafarm/web/views/__init__.py | 36 +++--- src/wuttafarm/web/views/settings.py | 2 +- 5 files changed, 235 insertions(+), 23 deletions(-) diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 087c48a..2df38e9 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -51,6 +51,30 @@ class WuttaFarmAppHandler(base.AppHandler): self.handlers["farmos"] = factory(self.config) return self.handlers["farmos"] + def get_farmos_integration_mode(self): + """ + Returns the integration mode for farmOS, i.e. to control the + app's behavior regarding that. + """ + handler = self.get_farmos_handler() + return handler.get_farmos_integration_mode() + + def is_farmos_mirror(self): + """ + Returns ``True`` if the app is configured in "mirror" + integration mode with regard to farmOS. + """ + handler = self.get_farmos_handler() + return handler.is_farmos_mirror() + + def is_farmos_wrapper(self): + """ + Returns ``True`` if the app is configured in "wrapper" + integration mode with regard to farmOS. + """ + handler = self.get_farmos_handler() + return handler.is_farmos_wrapper() + def get_farmos_url(self, *args, **kwargs): """ Get a farmOS URL. This is a convenience wrapper around diff --git a/src/wuttafarm/farmos/handler.py b/src/wuttafarm/farmos/handler.py index 6eee14f..393d121 100644 --- a/src/wuttafarm/farmos/handler.py +++ b/src/wuttafarm/farmos/handler.py @@ -34,6 +34,35 @@ class FarmOSHandler(GenericHandler): :term:`handler`. """ + def get_farmos_integration_mode(self): + """ + Returns the integration mode for farmOS, i.e. to control the + app's behavior regarding that. + """ + enum = self.app.enum + return self.config.get( + f"{self.app.appname}.farmos_integration_mode", + default=enum.FARMOS_INTEGRATION_MODE_WRAPPER, + ) + + def is_farmos_mirror(self): + """ + Returns ``True`` if the app is configured in "mirror" + integration mode with regard to farmOS. + """ + enum = self.app.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR + + def is_farmos_wrapper(self): + """ + Returns ``True`` if the app is configured in "wrapper" + integration mode with regard to farmOS. + """ + enum = self.app.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER + def get_farmos_client(self, hostname=None, **kwargs): """ Returns a new farmOS API client. diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 448fb8d..408fd2e 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -32,12 +32,31 @@ class WuttaFarmMenuHandler(base.MenuHandler): """ def make_menus(self, request, **kwargs): - return [ - self.make_asset_menu(request), - self.make_log_menu(request), - self.make_farmos_menu(request), - self.make_admin_menu(request, include_people=True), - ] + enum = self.app.enum + mode = self.app.get_farmos_integration_mode() + + if mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER: + return [ + 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), + ] + + elif mode == enum.FARMOS_INTEGRATION_MODE_MIRROR: + return [ + self.make_asset_menu(request), + self.make_log_menu(request), + self.make_farmos_full_menu(request), + self.make_admin_menu(request, include_people=True), + ] + + else: # FARMOS_INTEGRATION_MODE_NONE + return [ + self.make_asset_menu(request), + self.make_log_menu(request), + self.make_admin_menu(request, include_people=True), + ] def make_asset_menu(self, request): return { @@ -168,7 +187,7 @@ class WuttaFarmMenuHandler(base.MenuHandler): ], } - def make_farmos_menu(self, request): + def make_farmos_full_menu(self, request): config = request.wutta_config app = config.get_app() return { @@ -282,3 +301,137 @@ class WuttaFarmMenuHandler(base.MenuHandler): }, ], } + + def make_farmos_asset_menu(self, request): + config = request.wutta_config + app = config.get_app() + return { + "title": "Assets", + "type": "menu", + "items": [ + { + "title": "Animal", + "route": "farmos_animals", + "perm": "farmos_animals.list", + }, + { + "title": "Group", + "route": "farmos_groups", + "perm": "farmos_groups.list", + }, + { + "title": "Land", + "route": "farmos_land_assets", + "perm": "farmos_land_assets.list", + }, + { + "title": "Plant", + "route": "farmos_asset_plant", + "perm": "farmos_asset_plant.list", + }, + { + "title": "Structure", + "route": "farmos_structures", + "perm": "farmos_structures.list", + }, + {"type": "sep"}, + { + "title": "Animal Types", + "route": "farmos_animal_types", + "perm": "farmos_animal_types.list", + }, + { + "title": "Land Types", + "route": "farmos_land_types", + "perm": "farmos_land_types.list", + }, + { + "title": "Plant Types", + "route": "farmos_plant_types", + "perm": "farmos_plant_types.list", + }, + { + "title": "Structure Types", + "route": "farmos_structure_types", + "perm": "farmos_structure_types.list", + }, + {"type": "sep"}, + { + "title": "Asset Types", + "route": "farmos_asset_types", + "perm": "farmos_asset_types.list", + }, + ], + } + + def make_farmos_log_menu(self, request): + config = request.wutta_config + app = config.get_app() + return { + "title": "Logs", + "type": "menu", + "items": [ + { + "title": "Activity", + "route": "farmos_logs_activity", + "perm": "farmos_logs_activity.list", + }, + { + "title": "Harvest", + "route": "farmos_logs_harvest", + "perm": "farmos_logs_harvest.list", + }, + { + "title": "Medical", + "route": "farmos_logs_medical", + "perm": "farmos_logs_medical.list", + }, + { + "title": "Observation", + "route": "farmos_logs_observation", + "perm": "farmos_logs_observation.list", + }, + {"type": "sep"}, + { + "title": "Log Types", + "route": "farmos_log_types", + "perm": "farmos_log_types.list", + }, + { + "title": "Quantity Types", + "route": "farmos_quantity_types", + "perm": "farmos_quantity_types.list", + }, + { + "title": "Standard Quantities", + "route": "farmos_quantities_standard", + "perm": "farmos_quantities_standard.list", + }, + { + "title": "Units", + "route": "farmos_units", + "perm": "farmos_units.list", + }, + ], + } + + def make_farmos_other_menu(self, request): + config = request.wutta_config + app = config.get_app() + return { + "title": "farmOS", + "type": "menu", + "items": [ + { + "title": "Go to farmOS", + "url": app.get_farmos_url(), + "target": "_blank", + }, + {"type": "sep"}, + { + "title": "Users", + "route": "farmos_users", + "perm": "farmos_users.list", + }, + ], + } diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py index 5e31d84..6f77e57 100644 --- a/src/wuttafarm/web/views/__init__.py +++ b/src/wuttafarm/web/views/__init__.py @@ -29,6 +29,10 @@ from .master import WuttaFarmMasterView def includeme(config): + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + enum = app.enum + mode = app.get_farmos_integration_mode() # wuttaweb core essential.defaults( @@ -42,20 +46,22 @@ def includeme(config): ) # native table views - config.include("wuttafarm.web.views.units") - config.include("wuttafarm.web.views.quantities") - config.include("wuttafarm.web.views.asset_types") - config.include("wuttafarm.web.views.assets") - config.include("wuttafarm.web.views.land") - config.include("wuttafarm.web.views.structures") - config.include("wuttafarm.web.views.animals") - config.include("wuttafarm.web.views.groups") - config.include("wuttafarm.web.views.plants") - config.include("wuttafarm.web.views.logs") - config.include("wuttafarm.web.views.logs_activity") - config.include("wuttafarm.web.views.logs_harvest") - config.include("wuttafarm.web.views.logs_medical") - config.include("wuttafarm.web.views.logs_observation") + if mode != enum.FARMOS_INTEGRATION_MODE_WRAPPER: + config.include("wuttafarm.web.views.units") + config.include("wuttafarm.web.views.quantities") + config.include("wuttafarm.web.views.asset_types") + config.include("wuttafarm.web.views.assets") + config.include("wuttafarm.web.views.land") + config.include("wuttafarm.web.views.structures") + config.include("wuttafarm.web.views.animals") + config.include("wuttafarm.web.views.groups") + config.include("wuttafarm.web.views.plants") + config.include("wuttafarm.web.views.logs") + config.include("wuttafarm.web.views.logs_activity") + config.include("wuttafarm.web.views.logs_harvest") + config.include("wuttafarm.web.views.logs_medical") + config.include("wuttafarm.web.views.logs_observation") # views for farmOS - config.include("wuttafarm.web.views.farmos") + if mode != enum.FARMOS_INTEGRATION_MODE_NONE: + config.include("wuttafarm.web.views.farmos") diff --git a/src/wuttafarm/web/views/settings.py b/src/wuttafarm/web/views/settings.py index 3b2c858..6372c40 100644 --- a/src/wuttafarm/web/views/settings.py +++ b/src/wuttafarm/web/views/settings.py @@ -61,7 +61,7 @@ class AppInfoView(base.AppInfoView): {"name": "farmos.url.base"}, { "name": f"{self.app.appname}.farmos_integration_mode", - "default": "wrapper", + "default": self.app.get_farmos_integration_mode(), }, ] ) From 9cfa91e091d8579e5333a410589a69b5ce068655 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 19:56:44 -0600 Subject: [PATCH 100/166] fix: standardize a bit more for the farmOS Animal Assets view --- src/wuttafarm/web/menus.py | 8 +- src/wuttafarm/web/views/animals.py | 2 +- src/wuttafarm/web/views/assets.py | 2 +- src/wuttafarm/web/views/common.py | 4 +- src/wuttafarm/web/views/farmos/animals.py | 176 +++++----------------- src/wuttafarm/web/views/farmos/assets.py | 175 +++++++++++++++++++++ 6 files changed, 222 insertions(+), 145 deletions(-) create mode 100644 src/wuttafarm/web/views/farmos/assets.py diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 408fd2e..c79acec 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -202,8 +202,8 @@ class WuttaFarmMenuHandler(base.MenuHandler): {"type": "sep"}, { "title": "Animal Assets", - "route": "farmos_animals", - "perm": "farmos_animals.list", + "route": "farmos_assets_animal", + "perm": "farmos_assets_animal.list", }, { "title": "Group Assets", @@ -311,8 +311,8 @@ class WuttaFarmMenuHandler(base.MenuHandler): "items": [ { "title": "Animal", - "route": "farmos_animals", - "perm": "farmos_animals.list", + "route": "farmos_assets_animal", + "perm": "farmos_assets_animal.list", }, { "title": "Group", diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 72a05ee..09162b2 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -153,11 +153,11 @@ class AnimalAssetView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", + "produces_eggs", "animal_type", "birthdate", "is_sterile", "sex", - "produces_eggs", "archived", ] diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b918839..85835f9 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -282,7 +282,7 @@ class AssetMasterView(WuttaFarmMasterView): # TODO route = None if asset.asset_type == "animal": - route = "farmos_animals.view" + route = "farmos_assets_animal.view" elif asset.asset_type == "group": route = "farmos_groups.view" elif asset.asset_type == "land": diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 44a9598..2efd5c6 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -67,8 +67,8 @@ class CommonView(base.CommonView): "asset_types.versions", "farmos_animal_types.list", "farmos_animal_types.view", - "farmos_animals.list", - "farmos_animals.view", + "farmos_assets_animal.list", + "farmos_assets_animal.view", "farmos_asset_types.list", "farmos_asset_types.view", "farmos_groups.list", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index c9c2887..3a79c8c 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -30,28 +30,27 @@ import colander 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 UsersType, AnimalTypeType, StructureType -from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.web.views.farmos.assets import AssetMasterView +from wuttafarm.web.forms.schema import AnimalTypeType -class AnimalView(FarmOSMasterView): +class AnimalView(AssetMasterView): """ Master view for Farm Animals """ - model_name = "farmos_animal" - model_title = "farmOS Animal" - model_title_plural = "farmOS Animals" + model_name = "farmos_animal_asset" + model_title = "farmOS Animal Asset" + model_title_plural = "farmOS Animal Assets" - route_prefix = "farmos_animals" - url_prefix = "/farmOS/animals" + route_prefix = "farmos_assets_animal" + url_prefix = "/farmOS/assets/animal" + farmos_asset_type = "animal" farmos_refurl_path = "/assets/animal" labels = { "animal_type": "Species / Breed", - "location": "Current Location", } grid_columns = [ @@ -62,8 +61,6 @@ class AnimalView(FarmOSMasterView): "archived", ] - sort_defaults = "name" - form_fields = [ "name", "animal_type", @@ -80,38 +77,21 @@ class AnimalView(FarmOSMasterView): "image", ] - def get_grid_data(self, columns=None, session=None): - animals = self.farmos_client.resource.get("asset", "animal") - return [self.normalize_animal(a) for a in animals["data"]] - def configure_grid(self, grid): g = grid super().configure_grid(g) - # name - g.set_link("name") - g.set_searchable("name") - # birthdate g.set_renderer("birthdate", "date") # is_sterile g.set_renderer("is_sterile", "boolean") - # archived - g.set_renderer("archived", "boolean") - def get_instance(self): - animal = self.farmos_client.resource.get_id( - "asset", "animal", self.request.matchdict["uuid"] - ) - self.raw_json = animal + data = super().get_instance() - # instance data - data = self.normalize_animal(animal["data"]) - - if relationships := animal["data"].get("relationships"): + if relationships := self.raw_json["data"].get("relationships"): # add animal type if animal_type := relationships.get("animal_type"): @@ -124,54 +104,11 @@ class AnimalView(FarmOSMasterView): "name": animal_type["data"]["attributes"]["name"], } - # add location - if location := relationships.get("location"): - if location["data"]: - location = self.farmos_client.resource.get_id( - "asset", "structure", location["data"][0]["id"] - ) - data["location"] = { - "uuid": location["data"]["id"], - "name": location["data"]["attributes"]["name"], - } - - # add owners - if owner := relationships.get("owner"): - data["owners"] = [] - for owner_data in owner["data"]: - owner = self.farmos_client.resource.get_id( - "user", "user", owner_data["id"] - ) - data["owners"].append( - { - "uuid": owner["data"]["id"], - "display_name": owner["data"]["attributes"]["display_name"], - } - ) - - # add image urls - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - data["raw_image_url"] = self.app.get_farmos_url( - image["data"]["attributes"]["uri"]["url"] - ) - # nb. other styles available: medium, wide - data["large_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["large"] - data["thumbnail_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["thumbnail"] - return data - def get_instance_title(self, animal): - return animal["name"] + def normalize_asset(self, animal): - def normalize_animal(self, animal): + normal = super().normalize_asset(animal) birthdate = animal["attributes"]["birthdate"] if birthdate: @@ -184,30 +121,19 @@ class AnimalView(FarmOSMasterView): else: sterile = animal["attributes"]["is_castrated"] - if notes := animal["attributes"]["notes"]: - notes = notes["value"] + normal.update( + { + "birthdate": birthdate, + "sex": animal["attributes"]["sex"] or colander.null, + "is_sterile": sterile, + } + ) - if self.farmos_4x: - archived = animal["attributes"]["archived"] - else: - archived = animal["attributes"]["status"] == "archived" - - return { - "uuid": animal["id"], - "drupal_id": animal["attributes"]["drupal_internal__id"], - "name": animal["attributes"]["name"], - "birthdate": birthdate, - "sex": animal["attributes"]["sex"] or colander.null, - "is_sterile": sterile, - "location": colander.null, # TODO - "archived": archived, - "notes": notes or colander.null, - } + return normal def configure_form(self, form): f = form super().configure_form(f) - animal = f.model_instance # animal_type f.set_node("animal_type", AnimalTypeType(self.request)) @@ -219,52 +145,28 @@ class AnimalView(FarmOSMasterView): # is_sterile f.set_node("is_sterile", colander.Boolean()) - # location - f.set_node("location", StructureType(self.request)) - - # owners - f.set_node("owners", UsersType(self.request)) - - # notes - f.set_widget("notes", "notes") - - # archived - f.set_node("archived", colander.Boolean()) - - # image - if url := animal.get("large_image_url"): - f.set_widget("image", ImageWidget("animal image")) - f.set_default("image", url) - def get_xref_buttons(self, animal): - model = self.app.model - session = self.Session() + buttons = super().get_xref_buttons(animal) - buttons = [ - self.make_button( - "View in farmOS", - primary=True, - url=self.app.get_farmos_url(f"/asset/{animal['drupal_id']}"), - target="_blank", - icon_left="external-link-alt", - ), - ] + if self.app.is_farmos_mirror(): + model = self.app.model + session = self.Session() - if wf_animal := ( - session.query(model.Asset) - .filter(model.Asset.farmos_uuid == animal["uuid"]) - .first() - ): - buttons.append( - self.make_button( - f"View {self.app.get_title()} record", - primary=True, - url=self.request.route_url( - "animal_assets.view", uuid=wf_animal.uuid - ), - icon_left="eye", + if wf_animal := ( + session.query(model.Asset) + .filter(model.Asset.farmos_uuid == animal["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url( + "animal_assets.view", uuid=wf_animal.uuid + ), + icon_left="eye", + ) ) - ) return buttons diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py new file mode 100644 index 0000000..31f21c9 --- /dev/null +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -0,0 +1,175 @@ +# -*- 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 Asset master views +""" + +import colander + +from wuttafarm.web.views.farmos import FarmOSMasterView +from wuttafarm.web.forms.schema import UsersType, StructureType +from wuttafarm.web.forms.widgets import ImageWidget + + +class AssetMasterView(FarmOSMasterView): + """ + Base class for Asset master views + """ + + farmos_asset_type = None + + labels = { + "name": "Asset Name", + "location": "Current Location", + } + + grid_columns = [ + "name", + "archived", + ] + + sort_defaults = "name" + + def get_grid_data(self, columns=None, session=None): + result = self.farmos_client.asset.get(self.farmos_asset_type) + return [self.normalize_asset(a) for a in result["data"]] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + g.set_searchable("name") + + # archived + g.set_renderer("archived", "boolean") + + def get_instance(self): + asset = self.farmos_client.resource.get_id( + "asset", self.farmos_asset_type, self.request.matchdict["uuid"] + ) + self.raw_json = asset + + # instance data + data = self.normalize_asset(asset["data"]) + + if relationships := asset["data"].get("relationships"): + + # add location + if location := relationships.get("location"): + if location["data"]: + location = self.farmos_client.resource.get_id( + "asset", "structure", location["data"][0]["id"] + ) + data["location"] = { + "uuid": location["data"]["id"], + "name": location["data"]["attributes"]["name"], + } + + # add owners + if owner := relationships.get("owner"): + data["owners"] = [] + for owner_data in owner["data"]: + owner = self.farmos_client.resource.get_id( + "user", "user", owner_data["id"] + ) + data["owners"].append( + { + "uuid": owner["data"]["id"], + "display_name": owner["data"]["attributes"]["display_name"], + } + ) + + # add image urls + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + data["raw_image_url"] = self.app.get_farmos_url( + image["data"]["attributes"]["uri"]["url"] + ) + # nb. other styles available: medium, wide + data["large_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["large"] + data["thumbnail_image_url"] = image["data"]["attributes"][ + "image_style_uri" + ]["thumbnail"] + + return data + + def get_instance_title(self, asset): + return asset["name"] + + def normalize_asset(self, asset): + + if notes := asset["attributes"]["notes"]: + notes = notes["value"] + + if self.farmos_4x: + archived = asset["attributes"]["archived"] + else: + archived = asset["attributes"]["status"] == "archived" + + return { + "uuid": asset["id"], + "drupal_id": asset["attributes"]["drupal_internal__id"], + "name": asset["attributes"]["name"], + "location": colander.null, # TODO + "notes": notes or colander.null, + "archived": archived, + } + + def configure_form(self, form): + f = form + super().configure_form(f) + animal = f.model_instance + + # location + f.set_node("location", StructureType(self.request)) + + # owners + f.set_node("owners", UsersType(self.request)) + + # notes + f.set_widget("notes", "notes") + + # archived + f.set_node("archived", colander.Boolean()) + + # image + if url := animal.get("large_image_url"): + f.set_widget("image", ImageWidget("animal image")) + f.set_default("image", url) + + def get_xref_buttons(self, asset): + return [ + self.make_button( + "View in farmOS", + primary=True, + url=self.app.get_farmos_url(f"/asset/{asset['drupal_id']}"), + target="_blank", + icon_left="external-link-alt", + ), + ] From bbb1207b271070f6b6c77c9567f1997a2b1c39fe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 13:23:20 -0600 Subject: [PATCH 101/166] feat: add backend filters, sorting for farmOS animal types, assets could not add pagination due to quirks with how Drupal JSONAPI works for that. but so far it looks like we can add filter/sort to all of the farmOS grids..now just need to do it --- src/wuttafarm/web/grids.py | 200 ++++++++++++++++++++++ src/wuttafarm/web/views/farmos/animals.py | 30 +++- src/wuttafarm/web/views/farmos/assets.py | 39 ++++- src/wuttafarm/web/views/farmos/master.py | 19 +- 4 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 src/wuttafarm/web/grids.py diff --git a/src/wuttafarm/web/grids.py b/src/wuttafarm/web/grids.py new file mode 100644 index 0000000..198d591 --- /dev/null +++ b/src/wuttafarm/web/grids.py @@ -0,0 +1,200 @@ +# -*- 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 . +# +################################################################################ +""" +Custom grid stuff for use with farmOS / JSONAPI +""" + +from wuttaweb.grids.filters import GridFilter + + +class SimpleFilter(GridFilter): + + default_verbs = ["equal", "not_equal"] + + def __init__(self, request, key, path=None, **kwargs): + super().__init__(request, key, **kwargs) + self.path = path or key + + def filter_equal(self, data, value): + if value: + data.add_filter(self.path, "=", value) + return data + + def filter_not_equal(self, data, value): + if value: + data.add_filter(self.path, "<>", value) + return data + + def filter_is_null(self, data, value): + data.add_filter(self.path, "IS NULL", None) + return data + + def filter_is_not_null(self, data, value): + data.add_filter(self.path, "IS NOT NULL", None) + return data + + +class StringFilter(SimpleFilter): + + default_verbs = ["contains", "equal", "not_equal"] + + def filter_contains(self, data, value): + if value: + data.add_filter(self.path, "CONTAINS", value) + return data + + +class NullableStringFilter(StringFilter): + + default_verbs = ["contains", "equal", "not_equal", "is_null", "is_not_null"] + + +class IntegerFilter(SimpleFilter): + + default_verbs = [ + "equal", + "not_equal", + "less_than", + "less_equal", + "greater_than", + "greater_equal", + ] + + def filter_less_than(self, data, value): + if value: + data.add_filter(self.path, "<", value) + return data + + def filter_less_equal(self, data, value): + if value: + data.add_filter(self.path, "<=", value) + return data + + def filter_greater_than(self, data, value): + if value: + data.add_filter(self.path, ">", value) + return data + + def filter_greater_equal(self, data, value): + if value: + data.add_filter(self.path, ">=", value) + return data + + +class NullableIntegerFilter(IntegerFilter): + + default_verbs = ["equal", "not_equal", "is_null", "is_not_null"] + + +class BooleanFilter(SimpleFilter): + + default_verbs = ["is_true", "is_false"] + + def filter_is_true(self, data, value): + data.add_filter(self.path, "=", 1) + return data + + def filter_is_false(self, data, value): + data.add_filter(self.path, "=", 0) + return data + + +class NullableBooleanFilter(BooleanFilter): + + default_verbs = ["is_true", "is_false", "is_null", "is_not_null"] + + +class SimpleSorter: + + def __init__(self, key): + self.key = key + + def __call__(self, data, sortdir): + data.add_sorter(self.key, sortdir) + return data + + +class ResourceData: + + def __init__(self, config, farmos_client, content_type, normalizer=None): + self.config = config + self.farmos_client = farmos_client + self.entity, self.bundle = content_type.split("--") + self.filters = [] + self.sorters = [] + self.normalizer = normalizer + self._data = None + + def __bool__(self): + return True + + def __getitem__(self, subscript): + return self.get_data()[subscript] + + def __len__(self): + return len(self._data) + + def add_filter(self, path, operator, value): + self.filters.append((path, operator, value)) + + def add_sorter(self, path, sortdir): + self.sorters.append((path, sortdir)) + + def get_data(self): + if self._data is None: + params = {} + + for path, operator, value in self.filters: + params[f"filter[{path}][condition][path]"] = path + params[f"filter[{path}][condition][operator]"] = operator + params[f"filter[{path}][condition][value]"] = value + + sorters = [] + for path, sortdir in self.sorters: + prefix = "-" if sortdir == "desc" else "" + sorters.append(f"{prefix}{path}") + if sorters: + params["sort"] = ",".join(sorters) + + # nb. while the API allows for pagination, it does not + # tell me how many total records there are (IIUC). also + # if i ask for e.g. items 21-40 (page 2 @ 20/page) i am + # not guaranteed to get 20 items even if there are plenty + # in the DB, since Drupal may filter some out based on + # permissions. (granted that may not be an issue in + # practice, but can't rule it out.) so the punchline is, + # we fetch "all" (sic) data and send it to the frontend, + # and pagination happens there. + + # TODO: if we ever try again, this sort of works... + # params["page[offset]"] = start + # params["page[limit]"] = stop - start + + result = self.farmos_client.resource.get( + self.entity, self.bundle, params=params + ) + data = result["data"] + if self.normalizer: + data = [self.normalizer(d) for d in data] + + self._data = data + return self._data diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 3a79c8c..ce5cd40 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -31,6 +31,12 @@ from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos.assets import AssetMasterView +from wuttafarm.web.grids import ( + SimpleSorter, + StringFilter, + BooleanFilter, + NullableBooleanFilter, +) from wuttafarm.web.forms.schema import AnimalTypeType @@ -51,13 +57,16 @@ class AnimalView(AssetMasterView): labels = { "animal_type": "Species / Breed", + "is_sterile": "Sterile", } grid_columns = [ + "drupal_id", "name", + "produces_eggs", "birthdate", - "sex", "is_sterile", + "sex", "archived", ] @@ -65,6 +74,7 @@ class AnimalView(AssetMasterView): "name", "animal_type", "birthdate", + "produces_eggs", "sex", "is_sterile", "archived", @@ -80,12 +90,26 @@ class AnimalView(AssetMasterView): def configure_grid(self, grid): g = grid super().configure_grid(g) + enum = self.app.enum + + # produces_eggs + g.set_renderer("produces_eggs", "boolean") + g.set_sorter("produces_eggs", SimpleSorter("produces_eggs")) + g.set_filter("produces_eggs", NullableBooleanFilter) # birthdate g.set_renderer("birthdate", "date") + g.set_sorter("birthdate", SimpleSorter("birthdate")) + + # sex + g.set_enum("sex", enum.ANIMAL_SEX) + g.set_sorter("sex", SimpleSorter("sex")) + g.set_filter("sex", StringFilter) # is_sterile g.set_renderer("is_sterile", "boolean") + g.set_sorter("is_sterile", SimpleSorter("is_sterile")) + g.set_filter("is_sterile", BooleanFilter) def get_instance(self): @@ -126,6 +150,7 @@ class AnimalView(AssetMasterView): "birthdate": birthdate, "sex": animal["attributes"]["sex"] or colander.null, "is_sterile": sterile, + "produces_eggs": animal["attributes"].get("produces_eggs"), } ) @@ -142,6 +167,9 @@ class AnimalView(AssetMasterView): f.set_node("birthdate", WuttaDateTime()) f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + # produces_eggs + f.set_node("produces_eggs", colander.Boolean()) + # is_sterile f.set_node("is_sterile", colander.Boolean()) diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 31f21c9..06f9563 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -28,6 +28,13 @@ import colander from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.schema import UsersType, StructureType from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.web.grids import ( + ResourceData, + StringFilter, + IntegerFilter, + BooleanFilter, + SimpleSorter, +) class AssetMasterView(FarmOSMasterView): @@ -36,6 +43,8 @@ class AssetMasterView(FarmOSMasterView): """ farmos_asset_type = None + filterable = True + sort_on_backend = True labels = { "name": "Asset Name", @@ -43,26 +52,50 @@ class AssetMasterView(FarmOSMasterView): } grid_columns = [ + "drupal_id", "name", "archived", ] sort_defaults = "name" + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + "archived": {"active": True, "verb": "is_false"}, + } + def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.asset.get(self.farmos_asset_type) - return [self.normalize_asset(a) for a in result["data"]] + return ResourceData( + self.config, + self.farmos_client, + f"asset--{self.farmos_asset_type}", + normalizer=self.normalize_asset, + ) def configure_grid(self, grid): g = grid super().configure_grid(g) + # 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") + # name g.set_link("name") - g.set_searchable("name") + g.set_sorter("name", SimpleSorter("name")) + g.set_filter("name", StringFilter) # archived g.set_renderer("archived", "boolean") + g.set_sorter("archived", SimpleSorter("archived")) + g.set_filter("archived", BooleanFilter) + + def grid_row_class(self, asset, data, i): + """ """ + if asset["archived"]: + return "has-background-warning" + return None def get_instance(self): asset = self.farmos_client.resource.get_id( diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 56d70b6..90e8549 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -33,6 +33,7 @@ from wuttaweb.views import MasterView from wuttaweb.forms.schema import WuttaDateTime from wuttafarm.web.util import save_farmos_oauth2_token +from wuttafarm.web.grids import ResourceData, StringFilter, SimpleSorter class FarmOSMasterView(MasterView): @@ -53,6 +54,7 @@ class FarmOSMasterView(MasterView): farmos_refurl_path = None labels = { + "drupal_id": "Drupal ID", "raw_image_url": "Raw Image URL", "large_image_url": "Large Image URL", "thumbnail_image_url": "Thumbnail Image URL", @@ -111,6 +113,8 @@ class TaxonomyMasterView(FarmOSMasterView): """ farmos_taxonomy_type = None + filterable = True + sort_on_backend = True grid_columns = [ "name", @@ -120,6 +124,10 @@ class TaxonomyMasterView(FarmOSMasterView): sort_defaults = "name" + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + form_fields = [ "name", "description", @@ -127,10 +135,12 @@ class TaxonomyMasterView(FarmOSMasterView): ] def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.resource.get( - "taxonomy_term", self.farmos_taxonomy_type + return ResourceData( + self.config, + self.farmos_client, + f"taxonomy_term--{self.farmos_taxonomy_type}", + normalizer=self.normalize_taxonomy_term, ) - return [self.normalize_taxonomy_term(t) for t in result["data"]] def normalize_taxonomy_term(self, term): @@ -155,7 +165,8 @@ class TaxonomyMasterView(FarmOSMasterView): # name g.set_link("name") - g.set_searchable("name") + g.set_sorter("name", SimpleSorter("name")) + g.set_filter("name", StringFilter) # changed g.set_renderer("changed", "datetime") From 1af2b695dca81ac0e0e9342d5932866eecac25b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 19:02:36 -0600 Subject: [PATCH 102/166] feat: use 'include' API param for better Animal Assets grid data this commit also renames all farmOS asset routes, for some reason. at least now they are consistent --- src/wuttafarm/web/forms/widgets.py | 2 +- src/wuttafarm/web/grids.py | 17 +++- src/wuttafarm/web/menus.py | 32 +++---- src/wuttafarm/web/views/assets.py | 29 ++----- src/wuttafarm/web/views/common.py | 12 +-- src/wuttafarm/web/views/farmos/animals.py | 88 ++++++++++++++++---- src/wuttafarm/web/views/farmos/assets.py | 69 +++++++++++++-- src/wuttafarm/web/views/farmos/groups.py | 2 +- src/wuttafarm/web/views/farmos/plants.py | 4 +- src/wuttafarm/web/views/farmos/structures.py | 4 +- 10 files changed, 188 insertions(+), 71 deletions(-) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 24c33eb..7c807fa 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -189,7 +189,7 @@ class StructureWidget(Widget): return tags.link_to( structure["name"], self.request.route_url( - "farmos_structures.view", uuid=structure["uuid"] + "farmos_structure_assets.view", uuid=structure["uuid"] ), ) diff --git a/src/wuttafarm/web/grids.py b/src/wuttafarm/web/grids.py index 198d591..5e5e87c 100644 --- a/src/wuttafarm/web/grids.py +++ b/src/wuttafarm/web/grids.py @@ -135,12 +135,20 @@ class SimpleSorter: class ResourceData: - def __init__(self, config, farmos_client, content_type, normalizer=None): + def __init__( + self, + config, + farmos_client, + content_type, + include=None, + normalizer=None, + ): self.config = config self.farmos_client = farmos_client self.entity, self.bundle = content_type.split("--") self.filters = [] self.sorters = [] + self.include = include self.normalizer = normalizer self._data = None @@ -189,12 +197,17 @@ class ResourceData: # params["page[offset]"] = start # params["page[limit]"] = stop - start + if self.include: + params["include"] = self.include + result = self.farmos_client.resource.get( self.entity, self.bundle, params=params ) data = result["data"] + included = {obj["id"]: obj for obj in result.get("included", [])} + if self.normalizer: - data = [self.normalizer(d) for d in data] + data = [self.normalizer(d, included) for d in data] self._data = data return self._data diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index c79acec..be59006 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -202,13 +202,13 @@ class WuttaFarmMenuHandler(base.MenuHandler): {"type": "sep"}, { "title": "Animal Assets", - "route": "farmos_assets_animal", - "perm": "farmos_assets_animal.list", + "route": "farmos_animal_assets", + "perm": "farmos_animal_assets.list", }, { "title": "Group Assets", - "route": "farmos_groups", - "perm": "farmos_groups.list", + "route": "farmos_group_assets", + "perm": "farmos_group_assets.list", }, { "title": "Land Assets", @@ -217,13 +217,13 @@ class WuttaFarmMenuHandler(base.MenuHandler): }, { "title": "Plant Assets", - "route": "farmos_asset_plant", - "perm": "farmos_asset_plant.list", + "route": "farmos_plant_assets", + "perm": "farmos_plant_assets.list", }, { "title": "Structure Assets", - "route": "farmos_structures", - "perm": "farmos_structures.list", + "route": "farmos_structure_assets", + "perm": "farmos_structure_assets.list", }, {"type": "sep"}, { @@ -311,13 +311,13 @@ class WuttaFarmMenuHandler(base.MenuHandler): "items": [ { "title": "Animal", - "route": "farmos_assets_animal", - "perm": "farmos_assets_animal.list", + "route": "farmos_animal_assets", + "perm": "farmos_animal_assets.list", }, { "title": "Group", - "route": "farmos_groups", - "perm": "farmos_groups.list", + "route": "farmos_group_assets", + "perm": "farmos_group_assets.list", }, { "title": "Land", @@ -326,13 +326,13 @@ class WuttaFarmMenuHandler(base.MenuHandler): }, { "title": "Plant", - "route": "farmos_asset_plant", - "perm": "farmos_asset_plant.list", + "route": "farmos_plant_assets", + "perm": "farmos_plant_assets.list", }, { "title": "Structure", - "route": "farmos_structures", - "perm": "farmos_structures.list", + "route": "farmos_structure_assets", + "perm": "farmos_structure_assets.list", }, {"type": "sep"}, { diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 85835f9..b78f149 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -278,29 +278,14 @@ class AssetMasterView(WuttaFarmMasterView): buttons = super().get_xref_buttons(asset) if asset.farmos_uuid: - - # TODO - route = None - if asset.asset_type == "animal": - route = "farmos_assets_animal.view" - elif asset.asset_type == "group": - route = "farmos_groups.view" - elif asset.asset_type == "land": - route = "farmos_land_assets.view" - elif asset.asset_type == "plant": - route = "farmos_asset_plant.view" - elif asset.asset_type == "structure": - route = "farmos_structures.view" - - if route: - buttons.append( - self.make_button( - "View farmOS record", - primary=True, - url=self.request.route_url(route, uuid=asset.farmos_uuid), - icon_left="eye", - ) + asset_type = self.get_model_class().__wutta_hint__["farmos_asset_type"] + route = f"farmos_{asset_type}_assets.view" + url = self.request.route_url(route, uuid=asset.farmos_uuid) + buttons.append( + self.make_button( + "View farmOS record", primary=True, url=url, icon_left="eye" ) + ) return buttons diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 2efd5c6..f15e92b 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -65,14 +65,14 @@ class CommonView(base.CommonView): "asset_types.list", "asset_types.view", "asset_types.versions", + "farmos_animal_assets.list", + "farmos_animal_assets.view", "farmos_animal_types.list", "farmos_animal_types.view", - "farmos_assets_animal.list", - "farmos_assets_animal.view", "farmos_asset_types.list", "farmos_asset_types.view", - "farmos_groups.list", - "farmos_groups.view", + "farmos_group_assets.list", + "farmos_group_assets.view", "farmos_land_assets.list", "farmos_land_assets.view", "farmos_land_types.list", @@ -87,10 +87,10 @@ class CommonView(base.CommonView): "farmos_logs_medical.view", "farmos_logs_observation.list", "farmos_logs_observation.view", + "farmos_structure_assets.list", + "farmos_structure_assets.view", "farmos_structure_types.list", "farmos_structure_types.view", - "farmos_structures.list", - "farmos_structures.view", "farmos_users.list", "farmos_users.view", "group_assets.create", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index ce5cd40..3f329f0 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -26,6 +26,7 @@ Master view for Farm Animals import datetime import colander +from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget @@ -45,11 +46,11 @@ class AnimalView(AssetMasterView): Master view for Farm Animals """ - model_name = "farmos_animal_asset" + model_name = "farmos_animal_assets" model_title = "farmOS Animal Asset" model_title_plural = "farmOS Animal Assets" - route_prefix = "farmos_assets_animal" + route_prefix = "farmos_animal_assets" url_prefix = "/farmOS/assets/animal" farmos_asset_type = "animal" @@ -57,6 +58,7 @@ class AnimalView(AssetMasterView): labels = { "animal_type": "Species / Breed", + "animal_type_name": "Species / Breed", "is_sterile": "Sterile", } @@ -64,9 +66,13 @@ class AnimalView(AssetMasterView): "drupal_id", "name", "produces_eggs", + "animal_type_name", "birthdate", "is_sterile", "sex", + "groups", + "owners", + "locations", "archived", ] @@ -78,6 +84,7 @@ class AnimalView(AssetMasterView): "sex", "is_sterile", "archived", + "groups", "owners", "location", "notes", @@ -87,6 +94,10 @@ class AnimalView(AssetMasterView): "image", ] + def get_grid_data(self, **kwargs): + kwargs.setdefault("include", "animal_type,group,owner,location") + return super().get_grid_data(**kwargs) + def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -97,6 +108,11 @@ class AnimalView(AssetMasterView): g.set_sorter("produces_eggs", SimpleSorter("produces_eggs")) g.set_filter("produces_eggs", NullableBooleanFilter) + # animal_type_name + g.set_renderer("animal_type_name", self.render_animal_type_for_grid) + g.set_sorter("animal_type_name", SimpleSorter("animal_type.name")) + g.set_filter("animal_type_name", StringFilter, path="animal_type.name") + # birthdate g.set_renderer("birthdate", "date") g.set_sorter("birthdate", SimpleSorter("birthdate")) @@ -106,11 +122,27 @@ class AnimalView(AssetMasterView): g.set_sorter("sex", SimpleSorter("sex")) g.set_filter("sex", StringFilter) + # groups + g.set_label("groups", "Group Membership") + g.set_renderer("groups", self.render_groups_for_grid) + # is_sterile g.set_renderer("is_sterile", "boolean") g.set_sorter("is_sterile", SimpleSorter("is_sterile")) g.set_filter("is_sterile", BooleanFilter) + def render_animal_type_for_grid(self, animal, field, value): + uuid = animal["animal_type"]["uuid"] + url = self.request.route_url("farmos_animal_types.view", uuid=uuid) + return tags.link_to(value, url) + + def render_groups_for_grid(self, animal, field, value): + links = [] + for group in animal["group_objects"]: + url = self.request.route_url("farmos_group_assets.view", uuid=group["uuid"]) + links.append(tags.link_to(group["name"], url)) + return ", ".join(links) + def get_instance(self): data = super().get_instance() @@ -118,21 +150,21 @@ class AnimalView(AssetMasterView): if relationships := self.raw_json["data"].get("relationships"): # add animal type - if animal_type := relationships.get("animal_type"): - if animal_type["data"]: - animal_type = self.farmos_client.resource.get_id( - "taxonomy_term", "animal_type", animal_type["data"]["id"] - ) - data["animal_type"] = { - "uuid": animal_type["data"]["id"], - "name": animal_type["data"]["attributes"]["name"], - } + if not data.get("animal_type"): + if animal_type := relationships.get("animal_type"): + if animal_type["data"]: + animal_type = self.farmos_client.resource.get_id( + "taxonomy_term", "animal_type", animal_type["data"]["id"] + ) + data["animal_type"] = { + "uuid": animal_type["data"]["id"], + "name": animal_type["data"]["attributes"]["name"], + } return data - def normalize_asset(self, animal): - - normal = super().normalize_asset(animal) + def normalize_asset(self, animal, included): + normal = super().normalize_asset(animal, included) birthdate = animal["attributes"]["birthdate"] if birthdate: @@ -145,8 +177,36 @@ class AnimalView(AssetMasterView): else: sterile = animal["attributes"]["is_castrated"] + animal_type = None + animal_type_name = None + group_objects = [] + group_names = [] + if relationships := animal.get("relationships"): + + if animal_type := relationships.get("animal_type"): + if animal_type := included.get(animal_type["data"]["id"]): + animal_type = { + "uuid": animal_type["id"], + "name": animal_type["attributes"]["name"], + } + animal_type_name = animal_type["name"] + + if groups := relationships.get("group"): + for group in groups["data"]: + if group := included.get(group["id"]): + group = { + "uuid": group["id"], + "name": group["attributes"]["name"], + } + group_objects.append(group) + group_names.append(group["name"]) + normal.update( { + "animal_type": animal_type, + "animal_type_name": animal_type_name, + "group_objects": group_objects, + "group_names": group_names, "birthdate": birthdate, "sex": animal["attributes"]["sex"] or colander.null, "is_sterile": sterile, diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 06f9563..1a61d42 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -24,6 +24,7 @@ Base class for Asset master views """ import colander +from webhelpers2.html import tags from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.schema import UsersType, StructureType @@ -48,12 +49,15 @@ class AssetMasterView(FarmOSMasterView): labels = { "name": "Asset Name", - "location": "Current Location", + "owners": "Owner", + "locations": "Location", } grid_columns = [ "drupal_id", "name", + "owners", + "locations", "archived", ] @@ -64,12 +68,14 @@ class AssetMasterView(FarmOSMasterView): "archived": {"active": True, "verb": "is_false"}, } - def get_grid_data(self, columns=None, session=None): + def get_grid_data(self, columns=None, session=None, **kwargs): + kwargs.setdefault("include", "owner,location") + kwargs.setdefault("normalizer", self.normalize_asset) return ResourceData( self.config, self.farmos_client, f"asset--{self.farmos_asset_type}", - normalizer=self.normalize_asset, + **kwargs, ) def configure_grid(self, grid): @@ -86,11 +92,33 @@ class AssetMasterView(FarmOSMasterView): g.set_sorter("name", SimpleSorter("name")) g.set_filter("name", StringFilter) + # owners + g.set_renderer("owners", self.render_owners_for_grid) + + # locations + g.set_renderer("locations", self.render_locations_for_grid) + # archived g.set_renderer("archived", "boolean") g.set_sorter("archived", SimpleSorter("archived")) g.set_filter("archived", BooleanFilter) + def render_owners_for_grid(self, asset, field, value): + links = [] + for user in value: + url = self.request.route_url("farmos_users.view", uuid=user["uuid"]) + links.append(tags.link_to(user["name"], url)) + return ", ".join(links) + + def render_locations_for_grid(self, asset, field, value): + links = [] + for location in value: + asset_type = location["type"].split("--")[1] + route = f"farmos_{asset_type}_assets.view" + url = self.request.route_url(route, uuid=location["uuid"]) + links.append(tags.link_to(location["name"], url)) + return ", ".join(links) + def grid_row_class(self, asset, data, i): """ """ if asset["archived"]: @@ -104,7 +132,7 @@ class AssetMasterView(FarmOSMasterView): self.raw_json = asset # instance data - data = self.normalize_asset(asset["data"]) + data = self.normalize_asset(asset["data"], {}) if relationships := asset["data"].get("relationships"): @@ -155,7 +183,7 @@ class AssetMasterView(FarmOSMasterView): def get_instance_title(self, asset): return asset["name"] - def normalize_asset(self, asset): + def normalize_asset(self, asset, included): if notes := asset["attributes"]["notes"]: notes = notes["value"] @@ -165,12 +193,43 @@ class AssetMasterView(FarmOSMasterView): else: archived = asset["attributes"]["status"] == "archived" + owner_objects = [] + owner_names = [] + location_objects = [] + location_names = [] + if relationships := asset.get("relationships"): + + if owners := relationships.get("owner"): + for user in owners["data"]: + if user := included.get(user["id"]): + user = { + "uuid": user["id"], + "name": user["attributes"]["name"], + } + owner_objects.append(user) + owner_names.append(user["name"]) + + if locations := relationships.get("location"): + for location in locations["data"]: + if location := included.get(location["id"]): + location = { + "uuid": location["id"], + "type": location["type"], + "name": location["attributes"]["name"], + } + location_objects.append(location) + location_names.append(location["name"]) + return { "uuid": asset["id"], "drupal_id": asset["attributes"]["drupal_internal__id"], "name": asset["attributes"]["name"], "location": colander.null, # TODO "notes": notes or colander.null, + "owners": owner_objects, + "owner_names": owner_names, + "locations": location_objects, + "location_names": location_names, "archived": archived, } diff --git a/src/wuttafarm/web/views/farmos/groups.py b/src/wuttafarm/web/views/farmos/groups.py index ddb7278..8794965 100644 --- a/src/wuttafarm/web/views/farmos/groups.py +++ b/src/wuttafarm/web/views/farmos/groups.py @@ -41,7 +41,7 @@ class GroupView(FarmOSMasterView): model_title = "farmOS Group" model_title_plural = "farmOS Groups" - route_prefix = "farmos_groups" + route_prefix = "farmos_group_assets" url_prefix = "/farmOS/groups" farmos_refurl_path = "/assets/group" diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py index 95a2dab..57bf2d4 100644 --- a/src/wuttafarm/web/views/farmos/plants.py +++ b/src/wuttafarm/web/views/farmos/plants.py @@ -80,11 +80,11 @@ class PlantAssetView(FarmOSMasterView): Master view for farmOS Plant Assets """ - model_name = "farmos_asset_plant" + model_name = "farmos_plant_assets" model_title = "farmOS Plant Asset" model_title_plural = "farmOS Plant Assets" - route_prefix = "farmos_asset_plant" + route_prefix = "farmos_plant_assets" url_prefix = "/farmOS/assets/plant" farmos_refurl_path = "/assets/plant" diff --git a/src/wuttafarm/web/views/farmos/structures.py b/src/wuttafarm/web/views/farmos/structures.py index 550f432..b6dc97b 100644 --- a/src/wuttafarm/web/views/farmos/structures.py +++ b/src/wuttafarm/web/views/farmos/structures.py @@ -39,11 +39,11 @@ class StructureView(FarmOSMasterView): View for farmOS Structures """ - model_name = "farmos_structure" + model_name = "farmos_structure_asset" model_title = "farmOS Structure" model_title_plural = "farmOS Structures" - route_prefix = "farmos_structures" + route_prefix = "farmos_structure_assets" url_prefix = "/farmOS/structures" farmos_refurl_path = "/assets/structure" From e5e3d3836547077424004ab975d1842155e4bf04 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 20:22:48 -0600 Subject: [PATCH 103/166] fix: add setting to toggle "farmOS-style grid links" not sure yet if users prefer farmOS style, but will assume so by default just to be safe. but i want the "traditional" behavior myself, so setting is needed either way --- .../web/templates/appinfo/configure.mako | 33 +++++++++++++++---- src/wuttafarm/web/util.py | 4 +++ src/wuttafarm/web/views/animals.py | 10 ++++++ src/wuttafarm/web/views/farmos/animals.py | 18 +++++++--- src/wuttafarm/web/views/farmos/assets.py | 26 +++++++++------ src/wuttafarm/web/views/farmos/master.py | 3 +- src/wuttafarm/web/views/master.py | 6 ++++ src/wuttafarm/web/views/settings.py | 7 ++++ 8 files changed, 85 insertions(+), 22 deletions(-) diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako index d9e448f..3760577 100644 --- a/src/wuttafarm/web/templates/appinfo/configure.mako +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -7,6 +7,13 @@

farmOS

+ + + + + - - - - + + Use farmOS-style grid links + + <${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}"> + + +
diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py index 65d637d..cd4ec0d 100644 --- a/src/wuttafarm/web/util.py +++ b/src/wuttafarm/web/util.py @@ -38,3 +38,7 @@ def save_farmos_oauth2_token(request, token): # save token to user session request.session["farmos.oauth2.token"] = token + + +def use_farmos_style_grid_links(config): + return config.get_bool(f"{config.appname}.farmos_style_grid_links", default=True) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 09162b2..7fa6a09 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -23,6 +23,8 @@ Master view for Animals """ +from webhelpers2.html import tags + from wuttaweb.forms.schema import WuttaDictEnum from wuttafarm.db.model import AnimalType, AnimalAsset @@ -189,6 +191,10 @@ class AnimalAssetView(AssetMasterView): g.set_joiner("animal_type", lambda q: q.join(model.AnimalType)) g.set_sorter("animal_type", model.AnimalType.name) g.set_filter("animal_type", model.AnimalType.name) + if self.farmos_style_grid_links: + g.set_renderer("animal_type", self.render_animal_type_for_grid) + else: + g.set_link("animal_type") # birthdate g.set_renderer("birthdate", "date") @@ -196,6 +202,10 @@ class AnimalAssetView(AssetMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) + def render_animal_type_for_grid(self, animal, field, value): + url = self.request.route_url("animal_types.view", uuid=animal.animal_type_uuid) + return tags.link_to(value, url) + def configure_form(self, form): f = form super().configure_form(f) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 3f329f0..44a1cdc 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -109,7 +109,10 @@ class AnimalView(AssetMasterView): g.set_filter("produces_eggs", NullableBooleanFilter) # animal_type_name - g.set_renderer("animal_type_name", self.render_animal_type_for_grid) + if self.farmos_style_grid_links: + g.set_renderer("animal_type_name", self.render_animal_type_for_grid) + else: + g.set_link("animal_type_name") g.set_sorter("animal_type_name", SimpleSorter("animal_type.name")) g.set_filter("animal_type_name", StringFilter, path="animal_type.name") @@ -137,11 +140,16 @@ class AnimalView(AssetMasterView): return tags.link_to(value, url) def render_groups_for_grid(self, animal, field, value): - links = [] + groups = [] for group in animal["group_objects"]: - url = self.request.route_url("farmos_group_assets.view", uuid=group["uuid"]) - links.append(tags.link_to(group["name"], url)) - return ", ".join(links) + if self.farmos_style_grid_links: + url = self.request.route_url( + "farmos_group_assets.view", uuid=group["uuid"] + ) + groups.append(tags.link_to(group["name"], url)) + else: + groups.append(group["name"]) + return ", ".join(groups) def get_instance(self): diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 1a61d42..1b81b35 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -104,20 +104,26 @@ class AssetMasterView(FarmOSMasterView): g.set_filter("archived", BooleanFilter) def render_owners_for_grid(self, asset, field, value): - links = [] + owners = [] for user in value: - url = self.request.route_url("farmos_users.view", uuid=user["uuid"]) - links.append(tags.link_to(user["name"], url)) - return ", ".join(links) + 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): - links = [] + locations = [] for location in value: - asset_type = location["type"].split("--")[1] - route = f"farmos_{asset_type}_assets.view" - url = self.request.route_url(route, uuid=location["uuid"]) - links.append(tags.link_to(location["name"], url)) - return ", ".join(links) + if self.farmos_style_grid_links: + asset_type = location["type"].split("--")[1] + route = f"farmos_{asset_type}_assets.view" + url = self.request.route_url(route, uuid=location["uuid"]) + locations.append(tags.link_to(location["name"], url)) + else: + locations.append(location["name"]) + return ", ".join(locations) def grid_row_class(self, asset, data, i): """ """ diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 90e8549..ae4b97e 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -32,7 +32,7 @@ import markdown from wuttaweb.views import MasterView from wuttaweb.forms.schema import WuttaDateTime -from wuttafarm.web.util import save_farmos_oauth2_token +from wuttafarm.web.util import save_farmos_oauth2_token, use_farmos_style_grid_links from wuttafarm.web.grids import ResourceData, StringFilter, SimpleSorter @@ -65,6 +65,7 @@ class FarmOSMasterView(MasterView): self.farmos_client = self.get_farmos_client() self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) self.raw_json = None + self.farmos_style_grid_links = use_farmos_style_grid_links(self.config) def get_farmos_client(self): token = self.request.session.get("farmos.oauth2.token") diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 0e25a30..82d64bc 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -27,6 +27,8 @@ from webhelpers2.html import tags from wuttaweb.views import MasterView +from wuttafarm.web.util import use_farmos_style_grid_links + class WuttaFarmMasterView(MasterView): """ @@ -49,6 +51,10 @@ class WuttaFarmMasterView(MasterView): "thumbnail_url": "Thumbnail URL", } + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.farmos_style_grid_links = use_farmos_style_grid_links(self.config) + def get_farmos_url(self, obj): return None diff --git a/src/wuttafarm/web/views/settings.py b/src/wuttafarm/web/views/settings.py index 6372c40..86d7a0c 100644 --- a/src/wuttafarm/web/views/settings.py +++ b/src/wuttafarm/web/views/settings.py @@ -27,6 +27,8 @@ from webhelpers2.html import tags from wuttaweb.views import settings as base +from wuttafarm.web.util import use_farmos_style_grid_links + class AppInfoView(base.AppInfoView): """ @@ -63,6 +65,11 @@ class AppInfoView(base.AppInfoView): "name": f"{self.app.appname}.farmos_integration_mode", "default": self.app.get_farmos_integration_mode(), }, + { + "name": f"{self.app.appname}.farmos_style_grid_links", + "type": bool, + "default": use_farmos_style_grid_links(self.config), + }, ] ) return simple_settings From 5d7dea5a843e0ddfb594404f2dd372dd91bb57cb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 20:52:08 -0600 Subject: [PATCH 104/166] fix: add thumbnail to farmOS asset base view --- src/wuttafarm/web/views/farmos/animals.py | 3 ++- src/wuttafarm/web/views/farmos/assets.py | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 44a1cdc..e11ff59 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -63,6 +63,7 @@ class AnimalView(AssetMasterView): } grid_columns = [ + "thumbnail", "drupal_id", "name", "produces_eggs", @@ -95,7 +96,7 @@ class AnimalView(AssetMasterView): ] def get_grid_data(self, **kwargs): - kwargs.setdefault("include", "animal_type,group,owner,location") + kwargs.setdefault("include", "image,animal_type,group,owner,location") return super().get_grid_data(**kwargs) def configure_grid(self, grid): diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 1b81b35..c662cf8 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -54,6 +54,7 @@ class AssetMasterView(FarmOSMasterView): } grid_columns = [ + "thumbnail", "drupal_id", "name", "owners", @@ -69,7 +70,7 @@ class AssetMasterView(FarmOSMasterView): } def get_grid_data(self, columns=None, session=None, **kwargs): - kwargs.setdefault("include", "owner,location") + kwargs.setdefault("include", "image,owner,location") kwargs.setdefault("normalizer", self.normalize_asset) return ResourceData( self.config, @@ -82,6 +83,11 @@ class AssetMasterView(FarmOSMasterView): g = grid super().configure_grid(g) + # thumbnail + g.set_renderer("thumbnail", self.render_grid_thumbnail) + g.set_label("thumbnail", "", column_only=True) + g.set_centered("thumbnail") + # drupal_id g.set_label("drupal_id", "ID", column_only=True) g.set_sorter("drupal_id", SimpleSorter("drupal_internal__id")) @@ -103,6 +109,11 @@ class AssetMasterView(FarmOSMasterView): g.set_sorter("archived", SimpleSorter("archived")) g.set_filter("archived", BooleanFilter) + def render_grid_thumbnail(self, obj, field, value): + if url := obj.get("thumbnail_url"): + 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: @@ -203,6 +214,7 @@ class AssetMasterView(FarmOSMasterView): owner_names = [] location_objects = [] location_names = [] + thumbnail_url = None if relationships := asset.get("relationships"): if owners := relationships.get("owner"): @@ -226,6 +238,13 @@ class AssetMasterView(FarmOSMasterView): location_objects.append(location) location_names.append(location["name"]) + if images := relationships.get("image"): + for image in images["data"]: + if image := included.get(image["id"]): + thumbnail_url = image["attributes"]["image_style_uri"][ + "thumbnail" + ] + return { "uuid": asset["id"], "drupal_id": asset["attributes"]["drupal_internal__id"], @@ -237,6 +256,7 @@ class AssetMasterView(FarmOSMasterView): "locations": location_objects, "location_names": location_names, "archived": archived, + "thumbnail_url": thumbnail_url, } def configure_form(self, form): From c976d94bdda18db7db07f6b387d64a6e0919b48e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 21:37:57 -0600 Subject: [PATCH 105/166] fix: add grid filter for animal birthdate --- src/wuttafarm/web/grids.py | 107 ++++++++++++++++++++-- src/wuttafarm/web/views/farmos/animals.py | 2 + 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/src/wuttafarm/web/grids.py b/src/wuttafarm/web/grids.py index 5e5e87c..8f4cde5 100644 --- a/src/wuttafarm/web/grids.py +++ b/src/wuttafarm/web/grids.py @@ -23,6 +23,8 @@ Custom grid stuff for use with farmOS / JSONAPI """ +import datetime + from wuttaweb.grids.filters import GridFilter @@ -35,12 +37,12 @@ class SimpleFilter(GridFilter): self.path = path or key def filter_equal(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "=", value) return data def filter_not_equal(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "<>", value) return data @@ -58,7 +60,7 @@ class StringFilter(SimpleFilter): default_verbs = ["contains", "equal", "not_equal"] def filter_contains(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "CONTAINS", value) return data @@ -80,22 +82,22 @@ class IntegerFilter(SimpleFilter): ] def filter_less_than(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "<", value) return data def filter_less_equal(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, "<=", value) return data def filter_greater_than(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, ">", value) return data def filter_greater_equal(self, data, value): - if value: + if value := self.coerce_value(value): data.add_filter(self.path, ">=", value) return data @@ -123,6 +125,88 @@ class NullableBooleanFilter(BooleanFilter): default_verbs = ["is_true", "is_false", "is_null", "is_not_null"] +# TODO: this may not work, it's not used anywhere yet +class DateFilter(SimpleFilter): + + data_type = "date" + + default_verbs = [ + "equal", + "not_equal", + "greater_than", + "greater_equal", + "less_than", + "less_equal", + # 'between', + ] + + default_verb_labels = { + "equal": "on", + "not_equal": "not on", + "greater_than": "after", + "greater_equal": "on or after", + "less_than": "before", + "less_equal": "on or before", + # "between": "between", + "is_null": "is null", + "is_not_null": "is not null", + "is_any": "is any", + } + + def coerce_value(self, value): + if value: + if isinstance(value, datetime.date): + return value + + try: + dt = datetime.datetime.strptime(value, "%Y-%m-%d") + except ValueError: + log.warning("invalid date value: %s", value) + else: + return dt.date() + + return None + + +# TODO: this is not very complete yet, so far used only for animal birthdate +class DateTimeFilter(DateFilter): + + default_verbs = ["equal", "is_null", "is_not_null"] + + def coerce_value(self, value): + """ + Convert user input to a proper ``datetime.date`` object. + """ + if value: + if isinstance(value, datetime.date): + return value + + try: + dt = datetime.datetime.strptime(value, "%Y-%m-%d") + except ValueError: + log.warning("invalid date value: %s", value) + else: + return dt.date() + + return None + + def filter_equal(self, data, value): + if value := self.coerce_value(value): + + start = datetime.datetime.combine(value, datetime.time(0)) + start = self.app.localtime(start, from_utc=False) + + stop = datetime.datetime.combine( + value + datetime.timedelta(days=1), datetime.time(0) + ) + stop = self.app.localtime(stop, from_utc=False) + + data.add_filter(self.path, ">=", int(start.timestamp())) + data.add_filter(self.path, "<", int(stop.timestamp())) + + return data + + class SimpleSorter: def __init__(self, key): @@ -171,10 +255,13 @@ class ResourceData: if self._data is None: params = {} + i = 0 for path, operator, value in self.filters: - params[f"filter[{path}][condition][path]"] = path - params[f"filter[{path}][condition][operator]"] = operator - params[f"filter[{path}][condition][value]"] = value + i += 1 + key = f"{i:03d}" + params[f"filter[{key}][condition][path]"] = path + params[f"filter[{key}][condition][operator]"] = operator + params[f"filter[{key}][condition][value]"] = value sorters = [] for path, sortdir in self.sorters: diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index e11ff59..5389d8f 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -37,6 +37,7 @@ from wuttafarm.web.grids import ( StringFilter, BooleanFilter, NullableBooleanFilter, + DateTimeFilter, ) from wuttafarm.web.forms.schema import AnimalTypeType @@ -120,6 +121,7 @@ class AnimalView(AssetMasterView): # birthdate g.set_renderer("birthdate", "date") g.set_sorter("birthdate", SimpleSorter("birthdate")) + g.set_filter("birthdate", DateTimeFilter) # sex g.set_enum("sex", enum.ANIMAL_SEX) From ad6ac13d503337501edb8420a7105b9760113d60 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Feb 2026 18:36:26 -0600 Subject: [PATCH 106/166] feat: add basic CRUD for direct API views: animal types, animal assets --- src/wuttafarm/web/forms/schema.py | 59 +++++++- src/wuttafarm/web/forms/widgets.py | 64 ++++++++- src/wuttafarm/web/views/animals.py | 2 +- src/wuttafarm/web/views/farmos/animals.py | 101 +++++++++++--- src/wuttafarm/web/views/farmos/assets.py | 163 +++++++++++++--------- src/wuttafarm/web/views/farmos/master.py | 59 +++++++- 6 files changed, 351 insertions(+), 97 deletions(-) diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index df2a45c..469a466 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -58,10 +58,50 @@ class AnimalTypeRef(ObjectRef): class FarmOSRef(colander.SchemaType): def __init__(self, request, route_prefix, *args, **kwargs): + self.values = kwargs.pop("values", None) super().__init__(*args, **kwargs) self.request = request self.route_prefix = route_prefix + def get_values(self): + if callable(self.values): + self.values = self.values() + return self.values + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + # nb. keep a ref to this for later use + node.model_instance = appstruct + + # serialize to PK as string + return appstruct["uuid"] + + def deserialize(self, node, cstruct): + if not cstruct: + return colander.null + + # nb. deserialize to PK string, not dict + return cstruct + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import FarmOSRefWidget + + if not kwargs.get("readonly"): + if "values" not in kwargs: + if values := self.get_values(): + kwargs["values"] = values + + return FarmOSRefWidget(self.request, self.route_prefix, **kwargs) + + +class FarmOSRefs(WuttaSet): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(request, *args, **kwargs) + self.route_prefix = route_prefix + def serialize(self, node, appstruct): if appstruct is colander.null: return colander.null @@ -69,10 +109,23 @@ class FarmOSRef(colander.SchemaType): return json.dumps(appstruct) def widget_maker(self, **kwargs): - """ """ - from wuttafarm.web.forms.widgets import FarmOSRefWidget + from wuttafarm.web.forms.widgets import FarmOSRefsWidget - return FarmOSRefWidget(self.request, self.route_prefix, **kwargs) + return FarmOSRefsWidget(self.request, self.route_prefix, **kwargs) + + +class FarmOSLocationRefs(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 FarmOSLocationRefsWidget + + return FarmOSLocationRefsWidget(self.request, **kwargs) class AnimalTypeType(colander.SchemaType): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 7c807fa..dfbaefe 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -26,7 +26,7 @@ Custom form widgets for WuttaFarm import json import colander -from deform.widget import Widget +from deform.widget import Widget, SelectWidget from webhelpers2.html import HTML, tags from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget @@ -54,7 +54,7 @@ class ImageWidget(Widget): return super().serialize(field, cstruct, **kw) -class FarmOSRefWidget(Widget): +class FarmOSRefWidget(SelectWidget): """ Generic widget to display "any reference field" - as a link to view the farmOS record it references. Only used by the farmOS @@ -72,7 +72,12 @@ class FarmOSRefWidget(Widget): if cstruct in (colander.null, None): return HTML.tag("span") - obj = json.loads(cstruct) + try: + obj = json.loads(cstruct) + except json.JSONDecodeError: + name = dict(self.values)[cstruct] + obj = {"uuid": cstruct, "name": name} + return tags.link_to( obj["name"], self.request.route_url(f"{self.route_prefix}.view", uuid=obj["uuid"]), @@ -81,6 +86,59 @@ class FarmOSRefWidget(Widget): return super().serialize(field, cstruct, **kw) +class FarmOSRefsWidget(Widget): + + def __init__(self, request, route_prefix, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.route_prefix = route_prefix + + def serialize(self, field, cstruct, **kw): + readonly = kw.get("readonly", self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag("span") + + links = [] + for obj in json.loads(cstruct): + url = self.request.route_url( + f"{self.route_prefix}.view", uuid=obj["uuid"] + ) + links.append(HTML.tag("li", c=tags.link_to(obj["name"], url))) + + return HTML.tag("ul", c=links) + + return super().serialize(field, cstruct, **kw) + + +class FarmOSLocationRefsWidget(Widget): + """ + Widget to display a "Locations" 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") + + locations = [] + for location in json.loads(cstruct): + asset_type = location["type"].split("--")[1] + url = self.request.route_url( + f"farmos_{asset_type}_assets.view", uuid=location["uuid"] + ) + locations.append(HTML.tag("li", c=tags.link_to(location["name"], url))) + + return HTML.tag("ul", c=locations) + + return super().serialize(field, cstruct, **kw) + + class AnimalTypeWidget(Widget): """ Widget to display an "animal type" field. diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 7fa6a09..76e0335 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -167,9 +167,9 @@ class AnimalAssetView(AssetMasterView): "asset_name", "animal_type", "birthdate", + "produces_eggs", "sex", "is_sterile", - "produces_eggs", "notes", "asset_type", "archived", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 5389d8f..690e7ee 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -28,7 +28,7 @@ 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.assets import AssetMasterView @@ -39,7 +39,7 @@ from wuttafarm.web.grids import ( NullableBooleanFilter, DateTimeFilter, ) -from wuttafarm.web.forms.schema import AnimalTypeType +from wuttafarm.web.forms.schema import FarmOSRef class AnimalView(AssetMasterView): @@ -85,20 +85,23 @@ class AnimalView(AssetMasterView): "produces_eggs", "sex", "is_sterile", - "archived", + "notes", + "asset_type_name", "groups", "owners", - "location", - "notes", - "raw_image_url", - "large_image_url", - "thumbnail_image_url", + "locations", + "archived", + "thumbnail_url", + "image_url", + "thumbnail", "image", ] - def get_grid_data(self, **kwargs): - kwargs.setdefault("include", "image,animal_type,group,owner,location") - return super().get_grid_data(**kwargs) + def get_farmos_api_includes(self): + includes = super().get_farmos_api_includes() + includes.add("animal_type") + includes.add("group") + return includes def configure_grid(self, grid): g = grid @@ -188,19 +191,17 @@ class AnimalView(AssetMasterView): else: sterile = animal["attributes"]["is_castrated"] - animal_type = None - animal_type_name = None + animal_type_object = None group_objects = [] group_names = [] if relationships := animal.get("relationships"): if animal_type := relationships.get("animal_type"): if animal_type := included.get(animal_type["data"]["id"]): - animal_type = { + animal_type_object = { "uuid": animal_type["id"], "name": animal_type["attributes"]["name"], } - animal_type_name = animal_type["name"] if groups := relationships.get("group"): for group in groups["data"]: @@ -214,8 +215,9 @@ class AnimalView(AssetMasterView): normal.update( { - "animal_type": animal_type, - "animal_type_name": animal_type_name, + "animal_type": animal_type_object, + "animal_type_uuid": animal_type_object["uuid"], + "animal_type_name": animal_type_object["name"], "group_objects": group_objects, "group_names": group_names, "birthdate": birthdate, @@ -227,23 +229,78 @@ class AnimalView(AssetMasterView): return normal + def get_animal_types(self): + animal_types = [] + result = self.farmos_client.resource.get( + "taxonomy_term", "animal_type", params={"sort": "name"} + ) + for animal_type in result["data"]: + animal_types.append((animal_type["id"], animal_type["attributes"]["name"])) + return animal_types + def configure_form(self, form): f = form super().configure_form(f) + enum = self.app.enum + animal = f.model_instance # animal_type - f.set_node("animal_type", AnimalTypeType(self.request)) - - # birthdate - f.set_node("birthdate", WuttaDateTime()) - f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + f.set_node( + "animal_type", + FarmOSRef( + self.request, "farmos_animal_types", values=self.get_animal_types + ), + ) # produces_eggs f.set_node("produces_eggs", colander.Boolean()) + # birthdate + f.set_node("birthdate", WuttaDateTime()) + f.set_widget("birthdate", WuttaDateTimeWidget(self.request)) + f.set_required("birthdate", False) + + # sex + if not (self.creating or self.editing) and not animal["sex"]: + pass # TODO: dict enum widget does not handle null values well + else: + f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) + f.set_required("sex", False) + # is_sterile f.set_node("is_sterile", colander.Boolean()) + # groups + if self.creating or self.editing: + f.remove("groups") # TODO + + def get_api_payload(self, animal): + payload = super().get_api_payload(animal) + + birthdate = None + if animal["birthdate"]: + birthdate = self.app.localtime(animal["birthdate"]).timestamp() + + attrs = { + "sex": animal["sex"] or None, + "is_sterile": animal["is_sterile"], + "produces_eggs": animal["produces_eggs"], + "birthdate": birthdate, + } + + rels = { + "animal_type": { + "data": { + "id": animal["animal_type"], + "type": "taxonomy_term--animal_type", + } + } + } + + payload["attributes"].update(attrs) + payload.setdefault("relationships", {}).update(rels) + return payload + def get_xref_buttons(self, animal): buttons = super().get_xref_buttons(animal) diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index c662cf8..f985c6b 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -27,7 +27,7 @@ import colander from webhelpers2.html import tags from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType, StructureType +from wuttafarm.web.forms.schema import FarmOSRefs, FarmOSLocationRefs from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.grids import ( ResourceData, @@ -44,13 +44,19 @@ class AssetMasterView(FarmOSMasterView): """ farmos_asset_type = None + creatable = True + editable = True + deletable = True filterable = True sort_on_backend = True labels = { "name": "Asset Name", + "asset_type_name": "Asset Type", "owners": "Owner", "locations": "Location", + "thumbnail_url": "Thumbnail URL", + "image_url": "Image URL", } grid_columns = [ @@ -69,14 +75,13 @@ class AssetMasterView(FarmOSMasterView): "archived": {"active": True, "verb": "is_false"}, } - def get_grid_data(self, columns=None, session=None, **kwargs): - kwargs.setdefault("include", "image,owner,location") - kwargs.setdefault("normalizer", self.normalize_asset) + def get_grid_data(self, **kwargs): return ResourceData( self.config, self.farmos_client, f"asset--{self.farmos_asset_type}", - **kwargs, + include=",".join(self.get_farmos_api_includes()), + normalizer=self.normalize_asset, ) def configure_grid(self, grid): @@ -142,60 +147,19 @@ class AssetMasterView(FarmOSMasterView): return "has-background-warning" return None + def get_farmos_api_includes(self): + return {"asset_type", "location", "owner", "image"} + def get_instance(self): asset = self.farmos_client.resource.get_id( - "asset", self.farmos_asset_type, self.request.matchdict["uuid"] + "asset", + self.farmos_asset_type, + self.request.matchdict["uuid"], + params={"include": ",".join(self.get_farmos_api_includes())}, ) self.raw_json = asset - - # instance data - data = self.normalize_asset(asset["data"], {}) - - if relationships := asset["data"].get("relationships"): - - # add location - if location := relationships.get("location"): - if location["data"]: - location = self.farmos_client.resource.get_id( - "asset", "structure", location["data"][0]["id"] - ) - data["location"] = { - "uuid": location["data"]["id"], - "name": location["data"]["attributes"]["name"], - } - - # add owners - if owner := relationships.get("owner"): - data["owners"] = [] - for owner_data in owner["data"]: - owner = self.farmos_client.resource.get_id( - "user", "user", owner_data["id"] - ) - data["owners"].append( - { - "uuid": owner["data"]["id"], - "display_name": owner["data"]["attributes"]["display_name"], - } - ) - - # add image urls - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - data["raw_image_url"] = self.app.get_farmos_url( - image["data"]["attributes"]["uri"]["url"] - ) - # nb. other styles available: medium, wide - data["large_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["large"] - data["thumbnail_image_url"] = image["data"]["attributes"][ - "image_style_uri" - ]["thumbnail"] - - return data + included = {obj["id"]: obj for obj in asset.get("included", [])} + return self.normalize_asset(asset["data"], included) def get_instance_title(self, asset): return asset["name"] @@ -210,13 +174,24 @@ class AssetMasterView(FarmOSMasterView): else: archived = asset["attributes"]["status"] == "archived" + asset_type_object = {} + asset_type_name = None owner_objects = [] owner_names = [] location_objects = [] location_names = [] thumbnail_url = None + image_url = None if relationships := asset.get("relationships"): + if asset_type := relationships.get("asset_type"): + if asset_type := included.get(asset_type["data"]["id"]): + asset_type_object = { + "uuid": asset_type["id"], + "name": asset_type["attributes"]["label"], + } + asset_type_name = asset_type_object["name"] + if owners := relationships.get("owner"): for user in owners["data"]: if user := included.get(user["id"]): @@ -244,42 +219,102 @@ class AssetMasterView(FarmOSMasterView): thumbnail_url = image["attributes"]["image_style_uri"][ "thumbnail" ] + image_url = image["attributes"]["image_style_uri"]["large"] return { "uuid": asset["id"], "drupal_id": asset["attributes"]["drupal_internal__id"], "name": asset["attributes"]["name"], - "location": colander.null, # TODO + "asset_type": asset_type_object, + "asset_type_name": asset_type_name, "notes": notes or colander.null, "owners": owner_objects, "owner_names": owner_names, "locations": location_objects, "location_names": location_names, "archived": archived, - "thumbnail_url": thumbnail_url, + "thumbnail_url": thumbnail_url or colander.null, + "image_url": image_url or colander.null, } def configure_form(self, form): f = form super().configure_form(f) - animal = f.model_instance + asset = f.model_instance - # location - f.set_node("location", StructureType(self.request)) + # asset_type_name + if self.creating or self.editing: + f.remove("asset_type_name") + + # locations + if self.creating or self.editing: + f.remove("locations") + else: + f.set_node("locations", FarmOSLocationRefs(self.request)) # owners - f.set_node("owners", UsersType(self.request)) + if self.creating or self.editing: + f.remove("owners") # TODO + else: + f.set_node("owners", FarmOSRefs(self.request, "farmos_users")) # notes f.set_widget("notes", "notes") + f.set_required("notes", False) # archived f.set_node("archived", colander.Boolean()) + # thumbnail_url + if self.creating or self.editing: + f.remove("thumbnail_url") + + # image_url + if self.creating or self.editing: + f.remove("image_url") + + # thumbnail + if self.creating or self.editing: + f.remove("thumbnail") + elif asset.get("thumbnail_url"): + f.set_widget("thumbnail", ImageWidget("asset thumbnail")) + f.set_default("thumbnail", asset["thumbnail_url"]) + # image - if url := animal.get("large_image_url"): - f.set_widget("image", ImageWidget("animal image")) - f.set_default("image", url) + if self.creating or self.editing: + f.remove("image") + elif asset.get("image_url"): + f.set_widget("image", ImageWidget("asset image")) + f.set_default("image", asset["image_url"]) + + def persist(self, asset, session=None): + payload = self.get_api_payload(asset) + if self.editing: + payload["id"] = asset["uuid"] + + result = self.farmos_client.asset.send(self.farmos_asset_type, payload) + + if self.creating: + asset["uuid"] = result["data"]["id"] + + def get_api_payload(self, asset): + + attrs = { + "name": asset["name"], + "notes": {"value": asset["notes"] or None}, + "archived": asset["archived"], + } + + if "is_location" in asset: + attrs["is_location"] = asset["is_location"] + + if "is_fixed" in asset: + attrs["is_fixed"] = asset["is_fixed"] + + return {"attributes": attrs} + + def delete_instance(self, asset): + self.farmos_client.asset.delete(self.farmos_asset_type, asset["uuid"]) def get_xref_buttons(self, asset): return [ diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index ae4b97e..5c4c635 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -31,9 +31,16 @@ import markdown from wuttaweb.views import MasterView from wuttaweb.forms.schema import WuttaDateTime +from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.util import save_farmos_oauth2_token, use_farmos_style_grid_links -from wuttafarm.web.grids import ResourceData, StringFilter, SimpleSorter +from wuttafarm.web.grids import ( + ResourceData, + StringFilter, + NullableStringFilter, + DateTimeFilter, + SimpleSorter, +) class FarmOSMasterView(MasterView): @@ -114,6 +121,9 @@ class TaxonomyMasterView(FarmOSMasterView): """ farmos_taxonomy_type = None + creatable = True + editable = True + deletable = True filterable = True sort_on_backend = True @@ -143,7 +153,7 @@ class TaxonomyMasterView(FarmOSMasterView): normalizer=self.normalize_taxonomy_term, ) - def normalize_taxonomy_term(self, term): + def normalize_taxonomy_term(self, term, included): if changed := term["attributes"]["changed"]: changed = datetime.datetime.fromisoformat(changed) @@ -169,15 +179,21 @@ class TaxonomyMasterView(FarmOSMasterView): g.set_sorter("name", SimpleSorter("name")) g.set_filter("name", StringFilter) + # description + g.set_sorter("description", SimpleSorter("description.value")) + g.set_filter("description", NullableStringFilter, path="description.value") + # changed g.set_renderer("changed", "datetime") + g.set_sorter("changed", SimpleSorter("changed")) + g.set_filter("changed", DateTimeFilter) def get_instance(self): result = self.farmos_client.resource.get_id( "taxonomy_term", self.farmos_taxonomy_type, self.request.matchdict["uuid"] ) self.raw_json = result - return self.normalize_taxonomy_term(result["data"]) + return self.normalize_taxonomy_term(result["data"], {}) def get_instance_title(self, term): return term["name"] @@ -188,9 +204,44 @@ class TaxonomyMasterView(FarmOSMasterView): # description f.set_widget("description", "notes") + f.set_required("description", False) # changed - f.set_node("changed", WuttaDateTime()) + if self.creating or self.editing: + f.remove("changed") + else: + f.set_node("changed", WuttaDateTime()) + f.set_widget("changed", WuttaDateTimeWidget(self.request)) + + def get_api_payload(self, term): + + attrs = { + "name": term["name"], + } + + if description := term["description"]: + attrs["description"] = {"value": description} + else: + attrs["description"] = None + + return {"attributes": attrs} + + def persist(self, term, session=None): + payload = self.get_api_payload(term) + if self.editing: + payload["id"] = term["uuid"] + + result = self.farmos_client.resource.send( + "taxonomy_term", self.farmos_taxonomy_type, payload + ) + + if self.creating: + term["uuid"] = result["data"]["id"] + + def delete_instance(self, term): + self.farmos_client.resource.delete( + "taxonomy_term", self.farmos_taxonomy_type, term["uuid"] + ) def get_xref_buttons(self, term): return [ From 1a6870b8fef63306ca02c9940122acbc297d75d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 22 Feb 2026 14:51:15 -0600 Subject: [PATCH 107/166] feat: overhaul farmOS log views; add Eggs quick form probably a few other changes...i'm tired and need a savepoint --- src/wuttafarm/web/forms/schema.py | 56 ++++ src/wuttafarm/web/forms/widgets.py | 83 ++++++ src/wuttafarm/web/menus.py | 25 +- src/wuttafarm/web/templates/quick/form.mako | 14 + src/wuttafarm/web/util.py | 17 ++ src/wuttafarm/web/views/__init__.py | 4 + src/wuttafarm/web/views/farmos/assets.py | 19 +- src/wuttafarm/web/views/farmos/logs.py | 281 +++++++++++++++++- .../web/views/farmos/logs_harvest.py | 11 + src/wuttafarm/web/views/farmos/master.py | 11 + src/wuttafarm/web/views/farmos/quantities.py | 4 +- src/wuttafarm/web/views/logs.py | 8 +- src/wuttafarm/web/views/quick/__init__.py | 30 ++ src/wuttafarm/web/views/quick/base.py | 155 ++++++++++ src/wuttafarm/web/views/quick/eggs.py | 232 +++++++++++++++ 15 files changed, 914 insertions(+), 36 deletions(-) create mode 100644 src/wuttafarm/web/templates/quick/form.mako create mode 100644 src/wuttafarm/web/views/quick/__init__.py create mode 100644 src/wuttafarm/web/views/quick/base.py create mode 100644 src/wuttafarm/web/views/quick/eggs.py 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) From e7ef5c3d32255edeadd7789e27a7340ee355de4f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 22 Feb 2026 19:20:46 -0600 Subject: [PATCH 108/166] feat: add common normalizer to simplify code in view, importer etc. only the "log" normalizer exists so far, but will add more.. --- src/wuttafarm/app.py | 75 ++++++++- src/wuttafarm/farmos/handler.py | 29 ---- src/wuttafarm/importing/farmos.py | 66 +++++--- src/wuttafarm/normal.py | 199 +++++++++++++++++++++++ src/wuttafarm/web/views/farmos/logs.py | 151 +---------------- src/wuttafarm/web/views/farmos/master.py | 1 + src/wuttafarm/web/views/master.py | 2 +- src/wuttafarm/web/views/quick/base.py | 1 + src/wuttafarm/web/views/quick/eggs.py | 15 +- 9 files changed, 331 insertions(+), 208 deletions(-) create mode 100644 src/wuttafarm/normal.py diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 2df38e9..d0ca392 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -56,24 +56,38 @@ class WuttaFarmAppHandler(base.AppHandler): Returns the integration mode for farmOS, i.e. to control the app's behavior regarding that. """ - handler = self.get_farmos_handler() - return handler.get_farmos_integration_mode() + enum = self.enum + return self.config.get( + f"{self.appname}.farmos_integration_mode", + default=enum.FARMOS_INTEGRATION_MODE_WRAPPER, + ) def is_farmos_mirror(self): """ Returns ``True`` if the app is configured in "mirror" integration mode with regard to farmOS. """ - handler = self.get_farmos_handler() - return handler.is_farmos_mirror() + enum = self.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR def is_farmos_wrapper(self): """ Returns ``True`` if the app is configured in "wrapper" integration mode with regard to farmOS. """ - handler = self.get_farmos_handler() - return handler.is_farmos_wrapper() + enum = self.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER + + def is_standalone(self): + """ + Returns ``True`` if the app is configured in "standalone" mode + with regard to farmOS. + """ + enum = self.enum + mode = self.get_farmos_integration_mode() + return mode == enum.FARMOS_INTEGRATION_MODE_NONE def get_farmos_url(self, *args, **kwargs): """ @@ -109,7 +123,20 @@ class WuttaFarmAppHandler(base.AppHandler): handler = self.get_farmos_handler() return handler.is_farmos_4x(*args, **kwargs) - def export_to_farmos(self, obj, require=True): + def get_normalizer(self, farmos_client=None): + """ + Get the configured farmOS integration handler. + + :rtype: :class:`~wuttafarm.farmos.FarmOSHandler` + """ + spec = self.config.get( + f"{self.appname}.normalizer_spec", + default="wuttafarm.normal:Normalizer", + ) + factory = self.load_object(spec) + return factory(self.config, farmos_client) + + def auto_sync_to_farmos(self, obj, model_name=None, require=True): """ Export the given object to farmOS, using configured handler. @@ -127,7 +154,8 @@ class WuttaFarmAppHandler(base.AppHandler): """ handler = self.app.get_import_handler("export.to_farmos.from_wuttafarm") - model_name = type(obj).__name__ + if not model_name: + model_name = type(obj).__name__ if model_name not in handler.importers: if require: raise ValueError(f"no exporter found for {model_name}") @@ -141,6 +169,37 @@ class WuttaFarmAppHandler(base.AppHandler): normal = importer.normalize_source_object(obj) importer.process_data(source_data=[normal]) + def auto_sync_from_farmos(self, obj, model_name, require=True): + """ + Import the given object from farmOS, using configured handler. + + :param obj: Any data record from farmOS. + + :param model_name': Model name for the importer to use, + e.g. ``"AnimalAsset"``. + + :param require: If true, this will *require* the import + handler to support objects of the given type. If false, + then nothing will happen / import is silently skipped when + there is no such importer. + """ + handler = self.app.get_import_handler("import.to_wuttafarm.from_farmos") + + if model_name not in handler.importers: + if require: + raise ValueError(f"no importer found for {model_name}") + return + + # nb. begin txn to establish the API client + # TODO: should probably use current user oauth2 token instead + # of always making a new one here, which is what happens IIUC + handler.begin_source_transaction() + with self.short_session(commit=True) as session: + handler.target_session = session + importer = handler.get_importer(model_name, caches_target=False) + normal = importer.normalize_source_object(obj) + importer.process_data(source_data=[normal]) + class WuttaFarmAppProvider(base.AppProvider): """ diff --git a/src/wuttafarm/farmos/handler.py b/src/wuttafarm/farmos/handler.py index 393d121..6eee14f 100644 --- a/src/wuttafarm/farmos/handler.py +++ b/src/wuttafarm/farmos/handler.py @@ -34,35 +34,6 @@ class FarmOSHandler(GenericHandler): :term:`handler`. """ - def get_farmos_integration_mode(self): - """ - Returns the integration mode for farmOS, i.e. to control the - app's behavior regarding that. - """ - enum = self.app.enum - return self.config.get( - f"{self.app.appname}.farmos_integration_mode", - default=enum.FARMOS_INTEGRATION_MODE_WRAPPER, - ) - - def is_farmos_mirror(self): - """ - Returns ``True`` if the app is configured in "mirror" - integration mode with regard to farmOS. - """ - enum = self.app.enum - mode = self.get_farmos_integration_mode() - return mode == enum.FARMOS_INTEGRATION_MODE_MIRROR - - def is_farmos_wrapper(self): - """ - Returns ``True`` if the app is configured in "wrapper" - integration mode with regard to farmOS. - """ - enum = self.app.enum - mode = self.get_farmos_integration_mode() - return mode == enum.FARMOS_INTEGRATION_MODE_WRAPPER - def get_farmos_client(self, hostname=None, **kwargs): """ Returns a new farmOS API client. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 5cf2242..e17825b 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -53,6 +53,7 @@ class FromFarmOSHandler(ImportHandler): token = self.get_farmos_oauth2_token() self.farmos_client = self.app.get_farmos_client(token=token) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + self.normal = self.app.get_normalizer(self.farmos_client) def get_farmos_oauth2_token(self): @@ -76,6 +77,7 @@ class FromFarmOSHandler(ImportHandler): kwargs = super().get_importer_kwargs(key, **kwargs) kwargs["farmos_client"] = self.farmos_client kwargs["farmos_4x"] = self.farmos_4x + kwargs["normal"] = self.normal return kwargs @@ -981,33 +983,25 @@ class LogImporterBase(FromFarmOS, ToWutta): def get_source_objects(self): """ """ log_type = self.get_farmos_log_type() - result = self.farmos_client.log.get(log_type) - return result["data"] - - def get_asset_type(self, asset): - return asset["type"].split("--")[1] + return list(self.farmos_client.log.iterate(log_type)) def normalize_source_object(self, log): """ """ - if notes := log["attributes"]["notes"]: - notes = notes["value"] + data = self.normal.normalize_farmos_log(log) + + data["farmos_uuid"] = UUID(data.pop("uuid")) + data["message"] = data.pop("name") + data["timestamp"] = self.app.make_utc(data["timestamp"]) + + # TODO + data["log_type"] = self.get_farmos_log_type() - assets = None if "assets" in self.fields: - assets = [] - for asset in log["relationships"]["asset"]["data"]: - assets.append((self.get_asset_type(asset), UUID(asset["id"]))) + data["assets"] = [ + (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] + ] - return { - "farmos_uuid": UUID(log["id"]), - "drupal_id": log["attributes"]["drupal_internal__id"], - "log_type": self.get_farmos_log_type(), - "message": log["attributes"]["name"], - "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), - "notes": notes, - "status": log["attributes"]["status"], - "assets": assets, - } + return data def normalize_target_object(self, log): data = super().normalize_target_object(log) @@ -1183,6 +1177,28 @@ class QuantityImporterBase(FromFarmOS, ToWutta): result = self.farmos_client.resource.get("quantity", quantity_type) return result["data"] + def get_quantity_type_by_farmos_uuid(self, uuid): + if hasattr(self, "quantity_types_by_farmos_uuid"): + return self.quantity_types_by_farmos_uuid.get(UUID(uuid)) + + model = self.app.model + return ( + self.target_session.query(model.QuantityType) + .filter(model.QuantityType.farmos_uuid == uuid) + .one() + ) + + def get_unit_by_farmos_uuid(self, uuid): + if hasattr(self, "units_by_farmos_uuid"): + return self.units_by_farmos_uuid.get(UUID(uuid)) + + model = self.app.model + return ( + self.target_session.query(model.Unit) + .filter(model.Unit.farmos_uuid == uuid) + .one() + ) + def normalize_source_object(self, quantity): """ """ quantity_type_id = None @@ -1191,16 +1207,14 @@ class QuantityImporterBase(FromFarmOS, ToWutta): if quantity_type := relationships.get("quantity_type"): if quantity_type["data"]: - if wf_quantity_type := self.quantity_types_by_farmos_uuid.get( - UUID(quantity_type["data"]["id"]) + if wf_quantity_type := self.get_quantity_type_by_farmos_uuid( + quantity_type["data"]["id"] ): quantity_type_id = wf_quantity_type.drupal_id if units := relationships.get("units"): if units["data"]: - if wf_unit := self.units_by_farmos_uuid.get( - UUID(units["data"]["id"]) - ): + if wf_unit := self.get_unit_by_farmos_uuid(units["data"]["id"]): units_uuid = wf_unit.uuid if not quantity_type_id: diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py new file mode 100644 index 0000000..ca7be39 --- /dev/null +++ b/src/wuttafarm/normal.py @@ -0,0 +1,199 @@ +# -*- 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 . +# +################################################################################ +""" +Data normalizer for WuttaFarm / farmOS +""" + +import datetime + +from wuttjamaican.app import GenericHandler + + +class Normalizer(GenericHandler): + """ + Base class and default implementation for the global data + normalizer. This should be used for normalizing records from + WuttaFarm and/or farmOS. + + The point here is to have a single place to put the normalization + logic, and let it be another thing which can be customized via + subclass. + """ + + _farmos_units = None + _farmos_measures = None + + def __init__(self, config, farmos_client=None): + super().__init__(config) + self.farmos_client = farmos_client + + 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 get_farmos_unit(self, uuid): + units = self.get_farmos_units() + return units[uuid] + + 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 normalize_farmos_log(self, log, included={}): + + if timestamp := log["attributes"]["timestamp"]: + timestamp = datetime.datetime.fromisoformat(timestamp) + timestamp = self.app.localtime(timestamp) + + if notes := log["attributes"]["notes"]: + notes = notes["value"] + + log_type_object = {} + log_type_uuid = None + asset_objects = [] + quantity_objects = [] + quantity_uuids = [] + owner_objects = [] + owner_uuids = [] + if relationships := log.get("relationships"): + + if log_type := relationships.get("log_type"): + log_type_uuid = log_type["data"]["id"] + if log_type := included.get(log_type_uuid): + log_type_object = { + "uuid": log_type["id"], + "name": log_type["attributes"]["label"], + } + + if assets := relationships.get("asset"): + for asset in assets["data"]: + asset_object = { + "uuid": asset["id"], + "type": asset["type"], + "asset_type": asset["type"].split("--")[1], + } + if asset := included.get(asset["id"]): + attrs = asset["attributes"] + rels = asset["relationships"] + asset_object.update( + { + "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_objects.append(asset_object) + + if quantities := relationships.get("quantity"): + for quantity in quantities["data"]: + quantity_uuid = quantity["id"] + quantity_uuids.append(quantity_uuid) + if quantity := included.get(quantity_uuid): + 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_uuid = user["id"] + owner_uuids.append(user_uuid) + if user := included.get(user_uuid): + owner_objects.append( + { + "uuid": user["id"], + "name": user["attributes"]["name"], + } + ) + + return { + "uuid": log["id"], + "drupal_id": log["attributes"]["drupal_internal__id"], + "log_type_uuid": log_type_uuid, + "log_type": log_type_object, + "name": log["attributes"]["name"], + "timestamp": timestamp, + "assets": asset_objects, + "quantities": quantity_objects, + "quantity_uuids": quantity_uuids, + "is_group_assignment": log["attributes"]["is_group_assignment"], + "quick": log["attributes"]["quick"], + "status": log["attributes"]["status"], + "notes": notes, + "owners": owner_objects, + "owner_uuids": owner_uuids, + } diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index 4c704ce..f20eb0e 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -23,12 +23,9 @@ View for farmOS Harvest Logs """ -import datetime - -import colander from webhelpers2.html import tags -from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum +from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum, Notes from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos import FarmOSMasterView @@ -58,9 +55,6 @@ class LogMasterView(FarmOSMasterView): filterable = True sort_on_backend = True - _farmos_units = None - _farmos_measures = None - labels = { "name": "Log Name", "log_type_name": "Log Type", @@ -193,141 +187,14 @@ class LogMasterView(FarmOSMasterView): def get_instance_title(self, log): return log["name"] - 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) - timestamp = self.app.localtime(timestamp) - - 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, - } + data = self.normal.normalize_farmos_log(log, included) + data.update( + { + "log_type_name": data["log_type"].get("name"), + } + ) + return data def configure_form(self, form): f = form @@ -346,7 +213,7 @@ class LogMasterView(FarmOSMasterView): f.set_node("quantities", FarmOSQuantityRefs(self.request)) # notes - f.set_widget("notes", "notes") + f.set_node("notes", Notes()) # status f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 36d1778..742ce14 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -72,6 +72,7 @@ class FarmOSMasterView(MasterView): super().__init__(request, context=context) self.farmos_client = self.get_farmos_client() self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + self.normal = self.app.get_normalizer(self.farmos_client) self.raw_json = None self.farmos_style_grid_links = use_farmos_style_grid_links(self.config) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 82d64bc..2250d1b 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -105,4 +105,4 @@ class WuttaFarmMasterView(MasterView): def persist(self, obj, session=None): super().persist(obj, session) - self.app.export_to_farmos(obj, require=False) + self.app.auto_sync_to_farmos(obj, require=False) diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py index 29ba7ef..2fb73e4 100644 --- a/src/wuttafarm/web/views/quick/base.py +++ b/src/wuttafarm/web/views/quick/base.py @@ -44,6 +44,7 @@ class QuickFormView(View): super().__init__(request, context=context) self.farmos_client = self.get_farmos_client() self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) + self.normal = self.app.get_normalizer(self.farmos_client) @classmethod def get_route_slug(cls): diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index c505381..aa663b6 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -112,6 +112,18 @@ class EggsQuickForm(QuickFormView): return assets def save_quick_form(self, form): + + response = self.save_to_farmos(form) + log = json.loads(response["create-log#body{0}"]["body"]) + + if self.app.is_farmos_mirror(): + quantity = json.loads(response["create-quantity"]["body"]) + self.app.auto_sync_from_farmos(quantity["data"], "StandardQuantity") + self.app.auto_sync_from_farmos(log["data"], "HarvestLog") + + return log + + def save_to_farmos(self, form): data = form.validated assets = self.get_layer_assets() @@ -217,8 +229,7 @@ class EggsQuickForm(QuickFormView): 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 + return response def redirect_after_save(self, result): return self.redirect( From 331543d74b25e21ce3d7d05ec019addc439e26c9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Feb 2026 19:57:25 -0600 Subject: [PATCH 109/166] docs: fix sphinx warnings --- src/wuttafarm/web/forms/widgets.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 5fc9d55..9dcc51f 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -89,6 +89,7 @@ class FarmOSRefWidget(SelectWidget): self.route_prefix = route_prefix def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): @@ -116,6 +117,7 @@ class FarmOSRefsWidget(Widget): self.route_prefix = route_prefix def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): @@ -143,6 +145,7 @@ class FarmOSAssetRefsWidget(Widget): self.request = request def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): @@ -170,6 +173,7 @@ class FarmOSLocationRefsWidget(Widget): self.request = request def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): @@ -194,6 +198,7 @@ class FarmOSQuantityRefsWidget(Widget): """ def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): @@ -211,6 +216,7 @@ class FarmOSUnitRefWidget(Widget): """ def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get("readonly", self.readonly) if readonly: if cstruct in (colander.null, None): From b9ab27523f28986588ba5d07818652f1f9854c9c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Feb 2026 20:03:59 -0600 Subject: [PATCH 110/166] fix: add `Notes` schema type this is because the dict we get from (normalizing the) farmOS API record will have e.g. `notes=None` but that winds up rendering as "None" instead of empty string - so we use colander.null value in such cases so empty string is rendered --- src/wuttafarm/web/forms/schema.py | 23 +++++++++++++++++++++++ src/wuttafarm/web/views/farmos/logs.py | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index a5c396b..075c36c 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -28,6 +28,7 @@ import json import colander from wuttaweb.forms.schema import ObjectRef, WuttaSet +from wuttaweb.forms.widgets import NotesWidget class AnimalTypeRef(ObjectRef): @@ -380,3 +381,25 @@ class LogAssetRefs(WuttaSet): from wuttafarm.web.forms.widgets import LogAssetRefsWidget return LogAssetRefsWidget(self.request, **kwargs) + + +class Notes(colander.String): + """ + Custom schema type for "note" fields. + """ + + def serialize(self, node, appstruct): + """ """ + if not appstruct: + return colander.null + + return super().serialize(node, appstruct) + + def widget_maker(self, **kwargs): + """ + Construct a default widget for the field. + + :returns: Instance of + :class:`~wuttaweb.forms.widgets.NotesWidget`. + """ + return NotesWidget(**kwargs) diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index f20eb0e..6e6dc36 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -25,7 +25,7 @@ View for farmOS Harvest Logs from webhelpers2.html import tags -from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum, Notes +from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos import FarmOSMasterView @@ -42,6 +42,7 @@ from wuttafarm.web.forms.schema import ( FarmOSAssetRefs, FarmOSRefs, LogQuick, + Notes, ) from wuttafarm.web.util import render_quantity_objects From 11781dd70bc973fa30f5c01f1bf29a043bd52feb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Feb 2026 09:02:12 -0600 Subject: [PATCH 111/166] =?UTF-8?q?bump:=20version=200.5.0=20=E2=86=92=200?= =?UTF-8?q?.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf9487..13c040e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.6.0 (2026-02-25) + +### Feat + +- add common normalizer to simplify code in view, importer etc. +- overhaul farmOS log views; add Eggs quick form +- add basic CRUD for direct API views: animal types, animal assets +- use 'include' API param for better Animal Assets grid data +- add backend filters, sorting for farmOS animal types, assets +- include/exclude certain views, menus based on integration mode +- add Standard Quantities table, views, import +- add Quantity Types table, views, import +- add Units table, views, import/export + +### Fix + +- add `Notes` schema type +- add grid filter for animal birthdate +- add thumbnail to farmOS asset base view +- add setting to toggle "farmOS-style grid links" +- standardize a bit more for the farmOS Animal Assets view +- set *default* instead of configured menu handler +- expose farmOS integration mode, URL in app settings +- reword some menu entries +- add WuttaFarm -> farmOS export for Plant Assets +- fix default admin user perms, per new log schema + ## v0.5.0 (2026-02-18) ### Feat diff --git a/pyproject.toml b/pyproject.toml index a474302..c66f0b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.5.0" +version = "0.6.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From ec6ac443fb1b3a53a465b65edb431e8d7cddc073 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Feb 2026 11:22:49 -0600 Subject: [PATCH 112/166] fix: add separate permission for each quick form view --- src/wuttafarm/web/views/quick/__init__.py | 5 +++++ src/wuttafarm/web/views/quick/base.py | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/quick/__init__.py b/src/wuttafarm/web/views/quick/__init__.py index 92595e1..8423b0d 100644 --- a/src/wuttafarm/web/views/quick/__init__.py +++ b/src/wuttafarm/web/views/quick/__init__.py @@ -27,4 +27,9 @@ from .base import QuickFormView def includeme(config): + + # perm group + config.add_wutta_permission_group("quick", "Quick Forms", overwrite=False) + + # quick form views 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 index 2fb73e4..a40e8e3 100644 --- a/src/wuttafarm/web/views/quick/base.py +++ b/src/wuttafarm/web/views/quick/base.py @@ -151,6 +151,10 @@ class QuickFormView(View): def _defaults(cls, config): route_slug = cls.get_route_slug() url_slug = cls.get_url_slug() + form_title = cls.get_form_title() + config.add_wutta_permission("quick", f"quick.{route_slug}", form_title) config.add_route(f"quick.{route_slug}", f"/quick/{url_slug}") - config.add_view(cls, route_name=f"quick.{route_slug}") + config.add_view( + cls, route_name=f"quick.{route_slug}", permission=f"quick.{route_slug}" + ) From df517cfbfafc6a4e4a625b9bf70dfca256a0f064 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Feb 2026 14:36:28 -0600 Subject: [PATCH 113/166] fix: expose config for farmOS OAuth2 client_id and scope refs: #3 --- src/wuttafarm/farmos/handler.py | 6 +++++ .../web/templates/appinfo/configure.mako | 22 +++++++++++++++++++ src/wuttafarm/web/views/auth.py | 5 +++-- src/wuttafarm/web/views/settings.py | 13 ++++++++++- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/wuttafarm/farmos/handler.py b/src/wuttafarm/farmos/handler.py index 6eee14f..e905f92 100644 --- a/src/wuttafarm/farmos/handler.py +++ b/src/wuttafarm/farmos/handler.py @@ -94,3 +94,9 @@ class FarmOSHandler(GenericHandler): return f"{base}/{path}" return base + + def get_oauth2_client_id(self): + return self.config.get("farmos.oauth2.client_id", default="farm") + + def get_oauth2_scope(self): + return self.config.get("farmos.oauth2.scope", default="farm_manager") diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako index 3760577..8dc5e8a 100644 --- a/src/wuttafarm/web/templates/appinfo/configure.mako +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -14,6 +14,28 @@ + + + + + + + + + + + + + + + + + + Date: Wed, 25 Feb 2026 14:55:30 -0600 Subject: [PATCH 114/166] fix: only show quick form menu if perms allow --- src/wuttafarm/web/menus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index 6ce4a8d..fe7719e 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -72,7 +72,7 @@ class WuttaFarmMenuHandler(base.MenuHandler): { "title": "Eggs", "route": "quick.eggs", - # "perm": "assets.list", + "perm": "quick.eggs", }, ], } From 127ea49d744bd0492be424084501f985cb8710ba Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Feb 2026 14:55:47 -0600 Subject: [PATCH 115/166] fix: add more default perms for first site admin user --- src/wuttafarm/web/views/common.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index f15e92b..674d76e 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -87,10 +87,20 @@ class CommonView(base.CommonView): "farmos_logs_medical.view", "farmos_logs_observation.list", "farmos_logs_observation.view", + "farmos_plant_assets.list", + "farmos_plant_assets.view", + "farmos_plant_types.list", + "farmos_plant_types.view", + "farmos_quantities_standard.list", + "farmos_quantities_standard.view", + "farmos_quantity_types.list", + "farmos_quantity_types.view", "farmos_structure_assets.list", "farmos_structure_assets.view", "farmos_structure_types.list", "farmos_structure_types.view", + "farmos_units.list", + "farmos_units.view", "farmos_users.list", "farmos_users.view", "group_assets.create", @@ -121,6 +131,7 @@ class CommonView(base.CommonView): "logs_observation.list", "logs_observation.view", "logs_observation.versions", + "quick.eggs", "structure_types.list", "structure_types.view", "structure_types.versions", From f4b5f3960c7b251ecee78170024d047c0ec58645 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Feb 2026 15:22:25 -0600 Subject: [PATCH 116/166] fix: set log type, status enums for log grids --- src/wuttafarm/util.py | 37 +++++++++++++++++++++++++++++++ src/wuttafarm/web/views/assets.py | 7 ++++++ src/wuttafarm/web/views/logs.py | 22 ++++++++---------- 3 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 src/wuttafarm/util.py diff --git a/src/wuttafarm/util.py b/src/wuttafarm/util.py new file mode 100644 index 0000000..1700998 --- /dev/null +++ b/src/wuttafarm/util.py @@ -0,0 +1,37 @@ +# -*- 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 . +# +################################################################################ +""" +misc. utilities +""" + +from collections import OrderedDict + + +def get_log_type_enum(config, session=None): + app = config.get_app() + model = app.model + log_types = OrderedDict() + with app.short_session(session=session) as sess: + query = sess.query(model.LogType).order_by(model.LogType.name) + for log_type in query: + log_types[log_type.drupal_id] = log_type.name + return log_types diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b78f149..70534db 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -32,6 +32,7 @@ from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import Asset, Log from wuttafarm.web.forms.schema import AssetParentRefs from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.util import get_log_type_enum def get_asset_type_enum(config): @@ -301,7 +302,12 @@ class AssetMasterView(WuttaFarmMasterView): def configure_row_grid(self, grid): g = grid super().configure_row_grid(g) + enum = self.app.enum model = self.app.model + session = self.Session() + + # status + g.set_enum("status", enum.LOG_STATUS) # drupal_id g.set_label("drupal_id", "ID", column_only=True) @@ -318,6 +324,7 @@ class AssetMasterView(WuttaFarmMasterView): # log_type g.set_sorter("log_type", model.Log.log_type) g.set_filter("log_type", model.Log.log_type) + g.set_enum("log_type", get_log_type_enum(self.config, session=session)) def get_row_action_url_view(self, log, i): return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index eeef49e..b393cec 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -34,17 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import LogType, Log from wuttafarm.web.forms.schema import LogAssetRefs - - -def get_log_type_enum(config): - app = config.get_app() - model = app.model - session = Session() - log_types = OrderedDict() - query = session.query(model.LogType).order_by(model.LogType.name) - for log_type in query: - log_types[log_type.drupal_id] = log_type.name - return log_types +from wuttafarm.util import get_log_type_enum class LogTypeView(WuttaFarmMasterView): @@ -142,6 +132,7 @@ class LogView(WuttaFarmMasterView): def configure_grid(self, grid): g = grid super().configure_grid(g) + session = self.Session() # drupal_id g.set_label("drupal_id", "ID", column_only=True) @@ -154,7 +145,7 @@ class LogView(WuttaFarmMasterView): g.set_link("message") # log_type - g.set_enum("log_type", get_log_type_enum(self.config)) + g.set_enum("log_type", get_log_type_enum(self.config, session=session)) # assets g.set_renderer("assets", self.render_assets_for_grid) @@ -224,8 +215,10 @@ class LogMasterView(WuttaFarmMasterView): g = grid super().configure_grid(g) model = self.app.model + enum = self.app.enum # status + g.set_enum("status", enum.LOG_STATUS) g.set_sorter("status", model.Log.status) g.set_filter("status", model.Log.status) @@ -255,6 +248,7 @@ class LogMasterView(WuttaFarmMasterView): f = form super().configure_form(f) enum = self.app.enum + session = self.Session() log = f.model_instance # timestamp @@ -280,7 +274,9 @@ class LogMasterView(WuttaFarmMasterView): else: f.set_node( "log_type", - WuttaDictEnum(self.request, get_log_type_enum(self.config)), + WuttaDictEnum( + self.request, get_log_type_enum(self.config, session=session) + ), ) f.set_readonly("log_type") From 9b4afb845b546e1e0bf111df72594110ce751ee9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 17:04:55 -0600 Subject: [PATCH 117/166] fix: use current user token for auto-sync within web app to ensure data writes to farmOS have correct authorship --- src/wuttafarm/app.py | 18 ++++++++++-------- src/wuttafarm/farmos/importing/wuttafarm.py | 5 +++-- src/wuttafarm/importing/farmos.py | 5 +++-- src/wuttafarm/web/views/master.py | 3 ++- src/wuttafarm/web/views/quick/eggs.py | 7 +++++-- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index d0ca392..30c7f51 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -136,7 +136,7 @@ class WuttaFarmAppHandler(base.AppHandler): factory = self.load_object(spec) return factory(self.config, farmos_client) - def auto_sync_to_farmos(self, obj, model_name=None, require=True): + def auto_sync_to_farmos(self, obj, model_name=None, token=None, require=True): """ Export the given object to farmOS, using configured handler. @@ -147,6 +147,9 @@ class WuttaFarmAppHandler(base.AppHandler): :param obj: Any data object in WuttaFarm, e.g. AnimalAsset instance. + :param token: OAuth2 token for the farmOS client. If not + specified, the import handler will obtain a new token. + :param require: If true, this will *require* the export handler to support objects of the given type. If false, then nothing will happen / export is silently skipped when @@ -162,14 +165,12 @@ class WuttaFarmAppHandler(base.AppHandler): return # nb. begin txn to establish the API client - # TODO: should probably use current user oauth2 token instead - # of always making a new one here, which is what happens IIUC - handler.begin_target_transaction() + handler.begin_target_transaction(token) importer = handler.get_importer(model_name, caches_target=False) normal = importer.normalize_source_object(obj) importer.process_data(source_data=[normal]) - def auto_sync_from_farmos(self, obj, model_name, require=True): + def auto_sync_from_farmos(self, obj, model_name, token=None, require=True): """ Import the given object from farmOS, using configured handler. @@ -178,6 +179,9 @@ class WuttaFarmAppHandler(base.AppHandler): :param model_name': Model name for the importer to use, e.g. ``"AnimalAsset"``. + :param token: OAuth2 token for the farmOS client. If not + specified, the import handler will obtain a new token. + :param require: If true, this will *require* the import handler to support objects of the given type. If false, then nothing will happen / import is silently skipped when @@ -191,9 +195,7 @@ class WuttaFarmAppHandler(base.AppHandler): return # nb. begin txn to establish the API client - # TODO: should probably use current user oauth2 token instead - # of always making a new one here, which is what happens IIUC - handler.begin_source_transaction() + handler.begin_source_transaction(token) with self.short_session(commit=True) as session: handler.target_session = session importer = handler.get_importer(model_name, caches_target=False) diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index e11663f..a39fe97 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -50,11 +50,12 @@ class ToFarmOSHandler(ImportHandler): # TODO: a lot of duplication to cleanup here; see FromFarmOSHandler - def begin_target_transaction(self): + def begin_target_transaction(self, token=None): """ Establish the farmOS API client. """ - token = self.get_farmos_oauth2_token() + if not token: + token = self.get_farmos_oauth2_token() self.farmos_client = self.app.get_farmos_client(token=token) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index e17825b..a1e9631 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -46,11 +46,12 @@ class FromFarmOSHandler(ImportHandler): source_key = "farmos" generic_source_title = "farmOS" - def begin_source_transaction(self): + def begin_source_transaction(self, token=None): """ Establish the farmOS API client. """ - token = self.get_farmos_oauth2_token() + if not token: + token = self.get_farmos_oauth2_token() self.farmos_client = self.app.get_farmos_client(token=token) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) self.normal = self.app.get_normalizer(self.farmos_client) diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 2250d1b..6ab0631 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -105,4 +105,5 @@ class WuttaFarmMasterView(MasterView): def persist(self, obj, session=None): super().persist(obj, session) - self.app.auto_sync_to_farmos(obj, require=False) + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_to_farmos(obj, token=token, require=False) diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index aa663b6..0482132 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -118,8 +118,11 @@ class EggsQuickForm(QuickFormView): if self.app.is_farmos_mirror(): quantity = json.loads(response["create-quantity"]["body"]) - self.app.auto_sync_from_farmos(quantity["data"], "StandardQuantity") - self.app.auto_sync_from_farmos(log["data"], "HarvestLog") + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_from_farmos( + quantity["data"], "StandardQuantity", token=token + ) + self.app.auto_sync_from_farmos(log["data"], "HarvestLog", token=token) return log From f2be7d0a53edd92cc35bcf551c076e48af69d3f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 17:25:49 -0600 Subject: [PATCH 118/166] fix: add `get_farmos_client_for_user()` convenience function --- src/wuttafarm/web/util.py | 20 ++++++++++++++++++++ src/wuttafarm/web/views/farmos/master.py | 20 ++------------------ src/wuttafarm/web/views/quick/base.py | 20 ++------------------ 3 files changed, 24 insertions(+), 36 deletions(-) diff --git a/src/wuttafarm/web/util.py b/src/wuttafarm/web/util.py index 2d51851..977550a 100644 --- a/src/wuttafarm/web/util.py +++ b/src/wuttafarm/web/util.py @@ -23,9 +23,29 @@ Misc. utilities for web app """ +from pyramid import httpexceptions from webhelpers2.html import HTML +def get_farmos_client_for_user(request): + token = request.session.get("farmos.oauth2.token") + if not token: + raise httpexceptions.HTTPForbidden() + + # 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(request, token) + + config = request.wutta_config + app = config.get_app() + return app.get_farmos_client(token=token, token_updater=token_updater) + + def save_farmos_oauth2_token(request, token): """ Common logic for saving the given OAuth2 token within the user diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py index 742ce14..1e2ceab 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -34,7 +34,7 @@ from wuttaweb.views import MasterView from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget -from wuttafarm.web.util import save_farmos_oauth2_token, use_farmos_style_grid_links +from wuttafarm.web.util import get_farmos_client_for_user, use_farmos_style_grid_links from wuttafarm.web.grids import ( ResourceData, StringFilter, @@ -70,28 +70,12 @@ class FarmOSMasterView(MasterView): def __init__(self, request, context=None): super().__init__(request, context=context) - self.farmos_client = self.get_farmos_client() + self.farmos_client = get_farmos_client_for_user(self.request) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) self.normal = self.app.get_normalizer(self.farmos_client) self.raw_json = None self.farmos_style_grid_links = use_farmos_style_grid_links(self.config) - 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) - def get_fallback_templates(self, template): """ """ templates = super().get_fallback_templates(template) diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py index a40e8e3..9be6665 100644 --- a/src/wuttafarm/web/views/quick/base.py +++ b/src/wuttafarm/web/views/quick/base.py @@ -29,7 +29,7 @@ from pyramid.renderers import render_to_response from wuttaweb.views import View -from wuttafarm.web.util import save_farmos_oauth2_token +from wuttafarm.web.util import get_farmos_client_for_user log = logging.getLogger(__name__) @@ -42,7 +42,7 @@ class QuickFormView(View): def __init__(self, request, context=None): super().__init__(request, context=context) - self.farmos_client = self.get_farmos_client() + self.farmos_client = get_farmos_client_for_user(self.request) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) self.normal = self.app.get_normalizer(self.farmos_client) @@ -127,22 +127,6 @@ class QuickFormView(View): 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) From 38dad49bbda65f85a78c87c2c686ce83f3454043 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 17:35:05 -0600 Subject: [PATCH 119/166] fix: fix Sex field when empty and deleting an animal --- src/wuttafarm/web/views/animals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 76e0335..8f05584 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -216,7 +216,7 @@ class AnimalAssetView(AssetMasterView): f.set_node("animal_type", AnimalTypeRef(self.request)) # sex - if self.viewing and animal.sex is None: + if not (self.creating or self.editing) and animal.sex is None: pass # TODO: dict enum widget does not handle null values well else: f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) From 96ccf30e46f14af6780a0a58935b24e4106bb80b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 17:36:37 -0600 Subject: [PATCH 120/166] feat: auto-delete asset from farmOS if deleting via mirror app --- src/wuttafarm/web/views/assets.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 70534db..2dade09 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -33,6 +33,7 @@ from wuttafarm.db.model import Asset, Log from wuttafarm.web.forms.schema import AssetParentRefs from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.util import get_log_type_enum +from wuttafarm.web.util import get_farmos_client_for_user def get_asset_type_enum(config): @@ -267,11 +268,30 @@ class AssetMasterView(WuttaFarmMasterView): asset = super().objectify(form) if self.creating: - model_class = self.get_model_class() - asset.asset_type = model_class.__wutta_hint__["farmos_asset_type"] + asset.asset_type = self.get_asset_type() return asset + def get_asset_type(self): + model_class = self.get_model_class() + return model_class.__wutta_hint__["farmos_asset_type"] + + def delete_instance(self, obj): + + # save farmOS UUID if we need it + farmos_uuid = None + if self.app.is_farmos_mirror() and hasattr(obj, "farmos_uuid"): + farmos_uuid = obj.farmos_uuid + + # delete per usual + super().delete_instance(obj) + + # maybe delete from farmOS also + if farmos_uuid: + client = get_farmos_client_for_user(self.request) + asset_type = self.get_asset_type() + client.asset.delete(asset_type, farmos_uuid) + def get_farmos_url(self, asset): return self.app.get_farmos_url(f"/asset/{asset.drupal_id}") @@ -279,7 +299,7 @@ class AssetMasterView(WuttaFarmMasterView): buttons = super().get_xref_buttons(asset) if asset.farmos_uuid: - asset_type = self.get_model_class().__wutta_hint__["farmos_asset_type"] + asset_type = self.get_asset_type() route = f"farmos_{asset_type}_assets.view" url = self.request.route_url(route, uuid=asset.farmos_uuid) buttons.append( From a5d7f89fcb3df02bd2061b1e8d23c371040be671 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 19:10:41 -0600 Subject: [PATCH 121/166] feat: improve mirror/deletion for assets, logs, animal types --- src/wuttafarm/db/model/asset.py | 7 +++- src/wuttafarm/db/model/log.py | 7 +++- src/wuttafarm/web/views/animals.py | 3 ++ src/wuttafarm/web/views/assets.py | 18 +-------- src/wuttafarm/web/views/groups.py | 1 + src/wuttafarm/web/views/land.py | 1 + src/wuttafarm/web/views/logs.py | 2 + src/wuttafarm/web/views/logs_activity.py | 1 + src/wuttafarm/web/views/logs_harvest.py | 1 + src/wuttafarm/web/views/logs_medical.py | 1 + src/wuttafarm/web/views/logs_observation.py | 1 + src/wuttafarm/web/views/master.py | 44 +++++++++++++++++++-- src/wuttafarm/web/views/plants.py | 1 + src/wuttafarm/web/views/structures.py | 1 + src/wuttafarm/web/views/units.py | 2 + 15 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/wuttafarm/db/model/asset.py b/src/wuttafarm/db/model/asset.py index 90372e2..3e4de6e 100644 --- a/src/wuttafarm/db/model/asset.py +++ b/src/wuttafarm/db/model/asset.py @@ -196,7 +196,12 @@ class AssetMixin: @declared_attr def asset(cls): - return orm.relationship(Asset) + return orm.relationship( + Asset, + single_parent=True, + cascade="all, delete-orphan", + cascade_backrefs=False, + ) def __str__(self): return self.asset_name or "" diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index a86c447..fd59478 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -165,7 +165,12 @@ class LogMixin: @declared_attr def log(cls): - return orm.relationship(Log) + return orm.relationship( + Log, + single_parent=True, + cascade="all, delete-orphan", + cascade_backrefs=False, + ) def __str__(self): return self.message or "" diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 8f05584..241f1bb 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -42,6 +42,8 @@ class AnimalTypeView(AssetTypeMasterView): route_prefix = "animal_types" url_prefix = "/animal-types" + farmos_entity_type = "taxonomy_term" + farmos_bundle = "animal_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview" grid_columns = [ @@ -145,6 +147,7 @@ class AnimalAssetView(AssetMasterView): url_prefix = "/assets/animal" farmos_refurl_path = "/assets/animal" + farmos_bundle = "animal" labels = { "animal_type": "Species/Breed", diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 2dade09..f5046f9 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -135,6 +135,8 @@ class AssetMasterView(WuttaFarmMasterView): Base class for Asset master views """ + farmos_entity_type = "asset" + sort_defaults = "asset_name" filter_defaults = { @@ -276,22 +278,6 @@ class AssetMasterView(WuttaFarmMasterView): model_class = self.get_model_class() return model_class.__wutta_hint__["farmos_asset_type"] - def delete_instance(self, obj): - - # save farmOS UUID if we need it - farmos_uuid = None - if self.app.is_farmos_mirror() and hasattr(obj, "farmos_uuid"): - farmos_uuid = obj.farmos_uuid - - # delete per usual - super().delete_instance(obj) - - # maybe delete from farmOS also - if farmos_uuid: - client = get_farmos_client_for_user(self.request) - asset_type = self.get_asset_type() - client.asset.delete(asset_type, farmos_uuid) - def get_farmos_url(self, asset): return self.app.get_farmos_url(f"/asset/{asset.drupal_id}") diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 4b26463..61394fa 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -37,6 +37,7 @@ class GroupView(AssetMasterView): url_prefix = "/assets/group" farmos_refurl_path = "/assets/group" + farmos_bundle = "group" grid_columns = [ "thumbnail", diff --git a/src/wuttafarm/web/views/land.py b/src/wuttafarm/web/views/land.py index 22827a0..9523cb5 100644 --- a/src/wuttafarm/web/views/land.py +++ b/src/wuttafarm/web/views/land.py @@ -139,6 +139,7 @@ class LandAssetView(AssetMasterView): route_prefix = "land_assets" url_prefix = "/assets/land" + farmos_bundle = "land" farmos_refurl_path = "/assets/land" grid_columns = [ diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index b393cec..9dc2bd4 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -166,6 +166,8 @@ class LogMasterView(WuttaFarmMasterView): Base class for Asset master views """ + farmos_entity_type = "log" + labels = { "message": "Log Name", "owners": "Owner", diff --git a/src/wuttafarm/web/views/logs_activity.py b/src/wuttafarm/web/views/logs_activity.py index dda3ca7..19f8782 100644 --- a/src/wuttafarm/web/views/logs_activity.py +++ b/src/wuttafarm/web/views/logs_activity.py @@ -36,6 +36,7 @@ class ActivityLogView(LogMasterView): route_prefix = "logs_activity" url_prefix = "/logs/activity" + farmos_bundle = "activity" farmos_refurl_path = "/logs/activity" diff --git a/src/wuttafarm/web/views/logs_harvest.py b/src/wuttafarm/web/views/logs_harvest.py index 825c864..1c9a6f2 100644 --- a/src/wuttafarm/web/views/logs_harvest.py +++ b/src/wuttafarm/web/views/logs_harvest.py @@ -36,6 +36,7 @@ class HarvestLogView(LogMasterView): route_prefix = "logs_harvest" url_prefix = "/logs/harvest" + farmos_bundle = "harvest" farmos_refurl_path = "/logs/harvest" diff --git a/src/wuttafarm/web/views/logs_medical.py b/src/wuttafarm/web/views/logs_medical.py index d582db9..c5769e8 100644 --- a/src/wuttafarm/web/views/logs_medical.py +++ b/src/wuttafarm/web/views/logs_medical.py @@ -36,6 +36,7 @@ class MedicalLogView(LogMasterView): route_prefix = "logs_medical" url_prefix = "/logs/medical" + farmos_bundle = "medical" farmos_refurl_path = "/logs/medical" diff --git a/src/wuttafarm/web/views/logs_observation.py b/src/wuttafarm/web/views/logs_observation.py index a4b9e8e..5b190d1 100644 --- a/src/wuttafarm/web/views/logs_observation.py +++ b/src/wuttafarm/web/views/logs_observation.py @@ -36,6 +36,7 @@ class ObservationLogView(LogMasterView): route_prefix = "logs_observation" url_prefix = "/logs/observation" + farmos_bundle = "observation" farmos_refurl_path = "/logs/observation" diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index 6ab0631..ec3c913 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -27,7 +27,7 @@ from webhelpers2.html import tags from wuttaweb.views import MasterView -from wuttafarm.web.util import use_farmos_style_grid_links +from wuttafarm.web.util import use_farmos_style_grid_links, get_farmos_client_for_user class WuttaFarmMasterView(MasterView): @@ -36,6 +36,8 @@ class WuttaFarmMasterView(MasterView): """ farmos_refurl_path = None + farmos_entity_type = None + farmos_bundle = None labels = { "farmos_uuid": "farmOS UUID", @@ -104,6 +106,42 @@ class WuttaFarmMasterView(MasterView): f.set_readonly("drupal_id") def persist(self, obj, session=None): + + # save per usual super().persist(obj, session) - token = self.request.session.get("farmos.oauth2.token") - self.app.auto_sync_to_farmos(obj, token=token, require=False) + + # maybe also sync change to farmOS + if self.app.is_farmos_mirror(): + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_to_farmos(obj, token=token, require=False) + + def get_farmos_entity_type(self): + if self.farmos_entity_type: + return self.farmos_entity_type + raise NotImplementedError( + f"must define {self.__class__.__name__}.farmos_entity_type" + ) + + def get_farmos_bundle(self): + if self.farmos_bundle: + return self.farmos_bundle + raise NotImplementedError( + f"must define {self.__class__.__name__}.farmos_bundle" + ) + + def delete_instance(self, obj): + + # save farmOS UUID if we need it + farmos_uuid = None + if hasattr(obj, "farmos_uuid") and self.app.is_farmos_mirror(): + farmos_uuid = obj.farmos_uuid + + # delete per usual + super().delete_instance(obj) + + # maybe delete from farmOS also + if farmos_uuid: + entity_type = self.get_farmos_entity_type() + bundle = self.get_farmos_bundle() + client = get_farmos_client_for_user(self.request) + client.resource.delete(entity_type, bundle, farmos_uuid) diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index 4bd32c6..4a343a6 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -41,6 +41,7 @@ class PlantTypeView(AssetTypeMasterView): url_prefix = "/plant-types" farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" + farmos_bundle = "plant" grid_columns = [ "name", diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index aa9bf31..11a21b9 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -138,6 +138,7 @@ class StructureAssetView(AssetMasterView): route_prefix = "structure_assets" url_prefix = "/asset/structures" + farmos_bundle = "structure" farmos_refurl_path = "/assets/structure" grid_columns = [ diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index 3b86426..a36a238 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -69,6 +69,8 @@ class UnitView(WuttaFarmMasterView): route_prefix = "units" url_prefix = "/units" + farmos_entity_type = "taxonomy_term" + farmos_bundle = "unit" farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" grid_columns = [ From 2a375b0a6f08f71f2b145b38968084a537bcb8a5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 19:19:40 -0600 Subject: [PATCH 122/166] fix: add enum, row hilite for log status --- src/wuttafarm/web/views/logs.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 9dc2bd4..53fc91e 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -189,6 +189,7 @@ class LogMasterView(WuttaFarmMasterView): filter_defaults = { "message": {"active": True, "verb": "contains"}, + "status": {"active": True, "verb": "not_equal", "value": "abandoned"}, } form_fields = [ @@ -222,7 +223,12 @@ class LogMasterView(WuttaFarmMasterView): # status g.set_enum("status", enum.LOG_STATUS) g.set_sorter("status", model.Log.status) - g.set_filter("status", model.Log.status) + g.set_filter( + "status", + model.Log.status, + verbs=["equal", "not_equal"], + choices=enum.LOG_STATUS, + ) # drupal_id g.set_label("drupal_id", "ID", column_only=True) @@ -246,6 +252,13 @@ class LogMasterView(WuttaFarmMasterView): def render_assets_for_grid(self, log, field, value): return ", ".join([a.asset.asset_name for a in log.log._assets]) + def grid_row_class(self, log, data, i): + if log.status == "pending": + return "has-background-warning" + if log.status == "abandoned": + return "has-background-danger" + return None + def configure_form(self, form): f = form super().configure_form(f) From f374ae426ca2b6104c36ea47a014f7d2c0351633 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 19:23:39 -0600 Subject: [PATCH 123/166] fix: remove 'contains' verb for sex filter --- src/wuttafarm/web/views/animals.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 241f1bb..734763b 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -127,6 +127,7 @@ class AnimalTypeView(AssetTypeMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) + g.filters["sex"].verbs = ["equal", "not_equal"] # archived g.set_renderer("archived", "boolean") @@ -204,6 +205,7 @@ class AnimalAssetView(AssetMasterView): # sex g.set_enum("sex", enum.ANIMAL_SEX) + g.filters["sex"].verbs = ["equal", "not_equal"] def render_animal_type_for_grid(self, animal, field, value): url = self.request.route_url("animal_types.view", uuid=animal.animal_type_uuid) From 5046171b76baca3acda5e1124e7c9a6e4a126613 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2026 19:25:02 -0600 Subject: [PATCH 124/166] fix: prevent edit for user farmos_uuid, drupal_id --- src/wuttafarm/web/views/users.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wuttafarm/web/views/users.py b/src/wuttafarm/web/views/users.py index 21e26d9..ffda747 100644 --- a/src/wuttafarm/web/views/users.py +++ b/src/wuttafarm/web/views/users.py @@ -55,11 +55,13 @@ class UserView(base.UserView): # farmos_uuid if not self.creating: f.fields.append("farmos_uuid") + f.set_readonly("farmos_uuid") f.set_default("farmos_uuid", user.farmos_uuid or colander.null) # drupal_id if not self.creating: f.fields.append("drupal_id") + f.set_readonly("drupal_id") f.set_default("drupal_id", user.drupal_id or colander.null) def get_xref_buttons(self, user): From 7d5ff47e8e853769885560358edbe56eda772d91 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 11:53:12 -0600 Subject: [PATCH 125/166] feat: add related version tables for asset/log revision history --- src/wuttafarm/web/views/assets.py | 15 +++++++++++++++ src/wuttafarm/web/views/logs.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index f5046f9..d9e6205 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -296,6 +296,21 @@ class AssetMasterView(WuttaFarmMasterView): return buttons + def get_version_joins(self): + """ + We override this to declare the relationship between the + view's data model (which is some type of asset table) and the + canonical ``Asset`` model, so the revision history views + include transactions which reference either version table. + + See also parent method, + :meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_version_joins()` + """ + model = self.app.model + return super().get_version_joins() + [ + model.Asset, + ] + def get_row_grid_data(self, asset): model = self.app.model session = self.Session() diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 53fc91e..af0e375 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -344,6 +344,22 @@ class LogMasterView(WuttaFarmMasterView): return buttons + def get_version_joins(self): + """ + We override this to declare the relationship between the + view's data model (which is some type of log table) and the + canonical ``Log`` model, so the revision history views include + transactions which reference either version table. + + See also parent method, + :meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_version_joins()` + """ + model = self.app.model + return super().get_version_joins() + [ + model.Log, + (model.LogAsset, "log_uuid", "uuid"), + ] + def defaults(config, **kwargs): base = globals() From 1c0286eda0d9a69e9a4f6965f8c043bbd40a3aac Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 13:17:52 -0600 Subject: [PATCH 126/166] fix: add reminder to restart if changing integration mode --- .../web/templates/appinfo/configure.mako | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/wuttafarm/web/templates/appinfo/configure.mako b/src/wuttafarm/web/templates/appinfo/configure.mako index 8dc5e8a..912eef0 100644 --- a/src/wuttafarm/web/templates/appinfo/configure.mako +++ b/src/wuttafarm/web/templates/appinfo/configure.mako @@ -37,13 +37,24 @@ - - % for value, label in enum.FARMOS_INTEGRATION_MODE.items(): - - % endfor - +
+ + % for value, label in enum.FARMOS_INTEGRATION_MODE.items(): + + % endfor + + <${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}"> + + + +
Date: Fri, 27 Feb 2026 16:35:56 -0600 Subject: [PATCH 127/166] feat: add way to create animal type when editing animal --- src/wuttafarm/web/app.py | 9 ++ src/wuttafarm/web/forms/schema.py | 25 +--- src/wuttafarm/web/forms/widgets.py | 53 ++++---- src/wuttafarm/web/templates/base.mako | 6 + .../web/templates/deform/animaltyperef.pt | 13 ++ .../web/templates/wuttafarm-components.mako | 128 ++++++++++++++++++ src/wuttafarm/web/views/animals.py | 50 +++++++ 7 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 src/wuttafarm/web/templates/deform/animaltyperef.pt create mode 100644 src/wuttafarm/web/templates/wuttafarm-components.mako diff --git a/src/wuttafarm/web/app.py b/src/wuttafarm/web/app.py index 2fcb48d..161a876 100644 --- a/src/wuttafarm/web/app.py +++ b/src/wuttafarm/web/app.py @@ -40,6 +40,15 @@ def main(global_config, **settings): "wuttaweb:templates", ], ) + settings.setdefault( + "pyramid_deform.template_search_path", + " ".join( + [ + "wuttafarm.web:templates/deform", + "wuttaweb:templates/deform", + ] + ), + ) # make config objects wutta_config = base.make_wutta_config(settings) diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 075c36c..c6095ff 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -55,6 +55,12 @@ class AnimalTypeRef(ObjectRef): animal_type = obj return self.request.route_url("animal_types.view", uuid=animal_type.uuid) + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import AnimalTypeRefWidget + + kwargs["factory"] = AnimalTypeRefWidget + return super().widget_maker(**kwargs) + class LogQuick(WuttaSet): @@ -185,25 +191,6 @@ class FarmOSQuantityRefs(WuttaSet): return FarmOSQuantityRefsWidget(**kwargs) -class AnimalTypeType(colander.SchemaType): - - def __init__(self, request, *args, **kwargs): - super().__init__(*args, **kwargs) - self.request = request - - def serialize(self, node, appstruct): - if appstruct is colander.null: - return colander.null - - return json.dumps(appstruct) - - def widget_maker(self, **kwargs): # pylint: disable=empty-docstring - """ """ - from wuttafarm.web.forms.widgets import AnimalTypeWidget - - return AnimalTypeWidget(self.request, **kwargs) - - class FarmOSPlantTypes(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 9dcc51f..7f5808f 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -29,7 +29,7 @@ import colander from deform.widget import Widget, SelectWidget from webhelpers2.html import HTML, tags -from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget +from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget from wuttaweb.db import Session from wuttafarm.web.util import render_quantity_objects @@ -228,33 +228,6 @@ class FarmOSUnitRefWidget(Widget): return super().serialize(field, cstruct, **kw) -class AnimalTypeWidget(Widget): - """ - Widget to display an "animal type" field. - """ - - 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") - - animal_type = json.loads(cstruct) - return tags.link_to( - animal_type["name"], - self.request.route_url( - "farmos_animal_types.view", uuid=animal_type["uuid"] - ), - ) - - return super().serialize(field, cstruct, **kw) - - class FarmOSPlantTypesWidget(Widget): """ Widget to display a farmOS "plant types" field. @@ -372,6 +345,11 @@ class UsersWidget(Widget): return super().serialize(field, cstruct, **kw) +############################## +# native data widgets +############################## + + class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): """ Widget for Parents field which references assets. @@ -432,3 +410,22 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): return HTML.tag("ul", c=assets) return super().serialize(field, cstruct, **kw) + + +class AnimalTypeRefWidget(ObjectRefWidget): + """ + Custom widget which uses the ```` component. + """ + + template = "animaltyperef" + + def get_template_values(self, field, cstruct, kw): + """ """ + values = super().get_template_values(field, cstruct, kw) + + values["js_values"] = json.dumps(values["values"]) + + if self.request.has_perm("animal_types.create"): + values["can_create"] = True + + return values diff --git a/src/wuttafarm/web/templates/base.mako b/src/wuttafarm/web/templates/base.mako index 3e5d544..b28b52f 100644 --- a/src/wuttafarm/web/templates/base.mako +++ b/src/wuttafarm/web/templates/base.mako @@ -1,4 +1,5 @@ <%inherit file="wuttaweb:templates/base.mako" /> +<%namespace file="/wuttafarm-components.mako" import="make_wuttafarm_components" /> <%def name="index_title_controls()"> ${parent.index_title_controls()} @@ -14,3 +15,8 @@ % endif + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${make_wuttafarm_components()} + diff --git a/src/wuttafarm/web/templates/deform/animaltyperef.pt b/src/wuttafarm/web/templates/deform/animaltyperef.pt new file mode 100644 index 0000000..61dd770 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/animaltyperef.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako new file mode 100644 index 0000000..b973cb1 --- /dev/null +++ b/src/wuttafarm/web/templates/wuttafarm-components.mako @@ -0,0 +1,128 @@ + +<%def name="make_wuttafarm_components()"> + ${self.make_animal_type_picker_component()} + + +<%def name="make_animal_type_picker_component()"> + + + diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 734763b..1c9fdfe 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -26,6 +26,7 @@ Master view for Animals from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum +from wuttaweb.util import get_form_data from wuttafarm.db.model import AnimalType, AnimalAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView @@ -137,6 +138,55 @@ class AnimalTypeView(AssetTypeMasterView): def get_row_action_url_view(self, animal, i): return self.request.route_url("animal_assets.view", uuid=animal.uuid) + def ajax_create(self): + """ + AJAX view to create a new animal type. + """ + model = self.app.model + session = self.Session() + data = get_form_data(self.request) + + name = data.get("name") + if not name: + return {"error": "Name is required"} + + animal_type = model.AnimalType(name=name) + session.add(animal_type) + session.flush() + + if self.app.is_farmos_mirror(): + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_to_farmos(animal_type, token=token) + + return { + "uuid": animal_type.uuid.hex, + "name": animal_type.name, + "farmos_uuid": animal_type.farmos_uuid.hex, + "drupal_id": animal_type.drupal_id, + } + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._animal_type_defaults(config) + + @classmethod + def _animal_type_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + + # ajax_create + config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new") + config.add_view( + cls, + attr="ajax_create", + route_name=f"{route_prefix}.ajax_create", + permission=f"{permission_prefix}.create", + renderer="json", + ) + class AnimalAssetView(AssetMasterView): """ From 338da0208cb6171ed3128cddf5f7d3b7076f9f6f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 16:49:56 -0600 Subject: [PATCH 128/166] fix: prevent delete if animal type is still being referenced --- src/wuttafarm/db/model/asset_animal.py | 9 +++++++++ src/wuttafarm/web/views/animals.py | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/wuttafarm/db/model/asset_animal.py b/src/wuttafarm/db/model/asset_animal.py index 768b0f9..cf88b83 100644 --- a/src/wuttafarm/db/model/asset_animal.py +++ b/src/wuttafarm/db/model/asset_animal.py @@ -80,6 +80,14 @@ class AnimalType(model.Base): """, ) + animal_assets = orm.relationship( + "AnimalAsset", + doc=""" + List of animal assets of this type. + """, + back_populates="animal_type", + ) + def __str__(self): return self.name or "" @@ -103,6 +111,7 @@ class AnimalAsset(AssetMixin, EggMixin, model.Base): doc=""" Reference to the animal type. """, + back_populates="animal_assets", ) birthdate = sa.Column( diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 1c9fdfe..fc4c646 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -106,6 +106,19 @@ class AnimalTypeView(AssetTypeMasterView): return buttons + def delete(self): + animal_type = self.get_instance() + + if animal_type.animal_assets: + self.request.session.flash( + "Cannot delete animal type which is still referenced by animal assets.", + "warning", + ) + url = self.get_action_url("view", animal_type) + return self.redirect(self.request.get_referrer(default=url)) + + return super().delete() + def get_row_grid_data(self, animal_type): model = self.app.model session = self.Session() From 28ecb4d78675ef0e5a12a1736177297b1bd06393 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 16:55:02 -0600 Subject: [PATCH 129/166] fix: remove unique constraint for `AnimalType.name` since it is not guaranteed unique in farmOS; can't do it here either or else import may fail --- ...2ed2_remove_unique_for_animal_type_name.py | 37 +++++++++++++++++++ src/wuttafarm/db/model/asset_animal.py | 1 - 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/wuttafarm/db/alembic/versions/45c7718d2ed2_remove_unique_for_animal_type_name.py diff --git a/src/wuttafarm/db/alembic/versions/45c7718d2ed2_remove_unique_for_animal_type_name.py b/src/wuttafarm/db/alembic/versions/45c7718d2ed2_remove_unique_for_animal_type_name.py new file mode 100644 index 0000000..03759cf --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/45c7718d2ed2_remove_unique_for_animal_type_name.py @@ -0,0 +1,37 @@ +"""remove unique for animal_type.name + +Revision ID: 45c7718d2ed2 +Revises: 5b6c87d8cddf +Create Date: 2026-02-27 16:53:59.310342 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "45c7718d2ed2" +down_revision: Union[str, None] = "5b6c87d8cddf" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # animal_type + op.drop_constraint(op.f("uq_animal_type_name"), "animal_type", type_="unique") + + +def downgrade() -> None: + + # animal_type + op.create_unique_constraint( + op.f("uq_animal_type_name"), + "animal_type", + ["name"], + postgresql_nulls_not_distinct=False, + ) diff --git a/src/wuttafarm/db/model/asset_animal.py b/src/wuttafarm/db/model/asset_animal.py index cf88b83..443a984 100644 --- a/src/wuttafarm/db/model/asset_animal.py +++ b/src/wuttafarm/db/model/asset_animal.py @@ -48,7 +48,6 @@ class AnimalType(model.Base): name = sa.Column( sa.String(length=100), nullable=False, - unique=True, doc=""" Name of the animal type. """, From 3343524325f7888b8aa80efe5a8d8eb1c2d886d5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 17:05:20 -0600 Subject: [PATCH 130/166] fix: add farmOS-style links for Parents column in Land Assets grid --- src/wuttafarm/web/views/assets.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index d9e6205..ce101f8 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -25,6 +25,8 @@ Master view for Assets from collections import OrderedDict +from webhelpers2.html import tags + from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.db import Session @@ -210,7 +212,19 @@ class AssetMasterView(WuttaFarmMasterView): g.set_filter("archived", model.Asset.archived) def render_parents_for_grid(self, asset, field, value): - parents = [str(p.parent) for p in asset.asset._parents] + parents = asset.asset._parents + + if self.farmos_style_grid_links: + links = [] + for parent in parents: + parent = parent.parent + url = self.request.route_url( + f"{parent.asset_type}_assets.view", uuid=parent.uuid + ) + links.append(tags.link_to(str(parent), url)) + return ", ".join(links) + + parents = [str(p.parent) for p in parents] return ", ".join(parents) def grid_row_class(self, asset, data, i): From 2f84f76d89d8160eb9dfdc139d42b6bdb39a90fd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 17:16:50 -0600 Subject: [PATCH 131/166] fix: prevent edit for asset types, land types when app is mirror --- src/wuttafarm/web/views/asset_types.py | 13 +++++++++++++ src/wuttafarm/web/views/land.py | 13 +++++++++++++ src/wuttafarm/web/views/structures.py | 13 +++++++++++++ src/wuttafarm/web/views/units.py | 13 +++++++++++++ 4 files changed, 52 insertions(+) diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py index b9f560a..f9aadfb 100644 --- a/src/wuttafarm/web/views/asset_types.py +++ b/src/wuttafarm/web/views/asset_types.py @@ -78,6 +78,19 @@ class AssetTypeView(WuttaFarmMasterView): return buttons + @classmethod + def defaults(cls, config): + """ """ + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + + if app.is_farmos_mirror(): + cls.creatable = False + cls.editable = False + cls.deletable = False + + cls._defaults(config) + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/land.py b/src/wuttafarm/web/views/land.py index 9523cb5..23b899d 100644 --- a/src/wuttafarm/web/views/land.py +++ b/src/wuttafarm/web/views/land.py @@ -129,6 +129,19 @@ class LandTypeView(AssetTypeMasterView): def get_row_action_url_view(self, land_asset, i): return self.request.route_url("land_assets.view", uuid=land_asset.uuid) + @classmethod + def defaults(cls, config): + """ """ + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + + if app.is_farmos_mirror(): + cls.creatable = False + cls.editable = False + cls.deletable = False + + cls._defaults(config) + class LandAssetView(AssetMasterView): """ diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 11a21b9..4d36d41 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -128,6 +128,19 @@ class StructureTypeView(AssetTypeMasterView): def get_row_action_url_view(self, structure, i): return self.request.route_url("structure_assets.view", uuid=structure.uuid) + @classmethod + def defaults(cls, config): + """ """ + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + + if app.is_farmos_mirror(): + cls.creatable = False + cls.editable = False + cls.deletable = False + + cls._defaults(config) + class StructureAssetView(AssetMasterView): """ diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index a36a238..add7b2b 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -59,6 +59,19 @@ class MeasureView(WuttaFarmMasterView): # name g.set_link("name") + @classmethod + def defaults(cls, config): + """ """ + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + + if app.is_farmos_mirror(): + cls.creatable = False + cls.editable = False + cls.deletable = False + + cls._defaults(config) + class UnitView(WuttaFarmMasterView): """ From 0d989dcb2c74f33702b838926c748cd26cb85a1c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 17:17:18 -0600 Subject: [PATCH 132/166] fix: fix land asset type --- src/wuttafarm/db/model/asset_land.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttafarm/db/model/asset_land.py b/src/wuttafarm/db/model/asset_land.py index bbd7bf0..00bdd27 100644 --- a/src/wuttafarm/db/model/asset_land.py +++ b/src/wuttafarm/db/model/asset_land.py @@ -88,7 +88,7 @@ class LandAsset(AssetMixin, model.Base): __wutta_hint__ = { "model_title": "Land Asset", "model_title_plural": "Land Assets", - "farmos_asset_type": "animal", + "farmos_asset_type": "land", } land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False, unique=True) From bdda586ccdfb09397abb22698e611e430129dbc9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 17:23:49 -0600 Subject: [PATCH 133/166] fix: render links for Plant Type column in Plant Assets grid --- src/wuttafarm/web/views/plants.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index 4a343a6..cd6cb34 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -23,6 +23,8 @@ Master view for Plants """ +from webhelpers2.html import tags + from wuttaweb.forms.schema import WuttaDictEnum from wuttafarm.db.model import PlantType, PlantAsset @@ -172,10 +174,20 @@ class PlantAssetView(AssetMasterView): super().configure_grid(g) # plant_types - g.set_renderer("plant_types", self.render_grid_plant_types) + g.set_renderer("plant_types", self.render_plant_types_for_grid) - def render_grid_plant_types(self, plant, field, value): - return ", ".join([t.plant_type.name for t in plant._plant_types]) + def render_plant_types_for_grid(self, plant, field, value): + plant_types = plant._plant_types + + if self.farmos_style_grid_links: + links = [] + for plant_type in plant_types: + plant_type = plant_type.plant_type + url = self.request.route_url("plant_types.view", uuid=plant_type.uuid) + links.append(tags.link_to(str(plant_type), url)) + return ", ".join(links) + + return ", ".join([str(pt.plant_type) for pt in plant_types]) def configure_form(self, form): f = form From c353d5bcef076b623f94dddc57666a7786e0a714 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 22:34:51 -0600 Subject: [PATCH 134/166] feat: add support for edit, import/export of plant type data esp. plant types for a plant asset --- src/wuttafarm/db/model/asset_plant.py | 16 ++ src/wuttafarm/farmos/importing/model.py | 6 + src/wuttafarm/farmos/importing/wuttafarm.py | 23 ++ src/wuttafarm/importing/farmos.py | 6 +- src/wuttafarm/web/forms/schema.py | 17 +- src/wuttafarm/web/forms/widgets.py | 62 +++++- .../web/templates/deform/planttyperefs.pt | 13 ++ .../web/templates/wuttafarm-components.mako | 196 ++++++++++++++++++ src/wuttafarm/web/views/plants.py | 101 ++++++++- 9 files changed, 416 insertions(+), 24 deletions(-) create mode 100644 src/wuttafarm/web/templates/deform/planttyperefs.pt diff --git a/src/wuttafarm/db/model/asset_plant.py b/src/wuttafarm/db/model/asset_plant.py index 5f10e7c..62f7e9b 100644 --- a/src/wuttafarm/db/model/asset_plant.py +++ b/src/wuttafarm/db/model/asset_plant.py @@ -25,6 +25,7 @@ Model definition for Plant Assets import sqlalchemy as sa from sqlalchemy import orm +from sqlalchemy.ext.associationproxy import association_proxy from wuttjamaican.db import model @@ -80,6 +81,12 @@ class PlantType(model.Base): """, ) + _plant_assets = orm.relationship( + "PlantAssetPlantType", + cascade_backrefs=False, + back_populates="plant_type", + ) + def __str__(self): return self.name or "" @@ -99,9 +106,17 @@ class PlantAsset(AssetMixin, model.Base): _plant_types = orm.relationship( "PlantAssetPlantType", + cascade="all, delete-orphan", + cascade_backrefs=False, back_populates="plant_asset", ) + plant_types = association_proxy( + "_plant_types", + "plant_type", + creator=lambda pt: PlantAssetPlantType(plant_type=pt), + ) + add_asset_proxies(PlantAsset) @@ -129,4 +144,5 @@ class PlantAssetPlantType(model.Base): doc=""" Reference to the plant type. """, + back_populates="_plant_assets", ) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 337649c..04d80c1 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -347,6 +347,12 @@ class LandAssetImporter(ToFarmOSAsset): return payload +class PlantTypeImporter(ToFarmOSTaxonomy): + + model_title = "PlantType" + farmos_taxonomy_type = "plant_type" + + class PlantAssetImporter(ToFarmOSAsset): model_title = "PlantAsset" diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index a39fe97..bb5350d 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -99,6 +99,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalType"] = AnimalTypeImporter importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter + importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter importers["ActivityLog"] = ActivityLogImporter @@ -264,6 +265,28 @@ class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter) } +class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter): + """ + WuttaFarm → farmOS API exporter for Plant Types + """ + + source_model_class = model.PlantType + + supported_fields = [ + "uuid", + "name", + ] + + drupal_internal_id_field = "drupal_internal__tid" + + def normalize_source_object(self, plant_type): + return { + "uuid": plant_type.farmos_uuid or self.app.make_true_uuid(), + "name": plant_type.name, + "_src_object": plant_type, + } + + class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter): """ WuttaFarm → farmOS API exporter for Plant Assets diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index a1e9631..9e922da 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -623,7 +623,7 @@ class PlantAssetImporter(AssetImporterBase): def normalize_source_object(self, plant): """ """ - plant_types = None + plant_types = [] if relationships := plant.get("relationships"): if plant_type := relationships.get("plant_type"): @@ -640,7 +640,7 @@ class PlantAssetImporter(AssetImporterBase): data.update( { "asset_type": "plant", - "plant_types": plant_types, + "plant_types": set(plant_types), } ) return data @@ -649,7 +649,7 @@ class PlantAssetImporter(AssetImporterBase): data = super().normalize_target_object(plant) if "plant_types" in self.fields: - data["plant_types"] = [t.plant_type_uuid for t in plant._plant_types] + data["plant_types"] = set([pt.uuid for pt in plant.plant_types]) return data diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index c6095ff..548ee81 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -27,6 +27,7 @@ import json import colander +from wuttaweb.db import Session from wuttaweb.forms.schema import ObjectRef, WuttaSet from wuttaweb.forms.widgets import NotesWidget @@ -242,13 +243,23 @@ class PlantTypeRefs(WuttaSet): def serialize(self, node, appstruct): if not appstruct: - appstruct = [] - uuids = [u.hex for u in appstruct] - return json.dumps(uuids) + return colander.null + + return [uuid.hex for uuid in appstruct] def widget_maker(self, **kwargs): from wuttafarm.web.forms.widgets import PlantTypeRefsWidget + model = self.app.model + session = Session() + + if "values" not in kwargs: + plant_types = ( + session.query(model.PlantType).order_by(model.PlantType.name).all() + ) + values = [(pt.uuid.hex, str(pt)) for pt in plant_types] + kwargs["values"] = values + return PlantTypeRefsWidget(self.request, **kwargs) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 7f5808f..ae9aa10 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -26,7 +26,7 @@ Custom form widgets for WuttaFarm import json import colander -from deform.widget import Widget, SelectWidget +from deform.widget import Widget, SelectWidget, sequence_types, _normalize_choices from webhelpers2.html import HTML, tags from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget @@ -258,22 +258,40 @@ class FarmOSPlantTypesWidget(Widget): return super().serialize(field, cstruct, **kw) -class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget): +class PlantTypeRefsWidget(Widget): """ Widget for Plant Types field (on a Plant Asset). """ + template = "planttyperefs" + values = () + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + def serialize(self, field, cstruct, **kw): """ """ model = self.app.model session = Session() - readonly = kw.get("readonly", self.readonly) - if readonly: - plant_types = [] - for uuid in json.loads(cstruct): - plant_type = session.get(model.PlantType, uuid) - plant_types.append( + if cstruct in (colander.null, None): + cstruct = () + + if readonly := kw.get("readonly", self.readonly): + items = [] + + plant_types = ( + session.query(model.PlantType) + .filter(model.PlantType.uuid.in_(cstruct)) + .order_by(model.PlantType.name) + .all() + ) + + for plant_type in plant_types: + items.append( HTML.tag( "li", c=tags.link_to( @@ -284,9 +302,33 @@ class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget): ), ) ) - return HTML.tag("ul", c=plant_types) - return super().serialize(field, cstruct, **kw) + return HTML.tag("ul", c=items) + + values = kw.get("values", self.values) + if not isinstance(values, sequence_types): + raise TypeError("Values must be a sequence type (list, tuple, or range).") + + kw["values"] = _normalize_choices(values) + tmpl_values = self.get_template_values(field, cstruct, kw) + return field.renderer(self.template, **tmpl_values) + + def get_template_values(self, field, cstruct, kw): + """ """ + values = super().get_template_values(field, cstruct, kw) + + values["js_values"] = json.dumps(values["values"]) + + if self.request.has_perm("plant_types.create"): + values["can_create"] = True + + return values + + def deserialize(self, field, pstruct): + if not pstruct: + return colander.null + + return set(pstruct.split(",")) class StructureWidget(Widget): diff --git a/src/wuttafarm/web/templates/deform/planttyperefs.pt b/src/wuttafarm/web/templates/deform/planttyperefs.pt new file mode 100644 index 0000000..83cb095 --- /dev/null +++ b/src/wuttafarm/web/templates/deform/planttyperefs.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako index b973cb1..37b176e 100644 --- a/src/wuttafarm/web/templates/wuttafarm-components.mako +++ b/src/wuttafarm/web/templates/wuttafarm-components.mako @@ -1,6 +1,7 @@ <%def name="make_wuttafarm_components()"> ${self.make_animal_type_picker_component()} + ${self.make_plant_types_picker_component()} <%def name="make_animal_type_picker_component()"> @@ -126,3 +127,198 @@ <% request.register_component('animal-type-picker', 'AnimalTypePicker') %> + +<%def name="make_plant_types_picker_component()"> + + + diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index cd6cb34..a2d0cb1 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -26,6 +26,7 @@ Master view for Plants from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum +from wuttaweb.util import get_form_data from wuttafarm.db.model import PlantType, PlantAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView @@ -42,8 +43,9 @@ class PlantTypeView(AssetTypeMasterView): route_prefix = "plant_types" url_prefix = "/plant-types" + farmos_entity_type = "taxonomy_term" + farmos_bundle = "plant_type" farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview" - farmos_bundle = "plant" grid_columns = [ "name", @@ -101,6 +103,19 @@ class PlantTypeView(AssetTypeMasterView): return buttons + def delete(self): + plant_type = self.get_instance() + + if plant_type._plant_assets: + self.request.session.flash( + "Cannot delete plant type which is still referenced by plant assets.", + "warning", + ) + url = self.get_action_url("view", plant_type) + return self.redirect(self.request.get_referrer(default=url)) + + return super().delete() + def get_row_grid_data(self, plant_type): model = self.app.model session = self.Session() @@ -129,6 +144,55 @@ class PlantTypeView(AssetTypeMasterView): def get_row_action_url_view(self, plant, i): return self.request.route_url("plant_assets.view", uuid=plant.uuid) + def ajax_create(self): + """ + AJAX view to create a new plant type. + """ + model = self.app.model + session = self.Session() + data = get_form_data(self.request) + + name = data.get("name") + if not name: + return {"error": "Name is required"} + + plant_type = model.PlantType(name=name) + session.add(plant_type) + session.flush() + + if self.app.is_farmos_mirror(): + token = self.request.session.get("farmos.oauth2.token") + self.app.auto_sync_to_farmos(plant_type, token=token) + + return { + "uuid": plant_type.uuid.hex, + "name": plant_type.name, + "farmos_uuid": plant_type.farmos_uuid.hex, + "drupal_id": plant_type.drupal_id, + } + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._plant_type_defaults(config) + + @classmethod + def _plant_type_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + + # ajax_create + config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new") + config.add_view( + cls, + attr="ajax_create", + route_name=f"{route_prefix}.ajax_create", + permission=f"{permission_prefix}.create", + renderer="json", + ) + class PlantAssetView(AssetMasterView): """ @@ -139,6 +203,7 @@ class PlantAssetView(AssetMasterView): route_prefix = "plant_assets" url_prefix = "/assets/plant" + farmos_bundle = "plant" farmos_refurl_path = "/assets/plant" labels = { @@ -196,18 +261,38 @@ class PlantAssetView(AssetMasterView): plant = f.model_instance # plant_types - if self.creating or self.editing: - f.remove("plant_types") # TODO: add support for this - else: - f.set_node("plant_types", PlantTypeRefs(self.request)) - f.set_default( - "plant_types", [t.plant_type_uuid for t in plant._plant_types] - ) + f.set_node("plant_types", PlantTypeRefs(self.request)) + if not self.creating: + # nb. must explcitly declare value for non-standard field + f.set_default("plant_types", [pt.uuid for pt in plant.plant_types]) # season if self.creating or self.editing: f.remove("season") # TODO: add support for this + def objectify(self, form): + model = self.app.model + session = self.Session() + plant = super().objectify(form) + data = form.validated + + current = [pt.uuid for pt in plant.plant_types] + desired = data["plant_types"] + + for uuid in desired: + if uuid not in current: + plant_type = session.get(model.PlantType, uuid) + assert plant_type + plant.plant_types.append(plant_type) + + for uuid in current: + if uuid not in desired: + plant_type = session.get(model.PlantType, uuid) + assert plant_type + plant.plant_types.remove(plant_type) + + return plant + def defaults(config, **kwargs): base = globals() From d465934818a4827599a3b5a05beb5168a3d708af Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 12:53:18 -0600 Subject: [PATCH 135/166] fix: ensure token refresh works regardless where API client is used --- src/wuttafarm/app.py | 16 ++++++++-------- src/wuttafarm/farmos/importing/wuttafarm.py | 8 +++++--- src/wuttafarm/importing/farmos.py | 8 +++++--- src/wuttafarm/web/views/animals.py | 5 +++-- src/wuttafarm/web/views/master.py | 4 ++-- src/wuttafarm/web/views/plants.py | 5 +++-- src/wuttafarm/web/views/quick/eggs.py | 7 ++++--- 7 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 30c7f51..a3fa566 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -136,7 +136,7 @@ class WuttaFarmAppHandler(base.AppHandler): factory = self.load_object(spec) return factory(self.config, farmos_client) - def auto_sync_to_farmos(self, obj, model_name=None, token=None, require=True): + def auto_sync_to_farmos(self, obj, model_name=None, client=None, require=True): """ Export the given object to farmOS, using configured handler. @@ -147,8 +147,8 @@ class WuttaFarmAppHandler(base.AppHandler): :param obj: Any data object in WuttaFarm, e.g. AnimalAsset instance. - :param token: OAuth2 token for the farmOS client. If not - specified, the import handler will obtain a new token. + :param client: Existing farmOS API client to use. If not + specified, a new one will be instantiated. :param require: If true, this will *require* the export handler to support objects of the given type. If false, @@ -165,12 +165,12 @@ class WuttaFarmAppHandler(base.AppHandler): return # nb. begin txn to establish the API client - handler.begin_target_transaction(token) + handler.begin_target_transaction(client) importer = handler.get_importer(model_name, caches_target=False) normal = importer.normalize_source_object(obj) importer.process_data(source_data=[normal]) - def auto_sync_from_farmos(self, obj, model_name, token=None, require=True): + def auto_sync_from_farmos(self, obj, model_name, client=None, require=True): """ Import the given object from farmOS, using configured handler. @@ -179,8 +179,8 @@ class WuttaFarmAppHandler(base.AppHandler): :param model_name': Model name for the importer to use, e.g. ``"AnimalAsset"``. - :param token: OAuth2 token for the farmOS client. If not - specified, the import handler will obtain a new token. + :param client: Existing farmOS API client to use. If not + specified, a new one will be instantiated. :param require: If true, this will *require* the import handler to support objects of the given type. If false, @@ -195,7 +195,7 @@ class WuttaFarmAppHandler(base.AppHandler): return # nb. begin txn to establish the API client - handler.begin_source_transaction(token) + handler.begin_source_transaction(client) with self.short_session(commit=True) as session: handler.target_session = session importer = handler.get_importer(model_name, caches_target=False) diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index bb5350d..96cefb2 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -50,13 +50,15 @@ class ToFarmOSHandler(ImportHandler): # TODO: a lot of duplication to cleanup here; see FromFarmOSHandler - def begin_target_transaction(self, token=None): + def begin_target_transaction(self, client=None): """ Establish the farmOS API client. """ - if not token: + if client: + self.farmos_client = client + else: token = self.get_farmos_oauth2_token() - self.farmos_client = self.app.get_farmos_client(token=token) + self.farmos_client = self.app.get_farmos_client(token=token) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) def get_farmos_oauth2_token(self): diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 9e922da..5bc351e 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -46,13 +46,15 @@ class FromFarmOSHandler(ImportHandler): source_key = "farmos" generic_source_title = "farmOS" - def begin_source_transaction(self, token=None): + def begin_source_transaction(self, client=None): """ Establish the farmOS API client. """ - if not token: + if client: + self.farmos_client = client + else: token = self.get_farmos_oauth2_token() - self.farmos_client = self.app.get_farmos_client(token=token) + self.farmos_client = self.app.get_farmos_client(token=token) self.farmos_4x = self.app.is_farmos_4x(self.farmos_client) self.normal = self.app.get_normalizer(self.farmos_client) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index fc4c646..b52a353 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -32,6 +32,7 @@ from wuttafarm.db.model import AnimalType, AnimalAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.forms.schema import AnimalTypeRef from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.web.util import get_farmos_client_for_user class AnimalTypeView(AssetTypeMasterView): @@ -168,8 +169,8 @@ class AnimalTypeView(AssetTypeMasterView): session.flush() if self.app.is_farmos_mirror(): - token = self.request.session.get("farmos.oauth2.token") - self.app.auto_sync_to_farmos(animal_type, token=token) + client = get_farmos_client_for_user(self.request) + self.app.auto_sync_to_farmos(animal_type, client=client) return { "uuid": animal_type.uuid.hex, diff --git a/src/wuttafarm/web/views/master.py b/src/wuttafarm/web/views/master.py index ec3c913..747cdc5 100644 --- a/src/wuttafarm/web/views/master.py +++ b/src/wuttafarm/web/views/master.py @@ -112,8 +112,8 @@ class WuttaFarmMasterView(MasterView): # maybe also sync change to farmOS if self.app.is_farmos_mirror(): - token = self.request.session.get("farmos.oauth2.token") - self.app.auto_sync_to_farmos(obj, token=token, require=False) + client = get_farmos_client_for_user(self.request) + self.app.auto_sync_to_farmos(obj, client=client, require=False) def get_farmos_entity_type(self): if self.farmos_entity_type: diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index a2d0cb1..c831201 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -32,6 +32,7 @@ from wuttafarm.db.model import PlantType, PlantAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.forms.schema import PlantTypeRefs from wuttafarm.web.forms.widgets import ImageWidget +from wuttafarm.web.util import get_farmos_client_for_user class PlantTypeView(AssetTypeMasterView): @@ -161,8 +162,8 @@ class PlantTypeView(AssetTypeMasterView): session.flush() if self.app.is_farmos_mirror(): - token = self.request.session.get("farmos.oauth2.token") - self.app.auto_sync_to_farmos(plant_type, token=token) + client = get_farmos_client_for_user(self.request) + self.app.auto_sync_to_farmos(plant_type, client=client) return { "uuid": plant_type.uuid.hex, diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index 0482132..e5461b1 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -34,6 +34,7 @@ from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.quick import QuickFormView +from wuttafarm.web.util import get_farmos_client_for_user class EggsQuickForm(QuickFormView): @@ -118,11 +119,11 @@ class EggsQuickForm(QuickFormView): if self.app.is_farmos_mirror(): quantity = json.loads(response["create-quantity"]["body"]) - token = self.request.session.get("farmos.oauth2.token") + client = get_farmos_client_for_user(self.request) self.app.auto_sync_from_farmos( - quantity["data"], "StandardQuantity", token=token + quantity["data"], "StandardQuantity", client=client ) - self.app.auto_sync_from_farmos(log["data"], "HarvestLog", token=token) + self.app.auto_sync_from_farmos(log["data"], "HarvestLog", client=client) return log From d1817a3611aa66067ea1dd7a8f532d16097211e4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 18:40:35 -0600 Subject: [PATCH 136/166] fix: rename views for "all records" (all assets, all logs etc.) just for clarity's sake, i think it's better --- src/wuttafarm/web/views/assets.py | 6 +++--- src/wuttafarm/web/views/logs.py | 6 +++--- src/wuttafarm/web/views/quantities.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index ce101f8..963fe78 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -49,7 +49,7 @@ def get_asset_type_enum(config): return asset_types -class AssetView(WuttaFarmMasterView): +class AllAssetView(WuttaFarmMasterView): """ Master view for Assets """ @@ -368,8 +368,8 @@ class AssetMasterView(WuttaFarmMasterView): def defaults(config, **kwargs): base = globals() - AssetView = kwargs.get("AssetView", base["AssetView"]) - AssetView.defaults(config) + AllAssetView = kwargs.get("AllAssetView", base["AllAssetView"]) + AllAssetView.defaults(config) def includeme(config): diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index af0e375..7f5d9cf 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -89,7 +89,7 @@ class LogTypeView(WuttaFarmMasterView): return buttons -class LogView(WuttaFarmMasterView): +class AllLogView(WuttaFarmMasterView): """ Master view for All Logs """ @@ -367,8 +367,8 @@ def defaults(config, **kwargs): LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) LogTypeView.defaults(config) - LogView = kwargs.get("LogView", base["LogView"]) - LogView.defaults(config) + AllLogView = kwargs.get("AllLogView", base["AllLogView"]) + AllLogView.defaults(config) def includeme(config): diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py index 7d75290..fb5279d 100644 --- a/src/wuttafarm/web/views/quantities.py +++ b/src/wuttafarm/web/views/quantities.py @@ -248,7 +248,7 @@ class QuantityMasterView(WuttaFarmMasterView): return buttons -class QuantityView(QuantityMasterView): +class AllQuantityView(QuantityMasterView): """ Master view for All Quantities """ @@ -280,8 +280,8 @@ def defaults(config, **kwargs): QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"]) QuantityTypeView.defaults(config) - QuantityView = kwargs.get("QuantityView", base["QuantityView"]) - QuantityView.defaults(config) + AllQuantityView = kwargs.get("AllQuantityView", base["AllQuantityView"]) + AllQuantityView.defaults(config) StandardQuantityView = kwargs.get( "StandardQuantityView", base["StandardQuantityView"] From 86e36bc64ac8fb2bc4a8e6f3b9941a0f0ff63697 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 18:59:18 -0600 Subject: [PATCH 137/166] fix: make AllLogView inherit from LogMasterView and improve asset rendering for those grids --- src/wuttafarm/db/model/log.py | 8 ++ src/wuttafarm/web/views/logs.py | 136 +++++++++++++++----------------- 2 files changed, 70 insertions(+), 74 deletions(-) diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index fd59478..8352a8e 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -26,6 +26,7 @@ Model definition for Logs import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.ext.associationproxy import association_proxy from wuttjamaican.db import model @@ -155,6 +156,12 @@ class Log(model.Base): _assets = orm.relationship("LogAsset", back_populates="log") + assets = association_proxy( + "_assets", + "asset", + creator=lambda asset: LogAsset(asset=asset), + ) + def __str__(self): return self.message or "" @@ -184,6 +191,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "timestamp") Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") + Log.make_proxy(subclass, "log", "assets") class LogAsset(model.Base): diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 7f5d9cf..fe46298 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -26,6 +26,7 @@ Base views for Logs from collections import OrderedDict import colander +from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.db import Session @@ -89,78 +90,6 @@ class LogTypeView(WuttaFarmMasterView): return buttons -class AllLogView(WuttaFarmMasterView): - """ - Master view for All Logs - """ - - model_class = Log - route_prefix = "log" - url_prefix = "/logs" - - farmos_refurl_path = "/logs" - - viewable = False - creatable = False - editable = False - deletable = False - model_is_versioned = False - - labels = { - "message": "Log Name", - } - - grid_columns = [ - "status", - "drupal_id", - "timestamp", - "message", - "log_type", - "assets", - "location", - "quantity", - "groups", - "is_group_assignment", - ] - - sort_defaults = ("timestamp", "desc") - - filter_defaults = { - "message": {"active": True, "verb": "contains"}, - } - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - session = self.Session() - - # drupal_id - g.set_label("drupal_id", "ID", column_only=True) - - # timestamp - g.set_renderer("timestamp", "date") - g.set_link("timestamp") - - # message - g.set_link("message") - - # log_type - g.set_enum("log_type", get_log_type_enum(self.config, session=session)) - - # assets - g.set_renderer("assets", self.render_assets_for_grid) - - # view action links to final log record - def log_url(log, i): - return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) - - g.add_action("view", icon="eye", url=log_url) - - def render_assets_for_grid(self, log, field, value): - assets = [str(a.asset) for a in log._assets] - return ", ".join(assets) - - class LogMasterView(WuttaFarmMasterView): """ Base class for Asset master views @@ -212,7 +141,10 @@ class LogMasterView(WuttaFarmMasterView): model = self.app.model model_class = self.get_model_class() session = session or self.Session() - return session.query(model_class).join(model.Log) + query = session.query(model_class) + if model_class is not model.Log: + query = query.join(model.Log) + return query def configure_grid(self, grid): g = grid @@ -250,7 +182,17 @@ class LogMasterView(WuttaFarmMasterView): g.set_renderer("assets", self.render_assets_for_grid) def render_assets_for_grid(self, log, field, value): - return ", ".join([a.asset.asset_name for a in log.log._assets]) + + if self.farmos_style_grid_links: + links = [] + for asset in log.assets: + url = self.request.route_url( + f"{asset.asset_type}_assets.view", uuid=asset.uuid + ) + links.append(tags.link_to(str(asset), url)) + return ", ".join(links) + + return ", ".join([str(a) for a in log.assets]) def grid_row_class(self, log, data, i): if log.status == "pending": @@ -361,6 +303,52 @@ class LogMasterView(WuttaFarmMasterView): ] +class AllLogView(LogMasterView): + """ + Master view for All Logs + """ + + model_class = Log + route_prefix = "log" + url_prefix = "/logs" + + farmos_refurl_path = "/logs" + + viewable = False + creatable = False + editable = False + deletable = False + model_is_versioned = False + + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "log_type", + "assets", + "location", + "quantity", + "groups", + "is_group_assignment", + "owner", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + session = self.Session() + + # log_type + g.set_enum("log_type", get_log_type_enum(self.config, session=session)) + + # view action links to final log record + def log_url(log, i): + return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) + + g.add_action("view", icon="eye", url=log_url) + + def defaults(config, **kwargs): base = globals() From ae73d2f87fef74f0305dc24964f9158d4188faba Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 18:59:50 -0600 Subject: [PATCH 138/166] fix: define log grid columns to match farmOS some of these still do not have values yet.. --- src/wuttafarm/web/views/logs.py | 2 +- src/wuttafarm/web/views/logs_harvest.py | 10 ++++++++++ src/wuttafarm/web/views/logs_medical.py | 10 ++++++++++ src/wuttafarm/web/views/logs_observation.py | 12 ++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index fe46298..245c448 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -108,7 +108,7 @@ class LogMasterView(WuttaFarmMasterView): "timestamp", "message", "assets", - # "location", + "location", "quantity", "is_group_assignment", "owners", diff --git a/src/wuttafarm/web/views/logs_harvest.py b/src/wuttafarm/web/views/logs_harvest.py index 1c9a6f2..f2b29e0 100644 --- a/src/wuttafarm/web/views/logs_harvest.py +++ b/src/wuttafarm/web/views/logs_harvest.py @@ -39,6 +39,16 @@ class HarvestLogView(LogMasterView): farmos_bundle = "harvest" farmos_refurl_path = "/logs/harvest" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "assets", + "quantity", + "owners", + ] + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/logs_medical.py b/src/wuttafarm/web/views/logs_medical.py index c5769e8..2531237 100644 --- a/src/wuttafarm/web/views/logs_medical.py +++ b/src/wuttafarm/web/views/logs_medical.py @@ -39,6 +39,16 @@ class MedicalLogView(LogMasterView): farmos_bundle = "medical" farmos_refurl_path = "/logs/medical" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "assets", + "veterinarian", + "owners", + ] + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/logs_observation.py b/src/wuttafarm/web/views/logs_observation.py index 5b190d1..0485f50 100644 --- a/src/wuttafarm/web/views/logs_observation.py +++ b/src/wuttafarm/web/views/logs_observation.py @@ -39,6 +39,18 @@ class ObservationLogView(LogMasterView): farmos_bundle = "observation" farmos_refurl_path = "/logs/observation" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "message", + "assets", + "location", + "groups", + "is_group_assignment", + "owners", + ] + def defaults(config, **kwargs): base = globals() From 64e4392a926024c5148b1dc1e2c308ed51e4c747 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 19:52:09 -0600 Subject: [PATCH 139/166] feat: add support for log 'owners' --- .../versions/47d0ebd84554_add_logowner.py | 108 ++++++++++++++++++ src/wuttafarm/db/model/log.py | 45 +++++++- src/wuttafarm/importing/farmos.py | 40 +++++-- src/wuttafarm/web/forms/schema.py | 23 +++- src/wuttafarm/web/forms/widgets.py | 33 +++++- src/wuttafarm/web/views/logs.py | 27 ++++- 6 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py diff --git a/src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py b/src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py new file mode 100644 index 0000000..8dffce9 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py @@ -0,0 +1,108 @@ +"""add LogOwner + +Revision ID: 47d0ebd84554 +Revises: 45c7718d2ed2 +Create Date: 2026-02-28 19:18:49.122090 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "47d0ebd84554" +down_revision: Union[str, None] = "45c7718d2ed2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_owner + op.create_table( + "log_owner", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("user_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["log_uuid"], ["log.uuid"], name=op.f("fk_log_owner_log_uuid_log") + ), + sa.ForeignKeyConstraint( + ["user_uuid"], ["user.uuid"], name=op.f("fk_log_owner_user_uuid_user") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_owner")), + ) + op.create_table( + "log_owner_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "user_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_owner_version") + ), + ) + op.create_index( + op.f("ix_log_owner_version_end_transaction_id"), + "log_owner_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_owner_version_operation_type"), + "log_owner_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_owner_version_pk_transaction_id", + "log_owner_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_owner_version_pk_validity", + "log_owner_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_owner_version_transaction_id"), + "log_owner_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_owner + op.drop_index( + op.f("ix_log_owner_version_transaction_id"), table_name="log_owner_version" + ) + op.drop_index("ix_log_owner_version_pk_validity", table_name="log_owner_version") + op.drop_index( + "ix_log_owner_version_pk_transaction_id", table_name="log_owner_version" + ) + op.drop_index( + op.f("ix_log_owner_version_operation_type"), table_name="log_owner_version" + ) + op.drop_index( + op.f("ix_log_owner_version_end_transaction_id"), table_name="log_owner_version" + ) + op.drop_table("log_owner_version") + op.drop_table("log_owner") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 8352a8e..6142229 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -154,7 +154,12 @@ class Log(model.Base): """, ) - _assets = orm.relationship("LogAsset", back_populates="log") + _assets = orm.relationship( + "LogAsset", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) assets = association_proxy( "_assets", @@ -162,6 +167,19 @@ class Log(model.Base): creator=lambda asset: LogAsset(asset=asset), ) + _owners = orm.relationship( + "LogOwner", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) + + owners = association_proxy( + "_owners", + "user", + creator=lambda user: LogOwner(user=user), + ) + def __str__(self): return self.message or "" @@ -192,6 +210,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") Log.make_proxy(subclass, "log", "assets") + Log.make_proxy(subclass, "log", "owners") class LogAsset(model.Base): @@ -216,3 +235,27 @@ class LogAsset(model.Base): "Asset", foreign_keys=asset_uuid, ) + + +class LogOwner(model.Base): + """ + Represents a "log's owner relationship" from farmOS. + """ + + __tablename__ = "log_owner" + __versioned__ = {} + + uuid = model.uuid_column() + + log_uuid = model.uuid_fk_column("log.uuid", nullable=False) + log = orm.relationship( + Log, + foreign_keys=log_uuid, + back_populates="_owners", + ) + + user_uuid = model.uuid_fk_column("user.uuid", nullable=False) + user = orm.relationship( + model.User, + foreign_keys=user_uuid, + ) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 5bc351e..c931fb4 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -979,6 +979,7 @@ class LogImporterBase(FromFarmOS, ToWutta): fields.extend( [ "assets", + "owners", ] ) return fields @@ -1004,6 +1005,9 @@ class LogImporterBase(FromFarmOS, ToWutta): (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] ] + if "owners" in self.fields: + data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]] + return data def normalize_target_object(self, log): @@ -1011,9 +1015,12 @@ class LogImporterBase(FromFarmOS, ToWutta): if "assets" in self.fields: data["assets"] = [ - (a.asset.asset_type, a.asset.farmos_uuid) for a in log.log._assets + (asset.asset_type, asset.farmos_uuid) for asset in log.assets ] + if "owners" in self.fields: + data["owners"] = [user.farmos_uuid for user in log.owners] + return data def update_target_object(self, log, source_data, target_data=None): @@ -1026,14 +1033,13 @@ class LogImporterBase(FromFarmOS, ToWutta): for key in source_data["assets"]: asset_type, farmos_uuid = key if not target_data or key not in target_data["assets"]: - self.target_session.flush() asset = ( self.target_session.query(model.Asset) .filter(model.Asset.asset_type == asset_type) .filter(model.Asset.farmos_uuid == farmos_uuid) .one() ) - log.log._assets.append(model.LogAsset(asset=asset)) + log.assets.append(asset) if target_data: for key in target_data["assets"]: @@ -1045,13 +1051,31 @@ class LogImporterBase(FromFarmOS, ToWutta): .filter(model.Asset.farmos_uuid == farmos_uuid) .one() ) - asset = ( - self.target_session.query(model.LogAsset) - .filter(model.LogAsset.log == log) - .filter(model.LogAsset.asset == asset) + log.assets.remove(asset) + + if "owners" in self.fields: + if not target_data or target_data["owners"] != source_data["owners"]: + + for farmos_uuid in source_data["owners"]: + if not target_data or farmos_uuid not in target_data["assets"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) + .one() + ) + log.owners.append(user) + + if target_data: + for farmos_uuid in target_data["owners"]: + if farmos_uuid not in source_data["owners"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) .one() ) - self.target_session.delete(asset) + log.owners.remove(user) return log diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 548ee81..8a80054 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -371,9 +371,9 @@ class LogAssetRefs(WuttaSet): def serialize(self, node, appstruct): if not appstruct: - appstruct = [] - uuids = [u.hex for u in appstruct] - return json.dumps(uuids) + return colander.null + + return {asset.uuid for asset in appstruct} def widget_maker(self, **kwargs): from wuttafarm.web.forms.widgets import LogAssetRefsWidget @@ -381,6 +381,23 @@ class LogAssetRefs(WuttaSet): return LogAssetRefsWidget(self.request, **kwargs) +class LogOwnerRefs(WuttaSet): + """ + Schema type for Owners field (on a Log record) + """ + + def serialize(self, node, appstruct): + if not appstruct: + return colander.null + + return {user.uuid for user in appstruct} + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import LogOwnerRefsWidget + + return LogOwnerRefsWidget(self.request, **kwargs) + + class Notes(colander.String): """ Custom schema type for "note" fields. diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index ae9aa10..d3325e6 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -436,7 +436,7 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): readonly = kw.get("readonly", self.readonly) if readonly: assets = [] - for uuid in json.loads(cstruct): + for uuid in cstruct or []: asset = session.get(model.Asset, uuid) assets.append( HTML.tag( @@ -454,6 +454,37 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) +class LogOwnerRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for Owners field (on a Log record) + """ + + def serialize(self, field, cstruct, **kw): + """ """ + model = self.app.model + session = Session() + + readonly = kw.get("readonly", self.readonly) + if readonly: + owners = [session.get(model.User, uuid) for uuid in cstruct or []] + owners = [user for user in owners if user] + owners.sort(key=lambda user: user.username) + links = [] + for user in owners: + links.append( + HTML.tag( + "li", + c=tags.link_to( + user.username, + self.request.route_url("users.view", uuid=user.uuid), + ), + ) + ) + return HTML.tag("ul", c=links) + + return super().serialize(field, cstruct, **kw) + + class AnimalTypeRefWidget(ObjectRefWidget): """ Custom widget which uses the ```` component. diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 245c448..68a06d1 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import LogType, Log -from wuttafarm.web.forms.schema import LogAssetRefs +from wuttafarm.web.forms.schema import LogAssetRefs, LogOwnerRefs from wuttafarm.util import get_log_type_enum @@ -99,7 +99,6 @@ class LogMasterView(WuttaFarmMasterView): labels = { "message": "Log Name", - "owners": "Owner", } grid_columns = [ @@ -181,6 +180,10 @@ class LogMasterView(WuttaFarmMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # owners + g.set_label("owners", "Owner") + g.set_renderer("owners", self.render_owners_for_grid) + def render_assets_for_grid(self, log, field, value): if self.farmos_style_grid_links: @@ -194,6 +197,17 @@ class LogMasterView(WuttaFarmMasterView): return ", ".join([str(a) for a in log.assets]) + def render_owners_for_grid(self, log, field, value): + + if self.farmos_style_grid_links: + links = [] + for user in log.owners: + url = self.request.route_url("users.view", uuid=user.uuid) + links.append(tags.link_to(user.username, url)) + return ", ".join(links) + + return ", ".join([user.username for user in log.owners]) + def grid_row_class(self, log, data, i): if log.status == "pending": return "has-background-warning" @@ -219,7 +233,8 @@ class LogMasterView(WuttaFarmMasterView): f.remove("assets") # TODO: need to support this else: f.set_node("assets", LogAssetRefs(self.request)) - f.set_default("assets", [a.asset_uuid for a in log.log._assets]) + # nb. must explicity declare value for non-standard field + f.set_default("assets", log.assets) # location if self.creating or self.editing: @@ -247,6 +262,10 @@ class LogMasterView(WuttaFarmMasterView): # owners if self.creating or self.editing: f.remove("owners") # TODO: need to support this + else: + f.set_node("owners", LogOwnerRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("owners", log.owners) # status f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) @@ -331,7 +350,7 @@ class AllLogView(LogMasterView): "quantity", "groups", "is_group_assignment", - "owner", + "owners", ] def configure_grid(self, grid): From 61402c183e2a196542a85ac67fce48f04f0b1b61 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 19:58:54 -0600 Subject: [PATCH 140/166] fix: add placeholder for log 'quick' field --- src/wuttafarm/web/views/logs.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 68a06d1..b0b150c 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -131,6 +131,7 @@ class LogMasterView(WuttaFarmMasterView): "log_type", "owners", "is_group_assignment", + "quick", "farmos_uuid", "drupal_id", ] @@ -273,6 +274,9 @@ class LogMasterView(WuttaFarmMasterView): # is_group_assignment f.set_node("is_group_assignment", colander.Boolean()) + # quick + f.set_readonly("quick") # TODO + def objectify(self, form): log = super().objectify(form) From a5550091d3bcce1e8e1eec01781f2bc59587ca1d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 19:59:12 -0600 Subject: [PATCH 141/166] feat: add support for exporting log status, timestamp to farmOS --- src/wuttafarm/farmos/importing/model.py | 8 ++++++++ src/wuttafarm/farmos/importing/wuttafarm.py | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 04d80c1..a938423 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -458,6 +458,8 @@ class ToFarmOSLog(ToFarmOS): supported_fields = [ "uuid", "name", + "timestamp", + "status", "notes", ] @@ -513,6 +515,8 @@ class ToFarmOSLog(ToFarmOS): return { "uuid": UUID(log["id"]), "name": log["attributes"]["name"], + "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), + "status": log["attributes"]["status"], "notes": notes, } @@ -521,6 +525,10 @@ class ToFarmOSLog(ToFarmOS): attrs = {} if "name" in self.fields: attrs["name"] = source_data["name"] + if "timestamp" in self.fields: + attrs["timestamp"] = self.format_datetime(source_data["timestamp"]) + if "status" in self.fields: + attrs["status"] = source_data["status"] if "notes" in self.fields: attrs["notes"] = {"value": source_data["notes"]} diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 96cefb2..d61437b 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -360,6 +360,8 @@ class FromWuttaFarmLog(FromWuttaFarm): supported_fields = [ "uuid", "name", + "timestamp", + "status", "notes", ] @@ -367,6 +369,8 @@ class FromWuttaFarmLog(FromWuttaFarm): return { "uuid": log.farmos_uuid or self.app.make_true_uuid(), "name": log.message, + "timestamp": log.timestamp, + "status": log.status, "notes": log.notes, "_src_object": log, } From 3ae4d639ecf2b4ba5cd64b23139c0a2931ab3152 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 20:15:10 -0600 Subject: [PATCH 142/166] feat: add sync support for `Log.is_group_assignment` --- ...3c7e273bfa3_add_log_is_group_assignment.py | 39 +++++++++++++++++++ src/wuttafarm/db/model/log.py | 9 +++++ src/wuttafarm/farmos/importing/model.py | 4 ++ src/wuttafarm/farmos/importing/wuttafarm.py | 2 + src/wuttafarm/importing/farmos.py | 5 +++ src/wuttafarm/web/views/farmos/logs.py | 5 +++ src/wuttafarm/web/views/logs.py | 5 +++ 7 files changed, 69 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/f3c7e273bfa3_add_log_is_group_assignment.py diff --git a/src/wuttafarm/db/alembic/versions/f3c7e273bfa3_add_log_is_group_assignment.py b/src/wuttafarm/db/alembic/versions/f3c7e273bfa3_add_log_is_group_assignment.py new file mode 100644 index 0000000..986f4db --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/f3c7e273bfa3_add_log_is_group_assignment.py @@ -0,0 +1,39 @@ +"""add Log.is_group_assignment + +Revision ID: f3c7e273bfa3 +Revises: 47d0ebd84554 +Create Date: 2026-02-28 20:04:40.700474 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "f3c7e273bfa3" +down_revision: Union[str, None] = "47d0ebd84554" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log + op.add_column("log", sa.Column("is_group_assignment", sa.Boolean(), nullable=True)) + op.add_column( + "log_version", + sa.Column( + "is_group_assignment", sa.Boolean(), autoincrement=False, nullable=True + ), + ) + + +def downgrade() -> None: + + # log + op.drop_column("log_version", "is_group_assignment") + op.drop_column("log", "is_group_assignment") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 6142229..7234839 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -120,6 +120,14 @@ class Log(model.Base): """, ) + is_group_assignment = sa.Column( + sa.Boolean(), + nullable=True, + doc=""" + Whether the log represents a group assignment. + """, + ) + status = sa.Column( sa.String(length=20), nullable=False, @@ -207,6 +215,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "log_type") Log.make_proxy(subclass, "log", "message") Log.make_proxy(subclass, "log", "timestamp") + Log.make_proxy(subclass, "log", "is_group_assignment") Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") Log.make_proxy(subclass, "log", "assets") diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index a938423..7b900ff 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -459,6 +459,7 @@ class ToFarmOSLog(ToFarmOS): "uuid", "name", "timestamp", + "is_group_assignment", "status", "notes", ] @@ -516,6 +517,7 @@ class ToFarmOSLog(ToFarmOS): "uuid": UUID(log["id"]), "name": log["attributes"]["name"], "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), + "is_group_assignment": log["attributes"]["is_group_assignment"], "status": log["attributes"]["status"], "notes": notes, } @@ -527,6 +529,8 @@ class ToFarmOSLog(ToFarmOS): attrs["name"] = source_data["name"] if "timestamp" in self.fields: attrs["timestamp"] = self.format_datetime(source_data["timestamp"]) + if "is_group_assignment" in self.fields: + attrs["is_group_assignment"] = source_data["is_group_assignment"] if "status" in self.fields: attrs["status"] = source_data["status"] if "notes" in self.fields: diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index d61437b..8679a78 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -361,6 +361,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "uuid", "name", "timestamp", + "is_group_assignment", "status", "notes", ] @@ -370,6 +371,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "uuid": log.farmos_uuid or self.app.make_true_uuid(), "name": log.message, "timestamp": log.timestamp, + "is_group_assignment": log.is_group_assignment, "status": log.status, "notes": log.notes, "_src_object": log, diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index c931fb4..f65ac38 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -967,6 +967,7 @@ class LogImporterBase(FromFarmOS, ToWutta): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", ] @@ -1093,6 +1094,7 @@ class ActivityLogImporter(LogImporterBase): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", "assets", @@ -1112,6 +1114,7 @@ class HarvestLogImporter(LogImporterBase): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", "assets", @@ -1131,6 +1134,7 @@ class MedicalLogImporter(LogImporterBase): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", "assets", @@ -1150,6 +1154,7 @@ class ObservationLogImporter(LogImporterBase): "log_type", "message", "timestamp", + "is_group_assignment", "notes", "status", "assets", diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index 6e6dc36..fbd2a9d 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -23,6 +23,7 @@ View for farmOS Harvest Logs """ +import colander from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum @@ -85,6 +86,7 @@ class LogMasterView(FarmOSMasterView): "timestamp", "assets", "quantities", + "is_group_assignment", "notes", "status", "log_type_name", @@ -213,6 +215,9 @@ class LogMasterView(FarmOSMasterView): # quantities f.set_node("quantities", FarmOSQuantityRefs(self.request)) + # is_group_assignment + f.set_node("is_group_assignment", colander.Boolean()) + # notes f.set_node("notes", Notes()) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index b0b150c..626f34d 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -181,6 +181,11 @@ class LogMasterView(WuttaFarmMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # is_group_assignment + g.set_renderer("is_group_assignment", "boolean") + g.set_sorter("is_group_assignment", model.Log.is_group_assignment) + g.set_filter("is_group_assignment", model.Log.is_group_assignment) + # owners g.set_label("owners", "Owner") g.set_renderer("owners", self.render_owners_for_grid) From 87f3764ebfa4fb8ec1512e030c6e49fcbf9323da Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 20:56:01 -0600 Subject: [PATCH 143/166] feat: add schema, import support for `Log.locations` still need to add support for edit, export --- .../versions/3bef7d380a38_add_loglocation.py | 118 ++++++++++++++++++ src/wuttafarm/db/model/log.py | 38 ++++++ src/wuttafarm/importing/farmos.py | 38 ++++++ src/wuttafarm/normal.py | 28 +++++ src/wuttafarm/web/views/farmos/logs.py | 30 ++++- .../web/views/farmos/logs_observation.py | 12 ++ src/wuttafarm/web/views/logs.py | 23 ++-- src/wuttafarm/web/views/logs_observation.py | 2 +- 8 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/3bef7d380a38_add_loglocation.py diff --git a/src/wuttafarm/db/alembic/versions/3bef7d380a38_add_loglocation.py b/src/wuttafarm/db/alembic/versions/3bef7d380a38_add_loglocation.py new file mode 100644 index 0000000..0ed92d9 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/3bef7d380a38_add_loglocation.py @@ -0,0 +1,118 @@ +"""add LogLocation + +Revision ID: 3bef7d380a38 +Revises: f3c7e273bfa3 +Create Date: 2026-02-28 20:41:56.051847 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "3bef7d380a38" +down_revision: Union[str, None] = "f3c7e273bfa3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_location + op.create_table( + "log_location", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_uuid"], + ["asset.uuid"], + name=op.f("fk_log_location_asset_uuid_asset"), + ), + sa.ForeignKeyConstraint( + ["log_uuid"], ["log.uuid"], name=op.f("fk_log_location_log_uuid_log") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_location")), + ) + op.create_table( + "log_location_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_location_version") + ), + ) + op.create_index( + op.f("ix_log_location_version_end_transaction_id"), + "log_location_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_location_version_operation_type"), + "log_location_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_location_version_pk_transaction_id", + "log_location_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_location_version_pk_validity", + "log_location_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_location_version_transaction_id"), + "log_location_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_location + op.drop_index( + op.f("ix_log_location_version_transaction_id"), + table_name="log_location_version", + ) + op.drop_index( + "ix_log_location_version_pk_validity", table_name="log_location_version" + ) + op.drop_index( + "ix_log_location_version_pk_transaction_id", table_name="log_location_version" + ) + op.drop_index( + op.f("ix_log_location_version_operation_type"), + table_name="log_location_version", + ) + op.drop_index( + op.f("ix_log_location_version_end_transaction_id"), + table_name="log_location_version", + ) + op.drop_table("log_location_version") + op.drop_table("log_location") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 7234839..b770a12 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -175,6 +175,19 @@ class Log(model.Base): creator=lambda asset: LogAsset(asset=asset), ) + _locations = orm.relationship( + "LogLocation", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) + + locations = association_proxy( + "_locations", + "asset", + creator=lambda asset: LogLocation(asset=asset), + ) + _owners = orm.relationship( "LogOwner", cascade="all, delete-orphan", @@ -219,6 +232,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") Log.make_proxy(subclass, "log", "assets") + Log.make_proxy(subclass, "log", "locations") Log.make_proxy(subclass, "log", "owners") @@ -246,6 +260,30 @@ class LogAsset(model.Base): ) +class LogLocation(model.Base): + """ + Represents a "log's location relationship" from farmOS. + """ + + __tablename__ = "log_location" + __versioned__ = {} + + uuid = model.uuid_column() + + log_uuid = model.uuid_fk_column("log.uuid", nullable=False) + log = orm.relationship( + Log, + foreign_keys=log_uuid, + back_populates="_locations", + ) + + asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + asset = orm.relationship( + "Asset", + foreign_keys=asset_uuid, + ) + + class LogOwner(model.Base): """ Represents a "log's owner relationship" from farmOS. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index f65ac38..44933c3 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -980,6 +980,7 @@ class LogImporterBase(FromFarmOS, ToWutta): fields.extend( [ "assets", + "locations", "owners", ] ) @@ -1006,6 +1007,12 @@ class LogImporterBase(FromFarmOS, ToWutta): (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] ] + if "locations" in self.fields: + data["locations"] = [ + (asset["asset_type"], UUID(asset["uuid"])) + for asset in data["locations"] + ] + if "owners" in self.fields: data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]] @@ -1019,6 +1026,11 @@ class LogImporterBase(FromFarmOS, ToWutta): (asset.asset_type, asset.farmos_uuid) for asset in log.assets ] + if "locations" in self.fields: + data["locations"] = [ + (asset.asset_type, asset.farmos_uuid) for asset in log.locations + ] + if "owners" in self.fields: data["owners"] = [user.farmos_uuid for user in log.owners] @@ -1054,6 +1066,32 @@ class LogImporterBase(FromFarmOS, ToWutta): ) log.assets.remove(asset) + if "locations" in self.fields: + if not target_data or target_data["locations"] != source_data["locations"]: + + for key in source_data["locations"]: + asset_type, farmos_uuid = key + if not target_data or key not in target_data["locations"]: + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + log.locations.append(asset) + + if target_data: + for key in target_data["locations"]: + asset_type, farmos_uuid = key + if key not in source_data["locations"]: + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + log.locations.remove(asset) + if "owners" in self.fields: if not target_data or target_data["owners"] != source_data["owners"]: diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index ca7be39..af1ec17 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -98,6 +98,8 @@ class Normalizer(GenericHandler): asset_objects = [] quantity_objects = [] quantity_uuids = [] + location_objects = [] + location_uuids = [] owner_objects = [] owner_uuids = [] if relationships := log.get("relationships"): @@ -132,6 +134,30 @@ class Normalizer(GenericHandler): ) asset_objects.append(asset_object) + if locations := relationships.get("location"): + for location in locations["data"]: + location_uuid = location["id"] + location_uuids.append(location_uuid) + location_object = { + "uuid": location["id"], + "type": location["type"], + "asset_type": location["type"].split("--")[1], + } + if location := included.get(location_uuid): + attrs = location["attributes"] + rels = location["relationships"] + location_object.update( + { + "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"], + } + ) + location_objects.append(location_object) + if quantities := relationships.get("quantity"): for quantity in quantities["data"]: quantity_uuid = quantity["id"] @@ -194,6 +220,8 @@ class Normalizer(GenericHandler): "quick": log["attributes"]["quick"], "status": log["attributes"]["status"], "notes": notes, + "locations": location_objects, + "location_uuids": location_uuids, "owners": owner_objects, "owner_uuids": owner_uuids, } diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index fbd2a9d..e10001c 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -60,6 +60,7 @@ class LogMasterView(FarmOSMasterView): labels = { "name": "Log Name", "log_type_name": "Log Type", + "locations": "Location", "quantities": "Quantity", } @@ -69,6 +70,7 @@ class LogMasterView(FarmOSMasterView): "timestamp", "name", "assets", + "locations", "quantities", "is_group_assignment", "owners", @@ -85,18 +87,19 @@ class LogMasterView(FarmOSMasterView): "name", "timestamp", "assets", + "locations", "quantities", - "is_group_assignment", "notes", "status", "log_type_name", "owners", + "is_group_assignment", "quick", "drupal_id", ] def get_farmos_api_includes(self): - return {"log_type", "quantity", "asset", "owner"} + return {"log_type", "quantity", "asset", "location", "owner"} def get_grid_data(self, **kwargs): return ResourceData( @@ -141,6 +144,9 @@ class LogMasterView(FarmOSMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # locations + g.set_renderer("locations", self.render_locations_for_grid) + # quantities g.set_renderer("quantities", self.render_quantities_for_grid) @@ -165,6 +171,23 @@ class LogMasterView(FarmOSMasterView): assets.append(asset["name"]) return ", ".join(assets) + def render_locations_for_grid(self, log, field, value): + if not value: + return "" + + locations = [] + for location in value: + if self.farmos_style_grid_links: + text = location["name"] + url = self.request.route_url( + f"farmos_{location['asset_type']}_assets.view", + uuid=location["uuid"], + ) + locations.append(tags.link_to(text, url)) + else: + locations.append(text) + return ", ".join(locations) + def render_quantities_for_grid(self, log, field, value): if not value: return None @@ -212,6 +235,9 @@ class LogMasterView(FarmOSMasterView): # assets f.set_node("assets", FarmOSAssetRefs(self.request)) + # locations + f.set_node("locations", FarmOSAssetRefs(self.request)) + # quantities f.set_node("quantities", FarmOSQuantityRefs(self.request)) diff --git a/src/wuttafarm/web/views/farmos/logs_observation.py b/src/wuttafarm/web/views/farmos/logs_observation.py index ab27b5a..0193f93 100644 --- a/src/wuttafarm/web/views/farmos/logs_observation.py +++ b/src/wuttafarm/web/views/farmos/logs_observation.py @@ -41,6 +41,18 @@ class ObservationLogView(LogMasterView): farmos_log_type = "observation" farmos_refurl_path = "/logs/observation" + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "name", + "assets", + "locations", + "groups", + "is_group_assignment", + "owners", + ] + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 626f34d..35d9451 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -99,6 +99,7 @@ class LogMasterView(WuttaFarmMasterView): labels = { "message": "Log Name", + "locations": "Location", } grid_columns = [ @@ -107,7 +108,7 @@ class LogMasterView(WuttaFarmMasterView): "timestamp", "message", "assets", - "location", + "locations", "quantity", "is_group_assignment", "owners", @@ -124,7 +125,7 @@ class LogMasterView(WuttaFarmMasterView): "message", "timestamp", "assets", - "location", + "locations", "quantity", "notes", "status", @@ -181,6 +182,9 @@ class LogMasterView(WuttaFarmMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # locations + g.set_renderer("locations", self.render_assets_for_grid) + # is_group_assignment g.set_renderer("is_group_assignment", "boolean") g.set_sorter("is_group_assignment", model.Log.is_group_assignment) @@ -191,17 +195,18 @@ class LogMasterView(WuttaFarmMasterView): g.set_renderer("owners", self.render_owners_for_grid) def render_assets_for_grid(self, log, field, value): + assets = getattr(log, field) if self.farmos_style_grid_links: links = [] - for asset in log.assets: + for asset in assets: url = self.request.route_url( f"{asset.asset_type}_assets.view", uuid=asset.uuid ) links.append(tags.link_to(str(asset), url)) return ", ".join(links) - return ", ".join([str(a) for a in log.assets]) + return ", ".join([str(a) for a in assets]) def render_owners_for_grid(self, log, field, value): @@ -242,9 +247,13 @@ class LogMasterView(WuttaFarmMasterView): # nb. must explicity declare value for non-standard field f.set_default("assets", log.assets) - # location + # locations if self.creating or self.editing: - f.remove("location") # TODO: need to support this + f.remove("locations") # TODO: need to support this + else: + f.set_node("locations", LogAssetRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("locations", log.locations) # log_type if self.creating: @@ -355,7 +364,7 @@ class AllLogView(LogMasterView): "message", "log_type", "assets", - "location", + "locations", "quantity", "groups", "is_group_assignment", diff --git a/src/wuttafarm/web/views/logs_observation.py b/src/wuttafarm/web/views/logs_observation.py index 0485f50..6e283ae 100644 --- a/src/wuttafarm/web/views/logs_observation.py +++ b/src/wuttafarm/web/views/logs_observation.py @@ -45,7 +45,7 @@ class ObservationLogView(LogMasterView): "timestamp", "message", "assets", - "location", + "locations", "groups", "is_group_assignment", "owners", From 1d877545ae371c70a366fe1275e919757326a9e6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 21:43:03 -0600 Subject: [PATCH 144/166] feat: add schema, import support for `Log.groups` --- .../versions/74d32b4ec210_add_loggroup.py | 111 ++++++++++++++++++ src/wuttafarm/db/model/log.py | 38 ++++++ src/wuttafarm/importing/farmos.py | 85 ++++++-------- src/wuttafarm/normal.py | 28 +++++ src/wuttafarm/web/views/farmos/logs.py | 31 ++--- src/wuttafarm/web/views/logs.py | 12 ++ 6 files changed, 238 insertions(+), 67 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/74d32b4ec210_add_loggroup.py diff --git a/src/wuttafarm/db/alembic/versions/74d32b4ec210_add_loggroup.py b/src/wuttafarm/db/alembic/versions/74d32b4ec210_add_loggroup.py new file mode 100644 index 0000000..170e3d2 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/74d32b4ec210_add_loggroup.py @@ -0,0 +1,111 @@ +"""add LogGroup + +Revision ID: 74d32b4ec210 +Revises: 3bef7d380a38 +Create Date: 2026-02-28 21:35:24.125784 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "74d32b4ec210" +down_revision: Union[str, None] = "3bef7d380a38" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_group + op.create_table( + "log_group", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_uuid"], ["asset.uuid"], name=op.f("fk_log_group_asset_uuid_asset") + ), + sa.ForeignKeyConstraint( + ["log_uuid"], ["log.uuid"], name=op.f("fk_log_group_log_uuid_log") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_group")), + ) + op.create_table( + "log_group_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_group_version") + ), + ) + op.create_index( + op.f("ix_log_group_version_end_transaction_id"), + "log_group_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_group_version_operation_type"), + "log_group_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_group_version_pk_transaction_id", + "log_group_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_group_version_pk_validity", + "log_group_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_group_version_transaction_id"), + "log_group_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_group + op.drop_index( + op.f("ix_log_group_version_transaction_id"), table_name="log_group_version" + ) + op.drop_index("ix_log_group_version_pk_validity", table_name="log_group_version") + op.drop_index( + "ix_log_group_version_pk_transaction_id", table_name="log_group_version" + ) + op.drop_index( + op.f("ix_log_group_version_operation_type"), table_name="log_group_version" + ) + op.drop_index( + op.f("ix_log_group_version_end_transaction_id"), table_name="log_group_version" + ) + op.drop_table("log_group_version") + op.drop_table("log_group") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index b770a12..afa637b 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -175,6 +175,19 @@ class Log(model.Base): creator=lambda asset: LogAsset(asset=asset), ) + _groups = orm.relationship( + "LogGroup", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) + + groups = association_proxy( + "_groups", + "asset", + creator=lambda asset: LogGroup(asset=asset), + ) + _locations = orm.relationship( "LogLocation", cascade="all, delete-orphan", @@ -232,6 +245,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") Log.make_proxy(subclass, "log", "assets") + Log.make_proxy(subclass, "log", "groups") Log.make_proxy(subclass, "log", "locations") Log.make_proxy(subclass, "log", "owners") @@ -260,6 +274,30 @@ class LogAsset(model.Base): ) +class LogGroup(model.Base): + """ + Represents a "log's group relationship" from farmOS. + """ + + __tablename__ = "log_group" + __versioned__ = {} + + uuid = model.uuid_column() + + log_uuid = model.uuid_fk_column("log.uuid", nullable=False) + log = orm.relationship( + Log, + foreign_keys=log_uuid, + back_populates="_groups", + ) + + asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + asset = orm.relationship( + "Asset", + foreign_keys=asset_uuid, + ) + + class LogLocation(model.Base): """ Represents a "log's location relationship" from farmOS. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 44933c3..a1b539f 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -980,6 +980,7 @@ class LogImporterBase(FromFarmOS, ToWutta): fields.extend( [ "assets", + "groups", "locations", "owners", ] @@ -1007,6 +1008,11 @@ class LogImporterBase(FromFarmOS, ToWutta): (a["asset_type"], UUID(a["uuid"])) for a in data["assets"] ] + if "groups" in self.fields: + data["groups"] = [ + (asset["asset_type"], UUID(asset["uuid"])) for asset in data["groups"] + ] + if "locations" in self.fields: data["locations"] = [ (asset["asset_type"], UUID(asset["uuid"])) @@ -1026,6 +1032,11 @@ class LogImporterBase(FromFarmOS, ToWutta): (asset.asset_type, asset.farmos_uuid) for asset in log.assets ] + if "groups" in self.fields: + data["groups"] = [ + (asset.asset_type, asset.farmos_uuid) for asset in log.groups + ] + if "locations" in self.fields: data["locations"] = [ (asset.asset_type, asset.farmos_uuid) for asset in log.locations @@ -1066,6 +1077,32 @@ class LogImporterBase(FromFarmOS, ToWutta): ) log.assets.remove(asset) + if "groups" in self.fields: + if not target_data or target_data["groups"] != source_data["groups"]: + + for key in source_data["groups"]: + asset_type, farmos_uuid = key + if not target_data or key not in target_data["groups"]: + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + log.groups.append(asset) + + if target_data: + for key in target_data["groups"]: + asset_type, farmos_uuid = key + if key not in source_data["groups"]: + asset = ( + self.target_session.query(model.Asset) + .filter(model.Asset.asset_type == asset_type) + .filter(model.Asset.farmos_uuid == farmos_uuid) + .one() + ) + log.groups.remove(asset) + if "locations" in self.fields: if not target_data or target_data["locations"] != source_data["locations"]: @@ -1126,18 +1163,6 @@ class ActivityLogImporter(LogImporterBase): model_class = model.ActivityLog - supported_fields = [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "is_group_assignment", - "notes", - "status", - "assets", - ] - class HarvestLogImporter(LogImporterBase): """ @@ -1146,18 +1171,6 @@ class HarvestLogImporter(LogImporterBase): model_class = model.HarvestLog - supported_fields = [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "is_group_assignment", - "notes", - "status", - "assets", - ] - class MedicalLogImporter(LogImporterBase): """ @@ -1166,18 +1179,6 @@ class MedicalLogImporter(LogImporterBase): model_class = model.MedicalLog - supported_fields = [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "is_group_assignment", - "notes", - "status", - "assets", - ] - class ObservationLogImporter(LogImporterBase): """ @@ -1186,18 +1187,6 @@ class ObservationLogImporter(LogImporterBase): model_class = model.ObservationLog - supported_fields = [ - "farmos_uuid", - "drupal_id", - "log_type", - "message", - "timestamp", - "is_group_assignment", - "notes", - "status", - "assets", - ] - class QuantityImporterBase(FromFarmOS, ToWutta): """ diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index af1ec17..5c40a49 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -96,6 +96,8 @@ class Normalizer(GenericHandler): log_type_object = {} log_type_uuid = None asset_objects = [] + group_objects = [] + group_uuids = [] quantity_objects = [] quantity_uuids = [] location_objects = [] @@ -134,6 +136,30 @@ class Normalizer(GenericHandler): ) asset_objects.append(asset_object) + if groups := relationships.get("group"): + for group in groups["data"]: + group_uuid = group["id"] + group_uuids.append(group_uuid) + group_object = { + "uuid": group["id"], + "type": group["type"], + "asset_type": group["type"].split("--")[1], + } + if group := included.get(group_uuid): + attrs = group["attributes"] + rels = group["relationships"] + group_object.update( + { + "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"], + } + ) + group_objects.append(group_object) + if locations := relationships.get("location"): for location in locations["data"]: location_uuid = location["id"] @@ -214,6 +240,8 @@ class Normalizer(GenericHandler): "name": log["attributes"]["name"], "timestamp": timestamp, "assets": asset_objects, + "groups": group_objects, + "group_uuids": group_uuids, "quantities": quantity_objects, "quantity_uuids": quantity_uuids, "is_group_assignment": log["attributes"]["is_group_assignment"], diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index e10001c..d0ee388 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -87,6 +87,7 @@ class LogMasterView(FarmOSMasterView): "name", "timestamp", "assets", + "groups", "locations", "quantities", "notes", @@ -99,7 +100,7 @@ class LogMasterView(FarmOSMasterView): ] def get_farmos_api_includes(self): - return {"log_type", "quantity", "asset", "location", "owner"} + return {"log_type", "quantity", "asset", "group", "location", "owner"} def get_grid_data(self, **kwargs): return ResourceData( @@ -144,8 +145,11 @@ class LogMasterView(FarmOSMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # groups + g.set_renderer("groups", self.render_assets_for_grid) + # locations - g.set_renderer("locations", self.render_locations_for_grid) + g.set_renderer("locations", self.render_assets_for_grid) # quantities g.set_renderer("quantities", self.render_quantities_for_grid) @@ -160,6 +164,9 @@ class LogMasterView(FarmOSMasterView): g.set_renderer("owners", self.render_owners_for_grid) def render_assets_for_grid(self, log, field, value): + if not value: + return "" + assets = [] for asset in value: if self.farmos_style_grid_links: @@ -171,23 +178,6 @@ class LogMasterView(FarmOSMasterView): assets.append(asset["name"]) return ", ".join(assets) - def render_locations_for_grid(self, log, field, value): - if not value: - return "" - - locations = [] - for location in value: - if self.farmos_style_grid_links: - text = location["name"] - url = self.request.route_url( - f"farmos_{location['asset_type']}_assets.view", - uuid=location["uuid"], - ) - locations.append(tags.link_to(text, url)) - else: - locations.append(text) - return ", ".join(locations) - def render_quantities_for_grid(self, log, field, value): if not value: return None @@ -235,6 +225,9 @@ class LogMasterView(FarmOSMasterView): # assets f.set_node("assets", FarmOSAssetRefs(self.request)) + # groups + f.set_node("groups", FarmOSAssetRefs(self.request)) + # locations f.set_node("locations", FarmOSAssetRefs(self.request)) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 35d9451..2679c3f 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -125,6 +125,7 @@ class LogMasterView(WuttaFarmMasterView): "message", "timestamp", "assets", + "groups", "locations", "quantity", "notes", @@ -182,6 +183,9 @@ class LogMasterView(WuttaFarmMasterView): # assets g.set_renderer("assets", self.render_assets_for_grid) + # groups + g.set_renderer("groups", self.render_assets_for_grid) + # locations g.set_renderer("locations", self.render_assets_for_grid) @@ -247,6 +251,14 @@ class LogMasterView(WuttaFarmMasterView): # nb. must explicity declare value for non-standard field f.set_default("assets", log.assets) + # groups + if self.creating or self.editing: + f.remove("groups") # TODO: need to support this + else: + f.set_node("groups", LogAssetRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("groups", log.groups) + # locations if self.creating or self.editing: f.remove("locations") # TODO: need to support this From 7d2ae48067f70433067b9ab55235c3b45e316ce3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 22:06:41 -0600 Subject: [PATCH 145/166] feat: add schema, import support for `Log.quantities` --- .../versions/9e875e5cbdc1_add_logquantity.py | 118 ++++++++++++++++++ src/wuttafarm/db/model/__init__.py | 2 +- src/wuttafarm/db/model/log.py | 38 ++++++ src/wuttafarm/importing/farmos.py | 32 +++++ .../web/views/farmos/logs_harvest.py | 1 - src/wuttafarm/web/views/logs.py | 17 ++- src/wuttafarm/web/views/logs_harvest.py | 2 +- 7 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/9e875e5cbdc1_add_logquantity.py diff --git a/src/wuttafarm/db/alembic/versions/9e875e5cbdc1_add_logquantity.py b/src/wuttafarm/db/alembic/versions/9e875e5cbdc1_add_logquantity.py new file mode 100644 index 0000000..3867b17 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/9e875e5cbdc1_add_logquantity.py @@ -0,0 +1,118 @@ +"""add LogQuantity + +Revision ID: 9e875e5cbdc1 +Revises: 74d32b4ec210 +Create Date: 2026-02-28 21:55:31.876087 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "9e875e5cbdc1" +down_revision: Union[str, None] = "74d32b4ec210" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_quantity + op.create_table( + "log_quantity", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("quantity_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["log_uuid"], ["log.uuid"], name=op.f("fk_log_quantity_log_uuid_log") + ), + sa.ForeignKeyConstraint( + ["quantity_uuid"], + ["quantity.uuid"], + name=op.f("fk_log_quantity_quantity_uuid_quantity"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_quantity")), + ) + op.create_table( + "log_quantity_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "quantity_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_log_quantity_version") + ), + ) + op.create_index( + op.f("ix_log_quantity_version_end_transaction_id"), + "log_quantity_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_quantity_version_operation_type"), + "log_quantity_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_log_quantity_version_pk_transaction_id", + "log_quantity_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_log_quantity_version_pk_validity", + "log_quantity_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_log_quantity_version_transaction_id"), + "log_quantity_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # log_quantity + op.drop_index( + op.f("ix_log_quantity_version_transaction_id"), + table_name="log_quantity_version", + ) + op.drop_index( + "ix_log_quantity_version_pk_validity", table_name="log_quantity_version" + ) + op.drop_index( + "ix_log_quantity_version_pk_transaction_id", table_name="log_quantity_version" + ) + op.drop_index( + op.f("ix_log_quantity_version_operation_type"), + table_name="log_quantity_version", + ) + op.drop_index( + op.f("ix_log_quantity_version_end_transaction_id"), + table_name="log_quantity_version", + ) + op.drop_table("log_quantity_version") + op.drop_table("log_quantity") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 68695e5..15514fb 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -38,7 +38,7 @@ from .asset_structure import StructureType, StructureAsset from .asset_animal import AnimalType, AnimalAsset from .asset_group import GroupAsset from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType -from .log import LogType, Log, LogAsset +from .log import LogType, Log, LogAsset, LogGroup, LogLocation, LogQuantity, LogOwner from .log_activity import ActivityLog from .log_harvest import HarvestLog from .log_medical import MedicalLog diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index afa637b..b77898f 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -201,6 +201,19 @@ class Log(model.Base): creator=lambda asset: LogLocation(asset=asset), ) + _quantities = orm.relationship( + "LogQuantity", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="log", + ) + + quantities = association_proxy( + "_quantities", + "quantity", + creator=lambda quantity: LogQuantity(quantity=quantity), + ) + _owners = orm.relationship( "LogOwner", cascade="all, delete-orphan", @@ -247,6 +260,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "assets") Log.make_proxy(subclass, "log", "groups") Log.make_proxy(subclass, "log", "locations") + Log.make_proxy(subclass, "log", "quantities") Log.make_proxy(subclass, "log", "owners") @@ -322,6 +336,30 @@ class LogLocation(model.Base): ) +class LogQuantity(model.Base): + """ + Represents a "log's quantity relationship" from farmOS. + """ + + __tablename__ = "log_quantity" + __versioned__ = {} + + uuid = model.uuid_column() + + log_uuid = model.uuid_fk_column("log.uuid", nullable=False) + log = orm.relationship( + Log, + foreign_keys=log_uuid, + back_populates="_quantities", + ) + + quantity_uuid = model.uuid_fk_column("quantity.uuid", nullable=False) + quantity = orm.relationship( + "Quantity", + foreign_keys=quantity_uuid, + ) + + class LogOwner(model.Base): """ Represents a "log's owner relationship" from farmOS. diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index a1b539f..a35b35d 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -982,6 +982,7 @@ class LogImporterBase(FromFarmOS, ToWutta): "assets", "groups", "locations", + "quantities", "owners", ] ) @@ -1019,6 +1020,9 @@ class LogImporterBase(FromFarmOS, ToWutta): for asset in data["locations"] ] + if "quantities" in self.fields: + data["quantities"] = [UUID(uuid) for uuid in data["quantity_uuids"]] + if "owners" in self.fields: data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]] @@ -1042,6 +1046,9 @@ class LogImporterBase(FromFarmOS, ToWutta): (asset.asset_type, asset.farmos_uuid) for asset in log.locations ] + if "quantities" in self.fields: + data["quantities"] = [qty.farmos_uuid for qty in log.quantities] + if "owners" in self.fields: data["owners"] = [user.farmos_uuid for user in log.owners] @@ -1129,6 +1136,31 @@ class LogImporterBase(FromFarmOS, ToWutta): ) log.locations.remove(asset) + if "quantities" in self.fields: + if ( + not target_data + or target_data["quantities"] != source_data["quantities"] + ): + + for farmos_uuid in source_data["quantities"]: + if not target_data or farmos_uuid not in target_data["quantities"]: + qty = ( + self.target_session.query(model.Quantity) + .filter(model.Quantity.farmos_uuid == farmos_uuid) + .one() + ) + log.quantities.append(qty) + + if target_data: + for farmos_uuid in target_data["quantities"]: + if farmos_uuid not in source_data["quantities"]: + qty = ( + self.target_session.query(model.Quantity) + .filter(model.Quantity.farmos_uuid == farmos_uuid) + .one() + ) + log.quantities.remove(qty) + if "owners" in self.fields: if not target_data or target_data["owners"] != source_data["owners"]: diff --git a/src/wuttafarm/web/views/farmos/logs_harvest.py b/src/wuttafarm/web/views/farmos/logs_harvest.py index 08b2629..bfe7121 100644 --- a/src/wuttafarm/web/views/farmos/logs_harvest.py +++ b/src/wuttafarm/web/views/farmos/logs_harvest.py @@ -48,7 +48,6 @@ class HarvestLogView(LogMasterView): "name", "assets", "quantities", - "is_group_assignment", "owners", ] diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 2679c3f..0608573 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -26,7 +26,7 @@ Base views for Logs from collections import OrderedDict import colander -from webhelpers2.html import tags +from webhelpers2.html import tags, HTML from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.db import Session @@ -100,6 +100,7 @@ class LogMasterView(WuttaFarmMasterView): labels = { "message": "Log Name", "locations": "Location", + "quantities": "Quantity", } grid_columns = [ @@ -109,7 +110,7 @@ class LogMasterView(WuttaFarmMasterView): "message", "assets", "locations", - "quantity", + "quantities", "is_group_assignment", "owners", ] @@ -189,6 +190,9 @@ class LogMasterView(WuttaFarmMasterView): # locations g.set_renderer("locations", self.render_assets_for_grid) + # quantities + g.set_renderer("quantities", self.render_quantities_for_grid) + # is_group_assignment g.set_renderer("is_group_assignment", "boolean") g.set_sorter("is_group_assignment", model.Log.is_group_assignment) @@ -212,6 +216,13 @@ class LogMasterView(WuttaFarmMasterView): return ", ".join([str(a) for a in assets]) + def render_quantities_for_grid(self, log, field, value): + quantities = getattr(log, field) or [] + items = [] + for qty in quantities: + items.append(HTML.tag("li", c=qty.render_as_text(self.config))) + return HTML.tag("ul", c=items) + def render_owners_for_grid(self, log, field, value): if self.farmos_style_grid_links: @@ -377,7 +388,7 @@ class AllLogView(LogMasterView): "log_type", "assets", "locations", - "quantity", + "quantities", "groups", "is_group_assignment", "owners", diff --git a/src/wuttafarm/web/views/logs_harvest.py b/src/wuttafarm/web/views/logs_harvest.py index f2b29e0..e38c6d7 100644 --- a/src/wuttafarm/web/views/logs_harvest.py +++ b/src/wuttafarm/web/views/logs_harvest.py @@ -45,7 +45,7 @@ class HarvestLogView(LogMasterView): "timestamp", "message", "assets", - "quantity", + "quantities", "owners", ] From d07f3ed716d59b044c8951c730138e67a76e95c0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Feb 2026 22:27:39 -0600 Subject: [PATCH 146/166] feat: add sync support for `MedicalLog.vet` --- .../d459db991404_add_medicallog_vet.py | 37 +++++++++++++++++++ src/wuttafarm/db/model/log_medical.py | 10 +++++ src/wuttafarm/farmos/importing/model.py | 26 +++++++++++++ src/wuttafarm/farmos/importing/wuttafarm.py | 18 +++++++++ src/wuttafarm/importing/farmos.py | 11 ++++++ src/wuttafarm/normal.py | 2 + .../web/views/farmos/logs_medical.py | 30 +++++++++++++++ src/wuttafarm/web/views/logs_medical.py | 12 +++++- 8 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/wuttafarm/db/alembic/versions/d459db991404_add_medicallog_vet.py diff --git a/src/wuttafarm/db/alembic/versions/d459db991404_add_medicallog_vet.py b/src/wuttafarm/db/alembic/versions/d459db991404_add_medicallog_vet.py new file mode 100644 index 0000000..c65c93e --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/d459db991404_add_medicallog_vet.py @@ -0,0 +1,37 @@ +"""add MedicalLog.vet + +Revision ID: d459db991404 +Revises: 9e875e5cbdc1 +Create Date: 2026-02-28 22:17:57.001134 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "d459db991404" +down_revision: Union[str, None] = "9e875e5cbdc1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log_medical + op.add_column("log_medical", sa.Column("vet", sa.String(length=100), nullable=True)) + op.add_column( + "log_medical_version", + sa.Column("vet", sa.String(length=100), autoincrement=False, nullable=True), + ) + + +def downgrade() -> None: + + # log_medical + op.drop_column("log_medical_version", "vet") + op.drop_column("log_medical", "vet") diff --git a/src/wuttafarm/db/model/log_medical.py b/src/wuttafarm/db/model/log_medical.py index 439ee3b..6cf308f 100644 --- a/src/wuttafarm/db/model/log_medical.py +++ b/src/wuttafarm/db/model/log_medical.py @@ -23,6 +23,8 @@ Model definition for Medical Logs """ +import sqlalchemy as sa + from wuttjamaican.db import model from wuttafarm.db.model.log import LogMixin, add_log_proxies @@ -41,5 +43,13 @@ class MedicalLog(LogMixin, model.Base): "farmos_log_type": "medical", } + vet = sa.Column( + sa.String(length=100), + nullable=True, + doc=""" + Name of the veterinarian, if applicable. + """, + ) + add_log_proxies(MedicalLog) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 7b900ff..108ebde 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -558,6 +558,32 @@ class MedicalLogImporter(ToFarmOSLog): model_title = "MedicalLog" farmos_log_type = "medical" + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "vet", + ] + ) + return fields + + def normalize_target_object(self, log): + data = super().normalize_target_object(log) + data.update( + { + "vet": log["attributes"]["vet"], + } + ) + return data + + def get_log_payload(self, source_data): + payload = super().get_log_payload(source_data) + + if "vet" in self.fields: + payload["attributes"]["vet"] = source_data["vet"] + + return payload + class ObservationLogImporter(ToFarmOSLog): diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 8679a78..d0ac065 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -401,6 +401,24 @@ class MedicalLogImporter(FromWuttaFarmLog, farmos_importing.model.MedicalLogImpo source_model_class = model.MedicalLog + def get_supported_fields(self): + fields = list(super().get_supported_fields()) + fields.extend( + [ + "vet", + ] + ) + return fields + + def normalize_source_object(self, log): + data = super().normalize_source_object(log) + data.update( + { + "vet": log.vet, + } + ) + return data + class ObservationLogImporter( FromWuttaFarmLog, farmos_importing.model.ObservationLogImporter diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index a35b35d..ebc5b55 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -1211,6 +1211,17 @@ class MedicalLogImporter(LogImporterBase): model_class = model.MedicalLog + def get_simple_fields(self): + """ """ + fields = list(super().get_simple_fields()) + # nb. must explicitly declare proxy fields + fields.extend( + [ + "vet", + ] + ) + return fields + class ObservationLogImporter(LogImporterBase): """ diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index 5c40a49..3efd443 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -252,4 +252,6 @@ class Normalizer(GenericHandler): "location_uuids": location_uuids, "owners": owner_objects, "owner_uuids": owner_uuids, + # TODO: should we do this here or make caller do it? + "vet": log["attributes"].get("vet"), } diff --git a/src/wuttafarm/web/views/farmos/logs_medical.py b/src/wuttafarm/web/views/farmos/logs_medical.py index 95a88c5..2f6a606 100644 --- a/src/wuttafarm/web/views/farmos/logs_medical.py +++ b/src/wuttafarm/web/views/farmos/logs_medical.py @@ -24,6 +24,7 @@ View for farmOS Medical Logs """ from wuttafarm.web.views.farmos.logs import LogMasterView +from wuttafarm.web.grids import SimpleSorter, StringFilter class MedicalLogView(LogMasterView): @@ -41,6 +42,35 @@ class MedicalLogView(LogMasterView): farmos_log_type = "medical" farmos_refurl_path = "/logs/medical" + labels = { + "vet": "Veterinarian", + } + + grid_columns = [ + "status", + "drupal_id", + "timestamp", + "name", + "assets", + "vet", + "owners", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # vet + g.set_sorter("vet", SimpleSorter("vet")) + g.set_filter("vet", StringFilter) + + def configure_form(self, form): + f = form + super().configure_form(f) + + # vet + f.fields.insert_after("timestamp", "vet") + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttafarm/web/views/logs_medical.py b/src/wuttafarm/web/views/logs_medical.py index 2531237..d00d647 100644 --- a/src/wuttafarm/web/views/logs_medical.py +++ b/src/wuttafarm/web/views/logs_medical.py @@ -39,16 +39,26 @@ class MedicalLogView(LogMasterView): farmos_bundle = "medical" farmos_refurl_path = "/logs/medical" + labels = { + "vet": "Veterinarian", + } + grid_columns = [ "status", "drupal_id", "timestamp", "message", "assets", - "veterinarian", + "vet", "owners", ] + def configure_form(self, f): + super().configure_form(f) + + # vet + f.fields.insert_after("timestamp", "vet") + def defaults(config, **kwargs): base = globals() From 90ff7eb793ab3e960c0d33e509c5f9a0c2da1389 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:29:38 -0600 Subject: [PATCH 147/166] fix: set default grid pagesize to 50 to better match farmOS --- src/wuttafarm/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/config.py b/src/wuttafarm/config.py index 16a7578..b0c860b 100644 --- a/src/wuttafarm/config.py +++ b/src/wuttafarm/config.py @@ -50,11 +50,12 @@ class WuttaFarmConfig(WuttaConfigExtension): f"{config.appname}.app.handler", "wuttafarm.app:WuttaFarmAppHandler" ) - # web app menu + # web app stuff config.setdefault( f"{config.appname}.web.menus.handler.default_spec", "wuttafarm.web.menus:WuttaFarmMenuHandler", ) + config.setdefault("wuttaweb.grids.default_pagesize", "50") # web app libcache # config.setdefault('wuttaweb.static_libcache.module', 'wuttafarm.web.static') From 7890b185682711d7e4f99060bd9cc46d2f35464b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:33:00 -0600 Subject: [PATCH 148/166] fix: set timestamp for new log in quick eggs form --- src/wuttafarm/web/views/quick/eggs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index e5461b1..3a21ff7 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -196,6 +196,7 @@ class EggsQuickForm(QuickFormView): "type": "log--harvest", "attributes": { "name": f"Collected {data['count']} {unit_name}", + "timestamp": self.app.localtime(data["timestamp"]).timestamp(), "notes": notes, "quick": ["eggs"], }, From 32d23a7073725148d33368e95f60c284279af289 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:39:44 -0600 Subject: [PATCH 149/166] feat: show quantities when viewing log --- src/wuttafarm/web/forms/schema.py | 17 ++++++++++++++++ src/wuttafarm/web/forms/widgets.py | 32 ++++++++++++++++++++++++++++++ src/wuttafarm/web/views/logs.py | 12 +++++++---- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 8a80054..0a7a72f 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -381,6 +381,23 @@ class LogAssetRefs(WuttaSet): return LogAssetRefsWidget(self.request, **kwargs) +class LogQuantityRefs(WuttaSet): + """ + Schema type for Quantities field (on a Log record) + """ + + def serialize(self, node, appstruct): + if not appstruct: + return colander.null + + return {qty.uuid for qty in appstruct} + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import LogQuantityRefsWidget + + return LogQuantityRefsWidget(self.request, **kwargs) + + class LogOwnerRefs(WuttaSet): """ Schema type for Owners field (on a Log record) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index d3325e6..046e85b 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -454,6 +454,38 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) +class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for Quantities field (on a Log record) + """ + + def serialize(self, field, cstruct, **kw): + """ """ + model = self.app.model + session = Session() + + readonly = kw.get("readonly", self.readonly) + if readonly: + quantities = [] + for uuid in cstruct or []: + qty = session.get(model.Quantity, uuid) + quantities.append( + HTML.tag( + "li", + c=tags.link_to( + qty.render_as_text(self.config), + # TODO + self.request.route_url( + "quantities_standard.view", uuid=qty.uuid + ), + ), + ) + ) + return HTML.tag("ul", c=quantities) + + return super().serialize(field, cstruct, **kw) + + class LogOwnerRefsWidget(WuttaCheckboxChoiceWidget): """ Widget for Owners field (on a Log record) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 0608573..eba1b96 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import LogType, Log -from wuttafarm.web.forms.schema import LogAssetRefs, LogOwnerRefs +from wuttafarm.web.forms.schema import LogAssetRefs, LogQuantityRefs, LogOwnerRefs from wuttafarm.util import get_log_type_enum @@ -128,7 +128,7 @@ class LogMasterView(WuttaFarmMasterView): "assets", "groups", "locations", - "quantity", + "quantities", "notes", "status", "log_type", @@ -290,9 +290,13 @@ class LogMasterView(WuttaFarmMasterView): ) f.set_readonly("log_type") - # quantity + # quantities if self.creating or self.editing: - f.remove("quantity") # TODO: need to support this + f.remove("quantities") # TODO: need to support this + else: + f.set_node("quantities", LogQuantityRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("quantities", log.quantities) # notes f.set_widget("notes", "notes") From 547cc6e4aed89768b71e6afaf2efa4687a3198f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:47:27 -0600 Subject: [PATCH 150/166] feat: add schema, import support for `Log.quick` --- .../versions/85d4851e8292_add_log_quick.py | 37 +++++++++++++++++++ src/wuttafarm/db/model/log.py | 10 +++++ src/wuttafarm/farmos/importing/model.py | 4 ++ src/wuttafarm/importing/farmos.py | 2 + 4 files changed, 53 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/85d4851e8292_add_log_quick.py diff --git a/src/wuttafarm/db/alembic/versions/85d4851e8292_add_log_quick.py b/src/wuttafarm/db/alembic/versions/85d4851e8292_add_log_quick.py new file mode 100644 index 0000000..97e87bc --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/85d4851e8292_add_log_quick.py @@ -0,0 +1,37 @@ +"""add Log.quick + +Revision ID: 85d4851e8292 +Revises: d459db991404 +Create Date: 2026-03-02 18:42:56.070281 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "85d4851e8292" +down_revision: Union[str, None] = "d459db991404" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log + op.add_column("log", sa.Column("quick", sa.String(length=20), nullable=True)) + op.add_column( + "log_version", + sa.Column("quick", sa.String(length=20), autoincrement=False, nullable=True), + ) + + +def downgrade() -> None: + + # log + op.drop_column("log_version", "quick") + op.drop_column("log", "quick") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index b77898f..c3bfe14 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -144,6 +144,15 @@ class Log(model.Base): """, ) + quick = sa.Column( + sa.String(length=20), + nullable=True, + doc=""" + Identifier of quick form used to create the log, if + applicable. + """, + ) + farmos_uuid = sa.Column( model.UUID(), nullable=True, @@ -257,6 +266,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "is_group_assignment") Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") + Log.make_proxy(subclass, "log", "quick") Log.make_proxy(subclass, "log", "assets") Log.make_proxy(subclass, "log", "groups") Log.make_proxy(subclass, "log", "locations") diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index 108ebde..c785141 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -462,6 +462,7 @@ class ToFarmOSLog(ToFarmOS): "is_group_assignment", "status", "notes", + "quick", ] def get_target_objects(self, **kwargs): @@ -520,6 +521,7 @@ class ToFarmOSLog(ToFarmOS): "is_group_assignment": log["attributes"]["is_group_assignment"], "status": log["attributes"]["status"], "notes": notes, + "quick": log["attributes"]["quick"], } def get_log_payload(self, source_data): @@ -535,6 +537,8 @@ class ToFarmOSLog(ToFarmOS): attrs["status"] = source_data["status"] if "notes" in self.fields: attrs["notes"] = {"value": source_data["notes"]} + if "quick" in self.fields: + attrs["quick"] = {"value": source_data["quick"]} payload = {"attributes": attrs} diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index ebc5b55..da69813 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -970,6 +970,7 @@ class LogImporterBase(FromFarmOS, ToWutta): "is_group_assignment", "notes", "status", + "quick", ] ) return fields @@ -1000,6 +1001,7 @@ class LogImporterBase(FromFarmOS, ToWutta): data["farmos_uuid"] = UUID(data.pop("uuid")) data["message"] = data.pop("name") data["timestamp"] = self.app.make_utc(data["timestamp"]) + data["quick"] = ", ".join(data["quick"]) if data["quick"] else None # TODO data["log_type"] = self.get_farmos_log_type() From ce103137a52cf1da8a3ff68c7adbe87142fcf22e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:57:41 -0600 Subject: [PATCH 151/166] fix: add links for Parents column in All Assets grid --- src/wuttafarm/db/model/asset.py | 7 ++ src/wuttafarm/web/views/assets.py | 126 ++++++++++++------------------ 2 files changed, 55 insertions(+), 78 deletions(-) diff --git a/src/wuttafarm/db/model/asset.py b/src/wuttafarm/db/model/asset.py index 3e4de6e..8c975c9 100644 --- a/src/wuttafarm/db/model/asset.py +++ b/src/wuttafarm/db/model/asset.py @@ -26,6 +26,7 @@ Model definition for Asset Types import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.ext.associationproxy import association_proxy from wuttjamaican.db import model @@ -186,6 +187,12 @@ class Asset(model.Base): cascade_backrefs=False, ) + parents = association_proxy( + "_parents", + "parent", + creator=lambda parent: AssetParent(parent=parent), + ) + def __str__(self): return self.asset_name or "" diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 963fe78..b463953 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -49,79 +49,6 @@ def get_asset_type_enum(config): return asset_types -class AllAssetView(WuttaFarmMasterView): - """ - Master view for Assets - """ - - model_class = Asset - route_prefix = "assets" - url_prefix = "/assets" - - farmos_refurl_path = "/assets" - - viewable = False - creatable = False - editable = False - deletable = False - model_is_versioned = False - - grid_columns = [ - "thumbnail", - "drupal_id", - "asset_name", - "asset_type", - "parents", - "archived", - ] - - sort_defaults = "asset_name" - - filter_defaults = { - "asset_name": {"active": True, "verb": "contains"}, - "archived": {"active": True, "verb": "is_false"}, - } - - def configure_grid(self, grid): - g = grid - super().configure_grid(g) - - # thumbnail - g.set_renderer("thumbnail", self.render_grid_thumbnail) - g.set_label("thumbnail", "", column_only=True) - g.set_centered("thumbnail") - - # drupal_id - g.set_label("drupal_id", "ID", column_only=True) - - # asset_name - g.set_link("asset_name") - - # asset_type - g.set_enum("asset_type", get_asset_type_enum(self.config)) - - # parents - g.set_renderer("parents", self.render_parents_for_grid) - - # view action links to final asset record - def asset_url(asset, i): - return self.request.route_url( - f"{asset.asset_type}_assets.view", uuid=asset.uuid - ) - - g.add_action("view", icon="eye", url=asset_url) - - def render_parents_for_grid(self, asset, field, value): - parents = [str(p.parent) for p in asset._parents] - return ", ".join(parents) - - def grid_row_class(self, asset, data, i): - """ """ - if asset.archived: - return "has-background-warning" - return None - - class AssetTypeMasterView(WuttaFarmMasterView): """ Base class for "Asset Type" master views. @@ -181,7 +108,10 @@ class AssetMasterView(WuttaFarmMasterView): model = self.app.model model_class = self.get_model_class() session = session or self.Session() - return session.query(model_class).join(model.Asset) + query = session.query(model_class) + if model_class is not model.Asset: + query = query.join(model.Asset) + return query def configure_grid(self, grid): g = grid @@ -212,19 +142,17 @@ class AssetMasterView(WuttaFarmMasterView): g.set_filter("archived", model.Asset.archived) def render_parents_for_grid(self, asset, field, value): - parents = asset.asset._parents if self.farmos_style_grid_links: links = [] - for parent in parents: - parent = parent.parent + for parent in asset.parents: url = self.request.route_url( f"{parent.asset_type}_assets.view", uuid=parent.uuid ) links.append(tags.link_to(str(parent), url)) return ", ".join(links) - parents = [str(p.parent) for p in parents] + parents = [str(p.parent) for p in asset.parents] return ", ".join(parents) def grid_row_class(self, asset, data, i): @@ -365,6 +293,48 @@ class AssetMasterView(WuttaFarmMasterView): return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) +class AllAssetView(AssetMasterView): + """ + Master view for Assets + """ + + model_class = Asset + route_prefix = "assets" + url_prefix = "/assets" + + farmos_refurl_path = "/assets" + + viewable = False + creatable = False + editable = False + deletable = False + model_is_versioned = False + + grid_columns = [ + "thumbnail", + "drupal_id", + "asset_name", + "asset_type", + "parents", + "archived", + ] + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # asset_type + g.set_enum("asset_type", get_asset_type_enum(self.config)) + + # view action links to final asset record + def asset_url(asset, i): + return self.request.route_url( + f"{asset.asset_type}_assets.view", uuid=asset.uuid + ) + + g.add_action("view", icon="eye", url=asset_url) + + def defaults(config, **kwargs): base = globals() From eb16990b0b850d1bbcee7b181b116bf412f2f40c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 19:44:52 -0600 Subject: [PATCH 152/166] feat: add schema, import support for `Asset.owners` --- .../versions/12de43facb95_add_asset_owners.py | 114 +++++++++++++++ src/wuttafarm/db/model/asset.py | 39 +++++ src/wuttafarm/importing/farmos.py | 136 +++++++++--------- src/wuttafarm/normal.py | 34 +++++ src/wuttafarm/web/views/animals.py | 3 + src/wuttafarm/web/views/assets.py | 18 +++ src/wuttafarm/web/views/structures.py | 1 + 7 files changed, 273 insertions(+), 72 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py diff --git a/src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py b/src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py new file mode 100644 index 0000000..67a4c25 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py @@ -0,0 +1,114 @@ +"""add Asset.owners + +Revision ID: 12de43facb95 +Revises: 85d4851e8292 +Create Date: 2026-03-02 19:03:35.511398 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "12de43facb95" +down_revision: Union[str, None] = "85d4851e8292" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_owner + op.create_table( + "asset_owner", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("user_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_uuid"], ["asset.uuid"], name=op.f("fk_asset_owner_asset_uuid_asset") + ), + sa.ForeignKeyConstraint( + ["user_uuid"], ["user.uuid"], name=op.f("fk_asset_owner_user_uuid_user") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_owner")), + ) + op.create_table( + "asset_owner_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "user_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_asset_owner_version") + ), + ) + op.create_index( + op.f("ix_asset_owner_version_end_transaction_id"), + "asset_owner_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_owner_version_operation_type"), + "asset_owner_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_owner_version_pk_transaction_id", + "asset_owner_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_owner_version_pk_validity", + "asset_owner_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_owner_version_transaction_id"), + "asset_owner_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # asset_owner + op.drop_index( + op.f("ix_asset_owner_version_transaction_id"), table_name="asset_owner_version" + ) + op.drop_index( + "ix_asset_owner_version_pk_validity", table_name="asset_owner_version" + ) + op.drop_index( + "ix_asset_owner_version_pk_transaction_id", table_name="asset_owner_version" + ) + op.drop_index( + op.f("ix_asset_owner_version_operation_type"), table_name="asset_owner_version" + ) + op.drop_index( + op.f("ix_asset_owner_version_end_transaction_id"), + table_name="asset_owner_version", + ) + op.drop_table("asset_owner_version") + op.drop_table("asset_owner") diff --git a/src/wuttafarm/db/model/asset.py b/src/wuttafarm/db/model/asset.py index 8c975c9..0face47 100644 --- a/src/wuttafarm/db/model/asset.py +++ b/src/wuttafarm/db/model/asset.py @@ -193,6 +193,19 @@ class Asset(model.Base): creator=lambda parent: AssetParent(parent=parent), ) + _owners = orm.relationship( + "AssetOwner", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="asset", + ) + + owners = association_proxy( + "_owners", + "user", + creator=lambda user: AssetOwner(user=user), + ) + def __str__(self): return self.asset_name or "" @@ -225,6 +238,8 @@ def add_asset_proxies(subclass): Asset.make_proxy(subclass, "asset", "thumbnail_url") Asset.make_proxy(subclass, "asset", "image_url") Asset.make_proxy(subclass, "asset", "archived") + Asset.make_proxy(subclass, "asset", "parents") + Asset.make_proxy(subclass, "asset", "owners") class EggMixin: @@ -262,3 +277,27 @@ class AssetParent(model.Base): Asset, foreign_keys=parent_uuid, ) + + +class AssetOwner(model.Base): + """ + Represents a "asset's owner relationship" from farmOS. + """ + + __tablename__ = "asset_owner" + __versioned__ = {} + + uuid = model.uuid_column() + + asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + asset = orm.relationship( + Asset, + foreign_keys=asset_uuid, + back_populates="_owners", + ) + + user_uuid = model.uuid_fk_column("user.uuid", nullable=False) + user = orm.relationship( + model.User, + foreign_keys=user_uuid, + ) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index da69813..1cd3523 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -187,6 +187,7 @@ class AssetImporterBase(FromFarmOS, ToWutta): fields.extend( [ "parents", + "owners", ] ) return fields @@ -194,8 +195,9 @@ class AssetImporterBase(FromFarmOS, ToWutta): def get_source_objects(self): """ """ asset_type = self.get_farmos_asset_type() - result = self.farmos_client.asset.get(asset_type) - return result["data"] + return list( + self.farmos_client.asset.iterate(asset_type, params={"include": "image"}) + ) def normalize_source_data(self, **kwargs): """ """ @@ -208,49 +210,40 @@ class AssetImporterBase(FromFarmOS, ToWutta): return data - def normalize_asset(self, asset): + def normalize_source_object(self, asset): """ """ - image_url = None - thumbnail_url = None - if relationships := asset.get("relationships"): + data = self.normal.normalize_farmos_asset(asset) - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - if image_style := image["data"]["attributes"].get( - "image_style_uri" - ): - image_url = image_style["large"] - thumbnail_url = image_style["thumbnail"] + data["farmos_uuid"] = UUID(data.pop("uuid")) + data["asset_type"] = self.get_asset_type(asset) - if notes := asset["attributes"]["notes"]: - notes = notes["value"] + if "image_url" in self.fields or "thumbnail_url" in self.fields: + data["image_url"] = None + data["thumbnail_url"] = None + if relationships := asset.get("relationships"): - if self.farmos_4x: - archived = asset["attributes"]["archived"] - else: - archived = asset["attributes"]["status"] == "archived" + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + if image_style := image["data"]["attributes"].get( + "image_style_uri" + ): + data["image_url"] = image_style["large"] + data["thumbnail_url"] = image_style["thumbnail"] - parents = None if "parents" in self.fields: - parents = [] + data["parents"] = [] for parent in asset["relationships"]["parent"]["data"]: - parents.append((self.get_asset_type(parent), UUID(parent["id"]))) + data["parents"].append( + (self.get_asset_type(parent), UUID(parent["id"])) + ) - return { - "farmos_uuid": UUID(asset["id"]), - "drupal_id": asset["attributes"]["drupal_internal__id"], - "asset_name": asset["attributes"]["name"], - "is_location": asset["attributes"]["is_location"], - "is_fixed": asset["attributes"]["is_fixed"], - "archived": archived, - "notes": notes, - "image_url": image_url, - "thumbnail_url": thumbnail_url, - "parents": parents, - } + if "owners" in self.fields: + data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]] + + return data def get_asset_type(self, asset): return asset["type"].split("--")[1] @@ -259,10 +252,10 @@ class AssetImporterBase(FromFarmOS, ToWutta): data = super().normalize_target_object(asset) if "parents" in self.fields: - data["parents"] = [ - (p.parent.asset_type, p.parent.farmos_uuid) - for p in asset.asset._parents - ] + data["parents"] = [(p.asset_type, p.farmos_uuid) for p in asset.parents] + + if "owners" in self.fields: + data["owners"] = [user.farmos_uuid for user in asset.owners] return data @@ -303,6 +296,30 @@ class AssetImporterBase(FromFarmOS, ToWutta): ) self.target_session.delete(parent) + if "owners" in self.fields: + if not target_data or target_data["owners"] != source_data["owners"]: + + for farmos_uuid in source_data["owners"]: + if not target_data or farmos_uuid not in target_data["owners"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) + .one() + ) + asset.owners.append(user) + + if target_data: + for farmos_uuid in target_data["owners"]: + if farmos_uuid not in source_data["owners"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) + .one() + ) + asset.owners.remove(user) + return asset @@ -338,11 +355,6 @@ class AnimalAssetImporter(AssetImporterBase): if animal_type.farmos_uuid: self.animal_types_by_farmos_uuid[animal_type.farmos_uuid] = animal_type - def get_source_objects(self): - """ """ - animals = self.farmos_client.asset.get("animal") - return animals["data"] - def normalize_source_object(self, animal): """ """ animal_type_uuid = None @@ -374,10 +386,9 @@ class AnimalAssetImporter(AssetImporterBase): else: sterile = animal["attributes"]["is_castrated"] - data = self.normalize_asset(animal) + data = super().normalize_source_object(animal) data.update( { - "asset_type": "animal", "animal_type_uuid": animal_type_uuid, "sex": animal["attributes"]["sex"], "is_sterile": sterile, @@ -468,17 +479,11 @@ class GroupAssetImporter(AssetImporterBase): "parents", ] - def get_source_objects(self): - """ """ - groups = self.farmos_client.asset.get("group") - return groups["data"] - def normalize_source_object(self, group): """ """ - data = self.normalize_asset(group) + data = super().normalize_source_object(group) data.update( { - "asset_type": "group", "produces_eggs": group["attributes"]["produces_eggs"], } ) @@ -514,11 +519,6 @@ class LandAssetImporter(AssetImporterBase): for land_type in self.target_session.query(model.LandType): self.land_types_by_id[land_type.drupal_id] = land_type - def get_source_objects(self): - """ """ - land_assets = self.farmos_client.asset.get("land") - return land_assets["data"] - def normalize_source_object(self, land): """ """ land_type_id = land["attributes"]["land_type"] @@ -529,10 +529,9 @@ class LandAssetImporter(AssetImporterBase): ) return None - data = self.normalize_asset(land) + data = super().normalize_source_object(land) data.update( { - "asset_type": "land", "land_type_uuid": land_type.uuid, } ) @@ -638,10 +637,9 @@ class PlantAssetImporter(AssetImporterBase): else: log.warning("plant type not found: %s", plant_type["id"]) - data = self.normalize_asset(plant) + data = super().normalize_source_object(plant) data.update( { - "asset_type": "plant", "plant_types": set(plant_types), } ) @@ -718,11 +716,6 @@ class StructureAssetImporter(AssetImporterBase): for structure_type in self.target_session.query(model.StructureType): self.structure_types_by_id[structure_type.drupal_id] = structure_type - def get_source_objects(self): - """ """ - structures = self.farmos_client.asset.get("structure") - return structures["data"] - def normalize_source_object(self, structure): """ """ structure_type_id = structure["attributes"]["structure_type"] @@ -735,10 +728,9 @@ class StructureAssetImporter(AssetImporterBase): ) return None - data = self.normalize_asset(structure) + data = super().normalize_source_object(structure) data.update( { - "asset_type": "structure", "structure_type_uuid": structure_type.uuid, } ) @@ -1167,7 +1159,7 @@ class LogImporterBase(FromFarmOS, ToWutta): if not target_data or target_data["owners"] != source_data["owners"]: for farmos_uuid in source_data["owners"]: - if not target_data or farmos_uuid not in target_data["assets"]: + if not target_data or farmos_uuid not in target_data["owners"]: user = ( self.target_session.query(model.User) .join(model.WuttaFarmUser) diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index 3efd443..fa9b9da 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -84,6 +84,40 @@ class Normalizer(GenericHandler): self._farmos_units = units return self._farmos_units + def normalize_farmos_asset(self, asset, included={}): + """ """ + + if notes := asset["attributes"]["notes"]: + notes = notes["value"] + + owner_objects = [] + owner_uuids = [] + if relationships := asset.get("relationships"): + + if owners := relationships.get("owner"): + for user in owners["data"]: + user_uuid = user["id"] + owner_uuids.append(user_uuid) + if user := included.get(user_uuid): + owner_objects.append( + { + "uuid": user["id"], + "name": user["attributes"]["name"], + } + ) + + return { + "uuid": asset["id"], + "drupal_id": asset["attributes"]["drupal_internal__id"], + "asset_name": asset["attributes"]["name"], + "is_location": asset["attributes"]["is_location"], + "is_fixed": asset["attributes"]["is_fixed"], + "archived": asset["attributes"]["archived"], + "notes": notes, + "owners": owner_objects, + "owner_uuids": owner_uuids, + } + def normalize_farmos_log(self, log, included={}): if timestamp := log["attributes"]["timestamp"]: diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index b52a353..31bbfe8 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -228,6 +228,9 @@ class AnimalAssetView(AssetMasterView): "birthdate", "is_sterile", "sex", + "group_membership", + "owners", + "locations", "archived", ] diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b463953..38746bd 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -136,6 +136,10 @@ class AssetMasterView(WuttaFarmMasterView): # parents g.set_renderer("parents", self.render_parents_for_grid) + # owners + g.set_label("owners", "Owner") + g.set_renderer("owners", self.render_owners_for_grid) + # archived g.set_renderer("archived", "boolean") g.set_sorter("archived", model.Asset.archived) @@ -155,6 +159,17 @@ class AssetMasterView(WuttaFarmMasterView): parents = [str(p.parent) for p in asset.parents] return ", ".join(parents) + def render_owners_for_grid(self, asset, field, value): + + if self.farmos_style_grid_links: + links = [] + for user in asset.owners: + url = self.request.route_url("users.view", uuid=user.uuid) + links.append(tags.link_to(user.username, url)) + return ", ".join(links) + + return ", ".join([user.username for user in asset.owners]) + def grid_row_class(self, asset, data, i): """ """ if asset.archived: @@ -314,8 +329,11 @@ class AllAssetView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", + "group_membership", "asset_type", "parents", + "owners", + "locations", "archived", ] diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 4d36d41..9d5d227 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -160,6 +160,7 @@ class StructureAssetView(AssetMasterView): "asset_name", "structure_type", "parents", + "owners", "archived", ] From 0ac2485bff567f4953e6caed4c708949dbc096c1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 20:27:22 -0600 Subject: [PATCH 153/166] feat: add schema, sync support for `Log.is_movement` --- .../0771322957bd_add_log_is_movement.py | 37 +++++++++++++++++++ src/wuttafarm/db/model/log.py | 9 +++++ src/wuttafarm/farmos/importing/model.py | 4 ++ src/wuttafarm/farmos/importing/wuttafarm.py | 2 + src/wuttafarm/importing/farmos.py | 1 + src/wuttafarm/normal.py | 1 + src/wuttafarm/web/views/farmos/logs.py | 4 ++ src/wuttafarm/web/views/logs.py | 4 ++ 8 files changed, 62 insertions(+) create mode 100644 src/wuttafarm/db/alembic/versions/0771322957bd_add_log_is_movement.py diff --git a/src/wuttafarm/db/alembic/versions/0771322957bd_add_log_is_movement.py b/src/wuttafarm/db/alembic/versions/0771322957bd_add_log_is_movement.py new file mode 100644 index 0000000..0aa9d54 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/0771322957bd_add_log_is_movement.py @@ -0,0 +1,37 @@ +"""add Log.is_movement + +Revision ID: 0771322957bd +Revises: 12de43facb95 +Create Date: 2026-03-02 20:21:03.889847 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "0771322957bd" +down_revision: Union[str, None] = "12de43facb95" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # log + op.add_column("log", sa.Column("is_movement", sa.Boolean(), nullable=True)) + op.add_column( + "log_version", + sa.Column("is_movement", sa.Boolean(), autoincrement=False, nullable=True), + ) + + +def downgrade() -> None: + + # log + op.drop_column("log_version", "is_movement") + op.drop_column("log", "is_movement") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index c3bfe14..020b39d 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -120,6 +120,14 @@ class Log(model.Base): """, ) + is_movement = sa.Column( + sa.Boolean(), + nullable=True, + doc=""" + Whether the log represents a movement to new location. + """, + ) + is_group_assignment = sa.Column( sa.Boolean(), nullable=True, @@ -263,6 +271,7 @@ def add_log_proxies(subclass): Log.make_proxy(subclass, "log", "log_type") Log.make_proxy(subclass, "log", "message") Log.make_proxy(subclass, "log", "timestamp") + Log.make_proxy(subclass, "log", "is_movement") Log.make_proxy(subclass, "log", "is_group_assignment") Log.make_proxy(subclass, "log", "status") Log.make_proxy(subclass, "log", "notes") diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index c785141..fab984d 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -459,6 +459,7 @@ class ToFarmOSLog(ToFarmOS): "uuid", "name", "timestamp", + "is_movement", "is_group_assignment", "status", "notes", @@ -518,6 +519,7 @@ class ToFarmOSLog(ToFarmOS): "uuid": UUID(log["id"]), "name": log["attributes"]["name"], "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), + "is_movement": log["attributes"]["is_movement"], "is_group_assignment": log["attributes"]["is_group_assignment"], "status": log["attributes"]["status"], "notes": notes, @@ -531,6 +533,8 @@ class ToFarmOSLog(ToFarmOS): attrs["name"] = source_data["name"] if "timestamp" in self.fields: attrs["timestamp"] = self.format_datetime(source_data["timestamp"]) + if "is_movement" in self.fields: + attrs["is_movement"] = source_data["is_movement"] if "is_group_assignment" in self.fields: attrs["is_group_assignment"] = source_data["is_group_assignment"] if "status" in self.fields: diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index d0ac065..8d76285 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -361,6 +361,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "uuid", "name", "timestamp", + "is_movement", "is_group_assignment", "status", "notes", @@ -371,6 +372,7 @@ class FromWuttaFarmLog(FromWuttaFarm): "uuid": log.farmos_uuid or self.app.make_true_uuid(), "name": log.message, "timestamp": log.timestamp, + "is_movement": log.is_movement, "is_group_assignment": log.is_group_assignment, "status": log.status, "notes": log.notes, diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 1cd3523..6b21090 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -959,6 +959,7 @@ class LogImporterBase(FromFarmOS, ToWutta): "log_type", "message", "timestamp", + "is_movement", "is_group_assignment", "notes", "status", diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index fa9b9da..4fc8796 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -279,6 +279,7 @@ class Normalizer(GenericHandler): "quantities": quantity_objects, "quantity_uuids": quantity_uuids, "is_group_assignment": log["attributes"]["is_group_assignment"], + "is_movement": log["attributes"]["is_movement"], "quick": log["attributes"]["quick"], "status": log["attributes"]["status"], "notes": notes, diff --git a/src/wuttafarm/web/views/farmos/logs.py b/src/wuttafarm/web/views/farmos/logs.py index d0ee388..cb7a87b 100644 --- a/src/wuttafarm/web/views/farmos/logs.py +++ b/src/wuttafarm/web/views/farmos/logs.py @@ -94,6 +94,7 @@ class LogMasterView(FarmOSMasterView): "status", "log_type_name", "owners", + "is_movement", "is_group_assignment", "quick", "drupal_id", @@ -234,6 +235,9 @@ class LogMasterView(FarmOSMasterView): # quantities f.set_node("quantities", FarmOSQuantityRefs(self.request)) + # is_movement + f.set_node("is_movement", colander.Boolean()) + # is_group_assignment f.set_node("is_group_assignment", colander.Boolean()) diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index eba1b96..6a502d2 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -133,6 +133,7 @@ class LogMasterView(WuttaFarmMasterView): "status", "log_type", "owners", + "is_movement", "is_group_assignment", "quick", "farmos_uuid", @@ -312,6 +313,9 @@ class LogMasterView(WuttaFarmMasterView): # status f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) + # is_movement + f.set_node("is_movement", colander.Boolean()) + # is_group_assignment f.set_node("is_group_assignment", colander.Boolean()) From 41870ee2e2fe60a02a02116d7f8c50dbd1a6c21d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 20:33:19 -0600 Subject: [PATCH 154/166] fix: move farmOS UUID field below the Drupal ID --- src/wuttafarm/web/views/animals.py | 4 ++-- src/wuttafarm/web/views/asset_types.py | 2 +- src/wuttafarm/web/views/groups.py | 2 +- src/wuttafarm/web/views/land.py | 4 ++-- src/wuttafarm/web/views/logs.py | 4 ++-- src/wuttafarm/web/views/plants.py | 4 ++-- src/wuttafarm/web/views/quantities.py | 4 ++-- src/wuttafarm/web/views/structures.py | 4 ++-- src/wuttafarm/web/views/units.py | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 31bbfe8..d9d8db7 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -62,8 +62,8 @@ class AnimalTypeView(AssetTypeMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] has_rows = True @@ -244,8 +244,8 @@ class AnimalAssetView(AssetMasterView): "notes", "asset_type", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", "thumbnail_url", "image_url", "thumbnail", diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py index f9aadfb..4a76ccf 100644 --- a/src/wuttafarm/web/views/asset_types.py +++ b/src/wuttafarm/web/views/asset_types.py @@ -50,8 +50,8 @@ class AssetTypeView(WuttaFarmMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): diff --git a/src/wuttafarm/web/views/groups.py b/src/wuttafarm/web/views/groups.py index 61394fa..4331280 100644 --- a/src/wuttafarm/web/views/groups.py +++ b/src/wuttafarm/web/views/groups.py @@ -53,8 +53,8 @@ class GroupView(AssetMasterView): "asset_type", "produces_eggs", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] diff --git a/src/wuttafarm/web/views/land.py b/src/wuttafarm/web/views/land.py index 23b899d..ca1f016 100644 --- a/src/wuttafarm/web/views/land.py +++ b/src/wuttafarm/web/views/land.py @@ -51,8 +51,8 @@ class LandTypeView(AssetTypeMasterView): form_fields = [ "name", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] has_rows = True @@ -173,8 +173,8 @@ class LandAssetView(AssetMasterView): "is_location", "is_fixed", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 6a502d2..e88e550 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -61,8 +61,8 @@ class LogTypeView(WuttaFarmMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): @@ -136,8 +136,8 @@ class LogMasterView(WuttaFarmMasterView): "is_movement", "is_group_assignment", "quick", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def get_query(self, session=None): diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index c831201..a114e07 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -62,8 +62,8 @@ class PlantTypeView(AssetTypeMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] has_rows = True @@ -227,8 +227,8 @@ class PlantAssetView(AssetMasterView): "notes", "asset_type", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", "thumbnail_url", "image_url", "thumbnail", diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py index fb5279d..6f8bdec 100644 --- a/src/wuttafarm/web/views/quantities.py +++ b/src/wuttafarm/web/views/quantities.py @@ -66,8 +66,8 @@ class QuantityTypeView(WuttaFarmMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): @@ -119,8 +119,8 @@ class QuantityMasterView(WuttaFarmMasterView): "value", "units", "label", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def get_query(self, session=None): diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 9d5d227..e17a39f 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -50,8 +50,8 @@ class StructureTypeView(AssetTypeMasterView): form_fields = [ "name", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] has_rows = True @@ -173,8 +173,8 @@ class StructureAssetView(AssetMasterView): "is_location", "is_fixed", "archived", - "farmos_uuid", "drupal_id", + "farmos_uuid", "thumbnail_url", "image_url", "thumbnail", diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index add7b2b..549333e 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -100,8 +100,8 @@ class UnitView(WuttaFarmMasterView): form_fields = [ "name", "description", - "farmos_uuid", "drupal_id", + "farmos_uuid", ] def configure_grid(self, grid): From 759eb906b910625d70f91c400420b858ebd6a4d0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 20:59:27 -0600 Subject: [PATCH 155/166] feat: expose "current location" for assets based on most recent movement log, as in farmOS --- src/wuttafarm/app.py | 15 ++++++++ src/wuttafarm/assets.py | 49 ++++++++++++++++++++++++ src/wuttafarm/web/forms/schema.py | 12 +++--- src/wuttafarm/web/forms/widgets.py | 8 ++-- src/wuttafarm/web/views/animals.py | 4 +- src/wuttafarm/web/views/assets.py | 42 +++++++++++++++++++- src/wuttafarm/web/views/farmos/assets.py | 3 +- src/wuttafarm/web/views/logs.py | 10 ++--- 8 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 src/wuttafarm/assets.py diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index a3fa566..cb9aed3 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -36,6 +36,21 @@ class WuttaFarmAppHandler(base.AppHandler): default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler" default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler" + def get_asset_handler(self): + """ + Get the configured asset handler. + + :rtype: :class:`~wuttafarm.assets.AssetHandler` + """ + if "asset" not in self.handlers: + spec = self.config.get( + f"{self.appname}.asset_handler", + default="wuttafarm.assets:AssetHandler", + ) + factory = self.load_object(spec) + self.handlers["asset"] = factory(self.config) + return self.handlers["asset"] + def get_farmos_handler(self): """ Get the configured farmOS integration handler. diff --git a/src/wuttafarm/assets.py b/src/wuttafarm/assets.py new file mode 100644 index 0000000..321c3e5 --- /dev/null +++ b/src/wuttafarm/assets.py @@ -0,0 +1,49 @@ +# -*- 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 . +# +################################################################################ +""" +Asset handler +""" + +from wuttjamaican.app import GenericHandler + + +class AssetHandler(GenericHandler): + """ + Base class and default implementation for the asset + :term:`handler`. + """ + + def get_locations(self, asset): + model = self.app.model + session = self.app.get_session(asset) + + loclog = ( + session.query(model.Log) + .join(model.LogAsset) + .filter(model.LogAsset.asset == asset) + .filter(model.Log.is_movement == True) + .order_by(model.Log.timestamp.desc()) + .first() + ) + if loclog: + return loclog.locations + return [] diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 0a7a72f..a2a72b5 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -364,7 +364,7 @@ class AssetParentRefs(WuttaSet): return AssetParentRefsWidget(self.request, **kwargs) -class LogAssetRefs(WuttaSet): +class AssetRefs(WuttaSet): """ Schema type for Assets field (on a Log record) """ @@ -376,9 +376,9 @@ class LogAssetRefs(WuttaSet): return {asset.uuid for asset in appstruct} def widget_maker(self, **kwargs): - from wuttafarm.web.forms.widgets import LogAssetRefsWidget + from wuttafarm.web.forms.widgets import AssetRefsWidget - return LogAssetRefsWidget(self.request, **kwargs) + return AssetRefsWidget(self.request, **kwargs) class LogQuantityRefs(WuttaSet): @@ -398,7 +398,7 @@ class LogQuantityRefs(WuttaSet): return LogQuantityRefsWidget(self.request, **kwargs) -class LogOwnerRefs(WuttaSet): +class OwnerRefs(WuttaSet): """ Schema type for Owners field (on a Log record) """ @@ -410,9 +410,9 @@ class LogOwnerRefs(WuttaSet): return {user.uuid for user in appstruct} def widget_maker(self, **kwargs): - from wuttafarm.web.forms.widgets import LogOwnerRefsWidget + from wuttafarm.web.forms.widgets import OwnerRefsWidget - return LogOwnerRefsWidget(self.request, **kwargs) + return OwnerRefsWidget(self.request, **kwargs) class Notes(colander.String): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 046e85b..0fd9221 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -423,9 +423,9 @@ class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) -class LogAssetRefsWidget(WuttaCheckboxChoiceWidget): +class AssetRefsWidget(WuttaCheckboxChoiceWidget): """ - Widget for Assets field (on a Log record) + Widget for Assets field (of various kinds). """ def serialize(self, field, cstruct, **kw): @@ -486,9 +486,9 @@ class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) -class LogOwnerRefsWidget(WuttaCheckboxChoiceWidget): +class OwnerRefsWidget(WuttaCheckboxChoiceWidget): """ - Widget for Owners field (on a Log record) + Widget for Owners field (on an Asset or Log record) """ def serialize(self, field, cstruct, **kw): diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index d9d8db7..5756525 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -215,7 +215,7 @@ class AnimalAssetView(AssetMasterView): farmos_bundle = "animal" labels = { - "animal_type": "Species/Breed", + "animal_type": "Species / Breed", "is_sterile": "Sterile", } @@ -243,6 +243,8 @@ class AnimalAssetView(AssetMasterView): "is_sterile", "notes", "asset_type", + "owners", + "locations", "archived", "drupal_id", "farmos_uuid", diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 38746bd..7035413 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -32,7 +32,7 @@ from wuttaweb.db import Session from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import Asset, Log -from wuttafarm.web.forms.schema import AssetParentRefs +from wuttafarm.web.forms.schema import AssetParentRefs, OwnerRefs, AssetRefs from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.util import get_log_type_enum from wuttafarm.web.util import get_farmos_client_for_user @@ -140,6 +140,10 @@ class AssetMasterView(WuttaFarmMasterView): g.set_label("owners", "Owner") g.set_renderer("owners", self.render_owners_for_grid) + # locations + g.set_label("locations", "Location") + g.set_renderer("locations", self.render_locations_for_grid) + # archived g.set_renderer("archived", "boolean") g.set_sorter("archived", model.Asset.archived) @@ -170,6 +174,21 @@ class AssetMasterView(WuttaFarmMasterView): return ", ".join([user.username for user in asset.owners]) + def render_locations_for_grid(self, asset, field, value): + asset_handler = self.app.get_asset_handler() + locations = asset_handler.get_locations(asset) + + if self.farmos_style_grid_links: + links = [] + for loc in locations: + url = self.request.route_url( + f"{loc.asset_type}_assets.view", uuid=loc.uuid + ) + links.append(tags.link_to(str(loc), url)) + return ", ".join(links) + + return ", ".join([str(loc) for loc in locations]) + def grid_row_class(self, asset, data, i): """ """ if asset.archived: @@ -179,6 +198,7 @@ class AssetMasterView(WuttaFarmMasterView): def configure_form(self, form): f = form super().configure_form(f) + asset_handler = self.app.get_asset_handler() asset = form.model_instance # asset_type @@ -191,12 +211,30 @@ class AssetMasterView(WuttaFarmMasterView): ) f.set_readonly("asset_type") + # owners + if self.creating or self.editing: + f.remove("owners") # TODO: need to support this + else: + f.set_node("owners", OwnerRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("owners", asset.owners) + + # locations + if self.creating or self.editing: + # nb. this is a calculated field + f.remove("locations") + else: + f.set_label("locations", "Current Location") + f.set_node("locations", AssetRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("locations", asset_handler.get_locations(asset)) + # parents if self.creating or self.editing: f.remove("parents") # TODO: add support for this else: f.set_node("parents", AssetParentRefs(self.request)) - f.set_default("parents", [p.parent_uuid for p in asset.asset._parents]) + f.set_default("parents", [p.uuid for p in asset.parents]) # notes f.set_widget("notes", "notes") diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index d1ae226..69e6321 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -53,7 +53,6 @@ class AssetMasterView(FarmOSMasterView): labels = { "name": "Asset Name", "asset_type_name": "Asset Type", - "owners": "Owner", "locations": "Location", "thumbnail_url": "Thumbnail URL", "image_url": "Image URL", @@ -104,6 +103,7 @@ class AssetMasterView(FarmOSMasterView): g.set_filter("name", StringFilter) # owners + g.set_label("owners", "Owner") g.set_renderer("owners", self.render_owners_for_grid) # locations @@ -239,6 +239,7 @@ class AssetMasterView(FarmOSMasterView): if self.creating or self.editing: f.remove("locations") else: + f.set_label("locations", "Current Location") f.set_node("locations", FarmOSLocationRefs(self.request)) # owners diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index e88e550..9c983b7 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -34,7 +34,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import LogType, Log -from wuttafarm.web.forms.schema import LogAssetRefs, LogQuantityRefs, LogOwnerRefs +from wuttafarm.web.forms.schema import AssetRefs, LogQuantityRefs, OwnerRefs from wuttafarm.util import get_log_type_enum @@ -259,7 +259,7 @@ class LogMasterView(WuttaFarmMasterView): if self.creating or self.editing: f.remove("assets") # TODO: need to support this else: - f.set_node("assets", LogAssetRefs(self.request)) + f.set_node("assets", AssetRefs(self.request)) # nb. must explicity declare value for non-standard field f.set_default("assets", log.assets) @@ -267,7 +267,7 @@ class LogMasterView(WuttaFarmMasterView): if self.creating or self.editing: f.remove("groups") # TODO: need to support this else: - f.set_node("groups", LogAssetRefs(self.request)) + f.set_node("groups", AssetRefs(self.request)) # nb. must explicity declare value for non-standard field f.set_default("groups", log.groups) @@ -275,7 +275,7 @@ class LogMasterView(WuttaFarmMasterView): if self.creating or self.editing: f.remove("locations") # TODO: need to support this else: - f.set_node("locations", LogAssetRefs(self.request)) + f.set_node("locations", AssetRefs(self.request)) # nb. must explicity declare value for non-standard field f.set_default("locations", log.locations) @@ -306,7 +306,7 @@ class LogMasterView(WuttaFarmMasterView): if self.creating or self.editing: f.remove("owners") # TODO: need to support this else: - f.set_node("owners", LogOwnerRefs(self.request)) + f.set_node("owners", OwnerRefs(self.request)) # nb. must explicity declare value for non-standard field f.set_default("owners", log.owners) From b2c3d3a301db304338f52230c65aa04006d4a699 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 12:23:12 -0600 Subject: [PATCH 156/166] fix: remove unique constraint for `LandAsset.land_type_uuid` not sure why that was in there..assuming a mistake --- ...5a80e_remove_unwanted_unique_constraint.py | 39 +++++++++++++++++++ src/wuttafarm/db/model/asset_land.py | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/wuttafarm/db/alembic/versions/5f474125a80e_remove_unwanted_unique_constraint.py diff --git a/src/wuttafarm/db/alembic/versions/5f474125a80e_remove_unwanted_unique_constraint.py b/src/wuttafarm/db/alembic/versions/5f474125a80e_remove_unwanted_unique_constraint.py new file mode 100644 index 0000000..e5d28ab --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/5f474125a80e_remove_unwanted_unique_constraint.py @@ -0,0 +1,39 @@ +"""remove unwanted unique constraint + +Revision ID: 5f474125a80e +Revises: 0771322957bd +Create Date: 2026-03-04 12:03:16.034291 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "5f474125a80e" +down_revision: Union[str, None] = "0771322957bd" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_land + op.drop_constraint( + op.f("uq_asset_land_land_type_uuid"), "asset_land", type_="unique" + ) + + +def downgrade() -> None: + + # asset_land + op.create_unique_constraint( + op.f("uq_asset_land_land_type_uuid"), + "asset_land", + ["land_type_uuid"], + postgresql_nulls_not_distinct=False, + ) diff --git a/src/wuttafarm/db/model/asset_land.py b/src/wuttafarm/db/model/asset_land.py index 00bdd27..6c65c54 100644 --- a/src/wuttafarm/db/model/asset_land.py +++ b/src/wuttafarm/db/model/asset_land.py @@ -91,7 +91,7 @@ class LandAsset(AssetMixin, model.Base): "farmos_asset_type": "land", } - land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False, unique=True) + land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False) land_type = orm.relationship(LandType, back_populates="land_assets") From e8a8ce2528b318bf6032177834da1c54661576bd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 12:29:23 -0600 Subject: [PATCH 157/166] feat: expose "group membership" for assets --- src/wuttafarm/assets.py | 16 +++++++++++ src/wuttafarm/web/views/animals.py | 3 ++- src/wuttafarm/web/views/assets.py | 33 ++++++++++++++++++++++- src/wuttafarm/web/views/farmos/animals.py | 11 +++++--- src/wuttafarm/web/views/farmos/assets.py | 1 + 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/wuttafarm/assets.py b/src/wuttafarm/assets.py index 321c3e5..36d3b22 100644 --- a/src/wuttafarm/assets.py +++ b/src/wuttafarm/assets.py @@ -32,6 +32,22 @@ class AssetHandler(GenericHandler): :term:`handler`. """ + def get_groups(self, asset): + model = self.app.model + session = self.app.get_session(asset) + + grplog = ( + session.query(model.Log) + .join(model.LogAsset) + .filter(model.LogAsset.asset == asset) + .filter(model.Log.is_group_assignment == True) + .order_by(model.Log.timestamp.desc()) + .first() + ) + if grplog: + return grplog.groups + return [] + def get_locations(self, asset): model = self.app.model session = self.app.get_session(asset) diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index 5756525..f4c97e2 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -228,7 +228,7 @@ class AnimalAssetView(AssetMasterView): "birthdate", "is_sterile", "sex", - "group_membership", + "groups", "owners", "locations", "archived", @@ -245,6 +245,7 @@ class AnimalAssetView(AssetMasterView): "asset_type", "owners", "locations", + "groups", "archived", "drupal_id", "farmos_uuid", diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index 7035413..b4e4d31 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -66,6 +66,10 @@ class AssetMasterView(WuttaFarmMasterView): farmos_entity_type = "asset" + labels = { + "groups": "Group Membership", + } + sort_defaults = "asset_name" filter_defaults = { @@ -136,6 +140,9 @@ class AssetMasterView(WuttaFarmMasterView): # parents g.set_renderer("parents", self.render_parents_for_grid) + # groups + g.set_renderer("groups", self.render_groups_for_grid) + # owners g.set_label("owners", "Owner") g.set_renderer("owners", self.render_owners_for_grid) @@ -174,6 +181,21 @@ class AssetMasterView(WuttaFarmMasterView): return ", ".join([user.username for user in asset.owners]) + def render_groups_for_grid(self, asset, field, value): + asset_handler = self.app.get_asset_handler() + groups = asset_handler.get_groups(asset) + + if self.farmos_style_grid_links: + links = [] + for group in groups: + url = self.request.route_url( + f"{group.asset_type}_assets.view", uuid=group.uuid + ) + links.append(tags.link_to(str(group), url)) + return ", ".join(links) + + return ", ".join([str(group) for group in groups]) + def render_locations_for_grid(self, asset, field, value): asset_handler = self.app.get_asset_handler() locations = asset_handler.get_locations(asset) @@ -229,6 +251,15 @@ class AssetMasterView(WuttaFarmMasterView): # nb. must explicity declare value for non-standard field f.set_default("locations", asset_handler.get_locations(asset)) + # groups + if self.creating or self.editing: + # nb. this is a calculated field + f.remove("groups") + else: + f.set_node("groups", AssetRefs(self.request)) + # nb. must explicity declare value for non-standard field + f.set_default("groups", asset_handler.get_groups(asset)) + # parents if self.creating or self.editing: f.remove("parents") # TODO: add support for this @@ -367,7 +398,7 @@ class AllAssetView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", - "group_membership", + "groups", "asset_type", "parents", "owners", diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py index 690e7ee..c99cc5a 100644 --- a/src/wuttafarm/web/views/farmos/animals.py +++ b/src/wuttafarm/web/views/farmos/animals.py @@ -39,7 +39,7 @@ from wuttafarm.web.grids import ( NullableBooleanFilter, DateTimeFilter, ) -from wuttafarm.web.forms.schema import FarmOSRef +from wuttafarm.web.forms.schema import FarmOSRef, FarmOSAssetRefs class AnimalView(AssetMasterView): @@ -87,9 +87,9 @@ class AnimalView(AssetMasterView): "is_sterile", "notes", "asset_type_name", - "groups", "owners", "locations", + "groups", "archived", "thumbnail_url", "image_url", @@ -147,7 +147,7 @@ class AnimalView(AssetMasterView): def render_groups_for_grid(self, animal, field, value): groups = [] - for group in animal["group_objects"]: + for group in animal["groups"]: if self.farmos_style_grid_links: url = self.request.route_url( "farmos_group_assets.view", uuid=group["uuid"] @@ -209,6 +209,7 @@ class AnimalView(AssetMasterView): group = { "uuid": group["id"], "name": group["attributes"]["name"], + "asset_type": "group", } group_objects.append(group) group_names.append(group["name"]) @@ -218,7 +219,7 @@ class AnimalView(AssetMasterView): "animal_type": animal_type_object, "animal_type_uuid": animal_type_object["uuid"], "animal_type_name": animal_type_object["name"], - "group_objects": group_objects, + "groups": group_objects, "group_names": group_names, "birthdate": birthdate, "sex": animal["attributes"]["sex"] or colander.null, @@ -273,6 +274,8 @@ class AnimalView(AssetMasterView): # groups if self.creating or self.editing: f.remove("groups") # TODO + else: + f.set_node("groups", FarmOSAssetRefs(self.request)) def get_api_payload(self, animal): payload = super().get_api_payload(animal) diff --git a/src/wuttafarm/web/views/farmos/assets.py b/src/wuttafarm/web/views/farmos/assets.py index 69e6321..11f744b 100644 --- a/src/wuttafarm/web/views/farmos/assets.py +++ b/src/wuttafarm/web/views/farmos/assets.py @@ -54,6 +54,7 @@ class AssetMasterView(FarmOSMasterView): "name": "Asset Name", "asset_type_name": "Asset Type", "locations": "Location", + "groups": "Group Membership", "thumbnail_url": "Thumbnail URL", "image_url": "Image URL", } From a0f73e6a32717a881e6ad9aa09d1d75fa00c9cc1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 12:33:29 -0600 Subject: [PATCH 158/166] fix: show drupal ID column for asset types --- src/wuttafarm/web/views/asset_types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wuttafarm/web/views/asset_types.py b/src/wuttafarm/web/views/asset_types.py index 4a76ccf..2fb0239 100644 --- a/src/wuttafarm/web/views/asset_types.py +++ b/src/wuttafarm/web/views/asset_types.py @@ -38,6 +38,7 @@ class AssetTypeView(WuttaFarmMasterView): grid_columns = [ "name", + "drupal_id", "description", ] From 0a1aee591adad6c342e72d9673310e887a8de058 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 14:14:52 -0600 Subject: [PATCH 159/166] =?UTF-8?q?bump:=20version=200.6.0=20=E2=86=92=200?= =?UTF-8?q?.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c040e..f58be88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.7.0 (2026-03-04) + +### Feat + +- expose "group membership" for assets +- expose "current location" for assets +- add schema, sync support for `Log.is_movement` +- add schema, import support for `Asset.owners` +- add schema, import support for `Log.quick` +- show quantities when viewing log +- add sync support for `MedicalLog.vet` +- add schema, import support for `Log.quantities` +- add schema, import support for `Log.groups` +- add schema, import support for `Log.locations` +- add sync support for `Log.is_group_assignment` +- add support for exporting log status, timestamp to farmOS +- add support for log 'owners' +- add support for edit, import/export of plant type data +- add way to create animal type when editing animal +- add related version tables for asset/log revision history +- improve mirror/deletion for assets, logs, animal types +- auto-delete asset from farmOS if deleting via mirror app + +### Fix + +- show drupal ID column for asset types +- remove unique constraint for `LandAsset.land_type_uuid` +- move farmOS UUID field below the Drupal ID +- add links for Parents column in All Assets grid +- set timestamp for new log in quick eggs form +- set default grid pagesize to 50 +- add placeholder for log 'quick' field +- define log grid columns to match farmOS +- make AllLogView inherit from LogMasterView +- rename views for "all records" (all assets, all logs etc.) +- ensure token refresh works regardless where API client is used +- render links for Plant Type column in Plant Assets grid +- fix land asset type +- prevent edit for asset types, land types when app is mirror +- add farmOS-style links for Parents column in Land Assets grid +- remove unique constraint for `AnimalType.name` +- prevent delete if animal type is still being referenced +- add reminder to restart if changing integration mode +- prevent edit for user farmos_uuid, drupal_id +- remove 'contains' verb for sex filter +- add enum, row hilite for log status +- fix Sex field when empty and deleting an animal +- add `get_farmos_client_for_user()` convenience function +- use current user token for auto-sync within web app +- set log type, status enums for log grids +- add more default perms for first site admin user +- only show quick form menu if perms allow +- expose config for farmOS OAuth2 client_id and scope +- add separate permission for each quick form view + ## v0.6.0 (2026-02-25) ### Feat diff --git a/pyproject.toml b/pyproject.toml index c66f0b9..e04615f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.6.0" +version = "0.7.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [ From 7bffa6cba6b2d448db59a6280c31bed8bd4d82d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 14:15:23 -0600 Subject: [PATCH 160/166] fix: bump version requirement for wuttaweb --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e04615f..9617e5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "pyramid_exclog", "uvicorn[standard]", "WuttaSync", - "WuttaWeb[continuum]>=0.28.1", + "WuttaWeb[continuum]>=0.29.0", ] From b2b49d93aef87301d7469f2d05e4d015fb29624a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 14:20:09 -0600 Subject: [PATCH 161/166] docs: fix doc warning --- src/wuttafarm/web/forms/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index 0fd9221..0a14638 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -325,6 +325,7 @@ class PlantTypeRefsWidget(Widget): return values def deserialize(self, field, pstruct): + """ """ if not pstruct: return colander.null From 81fa22bbd8339b1e988cea7936190227db864c7d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 14:49:12 -0600 Subject: [PATCH 162/166] feat: show link to Log record when viewing Quantity --- src/wuttafarm/db/model/log.py | 1 + src/wuttafarm/db/model/quantities.py | 21 +++++++++++++++++++++ src/wuttafarm/web/forms/schema.py | 25 +++++++++++++++++++++++++ src/wuttafarm/web/views/quantities.py | 10 +++++++++- 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 020b39d..7823353 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -376,6 +376,7 @@ class LogQuantity(model.Base): quantity = orm.relationship( "Quantity", foreign_keys=quantity_uuid, + back_populates="_log", ) diff --git a/src/wuttafarm/db/model/quantities.py b/src/wuttafarm/db/model/quantities.py index 4f537b9..4bed6a0 100644 --- a/src/wuttafarm/db/model/quantities.py +++ b/src/wuttafarm/db/model/quantities.py @@ -26,6 +26,7 @@ Model definition for Quantities import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.ext.associationproxy import association_proxy from wuttjamaican.db import model @@ -161,6 +162,25 @@ class Quantity(model.Base): """, ) + _log = orm.relationship( + "LogQuantity", + uselist=False, + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="quantity", + ) + + def make_log_quantity(log): + from wuttafarm.db.model import LogQuantity + + return LogQuantity(log=log) + + log = association_proxy( + "_log", + "log", + creator=make_log_quantity, + ) + def render_as_text(self, config=None): measure = str(self.measure or self.measure_id or "") value = self.value_numerator / self.value_denominator @@ -202,6 +222,7 @@ def add_quantity_proxies(subclass): Quantity.make_proxy(subclass, "quantity", "units_uuid") Quantity.make_proxy(subclass, "quantity", "units") Quantity.make_proxy(subclass, "quantity", "label") + Quantity.make_proxy(subclass, "quantity", "log") class StandardQuantity(QuantityMixin, model.Base): diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index a2a72b5..6bf434e 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -77,6 +77,31 @@ class LogQuick(WuttaSet): return LogQuickWidget(**kwargs) +class LogRef(ObjectRef): + """ + Custom schema type for a + :class:`~wuttafarm.db.model.log.Log` reference field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): # pylint: disable=empty-docstring + """ """ + model = self.app.model + return model.Log + + def sort_query(self, query): # pylint: disable=empty-docstring + """ """ + return query.order_by(self.model_class.message) + + def get_object_url(self, obj): # pylint: disable=empty-docstring + """ """ + log = obj + return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) + + class FarmOSUnitRef(colander.SchemaType): def serialize(self, node, appstruct): diff --git a/src/wuttafarm/web/views/quantities.py b/src/wuttafarm/web/views/quantities.py index 6f8bdec..d4112cf 100644 --- a/src/wuttafarm/web/views/quantities.py +++ b/src/wuttafarm/web/views/quantities.py @@ -29,7 +29,7 @@ from wuttaweb.db import Session from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.db.model import QuantityType, Quantity, StandardQuantity -from wuttafarm.web.forms.schema import UnitRef +from wuttafarm.web.forms.schema import UnitRef, LogRef def get_quantity_type_enum(config): @@ -119,6 +119,7 @@ class QuantityMasterView(WuttaFarmMasterView): "value", "units", "label", + "log", "drupal_id", "farmos_uuid", ] @@ -231,6 +232,13 @@ class QuantityMasterView(WuttaFarmMasterView): # TODO: ugh f.set_default("units", quantity.quantity.units) + # log + if self.creating or self.editing: + f.remove("log") + else: + f.set_node("log", LogRef(self.request)) + f.set_default("log", quantity.log) + def get_xref_buttons(self, quantity): buttons = super().get_xref_buttons(quantity) From a547188a9057716788ef7bce0bd2f7283dd1115f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 16:49:28 -0600 Subject: [PATCH 163/166] feat: show related Quantity records when viewing a Unit --- src/wuttafarm/web/views/units.py | 53 +++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index 549333e..3356428 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -24,7 +24,7 @@ Master view for Units """ from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.db.model import Measure, Unit +from wuttafarm.db.model import Measure, Unit, Quantity class MeasureView(WuttaFarmMasterView): @@ -104,6 +104,26 @@ class UnitView(WuttaFarmMasterView): "farmos_uuid", ] + has_rows = True + row_model_class = Quantity + rows_viewable = True + + row_labels = { + "quantity_type_id": "Quantity Type ID", + "measure_id": "Measure ID", + } + + row_grid_columns = [ + "drupal_id", + "as_text", + "quantity_type", + "measure", + "value", + "label", + ] + + rows_sort_defaults = ("drupal_id", "desc") + def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -131,6 +151,37 @@ class UnitView(WuttaFarmMasterView): return buttons + def get_row_grid_data(self, unit): + model = self.app.model + session = self.Session() + return session.query(model.Quantity).filter(model.Quantity.units == unit) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + + # as_text + g.set_renderer("as_text", self.render_as_text_for_grid) + g.set_link("as_text") + + # value + g.set_renderer("value", self.render_value_for_grid) + + def render_as_text_for_grid(self, quantity, field, value): + return quantity.render_as_text(self.config) + + def render_value_for_grid(self, quantity, field, value): + value = quantity.value_numerator / quantity.value_denominator + return self.app.render_quantity(value) + + def get_row_action_url_view(self, quantity, i): + return self.request.route_url( + f"quantities_{quantity.quantity_type_id}.view", uuid=quantity.uuid + ) + def defaults(config, **kwargs): base = globals() From 609a900f3965bd5c85e0ea25ee324408776ebe2d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 16:51:26 -0600 Subject: [PATCH 164/166] feat: show related Quantity records when viewing a Measure --- src/wuttafarm/web/views/units.py | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/wuttafarm/web/views/units.py b/src/wuttafarm/web/views/units.py index 3356428..fe8dafe 100644 --- a/src/wuttafarm/web/views/units.py +++ b/src/wuttafarm/web/views/units.py @@ -52,6 +52,26 @@ class MeasureView(WuttaFarmMasterView): "drupal_id", ] + has_rows = True + row_model_class = Quantity + rows_viewable = True + + row_labels = { + "quantity_type_id": "Quantity Type ID", + "measure_id": "Measure ID", + } + + row_grid_columns = [ + "drupal_id", + "as_text", + "quantity_type", + "value", + "units", + "label", + ] + + rows_sort_defaults = ("drupal_id", "desc") + def configure_grid(self, grid): g = grid super().configure_grid(g) @@ -59,6 +79,37 @@ class MeasureView(WuttaFarmMasterView): # name g.set_link("name") + def get_row_grid_data(self, measure): + model = self.app.model + session = self.Session() + return session.query(model.Quantity).filter(model.Quantity.measure == measure) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + + # as_text + g.set_renderer("as_text", self.render_as_text_for_grid) + g.set_link("as_text") + + # value + g.set_renderer("value", self.render_value_for_grid) + + def render_as_text_for_grid(self, quantity, field, value): + return quantity.render_as_text(self.config) + + def render_value_for_grid(self, quantity, field, value): + value = quantity.value_numerator / quantity.value_denominator + return self.app.render_quantity(value) + + def get_row_action_url_view(self, quantity, i): + return self.request.route_url( + f"quantities_{quantity.quantity_type_id}.view", uuid=quantity.uuid + ) + @classmethod def defaults(cls, config): """ """ From 23af35842d17dd2016ce8a425950596d52ff04bb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 20:36:56 -0600 Subject: [PATCH 165/166] feat: improve support for exporting quantity, log data and make the Eggs quick form save to wuttafarm app DB first, then export to farmOS, if app is running as mirror --- src/wuttafarm/farmos/importing/model.py | 187 +++++++++++++++++-- src/wuttafarm/farmos/importing/wuttafarm.py | 50 +++++ src/wuttafarm/web/views/farmos/quantities.py | 81 +++++++- src/wuttafarm/web/views/quick/base.py | 3 + src/wuttafarm/web/views/quick/eggs.py | 147 +++++++++++++-- 5 files changed, 427 insertions(+), 41 deletions(-) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index fab984d..ad1cb38 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -443,6 +443,138 @@ class StructureAssetImporter(ToFarmOSAsset): return payload +############################## +# quantity importers +############################## + + +class ToFarmOSQuantity(ToFarmOS): + """ + Base class for quantity data importer targeting the farmOS API. + """ + + farmos_quantity_type = None + + supported_fields = [ + "uuid", + "measure", + "value_numerator", + "value_denominator", + "label", + "quantity_type_uuid", + "unit_uuid", + ] + + def get_target_objects(self, **kwargs): + return list( + self.farmos_client.resource.iterate("quantity", self.farmos_quantity_type) + ) + + def get_target_object(self, key): + + # fetch from cache, if applicable + if self.caches_target: + return super().get_target_object(key) + + # okay now must fetch via API + if self.get_keys() != ["uuid"]: + raise ValueError("must use uuid key for this to work") + uuid = key[0] + + try: + qty = self.farmos_client.resource.get_id( + "quantity", self.farmos_quantity_type, str(uuid) + ) + except requests.HTTPError as exc: + if exc.response.status_code == 404: + return None + raise + return qty["data"] + + def create_target_object(self, key, source_data): + if source_data.get("__ignoreme__"): + return None + if self.dry_run: + return source_data + + payload = self.get_quantity_payload(source_data) + result = self.farmos_client.resource.send( + "quantity", self.farmos_quantity_type, payload + ) + normal = self.normalize_target_object(result["data"]) + normal["_new_object"] = result["data"] + return normal + + def update_target_object(self, quantity, source_data, target_data=None): + if self.dry_run: + return quantity + + payload = self.get_quantity_payload(source_data) + payload["id"] = str(source_data["uuid"]) + result = self.farmos_client.resource.send( + "quantity", self.farmos_quantity_type, payload + ) + return self.normalize_target_object(result["data"]) + + def normalize_target_object(self, qty): + + result = { + "uuid": UUID(qty["id"]), + "measure": qty["attributes"]["measure"], + "value_numerator": qty["attributes"]["value"]["numerator"], + "value_denominator": qty["attributes"]["value"]["denominator"], + "label": qty["attributes"]["label"], + "quantity_type_uuid": UUID( + qty["relationships"]["quantity_type"]["data"]["id"] + ), + "unit_uuid": None, + } + + if unit := qty["relationships"]["units"]["data"]: + result["unit_uuid"] = UUID(unit["id"]) + + return result + + def get_quantity_payload(self, source_data): + + attrs = {} + if "measure" in self.fields: + attrs["measure"] = source_data["measure"] + if "value_numerator" in self.fields and "value_denominator" in self.fields: + attrs["value"] = { + "numerator": source_data["value_numerator"], + "denominator": source_data["value_denominator"], + } + if "label" in self.fields: + attrs["label"] = source_data["label"] + + rels = {} + if "quantity_type_uuid" in self.fields: + rels["quantity_type"] = { + "data": { + "id": str(source_data["quantity_type_uuid"]), + "type": "quantity_type--quantity_type", + } + } + if "unit_uuid" in self.fields: + rels["units"] = { + "data": { + "id": str(source_data["unit_uuid"]), + "type": "taxonomy_term--unit", + } + } + + payload = {"attributes": attrs, "relationships": rels} + + return payload + + +class StandardQuantityImporter(ToFarmOSQuantity): + + model_title = "StandardQuantity" + farmos_quantity_type = "standard" + + ############################## # log importers ############################## @@ -464,8 +596,14 @@ class ToFarmOSLog(ToFarmOS): "status", "notes", "quick", + "assets", + "quantities", ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.normal = self.app.get_normalizer(self.farmos_client) + def get_target_objects(self, **kwargs): result = self.farmos_client.log.get(self.farmos_log_type) return result["data"] @@ -511,19 +649,18 @@ class ToFarmOSLog(ToFarmOS): return self.normalize_target_object(result["data"]) def normalize_target_object(self, log): - - if notes := log["attributes"]["notes"]: - notes = notes["value"] - + normal = self.normal.normalize_farmos_log(log) return { - "uuid": UUID(log["id"]), - "name": log["attributes"]["name"], - "timestamp": self.normalize_datetime(log["attributes"]["timestamp"]), - "is_movement": log["attributes"]["is_movement"], - "is_group_assignment": log["attributes"]["is_group_assignment"], - "status": log["attributes"]["status"], - "notes": notes, - "quick": log["attributes"]["quick"], + "uuid": UUID(normal["uuid"]), + "name": normal["name"], + "timestamp": self.app.make_utc(normal["timestamp"]), + "is_movement": normal["is_movement"], + "is_group_assignment": normal["is_group_assignment"], + "status": normal["status"], + "notes": normal["notes"], + "quick": normal["quick"], + "assets": [(a["asset_type"], UUID(a["uuid"])) for a in normal["assets"]], + "quantities": [UUID(uuid) for uuid in normal["quantity_uuids"]], } def get_log_payload(self, source_data): @@ -542,10 +679,32 @@ class ToFarmOSLog(ToFarmOS): if "notes" in self.fields: attrs["notes"] = {"value": source_data["notes"]} if "quick" in self.fields: - attrs["quick"] = {"value": source_data["quick"]} + attrs["quick"] = source_data["quick"] - payload = {"attributes": attrs} + rels = {} + if "assets" in self.fields: + assets = [] + for asset_type, uuid in source_data["assets"]: + assets.append( + { + "type": f"asset--{asset_type}", + "id": str(uuid), + } + ) + rels["asset"] = {"data": assets} + if "quantities" in self.fields: + quantities = [] + for uuid in source_data["quantities"]: + quantities.append( + { + # TODO: support other quantity types + "type": "quantity--standard", + "id": str(uuid), + } + ) + rels["quantity"] = {"data": quantities} + payload = {"attributes": attrs, "relationships": rels} return payload diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index 8d76285..8394e4c 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -104,6 +104,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["PlantType"] = PlantTypeImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter + importers["StandardQuantity"] = StandardQuantityImporter importers["ActivityLog"] = ActivityLogImporter importers["HarvestLog"] = HarvestLogImporter importers["MedicalLog"] = MedicalLogImporter @@ -347,6 +348,49 @@ class StructureAssetImporter( } +############################## +# quantity importers +############################## + + +class FromWuttaFarmQuantity(FromWuttaFarm): + """ + Base class for WuttaFarm -> farmOS quantity importers + """ + + supported_fields = [ + "uuid", + "measure", + "value_numerator", + "value_denominator", + "label", + "quantity_type_uuid", + "unit_uuid", + ] + + def normalize_source_object(self, qty): + return { + "uuid": qty.farmos_uuid or self.app.make_true_uuid(), + "measure": qty.measure_id, + "value_numerator": qty.value_numerator, + "value_denominator": qty.value_denominator, + "label": qty.label, + "quantity_type_uuid": qty.quantity_type.farmos_uuid, + "unit_uuid": qty.units.farmos_uuid, + "_src_object": qty, + } + + +class StandardQuantityImporter( + FromWuttaFarmQuantity, farmos_importing.model.StandardQuantityImporter +): + """ + WuttaFarm → farmOS API exporter for Standard Quantities + """ + + source_model_class = model.StandardQuantity + + ############################## # log importers ############################## @@ -365,6 +409,9 @@ class FromWuttaFarmLog(FromWuttaFarm): "is_group_assignment", "status", "notes", + "quick", + "assets", + "quantities", ] def normalize_source_object(self, log): @@ -376,6 +423,9 @@ class FromWuttaFarmLog(FromWuttaFarm): "is_group_assignment": log.is_group_assignment, "status": log.status, "notes": log.notes, + "quick": self.config.parse_list(log.quick) if log.quick else [], + "assets": [(a.asset_type, a.farmos_uuid) for a in log.assets], + "quantities": [qty.farmos_uuid for qty in log.quantities], "_src_object": log, } diff --git a/src/wuttafarm/web/views/farmos/quantities.py b/src/wuttafarm/web/views/farmos/quantities.py index 8aafeea..a388559 100644 --- a/src/wuttafarm/web/views/farmos/quantities.py +++ b/src/wuttafarm/web/views/farmos/quantities.py @@ -32,6 +32,7 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.schema import FarmOSUnitRef +from wuttafarm.web.grids import ResourceData class QuantityTypeView(FarmOSMasterView): @@ -130,13 +131,15 @@ class QuantityMasterView(FarmOSMasterView): farmos_quantity_type = None grid_columns = [ + "drupal_id", + "as_text", "measure", "value", + "unit", "label", - "changed", ] - sort_defaults = ("changed", "desc") + sort_defaults = ("drupal_id", "desc") form_fields = [ "measure", @@ -147,20 +150,58 @@ class QuantityMasterView(FarmOSMasterView): "changed", ] - def get_grid_data(self, columns=None, session=None): - result = self.farmos_client.resource.get("quantity", self.farmos_quantity_type) - return [self.normalize_quantity(t) for t in result["data"]] + def get_farmos_api_includes(self): + return {"units"} + + def get_grid_data(self, **kwargs): + return ResourceData( + self.config, + self.farmos_client, + f"quantity--{self.farmos_quantity_type}", + include=",".join(self.get_farmos_api_includes()), + normalizer=self.normalize_quantity, + ) def configure_grid(self, grid): g = grid super().configure_grid(g) + # drupal_id + g.set_label("drupal_id", "ID", column_only=True) + + # as_text + g.set_renderer("as_text", self.render_as_text_for_grid) + + # measure + g.set_renderer("measure", self.render_measure_for_grid) + # value - g.set_link("value") + g.set_renderer("value", self.render_value_for_grid) + + # unit + g.set_renderer("unit", self.render_unit_for_grid) # changed g.set_renderer("changed", "datetime") + def render_as_text_for_grid(self, qty, field, value): + measure = qty["measure"].capitalize() + value = qty["value"]["decimal"] + units = qty["unit"]["name"] if qty["unit"] else "??" + return f"( {measure} ) {value} {units}" + + def render_measure_for_grid(self, qty, field, value): + return qty["measure"].capitalize() + + def render_unit_for_grid(self, qty, field, value): + unit = qty[field] + if not unit: + return "" + return unit["name"] + + def render_value_for_grid(self, qty, field, value): + return qty["value"]["decimal"] + def get_instance(self): quantity = self.farmos_client.resource.get_id( "quantity", self.farmos_quantity_type, self.request.matchdict["uuid"] @@ -187,7 +228,7 @@ class QuantityMasterView(FarmOSMasterView): def get_instance_title(self, quantity): return quantity["value"] - def normalize_quantity(self, quantity): + def normalize_quantity(self, quantity, included={}): if created := quantity["attributes"]["created"]: created = datetime.datetime.fromisoformat(created) @@ -197,11 +238,37 @@ class QuantityMasterView(FarmOSMasterView): changed = datetime.datetime.fromisoformat(changed) changed = self.app.localtime(changed) + quantity_type_object = None + quantity_type_uuid = None + unit_object = None + unit_uuid = None + if relationships := quantity["relationships"]: + + if quantity_type := relationships["quantity_type"]["data"]: + quantity_type_uuid = quantity_type["id"] + quantity_type_object = { + "uuid": quantity_type_uuid, + "type": "quantity_type--quantity_type", + } + + if unit := relationships["units"]["data"]: + unit_uuid = unit["id"] + if unit := included.get(unit_uuid): + unit_object = { + "uuid": unit_uuid, + "type": "taxonomy_term--unit", + "name": unit["attributes"]["name"], + } + return { "uuid": quantity["id"], "drupal_id": quantity["attributes"]["drupal_internal__id"], + "quantity_type": quantity_type_object, + "quantity_type_uuid": quantity_type_uuid, "measure": quantity["attributes"]["measure"], "value": quantity["attributes"]["value"], + "unit": unit_object, + "unit_uuid": unit_uuid, "label": quantity["attributes"]["label"] or colander.null, "created": created, "changed": changed, diff --git a/src/wuttafarm/web/views/quick/base.py b/src/wuttafarm/web/views/quick/base.py index 9be6665..059ac01 100644 --- a/src/wuttafarm/web/views/quick/base.py +++ b/src/wuttafarm/web/views/quick/base.py @@ -28,6 +28,7 @@ import logging from pyramid.renderers import render_to_response from wuttaweb.views import View +from wuttaweb.db import Session from wuttafarm.web.util import get_farmos_client_for_user @@ -40,6 +41,8 @@ class QuickFormView(View): Base class for quick form views. """ + Session = Session + def __init__(self, request, context=None): super().__init__(request, context=context) self.farmos_client = get_farmos_client_for_user(self.request) diff --git a/src/wuttafarm/web/views/quick/eggs.py b/src/wuttafarm/web/views/quick/eggs.py index 3a21ff7..8aae46e 100644 --- a/src/wuttafarm/web/views/quick/eggs.py +++ b/src/wuttafarm/web/views/quick/eggs.py @@ -34,7 +34,6 @@ from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.quick import QuickFormView -from wuttafarm.web.util import get_farmos_client_for_user class EggsQuickForm(QuickFormView): @@ -49,6 +48,9 @@ class EggsQuickForm(QuickFormView): _layer_assets = None + # TODO: make this configurable? + unit_name = "egg(s)" + def make_quick_form(self): f = self.make_form( fields=[ @@ -89,6 +91,47 @@ class EggsQuickForm(QuickFormView): if self._layer_assets is not None: return self._layer_assets + if self.app.is_farmos_wrapper(): + assets = self.get_layer_assets_from_farmos() + else: + assets = self.get_layer_assets_from_wuttafarm() + + assets.sort(key=lambda a: a["name"]) + self._layer_assets = assets + return assets + + def get_layer_assets_from_wuttafarm(self): + model = self.app.model + session = self.Session() + assets = [] + + def normalize(asset): + asset_type = asset.__wutta_hint__["farmos_asset_type"] + return { + "uuid": str(asset.farmos_uuid), + "name": asset.asset_name, + "type": f"asset--{asset_type}", + } + + query = ( + session.query(model.AnimalAsset) + .join(model.Asset) + .filter(model.AnimalAsset.produces_eggs == True) + .order_by(model.Asset.asset_name) + ) + assets.extend([normalize(a) for a in query]) + + query = ( + session.query(model.GroupAsset) + .join(model.Asset) + .filter(model.GroupAsset.produces_eggs == True) + .order_by(model.Asset.asset_name) + ) + assets.extend([normalize(a) for a in query]) + + return assets + + def get_layer_assets_from_farmos(self): assets = [] params = { "filter[produces_eggs]": 1, @@ -108,24 +151,14 @@ class EggsQuickForm(QuickFormView): 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): - response = self.save_to_farmos(form) - log = json.loads(response["create-log#body{0}"]["body"]) + if self.app.is_farmos_wrapper(): + return self.save_to_farmos(form) - if self.app.is_farmos_mirror(): - quantity = json.loads(response["create-quantity"]["body"]) - client = get_farmos_client_for_user(self.request) - self.app.auto_sync_from_farmos( - quantity["data"], "StandardQuantity", client=client - ) - self.app.auto_sync_from_farmos(log["data"], "HarvestLog", client=client) - - return log + return self.save_to_wuttafarm(form) def save_to_farmos(self, form): data = form.validated @@ -135,7 +168,7 @@ class EggsQuickForm(QuickFormView): asset = assets[data["asset"]] # TODO: make this configurable? - unit_name = "egg(s)" + unit_name = self.unit_name unit = {"data": {"type": "taxonomy_term--unit"}} new_unit = None @@ -234,13 +267,87 @@ class EggsQuickForm(QuickFormView): blueprints.insert(0, new_unit) blueprint = SubrequestsBlueprint.parse_obj(blueprints) response = self.farmos_client.subrequests.send(blueprint, format=Format.json) - return response - def redirect_after_save(self, result): - return self.redirect( - self.request.route_url( - "farmos_logs_harvest.view", uuid=result["data"]["id"] + log = json.loads(response["create-log#body{0}"]["body"]) + + if self.app.is_farmos_mirror(): + if new_unit: + unit = json.loads(response["create-unit"]["body"]) + self.app.auto_sync_from_farmos( + unit["data"], "Unit", client=self.farmos_client + ) + quantity = json.loads(response["create-quantity"]["body"]) + self.app.auto_sync_from_farmos( + quantity["data"], "StandardQuantity", client=self.farmos_client ) + self.app.auto_sync_from_farmos( + log["data"], "HarvestLog", client=self.farmos_client + ) + + return log + + def save_to_wuttafarm(self, form): + model = self.app.model + session = self.Session() + data = form.validated + + asset = ( + session.query(model.Asset) + .filter(model.Asset.farmos_uuid == data["asset"]) + .one() + ) + + # TODO: make this configurable? + unit_name = self.unit_name + + new_unit = False + unit = session.query(model.Unit).filter(model.Unit.name == unit_name).first() + if not unit: + unit = model.Unit(name=unit_name) + session.add(unit) + new_unit = True + + quantity = model.StandardQuantity( + quantity_type_id="standard", + measure_id="count", + value_numerator=data["count"], + value_denominator=1, + units=unit, + ) + session.add(quantity) + + log = model.HarvestLog( + log_type="harvest", + message=f"Collected {data['count']} {unit_name}", + timestamp=self.app.make_utc(data["timestamp"]), + notes=data["notes"] or None, + quick="eggs", + status="done", + ) + session.add(log) + log.assets.append(asset) + log.quantities.append(quantity.quantity) + log.owners.append(self.request.user) + session.flush() + + if self.app.is_farmos_mirror(): + if new_unit: + self.app.auto_sync_to_farmos(unit, client=self.farmos_client) + self.app.auto_sync_to_farmos(quantity, client=self.farmos_client) + self.app.auto_sync_to_farmos(log, client=self.farmos_client) + + return log + + def redirect_after_save(self, log): + model = self.app.model + + if isinstance(log, model.HarvestLog): + return self.redirect( + self.request.route_url("logs_harvest.view", uuid=log.uuid) + ) + + return self.redirect( + self.request.route_url("farmos_logs_harvest.view", uuid=log["data"]["id"]) ) From af2ea18e1d022da1bb9c68c7259872245ba66010 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 20:43:03 -0600 Subject: [PATCH 166/166] =?UTF-8?q?bump:=20version=200.7.0=20=E2=86=92=200?= =?UTF-8?q?.8.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f58be88..f1eedfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to WuttaFarm will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.8.0 (2026-03-04) + +### Feat + +- improve support for exporting quantity, log data +- show related Quantity records when viewing a Measure +- show related Quantity records when viewing a Unit +- show link to Log record when viewing Quantity + +### Fix + +- bump version requirement for wuttaweb + ## v0.7.0 (2026-03-04) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 9617e5a..1bb1dda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.7.0" +version = "0.8.0" description = "Web app to integrate with and extend farmOS" readme = "README.md" authors = [