From 8ef59bc53f785786f88327c461e6a00ae284c6ce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 Feb 2026 19:37:55 -0600 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] =?UTF-8?q?bump:=20version=200.1.0=20=E2=86=92=200.1?= =?UTF-8?q?.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 07/10] 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 08/10] =?UTF-8?q?bump:=20version=200.1.1=20=E2=86=92=200.1?= =?UTF-8?q?.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 09/10] 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 10/10] =?UTF-8?q?bump:=20version=200.1.2=20=E2=86=92=200.1?= =?UTF-8?q?.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 = [