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>################################################## +# +# ${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 + 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/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")