diff --git a/CHANGELOG.md b/CHANGELOG.md index b98f5a0..c24d5c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,27 +5,6 @@ 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 3178ab3..c159419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "WuttaFarm" -version = "0.1.3" +version = "0.1.0" 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]>=0.27.2", + "WuttaWeb[continuum]", ] diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py index 26c6ef8..bafbdad 100644 --- a/src/wuttafarm/app.py +++ b/src/wuttafarm/app.py @@ -32,7 +32,6 @@ class WuttaFarmAppHandler(base.AppHandler): """ default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler" - default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler" def get_farmos_handler(self): """ @@ -43,7 +42,7 @@ class WuttaFarmAppHandler(base.AppHandler): if "farmos" not in self.handlers: spec = self.config.get( f"{self.appname}.farmos_handler", - default="wuttafarm.farmos.handler:FarmOSHandler", + default="wuttafarm.farmos:FarmOSHandler", ) factory = self.load_object(spec) self.handlers["farmos"] = factory(self.config) @@ -52,15 +51,7 @@ class WuttaFarmAppHandler(base.AppHandler): def get_farmos_url(self, *args, **kwargs): """ Get a farmOS URL. This is a convenience wrapper around - :meth:`~wuttafarm.farmos.handler.FarmOSHandler.get_farmos_url()`. + :meth:`~wuttafarm.farmos.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 1155046..ab3f67a 100644 --- a/src/wuttafarm/auth.py +++ b/src/wuttafarm/auth.py @@ -25,6 +25,7 @@ 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 @@ -88,7 +89,8 @@ class WuttaFarmAuthHandler(AuthHandler): return None def get_farmos_oauth2_token(self, username, password): - client = self.app.get_farmos_client() + url = self.app.get_farmos_url() + client = farmOS(url) try: return client.authorize(username=username, password=password) except InvalidGrantError: diff --git a/src/wuttafarm/farmos/handler.py b/src/wuttafarm/farmos.py similarity index 87% rename from src/wuttafarm/farmos/handler.py rename to src/wuttafarm/farmos.py index 78e76b6..16ec1a6 100644 --- a/src/wuttafarm/farmos/handler.py +++ b/src/wuttafarm/farmos.py @@ -23,8 +23,6 @@ farmOS integration handler """ -from farmOS import farmOS - from wuttjamaican.app import GenericHandler @@ -34,14 +32,6 @@ 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/farmos/__init__.py b/src/wuttafarm/farmos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/wuttafarm/install.py b/src/wuttafarm/install.py deleted file mode 100644 index b67b6e2..0000000 --- a/src/wuttafarm/install.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- 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 deleted file mode 100755 index aadc31a..0000000 --- a/src/wuttafarm/templates/install/upgrade.sh.mako +++ /dev/null @@ -1,29 +0,0 @@ -#!/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 deleted file mode 100644 index 4d2b3c7..0000000 --- a/src/wuttafarm/templates/install/web.conf.mako +++ /dev/null @@ -1,90 +0,0 @@ -## -*- 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 deleted file mode 100644 index 5049808..0000000 --- a/src/wuttafarm/templates/install/wutta.conf.mako +++ /dev/null @@ -1,171 +0,0 @@ -## -*- 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 deleted file mode 100644 index 2c71694..0000000 --- a/src/wuttafarm/web/templates/auth/login.mako +++ /dev/null @@ -1,33 +0,0 @@ -## -*- 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 deleted file mode 100644 index 65d637d..0000000 --- a/src/wuttafarm/web/util.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- 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 db757cc..ea3207f 100644 --- a/src/wuttafarm/web/views/auth.py +++ b/src/wuttafarm/web/views/auth.py @@ -23,14 +23,7 @@ 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): @@ -54,93 +47,6 @@ 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 aa00412..d765cd4 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 -import colander +from farmOS import farmOS from wuttafarm.web.views.farmos import FarmOSMasterView from wuttafarm.web.forms.widgets import AnimalImage @@ -46,8 +46,7 @@ class AnimalView(FarmOSMasterView): farmos_refurl_path = "/assets/animal" labels = { - "is_castrated": "Castrated", - "location_name": "Current Location", + "species_breed": "Species / Breed", "raw_image_url": "Raw Image URL", "large_image_url": "Large Image URL", "thumbnail_image_url": "Thumbnail Image URL", @@ -55,24 +54,20 @@ class AnimalView(FarmOSMasterView): grid_columns = [ "name", + "species_breed", "birthdate", "sex", - "is_castrated", - "status", + "location", ] sort_defaults = "name" form_fields = [ "name", - "animal_type_name", + "species_breed", "birthdate", "sex", - "is_castrated", - "status", - "owners", - "location_name", - "notes", + "location", "raw_image_url", "large_image_url", "thumbnail_image_url", @@ -103,38 +98,8 @@ 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( @@ -170,10 +135,7 @@ 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): @@ -181,12 +143,6 @@ 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 eed04d1..40f7bfc 100644 --- a/src/wuttafarm/web/views/farmos/master.py +++ b/src/wuttafarm/web/views/farmos/master.py @@ -23,9 +23,9 @@ Base class for farmOS master views """ -from wuttaweb.views import MasterView +from farmOS import farmOS -from wuttafarm.web.util import save_farmos_oauth2_token +from wuttaweb.views import MasterView class FarmOSMasterView(MasterView): @@ -54,16 +54,8 @@ class FarmOSMasterView(MasterView): if not token: raise self.forbidden() - # 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) + url = self.app.get_farmos_url() + return farmOS(url, token=token) def get_template_context(self, context): diff --git a/tasks.py b/tasks.py index fead300..ce0b62e 100644 --- a/tasks.py +++ b/tasks.py @@ -15,9 +15,7 @@ def release(c, skip_tests=False): Release a new version of WuttaFarm """ if not skip_tests: - # TODO - # c.run("pytest") - pass + c.run("pytest") if os.path.exists("dist"): shutil.rmtree("dist")