diff --git a/CHANGELOG.md b/CHANGELOG.md
index c24d5c8..b98f5a0 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.1.3 (2026-02-06)
+
+### Fix
+
+- fix a couple more edge cases around oauth2 token refresh
+
+## v0.1.2 (2026-02-06)
+
+### Fix
+
+- add support for farmOS/OAuth2 Authorization Code grant/workflow
+
+## 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 c159419..3178ab3 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.3"
description = "Web app to integrate with and extend farmOS"
readme = "README.md"
authors = [
@@ -31,7 +31,7 @@ license = {text = "GNU General Public License v3"}
dependencies = [
"farmOS",
"psycopg2",
- "WuttaWeb[continuum]",
+ "WuttaWeb[continuum]>=0.27.2",
]
diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py
index bafbdad..26c6ef8 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):
"""
@@ -42,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)
@@ -51,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/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>##################################################%text>
+#
+# ${app_title} - upgrade script
+#
+<%text>##################################################%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>############################################################%text>
+#
+# ${app_title} - web app config
+#
+<%text>############################################################%text>
+
+
+<%text>##############################%text>
+# wutta
+<%text>##############################%text>
+
+${self.section_wutta_config()}
+
+
+<%text>##############################%text>
+# pyramid
+<%text>##############################%text>
+
+${self.section_app_main()}
+
+${self.section_server_main()}
+
+
+<%text>##############################%text>
+# logging
+<%text>##############################%text>
+
+${self.sectiongroup_logging()}
+
+
+######################################################################
+## section templates below
+######################################################################
+
+<%def name="section_wutta_config()">
+[wutta.config]
+require = %(here)s/wutta.conf
+%def>
+
+<%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>
+
+<%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>
+
+<%def name="sectiongroup_logging()">
+[handler_console]
+level = INFO
+
+[handler_file]
+args = (${repr(os.path.join(appdir, 'log', 'web.log'))}, 'a', 1000000, 100, 'utf_8')
+%def>
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>############################################################%text>
+#
+# ${app_title} - base config
+#
+<%text>############################################################%text>
+
+
+[farmos]
+url.base = ${farmos_url}
+
+
+<%text>##############################%text>
+# wutta
+<%text>##############################%text>
+
+${self.section_wutta()}
+
+${self.section_wutta_config()}
+
+${self.section_wutta_db()}
+
+${self.section_wutta_mail()}
+
+${self.section_wutta_upgrades()}
+
+
+<%text>##############################%text>
+# alembic
+<%text>##############################%text>
+
+${self.section_alembic()}
+
+
+<%text>##############################%text>
+# logging
+<%text>##############################%text>
+
+${self.sectiongroup_logging()}
+
+
+######################################################################
+## section templates below
+######################################################################
+
+<%def name="section_wutta()">
+[wutta]
+#app_title = ${app_title}
+%def>
+
+<%def name="section_wutta_config()">
+[wutta.config]
+#require = /etc/wutta/wutta.conf
+configure_logging = true
+usedb = true
+preferdb = true
+%def>
+
+<%def name="section_wutta_db()">
+[wutta.db]
+default.url = ${db_url}
+
+% if wants_continuum:
+[wutta_continuum]
+enable_versioning = true
+% endif
+%def>
+
+<%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>
+
+<%def name="section_wutta_upgrades()">
+## TODO
+## [wutta.upgrades]
+## command = ${os.path.join(appdir, 'upgrade.sh')} --verbose
+## files = ${os.path.join(appdir, 'data', 'upgrades')}
+%def>
+
+<%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>
+
+<%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
+%def>
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()">
+
+
${base_meta.full_logo(image_url or None)}
+
+
+
+
+ ${form.render_vue_tag()}
+
+
+
+
+ - OR -
+
+
+
+
+
+
+%def>
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 ea3207f..db757cc 100644
--- a/src/wuttafarm/web/views/auth.py
+++ b/src/wuttafarm/web/views/auth.py
@@ -23,7 +23,14 @@
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
+
+from wuttafarm.web.util import save_farmos_oauth2_token
class AuthView(base.AuthView):
@@ -47,6 +54,93 @@ 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
+ 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)
+ 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()
diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py
index d765cd4..aa00412 100644
--- a/src/wuttafarm/web/views/farmos/animals.py
+++ b/src/wuttafarm/web/views/farmos/animals.py
@@ -25,7 +25,7 @@ Master view for Farm Animals
import datetime
-from farmOS import farmOS
+import colander
from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.widgets import AnimalImage
@@ -46,7 +46,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 +55,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 +103,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 +170,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 +181,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())
diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py
index 40f7bfc..eed04d1 100644
--- a/src/wuttafarm/web/views/farmos/master.py
+++ b/src/wuttafarm/web/views/farmos/master.py
@@ -23,10 +23,10 @@
Base class for farmOS master views
"""
-from farmOS import farmOS
-
from wuttaweb.views import MasterView
+from wuttafarm.web.util import save_farmos_oauth2_token
+
class FarmOSMasterView(MasterView):
"""
@@ -54,8 +54,16 @@ class FarmOSMasterView(MasterView):
if not token:
raise self.forbidden()
- url = self.app.get_farmos_url()
- return farmOS(url, 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):
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")