diff --git a/pyproject.toml b/pyproject.toml
index 837096d..cea7dcc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaFarm"
version = "0.0.0"
-description = "Web app to expose / extend farmOS"
+description = "Web app to integrate with and extend farmOS"
readme = "README.md"
authors = [
{name = "Lance Edgar", email = "lance@wuttaproject.org"}
@@ -29,8 +29,9 @@ classifiers = [
]
license = {text = "GNU General Public License v3"}
dependencies = [
- "psycopg2",
- "WuttaWeb[continuum]",
+ "farmOS",
+ "psycopg2",
+ "WuttaWeb[continuum]",
]
diff --git a/src/wuttafarm/__init__.py b/src/wuttafarm/__init__.py
new file mode 100644
index 0000000..a48e2e6
--- /dev/null
+++ b/src/wuttafarm/__init__.py
@@ -0,0 +1,26 @@
+# -*- 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 .
+#
+################################################################################
+"""
+package root
+"""
+
+from ._version import __version__
diff --git a/src/wuttafarm/_version.py b/src/wuttafarm/_version.py
new file mode 100644
index 0000000..bbd4c45
--- /dev/null
+++ b/src/wuttafarm/_version.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8; -*-
+"""
+Package Version
+"""
+
+from importlib.metadata import version
+
+
+__version__ = version("WuttaFarm")
diff --git a/src/wuttafarm/app.py b/src/wuttafarm/app.py
new file mode 100644
index 0000000..bafbdad
--- /dev/null
+++ b/src/wuttafarm/app.py
@@ -0,0 +1,57 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Custom app handler for WuttaFarm
+"""
+
+from wuttjamaican import app as base
+
+
+class WuttaFarmAppHandler(base.AppHandler):
+ """
+ Custom :term:`app handler` for WuttaFarm.
+ """
+
+ default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler"
+
+ def get_farmos_handler(self):
+ """
+ Get the configured farmOS integration handler.
+
+ :rtype: :class:`~wuttafarm.farmos.FarmOSHandler`
+ """
+ if "farmos" not in self.handlers:
+ spec = self.config.get(
+ f"{self.appname}.farmos_handler",
+ default="wuttafarm.farmos:FarmOSHandler",
+ )
+ factory = self.load_object(spec)
+ self.handlers["farmos"] = factory(self.config)
+ return self.handlers["farmos"]
+
+ def get_farmos_url(self, *args, **kwargs):
+ """
+ Get a farmOS URL. This is a convenience wrapper around
+ :meth:`~wuttafarm.farmos.FarmOSHandler.get_farmos_url()`.
+ """
+ handler = self.get_farmos_handler()
+ return handler.get_farmos_url(*args, **kwargs)
diff --git a/src/wuttafarm/auth.py b/src/wuttafarm/auth.py
new file mode 100644
index 0000000..ab3f67a
--- /dev/null
+++ b/src/wuttafarm/auth.py
@@ -0,0 +1,117 @@
+# -*- 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 .
+#
+################################################################################
+"""
+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
+
+from wuttjamaican.auth import AuthHandler
+
+
+class WuttaFarmAuthHandler(AuthHandler):
+ """
+ Custom auth handler for WuttaFarm. This adds some magic around
+ the user login process.
+
+ It can attempt authentication against the configured farmOS
+ instance, auto-creating native users within the app DB as needed.
+
+ """
+
+ def get_role_farm_manager(self, session):
+ """
+ Returns the special "Farm Manager" role.
+ """
+ return self._special_role(
+ session, UUID("06979646-b1b9-723b-8000-b86161427f9f"), "Farm Manager"
+ )
+
+ def get_role_farm_worker(self, session):
+ """
+ Returns the special "Farm Worker" role.
+ """
+ return self._special_role(
+ session, UUID("06979648-9bd6-7b61-8000-ee8818052ee8"), "Farm Worker"
+ )
+
+ def get_role_farm_viewer(self, session):
+ """
+ Returns the special "Farm Viewer" role.
+ """
+ return self._special_role(
+ session, UUID("06979649-ed32-7e97-8000-113ddf0ab5f3"), "Farm Viewer"
+ )
+
+ def authenticate_user(self, session, username, password):
+ """
+ When authentication is attempted, this first will check
+ credentials against the app DB per normal logic. If that
+ succeeds, the result is no different from the typical
+ behavior.
+
+ If default logic fails, this will try to obtain an OAuth2
+ token from the farmOS site. If that succeeds, then a lookup
+ is done in the app DB for a matching user. If the user does
+ not yet exist it will be created automatically.
+ """
+ if user := super().authenticate_user(session, username, password):
+ return user
+
+ if token := self.get_farmos_oauth2_token(username, password):
+ if user := self.get_or_make_farmos_user(session, username):
+ user.__dict__["farmos_oauth2_token"] = token
+ return user
+
+ return None
+
+ def get_farmos_oauth2_token(self, username, password):
+ url = self.app.get_farmos_url()
+ client = farmOS(url)
+ try:
+ return client.authorize(username=username, password=password)
+ except InvalidGrantError:
+ return None
+
+ def get_or_make_farmos_user(self, session, username):
+ model = self.app.model
+ try:
+ user = (
+ session.query(model.User).filter(model.User.username == username).one()
+ )
+ except orm.exc.NoResultFound:
+ pass
+ else:
+ return user if user.active else None
+
+ # nb. prevent edit for farmOS mirrored user accounts
+ user = self.make_user(session, username=username, prevent_edit=True)
+
+ # TODO
+ manager = self.get_role_farm_manager(session)
+ user.roles.append(manager)
+
+ return user
diff --git a/src/wuttafarm/cli.py b/src/wuttafarm/cli.py
index 702d572..2f377a3 100644
--- a/src/wuttafarm/cli.py
+++ b/src/wuttafarm/cli.py
@@ -1,4 +1,24 @@
# -*- 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 .
+#
+################################################################################
"""
WuttaFarm CLI
"""
@@ -9,22 +29,23 @@ from wuttjamaican.cli import make_typer
wuttafarm_typer = make_typer(
- name='wuttafarm',
- help="WuttaFarm -- Web app to expose / extend farmOS"
+ name="wuttafarm", help="WuttaFarm -- Web app to integrate with and extend farmOS"
)
@wuttafarm_typer.command()
def install(
- ctx: typer.Context,
+ ctx: typer.Context,
):
"""
Install the WuttaFarm app
"""
config = ctx.parent.wutta_config
app = config.get_app()
- install = app.get_install_handler(pkg_name='wuttafarm',
- app_title="WuttaFarm",
- pypi_name='WuttaFarm',
- egg_name='WuttaFarm')
+ install = app.get_install_handler(
+ pkg_name="wuttafarm",
+ app_title="WuttaFarm",
+ pypi_name="WuttaFarm",
+ egg_name="WuttaFarm",
+ )
install.run()
diff --git a/src/wuttafarm/config.py b/src/wuttafarm/config.py
index 0a1e657..fcc8aae 100644
--- a/src/wuttafarm/config.py
+++ b/src/wuttafarm/config.py
@@ -1,4 +1,24 @@
# -*- 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 .
+#
+################################################################################
"""
WuttaFarm config extensions
"""
@@ -10,20 +30,28 @@ class WuttaFarmConfig(WuttaConfigExtension):
"""
Config extension for WuttaFarm
"""
- key = 'wuttafarm'
+
+ key = "wuttafarm"
def configure(self, config):
# app info
- config.setdefault(f'{config.appname}.app_title', "WuttaFarm")
- config.setdefault(f'{config.appname}.app_dist', "WuttaFarm")
+ config.setdefault(f"{config.appname}.app_title", "WuttaFarm")
+ config.setdefault(f"{config.appname}.app_dist", "WuttaFarm")
# app model
- config.setdefault(f'{config.appname}.model_spec', 'wuttafarm.db.model')
+ config.setdefault(f"{config.appname}.model_spec", "wuttafarm.db.model")
+
+ # app handler
+ config.setdefault(
+ f"{config.appname}.app.handler", "wuttafarm.app:WuttaFarmAppHandler"
+ )
# web app menu
- config.setdefault(f'{config.appname}.web.menus.handler.spec',
- 'wuttafarm.web.menus:WuttaFarmMenuHandler')
+ config.setdefault(
+ f"{config.appname}.web.menus.handler.spec",
+ "wuttafarm.web.menus:WuttaFarmMenuHandler",
+ )
# web app libcache
- #config.setdefault('wuttaweb.static_libcache.module', 'wuttafarm.web.static')
+ # config.setdefault('wuttaweb.static_libcache.module', 'wuttafarm.web.static')
diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py
index cf3096a..b52d7c8 100644
--- a/src/wuttafarm/db/model/__init__.py
+++ b/src/wuttafarm/db/model/__init__.py
@@ -1,4 +1,24 @@
# -*- 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 .
+#
+################################################################################
"""
WuttaFarm data models
"""
diff --git a/src/wuttafarm/farmos.py b/src/wuttafarm/farmos.py
new file mode 100644
index 0000000..16ec1a6
--- /dev/null
+++ b/src/wuttafarm/farmos.py
@@ -0,0 +1,57 @@
+# -*- 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 .
+#
+################################################################################
+"""
+farmOS integration handler
+"""
+
+from wuttjamaican.app import GenericHandler
+
+
+class FarmOSHandler(GenericHandler):
+ """
+ Base class and default implementation for the farmOS integration
+ :term:`handler`.
+ """
+
+ def get_farmos_url(self, path=None, require=True):
+ """
+ Returns the base URL for farmOS, or one with ``path`` appended.
+
+ Note that if no path is provided, the final slash will be
+ stripped from the base URL.
+
+ :param path: Optional path to append to the base URL.
+
+ :param require: If true, an error is raised when base URL
+ cannot be determined.
+
+ :returns: URL string
+ """
+ base = self.config.get("farmos.url.base", require=require)
+ if base:
+ base = base.rstrip("/")
+
+ if path:
+ path = path.lstrip("/")
+ return f"{base}/{path}"
+
+ return base
diff --git a/src/wuttafarm/web/app.py b/src/wuttafarm/web/app.py
index 6664579..b21bd5c 100644
--- a/src/wuttafarm/web/app.py
+++ b/src/wuttafarm/web/app.py
@@ -1,4 +1,24 @@
# -*- 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 .
+#
+################################################################################
"""
WuttaFarm web app
"""
diff --git a/src/wuttafarm/web/forms/__init__.py b/src/wuttafarm/web/forms/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py
new file mode 100644
index 0000000..047a8cd
--- /dev/null
+++ b/src/wuttafarm/web/forms/widgets.py
@@ -0,0 +1,47 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Custom form widgets for WuttaFarm
+"""
+
+import colander
+from deform.widget import Widget
+from webhelpers2.html import HTML, tags
+
+
+class AnimalImage(Widget):
+ """
+ Widget to display an image URL for an animal.
+
+ TODO: this should be refactored to a more general name, once more
+ types of images need to be supported.
+ """
+
+ def serialize(self, field, cstruct, **kw):
+ readonly = kw.get("readonly", self.readonly)
+ if readonly:
+ if cstruct in (colander.null, None):
+ return HTML.tag("span")
+
+ return tags.image(cstruct, "animal image", **kw)
+
+ return super().serialize(field, cstruct, **kw)
diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py
index aaf26c6..e999944 100644
--- a/src/wuttafarm/web/menus.py
+++ b/src/wuttafarm/web/menus.py
@@ -1,4 +1,24 @@
# -*- 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 .
+#
+################################################################################
"""
WuttaFarm Menu
"""
@@ -12,30 +32,20 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"""
def make_menus(self, request, **kwargs):
-
- # nb. the products menu is just an example; you should
- # replace it and add more as needed
-
return [
- self.make_products_menu(request),
+ self.make_farmos_menu(request),
self.make_admin_menu(request, include_people=True),
]
- def make_products_menu(self, request):
+ def make_farmos_menu(self, request):
return {
- "title": "Products",
+ "title": "farmOS",
"type": "menu",
"items": [
{
- "title": "Products",
- "route": "products",
- "perm": "products.list",
- },
- {'type': 'sep'},
- {
- "title": "Vendors",
- "route": "vendors",
- "perm": "vendors.list",
+ "title": "Animals",
+ "route": "farmos_animals",
+ "perm": "farmos_animals.list",
},
],
}
diff --git a/src/wuttafarm/web/static/__init__.py b/src/wuttafarm/web/static/__init__.py
index 325e6fa..f61a9e7 100644
--- a/src/wuttafarm/web/static/__init__.py
+++ b/src/wuttafarm/web/static/__init__.py
@@ -1,4 +1,24 @@
# -*- 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 .
+#
+################################################################################
"""
Static assets
"""
diff --git a/src/wuttafarm/web/subscribers.py b/src/wuttafarm/web/subscribers.py
index c3434fe..847be42 100644
--- a/src/wuttafarm/web/subscribers.py
+++ b/src/wuttafarm/web/subscribers.py
@@ -1,4 +1,24 @@
# -*- 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 .
+#
+################################################################################
"""
Pyramid event subscribers
"""
@@ -8,9 +28,9 @@ import wuttafarm
def add_wuttafarm_to_context(event):
renderer_globals = event
- renderer_globals['wuttafarm'] = wuttafarm
+ renderer_globals["wuttafarm"] = wuttafarm
def includeme(config):
- config.include('wuttaweb.subscribers')
- config.add_subscriber(add_wuttafarm_to_context, 'pyramid.events.BeforeRender')
+ config.include("wuttaweb.subscribers")
+ config.add_subscriber(add_wuttafarm_to_context, "pyramid.events.BeforeRender")
diff --git a/src/wuttafarm/web/templates/base.mako b/src/wuttafarm/web/templates/base.mako
new file mode 100644
index 0000000..3e5d544
--- /dev/null
+++ b/src/wuttafarm/web/templates/base.mako
@@ -0,0 +1,16 @@
+<%inherit file="wuttaweb:templates/base.mako" />
+
+<%def name="index_title_controls()">
+ ${parent.index_title_controls()}
+
+ % if master is not Undefined and master.listing and farmos_refurl is not Undefined:
+
+ View in farmOS
+
+ % endif
+
+%def>
diff --git a/src/wuttafarm/web/views/__init__.py b/src/wuttafarm/web/views/__init__.py
index 09802ca..63ce536 100644
--- a/src/wuttafarm/web/views/__init__.py
+++ b/src/wuttafarm/web/views/__init__.py
@@ -1,13 +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 .
+#
+################################################################################
"""
WuttaFarm Views
"""
+from wuttaweb.views import essential
+
def includeme(config):
- # core views for wuttaweb
- config.include("wuttaweb.views.essential")
+ # wuttaweb core
+ essential.defaults(
+ config,
+ **{
+ "wuttaweb.views.auth": "wuttafarm.web.views.auth",
+ "wuttaweb.views.common": "wuttafarm.web.views.common",
+ }
+ )
- # TODO: include your own views here
- # config.include('wuttafarm.web.views.widgets')
+ # views for farmOS
+ config.include("wuttafarm.web.views.farmos")
diff --git a/src/wuttafarm/web/views/auth.py b/src/wuttafarm/web/views/auth.py
new file mode 100644
index 0000000..ea3207f
--- /dev/null
+++ b/src/wuttafarm/web/views/auth.py
@@ -0,0 +1,58 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Auth views
+"""
+
+from wuttaweb.views import auth as base
+
+
+class AuthView(base.AuthView):
+ """
+ Auth views
+ """
+
+ def authenticate_user(self, session, username, password):
+ if user := super().authenticate_user(session, username, password):
+
+ # nb. auth handler will stash the farmOS oauth2 token in
+ # the user object, if applicable (i.e. unless the user
+ # authenticated normally via the app DB). when a token is
+ # involved we need to put that in the user's web session.
+ if "farmos_oauth2_token" in user.__dict__:
+ self.request.session["farmos.oauth2.token"] = user.__dict__.pop(
+ "farmos_oauth2_token"
+ )
+
+ return user
+
+ return None
+
+
+def defaults(config, **kwargs):
+ local = globals()
+ AuthView = kwargs.get("AuthView", local["AuthView"])
+ base.defaults(config, **{"AuthView": AuthView})
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py
new file mode 100644
index 0000000..bb8a9c9
--- /dev/null
+++ b/src/wuttafarm/web/views/common.py
@@ -0,0 +1,65 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Common views
+"""
+
+from wuttaweb.views import common as base
+
+
+class CommonView(base.CommonView):
+ """
+ Common views
+ """
+
+ def setup_enhance_admin_user(self, user):
+ """ """
+ model = self.app.model
+ session = self.app.get_session(user)
+ auth = self.app.get_auth_handler()
+
+ # initialize built-in roles
+ farm_manager = auth.get_role_farm_manager(session)
+ farm_manager.notes = "this is meant to mirror the corresponding role in farmOS"
+ farm_worker = auth.get_role_farm_worker(session)
+ farm_worker.notes = "this is meant to mirror the corresponding role in farmOS"
+ farm_viewer = auth.get_role_farm_viewer(session)
+ farm_viewer.notes = "this is meant to mirror the corresponding role in farmOS"
+
+ site_admin = session.query(model.Role).filter_by(name="Site Admin").first()
+ if site_admin:
+ site_admin_perms = [
+ "farmos_animals.list",
+ "farmos_animals.view",
+ ]
+ for perm in site_admin_perms:
+ auth.grant_permission(site_admin, perm)
+
+
+def defaults(config, **kwargs):
+ local = globals()
+ CommonView = kwargs.get("CommonView", local["CommonView"])
+ base.defaults(config, **{"CommonView": CommonView})
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/__init__.py b/src/wuttafarm/web/views/farmos/__init__.py
new file mode 100644
index 0000000..df789b3
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/__init__.py
@@ -0,0 +1,30 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Views to expose farmOS data directly
+"""
+
+from .master import FarmOSMasterView
+
+
+def includeme(config):
+ config.include("wuttafarm.web.views.farmos.animals")
diff --git a/src/wuttafarm/web/views/farmos/animals.py b/src/wuttafarm/web/views/farmos/animals.py
new file mode 100644
index 0000000..d765cd4
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/animals.py
@@ -0,0 +1,171 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Master view for Farm Animals
+"""
+
+import datetime
+
+from farmOS import farmOS
+
+from wuttafarm.web.views.farmos import FarmOSMasterView
+from wuttafarm.web.forms.widgets import AnimalImage
+
+
+class AnimalView(FarmOSMasterView):
+ """
+ Master view for Farm Animals
+ """
+
+ model_name = "farmos_animal"
+ model_title = "farmOS Animal"
+ model_title_plural = "farmOS Animals"
+
+ route_prefix = "farmos_animals"
+ url_prefix = "/farmOS/animals"
+
+ farmos_refurl_path = "/assets/animal"
+
+ labels = {
+ "species_breed": "Species / Breed",
+ "raw_image_url": "Raw Image URL",
+ "large_image_url": "Large Image URL",
+ "thumbnail_image_url": "Thumbnail Image URL",
+ }
+
+ grid_columns = [
+ "name",
+ "species_breed",
+ "birthdate",
+ "sex",
+ "location",
+ ]
+
+ sort_defaults = "name"
+
+ form_fields = [
+ "name",
+ "species_breed",
+ "birthdate",
+ "sex",
+ "location",
+ "raw_image_url",
+ "large_image_url",
+ "thumbnail_image_url",
+ "image",
+ ]
+
+ def get_grid_data(self, columns=None, session=None):
+ animals = self.farmos_client.resource.get("asset", "animal")
+ return [self.normalize_animal(a) for a in animals["data"]]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # name
+ g.set_link("name")
+ g.set_searchable("name")
+
+ # birthdate
+ g.set_renderer("birthdate", "date")
+
+ def get_instance(self):
+
+ animal = self.farmos_client.resource.get_id(
+ "asset", "animal", self.request.matchdict["uuid"]
+ )
+
+ # instance data
+ data = self.normalize_animal(animal["data"])
+
+ # add image_url
+ if relationships := animal["data"].get("relationships"):
+ if image := relationships.get("image"):
+ if image["data"]:
+ image = self.farmos_client.resource.get_id(
+ "file", "file", image["data"][0]["id"]
+ )
+ data["raw_image_url"] = self.app.get_farmos_url(
+ image["data"]["attributes"]["uri"]["url"]
+ )
+ # nb. other styles available: medium, wide
+ data["large_image_url"] = image["data"]["attributes"][
+ "image_style_uri"
+ ]["large"]
+ data["thumbnail_image_url"] = image["data"]["attributes"][
+ "image_style_uri"
+ ]["thumbnail"]
+
+ return data
+
+ def get_instance_title(self, animal):
+ return animal["name"]
+
+ def normalize_animal(self, animal):
+
+ birthdate = animal["attributes"]["birthdate"]
+ if birthdate:
+ birthdate = datetime.datetime.fromisoformat(birthdate)
+ birthdate = self.app.localtime(birthdate)
+
+ return {
+ "uuid": animal["id"],
+ "drupal_internal_id": animal["attributes"]["drupal_internal__id"],
+ "name": animal["attributes"]["name"],
+ "species_breed": "", # TODO
+ "birthdate": birthdate,
+ "sex": animal["attributes"]["sex"],
+ "location": "", # TODO
+ }
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+ animal = f.model_instance
+
+ # image
+ if url := animal.get("large_image_url"):
+ f.set_widget("image", AnimalImage())
+ f.set_default("image", url)
+
+ def get_xref_buttons(self, animal):
+ return [
+ self.make_button(
+ "View in farmOS",
+ primary=True,
+ url=self.app.get_farmos_url(f"/asset/{animal['drupal_internal_id']}"),
+ target="_blank",
+ icon_left="external-link-alt",
+ ),
+ ]
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ AnimalView = kwargs.get("AnimalView", base["AnimalView"])
+ AnimalView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttafarm/web/views/farmos/master.py b/src/wuttafarm/web/views/farmos/master.py
new file mode 100644
index 0000000..40f7bfc
--- /dev/null
+++ b/src/wuttafarm/web/views/farmos/master.py
@@ -0,0 +1,65 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Base class for farmOS master views
+"""
+
+from farmOS import farmOS
+
+from wuttaweb.views import MasterView
+
+
+class FarmOSMasterView(MasterView):
+ """
+ Base class for farmOS master views
+ """
+
+ model_key = "uuid"
+
+ creatable = False
+ editable = False
+ deletable = False
+
+ filterable = False
+ sort_on_backend = False
+ paginate_on_backend = False
+
+ farmos_refurl_path = None
+
+ def __init__(self, request, context=None):
+ super().__init__(request, context=context)
+ self.farmos_client = self.get_farmos_client()
+
+ def get_farmos_client(self):
+ token = self.request.session.get("farmos.oauth2.token")
+ if not token:
+ raise self.forbidden()
+
+ url = self.app.get_farmos_url()
+ return farmOS(url, token=token)
+
+ def get_template_context(self, context):
+
+ if self.listing and self.farmos_refurl_path:
+ context["farmos_refurl"] = self.app.get_farmos_url(self.farmos_refurl_path)
+
+ return context
diff --git a/tasks.py b/tasks.py
new file mode 100644
index 0000000..ce0b62e
--- /dev/null
+++ b/tasks.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8; -*-
+"""
+Tasks for WuttaFarm
+"""
+
+import os
+import shutil
+
+from invoke import task
+
+
+@task
+def release(c, skip_tests=False):
+ """
+ Release a new version of WuttaFarm
+ """
+ if not skip_tests:
+ c.run("pytest")
+
+ if os.path.exists("dist"):
+ shutil.rmtree("dist")
+
+ c.run("python -m build --sdist")
+ c.run("twine upload dist/*")