Compare commits

...

10 commits

16 changed files with 609 additions and 19 deletions

View file

@ -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/) 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). 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) ## v0.1.0 (2026-02-03)
### Feat ### Feat

View file

@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaFarm" name = "WuttaFarm"
version = "0.1.0" version = "0.1.3"
description = "Web app to integrate with and extend farmOS" description = "Web app to integrate with and extend farmOS"
readme = "README.md" readme = "README.md"
authors = [ authors = [
@ -31,7 +31,7 @@ license = {text = "GNU General Public License v3"}
dependencies = [ dependencies = [
"farmOS", "farmOS",
"psycopg2", "psycopg2",
"WuttaWeb[continuum]", "WuttaWeb[continuum]>=0.27.2",
] ]

View file

@ -32,6 +32,7 @@ class WuttaFarmAppHandler(base.AppHandler):
""" """
default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler" default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler"
default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler"
def get_farmos_handler(self): def get_farmos_handler(self):
""" """
@ -42,7 +43,7 @@ class WuttaFarmAppHandler(base.AppHandler):
if "farmos" not in self.handlers: if "farmos" not in self.handlers:
spec = self.config.get( spec = self.config.get(
f"{self.appname}.farmos_handler", f"{self.appname}.farmos_handler",
default="wuttafarm.farmos:FarmOSHandler", default="wuttafarm.farmos.handler:FarmOSHandler",
) )
factory = self.load_object(spec) factory = self.load_object(spec)
self.handlers["farmos"] = factory(self.config) self.handlers["farmos"] = factory(self.config)
@ -51,7 +52,15 @@ class WuttaFarmAppHandler(base.AppHandler):
def get_farmos_url(self, *args, **kwargs): def get_farmos_url(self, *args, **kwargs):
""" """
Get a farmOS URL. This is a convenience wrapper around 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() handler = self.get_farmos_handler()
return handler.get_farmos_url(*args, **kwargs) 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)

View file

@ -25,7 +25,6 @@ Auth handler for use with farmOS
from uuid import UUID from uuid import UUID
from farmOS import farmOS
from oauthlib.oauth2.rfc6749.errors import InvalidGrantError from oauthlib.oauth2.rfc6749.errors import InvalidGrantError
from sqlalchemy import orm from sqlalchemy import orm
@ -89,8 +88,7 @@ class WuttaFarmAuthHandler(AuthHandler):
return None return None
def get_farmos_oauth2_token(self, username, password): def get_farmos_oauth2_token(self, username, password):
url = self.app.get_farmos_url() client = self.app.get_farmos_client()
client = farmOS(url)
try: try:
return client.authorize(username=username, password=password) return client.authorize(username=username, password=password)
except InvalidGrantError: except InvalidGrantError:

View file

View file

@ -23,6 +23,8 @@
farmOS integration handler farmOS integration handler
""" """
from farmOS import farmOS
from wuttjamaican.app import GenericHandler from wuttjamaican.app import GenericHandler
@ -32,6 +34,14 @@ class FarmOSHandler(GenericHandler):
:term:`handler`. :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): def get_farmos_url(self, path=None, require=True):
""" """
Returns the base URL for farmOS, or one with ``path`` appended. Returns the base URL for farmOS, or one with ``path`` appended.

41
src/wuttafarm/install.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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()">
<div class="wutta-page-content">
<div class="wutta-logo">${base_meta.full_logo(image_url or None)}</div>
<div style="display: flex; gap: 1rem; align-items: center;">
<div class="card">
<div class="card-content">
${form.render_vue_tag()}
</div>
</div>
<div class="has-text-italic">
- OR -
</div>
<div class="card">
<div class="card-content">
<wutta-button type="is-primary"
tag="a"
href="${url('farmos_oauth_login')}"
icon-left="user"
label="Login via farmOS / OAuth2"
once />
</div>
</div>
</div>
</div>
</%def>

40
src/wuttafarm/web/util.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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

View file

@ -23,7 +23,14 @@
Auth views Auth views
""" """
from oauthlib.oauth2 import AccessDeniedError
from requests_oauthlib import OAuth2Session
from wuttaweb.views import auth as base 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): class AuthView(base.AuthView):
@ -47,6 +54,93 @@ class AuthView(base.AuthView):
return None 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): def defaults(config, **kwargs):
local = globals() local = globals()

View file

@ -25,7 +25,7 @@ Master view for Farm Animals
import datetime import datetime
from farmOS import farmOS import colander
from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.views.farmos import FarmOSMasterView
from wuttafarm.web.forms.widgets import AnimalImage from wuttafarm.web.forms.widgets import AnimalImage
@ -46,7 +46,8 @@ class AnimalView(FarmOSMasterView):
farmos_refurl_path = "/assets/animal" farmos_refurl_path = "/assets/animal"
labels = { labels = {
"species_breed": "Species / Breed", "is_castrated": "Castrated",
"location_name": "Current Location",
"raw_image_url": "Raw Image URL", "raw_image_url": "Raw Image URL",
"large_image_url": "Large Image URL", "large_image_url": "Large Image URL",
"thumbnail_image_url": "Thumbnail Image URL", "thumbnail_image_url": "Thumbnail Image URL",
@ -54,20 +55,24 @@ class AnimalView(FarmOSMasterView):
grid_columns = [ grid_columns = [
"name", "name",
"species_breed",
"birthdate", "birthdate",
"sex", "sex",
"location", "is_castrated",
"status",
] ]
sort_defaults = "name" sort_defaults = "name"
form_fields = [ form_fields = [
"name", "name",
"species_breed", "animal_type_name",
"birthdate", "birthdate",
"sex", "sex",
"location", "is_castrated",
"status",
"owners",
"location_name",
"notes",
"raw_image_url", "raw_image_url",
"large_image_url", "large_image_url",
"thumbnail_image_url", "thumbnail_image_url",
@ -98,8 +103,38 @@ class AnimalView(FarmOSMasterView):
# instance data # instance data
data = self.normalize_animal(animal["data"]) data = self.normalize_animal(animal["data"])
# add image_url
if relationships := animal["data"].get("relationships"): 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 := relationships.get("image"):
if image["data"]: if image["data"]:
image = self.farmos_client.resource.get_id( image = self.farmos_client.resource.get_id(
@ -135,7 +170,10 @@ class AnimalView(FarmOSMasterView):
"species_breed": "", # TODO "species_breed": "", # TODO
"birthdate": birthdate, "birthdate": birthdate,
"sex": animal["attributes"]["sex"], "sex": animal["attributes"]["sex"],
"is_castrated": animal["attributes"]["is_castrated"],
"location": "", # TODO "location": "", # TODO
"status": animal["attributes"]["status"],
"notes": animal["attributes"]["notes"]["value"],
} }
def configure_form(self, form): def configure_form(self, form):
@ -143,6 +181,12 @@ class AnimalView(FarmOSMasterView):
super().configure_form(f) super().configure_form(f)
animal = f.model_instance animal = f.model_instance
# is_castrated
f.set_node("is_castrated", colander.Boolean())
# notes
f.set_widget("notes", "notes")
# image # image
if url := animal.get("large_image_url"): if url := animal.get("large_image_url"):
f.set_widget("image", AnimalImage()) f.set_widget("image", AnimalImage())

View file

@ -23,10 +23,10 @@
Base class for farmOS master views Base class for farmOS master views
""" """
from farmOS import farmOS
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttafarm.web.util import save_farmos_oauth2_token
class FarmOSMasterView(MasterView): class FarmOSMasterView(MasterView):
""" """
@ -54,8 +54,16 @@ class FarmOSMasterView(MasterView):
if not token: if not token:
raise self.forbidden() raise self.forbidden()
url = self.app.get_farmos_url() # nb. must give a *copy* of the token to farmOS client, since
return farmOS(url, token=token) # 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): def get_template_context(self, context):

View file

@ -15,7 +15,9 @@ def release(c, skip_tests=False):
Release a new version of WuttaFarm Release a new version of WuttaFarm
""" """
if not skip_tests: if not skip_tests:
c.run("pytest") # TODO
# c.run("pytest")
pass
if os.path.exists("dist"): if os.path.exists("dist"):
shutil.rmtree("dist") shutil.rmtree("dist")