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/)
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

View file

@ -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",
]

View file

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

View file

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

View file

View file

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

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
"""
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()

View file

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

View file

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

View file

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