From 3b6b3173776829986577133919e170a8e3f171f2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 4 Aug 2024 14:55:32 -0500
Subject: [PATCH 01/11] feat: add `util.get_form_data()` convenience function

---
 src/wuttaweb/util.py | 23 ++++++++++++++++++++++-
 tests/test_util.py   | 27 +++++++++++++++++++++++++++
 2 files changed, 49 insertions(+), 1 deletion(-)

diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py
index 3275301..1cf7804 100644
--- a/src/wuttaweb/util.py
+++ b/src/wuttaweb/util.py
@@ -21,12 +21,33 @@
 #
 ################################################################################
 """
-Utilities
+Web Utilities
 """
 
 import importlib
 
 
+def get_form_data(request):
+    """
+    Returns the effective form data for the given request.
+
+    Mostly this is a convenience, which simply returns one of the
+    following, depending on various attributes of the request.
+
+    * :attr:`pyramid:pyramid.request.Request.POST`
+    * :attr:`pyramid:pyramid.request.Request.json_body`
+    """
+    # nb. we prefer JSON only if no POST is present
+    # TODO: this seems to work for our use case at least, but perhaps
+    # there is a better way?  see also
+    # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
+    if not request.POST and (
+            getattr(request, 'is_xhr', False)
+            or request.content_type == 'application/json'):
+        return request.json_body
+    return request.POST
+
+
 def get_libver(
         request,
         key,
diff --git a/tests/test_util.py b/tests/test_util.py
index d492943..c68d42c 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -263,3 +263,30 @@ class TestGetLibUrl(TestCase):
         self.config.setdefault('wuttaweb.liburl.bb_vue_fontawesome', '/lib/vue-fontawesome.js')
         url = util.get_liburl(self.request, 'bb_vue_fontawesome')
         self.assertEqual(url, '/lib/vue-fontawesome.js')
+
+
+class TestGetFormData(TestCase):
+
+    def setUp(self):
+        self.config = WuttaConfig()
+
+    def make_request(self, **kwargs):
+        kwargs.setdefault('wutta_config', self.config)
+        kwargs.setdefault('POST', {'foo1': 'bar'})
+        kwargs.setdefault('json_body', {'foo2': 'baz'})
+        return testing.DummyRequest(**kwargs)
+
+    def test_default(self):
+        request = self.make_request()
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo1': 'bar'})
+
+    def test_is_xhr(self):
+        request = self.make_request(POST=None, is_xhr=True)
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo2': 'baz'})
+
+    def test_content_type(self):
+        request = self.make_request(POST=None, content_type='application/json')
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo2': 'baz'})

From 0604651be5c468477073571da1f51c82392dda7d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 4 Aug 2024 15:34:23 -0500
Subject: [PATCH 02/11] feat: add `wuttaweb.db` module, with `Session`

---
 docs/api/wuttaweb/db.rst    |  6 ++++
 docs/api/wuttaweb/index.rst |  1 +
 pyproject.toml              |  1 +
 src/wuttaweb/app.py         | 12 +++++--
 src/wuttaweb/db.py          | 66 +++++++++++++++++++++++++++++++++++++
 5 files changed, 83 insertions(+), 3 deletions(-)
 create mode 100644 docs/api/wuttaweb/db.rst
 create mode 100644 src/wuttaweb/db.py

diff --git a/docs/api/wuttaweb/db.rst b/docs/api/wuttaweb/db.rst
new file mode 100644
index 0000000..b90e227
--- /dev/null
+++ b/docs/api/wuttaweb/db.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.db``
+===============
+
+.. automodule:: wuttaweb.db
+   :members:
diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 2e49d4b..1e8ab57 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -8,6 +8,7 @@
    :maxdepth: 1
 
    app
+   db
    handler
    helpers
    menus
diff --git a/pyproject.toml b/pyproject.toml
index 985bef5..baa0fed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,7 @@ dependencies = [
         "waitress",
         "WebHelpers2",
         "WuttJamaican[db]>=0.7.0",
+        "zope.sqlalchemy>=1.5",
 ]
 
 
diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py
index 18b07fb..506bacc 100644
--- a/src/wuttaweb/app.py
+++ b/src/wuttaweb/app.py
@@ -31,6 +31,8 @@ from wuttjamaican.conf import make_config
 
 from pyramid.config import Configurator
 
+import wuttaweb.db
+
 
 class WebAppProvider(AppProvider):
     """
@@ -83,17 +85,21 @@ def make_wutta_config(settings):
 
     If this config file path cannot be discovered, an error is raised.
     """
-    # initialize config and embed in settings dict, to make
-    # available for web requests later
+    # validate config file path
     path = settings.get('wutta.config')
     if not path or not os.path.exists(path):
         raise ValueError("Please set 'wutta.config' in [app:main] "
                          "section of config to the path of your "
                          "config file.  Lame, but necessary.")
 
+    # make config per usual, add to settings
     wutta_config = make_config(path)
-
     settings['wutta_config'] = wutta_config
+
+    # configure database sessions
+    if hasattr(wutta_config, 'appdb_engine'):
+        wuttaweb.db.Session.configure(bind=wutta_config.appdb_engine)
+
     return wutta_config
 
 
diff --git a/src/wuttaweb/db.py b/src/wuttaweb/db.py
new file mode 100644
index 0000000..32d2418
--- /dev/null
+++ b/src/wuttaweb/db.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  wuttaweb -- Web App for Wutta Framework
+#  Copyright © 2024 Lance Edgar
+#
+#  This file is part of Wutta Framework.
+#
+#  Wutta Framework 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.
+#
+#  Wutta Framework 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
+#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Database sessions for web app
+
+The web app uses a different database session than other
+(e.g. console) apps.  The web session is "registered" to the HTTP
+request/response life cycle (aka. transaction) such that the session
+is automatically rolled back on error, and automatically committed if
+the response is finalized without error.
+
+.. class:: Session
+
+   Primary database session class for the web app.
+
+   Note that you often do not need to "instantiate" this session, and
+   can instead call methods directly on the class::
+
+      from wuttaweb.db import Session
+
+      users = Session.query(model.User).all()
+
+   However in certain cases you may still want/need to instantiate it,
+   e.g. when passing a "true/normal" session to other logic.  But you
+   can always call instance methods as well::
+
+      from wuttaweb.db import Session
+      from some_place import some_func
+
+      session = Session()
+
+      # nb. assuming func does not expect a "web" session per se, pass instance
+      some_func(session)
+
+      # nb. these behave the same (instance vs. class method)
+      users = session.query(model.User).all()
+      users = Session.query(model.User).all()
+"""
+
+from sqlalchemy import orm
+from zope.sqlalchemy.datamanager import register
+
+
+Session = orm.scoped_session(orm.sessionmaker())
+
+register(Session)

From 95d3623a5e8b0910803639fb16dafab7797453ca Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 4 Aug 2024 20:35:41 -0500
Subject: [PATCH 03/11] feat: add initial/basic forms support

---
 docs/api/wuttaweb/forms.base.rst              |   6 +
 docs/api/wuttaweb/forms.rst                   |   6 +
 docs/api/wuttaweb/index.rst                   |   2 +
 docs/conf.py                                  |   3 +
 pyproject.toml                                |   1 +
 src/wuttaweb/app.py                           |   4 +
 src/wuttaweb/forms/__init__.py                |  31 ++
 src/wuttaweb/forms/base.py                    | 421 ++++++++++++++++++
 src/wuttaweb/templates/deform/password.pt     |   8 +
 src/wuttaweb/templates/deform/textinput.pt    |   7 +
 src/wuttaweb/templates/form.mako              |  18 +
 .../templates/forms/vue_template.mako         |  58 +++
 src/wuttaweb/views/base.py                    |  34 +-
 tests/forms/__init__.py                       |   0
 tests/forms/test_base.py                      | 241 ++++++++++
 tests/views/test_base.py                      |  28 +-
 16 files changed, 858 insertions(+), 10 deletions(-)
 create mode 100644 docs/api/wuttaweb/forms.base.rst
 create mode 100644 docs/api/wuttaweb/forms.rst
 create mode 100644 src/wuttaweb/forms/__init__.py
 create mode 100644 src/wuttaweb/forms/base.py
 create mode 100644 src/wuttaweb/templates/deform/password.pt
 create mode 100644 src/wuttaweb/templates/deform/textinput.pt
 create mode 100644 src/wuttaweb/templates/form.mako
 create mode 100644 src/wuttaweb/templates/forms/vue_template.mako
 create mode 100644 tests/forms/__init__.py
 create mode 100644 tests/forms/test_base.py

diff --git a/docs/api/wuttaweb/forms.base.rst b/docs/api/wuttaweb/forms.base.rst
new file mode 100644
index 0000000..8569309
--- /dev/null
+++ b/docs/api/wuttaweb/forms.base.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.forms.base``
+=======================
+
+.. automodule:: wuttaweb.forms.base
+   :members:
diff --git a/docs/api/wuttaweb/forms.rst b/docs/api/wuttaweb/forms.rst
new file mode 100644
index 0000000..1d83240
--- /dev/null
+++ b/docs/api/wuttaweb/forms.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.forms``
+==================
+
+.. automodule:: wuttaweb.forms
+   :members:
diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 1e8ab57..6b305cf 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -9,6 +9,8 @@
 
    app
    db
+   forms
+   forms.base
    handler
    helpers
    menus
diff --git a/docs/conf.py b/docs/conf.py
index 3955a96..3d568ef 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -20,12 +20,15 @@ extensions = [
     'sphinx.ext.autodoc',
     'sphinx.ext.intersphinx',
     'sphinx.ext.viewcode',
+    'sphinx.ext.todo',
 ]
 
 templates_path = ['_templates']
 exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
 
 intersphinx_mapping = {
+    'colander': ('https://docs.pylonsproject.org/projects/colander/en/latest/', None),
+    'deform': ('https://docs.pylonsproject.org/projects/deform/en/latest/', None),
     'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None),
     'python': ('https://docs.python.org/3/', None),
     'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
diff --git a/pyproject.toml b/pyproject.toml
index baa0fed..ce72e00 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,6 +32,7 @@ requires-python = ">= 3.8"
 dependencies = [
         "pyramid>=2",
         "pyramid_beaker",
+        "pyramid_deform",
         "pyramid_mako",
         "waitress",
         "WebHelpers2",
diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py
index 506bacc..a35d00d 100644
--- a/src/wuttaweb/app.py
+++ b/src/wuttaweb/app.py
@@ -110,9 +110,13 @@ def make_pyramid_config(settings):
     The config is initialized with certain features deemed useful for
     all apps.
     """
+    settings.setdefault('pyramid_deform.template_search_path',
+                        'wuttaweb:templates/deform')
+
     pyramid_config = Configurator(settings=settings)
 
     pyramid_config.include('pyramid_beaker')
+    pyramid_config.include('pyramid_deform')
     pyramid_config.include('pyramid_mako')
 
     return pyramid_config
diff --git a/src/wuttaweb/forms/__init__.py b/src/wuttaweb/forms/__init__.py
new file mode 100644
index 0000000..35102be
--- /dev/null
+++ b/src/wuttaweb/forms/__init__.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  wuttaweb -- Web App for Wutta Framework
+#  Copyright © 2024 Lance Edgar
+#
+#  This file is part of Wutta Framework.
+#
+#  Wutta Framework 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.
+#
+#  Wutta Framework 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
+#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Forms Library
+
+The ``wuttaweb.forms`` namespace contains the following:
+
+* :class:`~wuttaweb.forms.base.Form`
+"""
+
+from .base import Form
diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py
new file mode 100644
index 0000000..b8c4a40
--- /dev/null
+++ b/src/wuttaweb/forms/base.py
@@ -0,0 +1,421 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  wuttaweb -- Web App for Wutta Framework
+#  Copyright © 2024 Lance Edgar
+#
+#  This file is part of Wutta Framework.
+#
+#  Wutta Framework 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.
+#
+#  Wutta Framework 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
+#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Base form classes
+"""
+
+import json
+import logging
+
+import colander
+import deform
+from pyramid.renderers import render
+from webhelpers2.html import HTML
+
+from wuttaweb.util import get_form_data
+
+
+log = logging.getLogger(__name__)
+
+
+class FieldList(list):
+    """
+    Convenience wrapper for a form's field list.  This is a subclass
+    of :class:`python:list`.
+
+    You normally would not need to instantiate this yourself, but it
+    is used under the hood for e.g. :attr:`Form.fields`.
+    """
+
+    def insert_before(self, field, newfield):
+        """
+        Insert a new field, before an existing field.
+
+        :param field: String name for the existing field.
+
+        :param newfield: String name for the new field, to be inserted
+           just before the existing ``field``.
+        """
+        if field in self:
+            i = self.index(field)
+            self.insert(i, newfield)
+        else:
+            log.warning("field '%s' not found, will append new field: %s",
+                        field, newfield)
+            self.append(newfield)
+
+    def insert_after(self, field, newfield):
+        """
+        Insert a new field, after an existing field.
+
+        :param field: String name for the existing field.
+
+        :param newfield: String name for the new field, to be inserted
+           just after the existing ``field``.
+        """
+        if field in self:
+            i = self.index(field)
+            self.insert(i + 1, newfield)
+        else:
+            log.warning("field '%s' not found, will append new field: %s",
+                        field, newfield)
+            self.append(newfield)
+
+
+class Form:
+    """
+    Base class for all forms.
+
+    :param request: Reference to current :term:`request` object.
+
+    :param fields: List of field names for the form.  This is
+       optional; if not specified an attempt will be made to deduce
+       the list automatically.  See also :attr:`fields`.
+
+    :param schema: Colander-based schema object for the form.  This is
+       optional; if not specified an attempt will be made to construct
+       one automatically.  See also :meth:`get_schema()`.
+
+    :param labels: Optional dict of default field labels.
+
+    .. note::
+
+       Some parameters are not explicitly described above.  However
+       their corresponding attributes are described below.
+
+    Form instances contain the following attributes:
+
+    .. attribute:: fields
+
+       :class:`FieldList` instance containing string field names for
+       the form.  By default, fields will appear in the same order as
+       they are in this list.
+
+    .. attribute:: request
+
+       Reference to current :term:`request` object.
+
+    .. attribute:: action_url
+
+       String URL to which the form should be submitted, if applicable.
+
+    .. attribute:: vue_tagname
+
+       String name for Vue component tag.  By default this is
+       ``'wutta-form'``.  See also :meth:`render_vue_tag()`.
+
+    .. attribute:: align_buttons_right
+
+       Flag indicating whether the buttons (submit, cancel etc.)
+       should be aligned to the right of the area below the form.  If
+       not set, the buttons are left-aligned.
+
+    .. attribute:: auto_disable_submit
+
+       Flag indicating whether the submit button should be
+       auto-disabled, whenever the form is submitted.
+
+    .. attribute:: button_label_submit
+
+       String label for the form submit button.  Default is ``"Save"``.
+
+    .. attribute:: button_icon_submit
+
+       String icon name for the form submit button.  Default is ``'save'``.
+
+    .. attribute:: show_button_reset
+
+       Flag indicating whether a Reset button should be shown.
+
+    .. attribute:: validated
+
+       If the :meth:`validate()` method was called, and it succeeded,
+       this will be set to the validated data dict.
+
+       Note that in all other cases, this attribute may not exist.
+    """
+
+    def __init__(
+            self,
+            request,
+            fields=None,
+            schema=None,
+            labels={},
+            action_url=None,
+            vue_tagname='wutta-form',
+            align_buttons_right=False,
+            auto_disable_submit=True,
+            button_label_submit="Save",
+            button_icon_submit='save',
+            show_button_reset=False,
+    ):
+        self.request = request
+        self.schema = schema
+        self.labels = labels or {}
+        self.action_url = action_url
+        self.vue_tagname = vue_tagname
+        self.align_buttons_right = align_buttons_right
+        self.auto_disable_submit = auto_disable_submit
+        self.button_label_submit = button_label_submit
+        self.button_icon_submit = button_icon_submit
+        self.show_button_reset = show_button_reset
+
+        self.config = self.request.wutta_config
+        self.app = self.config.get_app()
+
+        if fields is not None:
+            self.set_fields(fields)
+        elif self.schema:
+            self.set_fields([f.name for f in self.schema])
+        else:
+            self.fields = None
+
+    def __contains__(self, name):
+        """
+        Custom logic for the ``in`` operator, to allow easily checking
+        if the form contains a given field::
+
+           myform = Form()
+           if 'somefield' in myform:
+               print("my form has some field")
+        """
+        return bool(self.fields and name in self.fields)
+
+    def __iter__(self):
+        """
+        Custom logic to allow iterating over form field names::
+
+           myform = Form(fields=['foo', 'bar'])
+           for fieldname in myform:
+               print(fieldname)
+        """
+        return iter(self.fields)
+
+    @property
+    def vue_component(self):
+        """
+        String name for the Vue component, e.g. ``'WuttaForm'``.
+
+        This is a generated value based on :attr:`vue_tagname`.
+        """
+        words = self.vue_tagname.split('-')
+        return ''.join([word.capitalize() for word in words])
+
+    def set_fields(self, fields):
+        """
+        Explicitly set the list of form fields.
+
+        This will overwrite :attr:`fields` with a new
+        :class:`FieldList` instance.
+
+        :param fields: List of string field names.
+        """
+        self.fields = FieldList(fields)
+
+    def set_label(self, key, label):
+        """
+        Set the label for given field name.
+
+        See also :meth:`get_label()`.
+        """
+        self.labels[key] = label
+
+        # update schema if necessary
+        if self.schema and key in self.schema:
+            self.schema[key].title = label
+
+    def get_label(self, key):
+        """
+        Get the label for given field name.
+
+        Note that this will always return a string, auto-generating
+        the label if needed.
+
+        See also :meth:`set_label()`.
+        """
+        return self.labels.get(key, self.app.make_title(key))
+
+    def get_schema(self):
+        """
+        Return the :class:`colander:colander.Schema` object for the
+        form, generating it automatically if necessary.
+        """
+        if not self.schema:
+            raise NotImplementedError
+
+        return self.schema
+
+    def get_deform(self):
+        """
+        Return the :class:`deform:deform.Form` instance for the form,
+        generating it automatically if necessary.
+        """
+        if not hasattr(self, 'deform_form'):
+            schema = self.get_schema()
+            form = deform.Form(schema)
+            self.deform_form = form
+
+        return self.deform_form
+
+    def render_vue_tag(self, **kwargs):
+        """
+        Render the Vue component tag for the form.
+
+        By default this simply returns:
+
+        .. code-block:: html
+
+           <wutta-form></wutta-form>
+
+        The actual output will depend on various form attributes, in
+        particular :attr:`vue_tagname`.
+        """
+        return HTML.tag(self.vue_tagname, **kwargs)
+
+    def render_vue_template(
+            self,
+            template='/forms/vue_template.mako',
+            **context):
+        """
+        Render the Vue template block for the form.
+
+        This returns something like:
+
+        .. code-block:: none
+
+           <script type="text/x-template" id="wutta-form-template">
+             <form>
+               <!-- fields etc. -->
+             </form>
+           </script>
+
+        .. todo::
+
+           Why can't Sphinx render the above code block as 'html' ?
+
+           It acts like it can't handle a ``<script>`` tag at all?
+
+        Actual output will of course depend on form attributes, i.e.
+        :attr:`vue_tagname` and :attr:`fields` list etc.
+
+        :param template: Path to Mako template which is used to render
+           the output.
+        """
+        context['form'] = self
+        context.setdefault('form_attrs', {})
+
+        # auto disable button on submit
+        if self.auto_disable_submit:
+            context['form_attrs']['@submit'] = 'formSubmitting = true'
+
+        output = render(template, context)
+        return HTML.literal(output)
+
+    def render_vue_field(self, fieldname):
+        """
+        Render the given field completely, i.e. ``<b-field>`` wrapper
+        with label and containing a widget.
+
+        Actual output will depend on the field attributes etc.
+        """
+        dform = self.get_deform()
+        field = dform[fieldname]
+
+        # render the field widget or whatever
+        html = field.serialize()
+        html = HTML.literal(html)
+
+        # render field label
+        label = self.get_label(fieldname)
+
+        # b-field attrs
+        attrs = {
+            ':horizontal': 'true',
+            'label': label,
+        }
+
+        return HTML.tag('b-field', c=[html], **attrs)
+
+    def get_vue_field_value(self, field):
+        """
+        This method returns a JSON string which will be assigned as
+        the initial model value for the given field.  This JSON will
+        be written as part of the overall response, to be interpreted
+        on the client side.
+
+        Again, this must return a *string* such as:
+
+        * ``'null'``
+        * ``'{"foo": "bar"}'``
+
+        In practice this calls :meth:`jsonify_value()` to convert the
+        ``field.cstruct`` value to string.
+        """
+        if isinstance(field, str):
+            dform = self.get_deform()
+            field = dform[field]
+
+        return self.jsonify_value(field.cstruct)
+
+    def jsonify_value(self, value):
+        """
+        Convert a Python value to JSON string.
+
+        See also :meth:`get_vue_field_value()`.
+        """
+        if value is colander.null:
+            return 'null'
+
+        return json.dumps(value)
+
+    def validate(self):
+        """
+        Try to validate the form.
+
+        This should work whether request data was submitted as classic
+        POST data, or as JSON body.
+
+        If the form data is valid, this method returns the data dict.
+        This data dict is also then available on the form object via
+        the :attr:`validated` attribute.
+
+        However if the data is not valid, ``False`` is returned, and
+        there will be no :attr:`validated` attribute.
+
+        :returns: Data dict, or ``False``.
+        """
+        if hasattr(self, 'validated'):
+            del self.validated
+
+        if self.request.method != 'POST':
+            return False
+
+        dform = self.get_deform()
+        controls = get_form_data(self.request).items()
+
+        try:
+            self.validated = dform.validate(controls)
+        except deform.ValidationFailure:
+            return False
+
+        return self.validated
diff --git a/src/wuttaweb/templates/deform/password.pt b/src/wuttaweb/templates/deform/password.pt
new file mode 100644
index 0000000..7a56879
--- /dev/null
+++ b/src/wuttaweb/templates/deform/password.pt
@@ -0,0 +1,8 @@
+<div tal:omit-tag=""
+     tal:define="name name|field.name;
+                 vmodel vmodel|'model_'+name;">
+  <b-input name="${name}"
+           v-model="${vmodel}"
+           type="password"
+           tal:attributes="attributes|field.widget.attributes|{};" />
+</div>
diff --git a/src/wuttaweb/templates/deform/textinput.pt b/src/wuttaweb/templates/deform/textinput.pt
new file mode 100644
index 0000000..4f09fc6
--- /dev/null
+++ b/src/wuttaweb/templates/deform/textinput.pt
@@ -0,0 +1,7 @@
+<div tal:omit-tag=""
+     tal:define="name name|field.name;
+                 vmodel vmodel|'model_'+name;">
+  <b-input name="${name}"
+           v-model="${vmodel}"
+           tal:attributes="attributes|field.widget.attributes|{};" />
+</div>
diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako
new file mode 100644
index 0000000..efa320f
--- /dev/null
+++ b/src/wuttaweb/templates/form.mako
@@ -0,0 +1,18 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="render_this_page_template()">
+  ${parent.render_this_page_template()}
+  ${form.render_vue_template()}
+</%def>
+
+<%def name="finalize_this_page_vars()">
+  ${parent.finalize_this_page_vars()}
+  <script>
+    ${form.vue_component}.data = function() { return ${form.vue_component}Data }
+    Vue.component('${form.vue_tagname}', ${form.vue_component})
+  </script>
+</%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako
new file mode 100644
index 0000000..0151632
--- /dev/null
+++ b/src/wuttaweb/templates/forms/vue_template.mako
@@ -0,0 +1,58 @@
+## -*- coding: utf-8; -*-
+
+<script type="text/x-template" id="${form.vue_tagname}-template">
+  ${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)}
+
+    <section>
+      % for fieldname in form:
+          ${form.render_vue_field(fieldname)}
+      % endfor
+    </section>
+
+    <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: end; width: 100%;">
+
+      % if form.show_button_reset:
+          <b-button native-type="reset">
+            Reset
+          </b-button>
+      % endif
+
+      <b-button type="is-primary"
+                native-type="submit"
+                % if form.auto_disable_submit:
+                    :disabled="formSubmitting"
+                % endif
+                icon-pack="fas"
+                icon-left="${form.button_icon_submit}">
+        % if form.auto_disable_submit:
+            {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
+        % else:
+            ${form.button_label_submit}
+        % endif
+      </b-button>
+
+    </div>
+
+  ${h.end_form()}
+</script>
+
+<script>
+
+  let ${form.vue_component} = {
+      template: '#${form.vue_tagname}-template',
+      methods: {},
+  }
+
+  let ${form.vue_component}Data = {
+
+      ## field model values
+      % for key in form:
+          model_${key}: ${form.get_vue_field_value(key)|n},
+      % endfor
+
+      % if form.auto_disable_submit:
+          formSubmitting: false,
+      % endif
+  }
+
+</script>
diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py
index 3906c0b..e7bfea3 100644
--- a/src/wuttaweb/views/base.py
+++ b/src/wuttaweb/views/base.py
@@ -24,6 +24,10 @@
 Base Logic for Views
 """
 
+from pyramid import httpexceptions
+
+from wuttaweb import forms
+
 
 class View:
     """
@@ -35,8 +39,7 @@ class View:
 
     .. attribute:: request
 
-       Reference to the current
-       :class:`pyramid:pyramid.request.Request` object.
+       Reference to the current :term:`request` object.
 
     .. attribute:: app
 
@@ -51,3 +54,30 @@ class View:
         self.request = request
         self.config = self.request.wutta_config
         self.app = self.config.get_app()
+
+    def make_form(self, **kwargs):
+        """
+        Make and return a new :class:`~wuttaweb.forms.base.Form`
+        instance, per the given ``kwargs``.
+
+        This is the "default" form factory which merely invokes
+        the constructor.
+        """
+        return forms.Form(self.request, **kwargs)
+
+    def redirect(self, url, **kwargs):
+        """
+        Convenience method to return a HTTP 302 response.
+
+        Note that this technically returns an "exception" - so in
+        your code, you can either return that error, or raise it::
+
+           return self.redirect('/')
+           # ..or
+           raise self.redirect('/')
+
+        Which you should do will depend on context, but raising the
+        error is always "safe" since Pyramid will handle that
+        correctly no matter what.
+        """
+        return httpexceptions.HTTPFound(location=url, **kwargs)
diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py
new file mode 100644
index 0000000..96a0805
--- /dev/null
+++ b/tests/forms/test_base.py
@@ -0,0 +1,241 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+
+import colander
+import deform
+from pyramid import testing
+
+from wuttjamaican.conf import WuttaConfig
+from wuttaweb.forms import base
+from wuttaweb import helpers
+
+
+class TestFieldList(TestCase):
+
+    def test_insert_before(self):
+        fields = base.FieldList(['f1', 'f2'])
+        self.assertEqual(fields, ['f1', 'f2'])
+
+        # typical
+        fields.insert_before('f1', 'XXX')
+        self.assertEqual(fields, ['XXX', 'f1', 'f2'])
+        fields.insert_before('f2', 'YYY')
+        self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2'])
+
+        # appends new field if reference field is invalid
+        fields.insert_before('f3', 'ZZZ')
+        self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ'])
+
+    def test_insert_after(self):
+        fields = base.FieldList(['f1', 'f2'])
+        self.assertEqual(fields, ['f1', 'f2'])
+
+        # typical
+        fields.insert_after('f1', 'XXX')
+        self.assertEqual(fields, ['f1', 'XXX', 'f2'])
+        fields.insert_after('XXX', 'YYY')
+        self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2'])
+
+        # appends new field if reference field is invalid
+        fields.insert_after('f3', 'ZZZ')
+        self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ'])
+
+
+class TestForm(TestCase):
+
+    def setUp(self):
+        self.config = WuttaConfig()
+        self.request = testing.DummyRequest(wutta_config=self.config)
+
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'mako.directories': ['wuttaweb:templates'],
+            'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
+        })
+
+    def tearDown(self):
+        testing.tearDown()
+
+    def make_form(self, request=None, **kwargs):
+        return base.Form(request or self.request, **kwargs)
+
+    def make_schema(self):
+        schema = colander.Schema(children=[
+            colander.SchemaNode(colander.String(),
+                                name='foo'),
+            colander.SchemaNode(colander.String(),
+                                name='bar'),
+        ])
+        return schema
+
+    def test_init_with_none(self):
+        form = self.make_form()
+        self.assertIsNone(form.fields)
+
+    def test_init_with_fields(self):
+        form = self.make_form(fields=['foo', 'bar'])
+        self.assertEqual(form.fields, ['foo', 'bar'])
+
+    def test_init_with_schema(self):
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+        self.assertEqual(form.fields, ['foo', 'bar'])
+
+    def test_vue_tagname(self):
+        form = self.make_form()
+        self.assertEqual(form.vue_tagname, 'wutta-form')
+
+    def test_vue_component(self):
+        form = self.make_form()
+        self.assertEqual(form.vue_component, 'WuttaForm')
+
+    def test_contains(self):
+        form = self.make_form(fields=['foo', 'bar'])
+        self.assertIn('foo', form)
+        self.assertNotIn('baz', form)
+
+    def test_iter(self):
+        form = self.make_form(fields=['foo', 'bar'])
+
+        fields = list(iter(form))
+        self.assertEqual(fields, ['foo', 'bar'])
+
+        fields = []
+        for field in form:
+            fields.append(field)
+        self.assertEqual(fields, ['foo', 'bar'])
+
+    def test_set_fields(self):
+        form = self.make_form(fields=['foo', 'bar'])
+        self.assertEqual(form.fields, ['foo', 'bar'])
+        form.set_fields(['baz'])
+        self.assertEqual(form.fields, ['baz'])
+
+    def test_get_schema(self):
+        form = self.make_form()
+        self.assertIsNone(form.schema)
+
+        # provided schema is returned
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+        self.assertIs(form.schema, schema)
+        self.assertIs(form.get_schema(), schema)
+
+        # auto-generating schema not yet supported
+        form = self.make_form(fields=['foo', 'bar'])
+        self.assertIsNone(form.schema)
+        self.assertRaises(NotImplementedError, form.get_schema)
+
+    def test_get_deform(self):
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+        self.assertFalse(hasattr(form, 'deform_form'))
+        dform = form.get_deform()
+        self.assertIsInstance(dform, deform.Form)
+        self.assertIs(form.deform_form, dform)
+
+    def test_get_label(self):
+        form = self.make_form(fields=['foo', 'bar'])
+        self.assertEqual(form.get_label('foo'), "Foo")
+        form.set_label('foo', "Baz")
+        self.assertEqual(form.get_label('foo'), "Baz")
+
+    def test_set_label(self):
+        form = self.make_form(fields=['foo', 'bar'])
+        self.assertEqual(form.get_label('foo'), "Foo")
+        form.set_label('foo', "Baz")
+        self.assertEqual(form.get_label('foo'), "Baz")
+
+        # schema should be updated when setting label
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+        form.set_label('foo', "Woohoo")
+        self.assertEqual(form.get_label('foo'), "Woohoo")
+        self.assertEqual(schema['foo'].title, "Woohoo")
+
+    def test_render_vue_tag(self):
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+        html = form.render_vue_tag()
+        self.assertEqual(html, '<wutta-form></wutta-form>')
+
+    def test_render_vue_template(self):
+        self.pyramid_config.include('pyramid_mako')
+        self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
+                                           'pyramid.events.BeforeRender')
+
+        # form button is disabled on @submit by default
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+        html = form.render_vue_template()
+        self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
+        self.assertIn('@submit', html)
+
+        # but not if form is configured otherwise
+        form = self.make_form(schema=schema, auto_disable_submit=False)
+        html = form.render_vue_template()
+        self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
+        self.assertNotIn('@submit', html)
+
+    def test_render_vue_field(self):
+        self.pyramid_config.include('pyramid_deform')
+
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+        html = form.render_vue_field('foo')
+        self.assertIn('<b-field :horizontal="true" label="Foo">', html)
+        self.assertIn('<b-input name="foo"', html)
+
+    def test_get_vue_field_value(self):
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+
+        # null field value
+        value = form.get_vue_field_value('foo')
+        self.assertEqual(value, 'null')
+
+        # non-default / explicit value
+        # TODO: surely need a different approach to set value
+        dform = form.get_deform()
+        dform['foo'].cstruct = 'blarg'
+        value = form.get_vue_field_value('foo')
+        self.assertEqual(value, '"blarg"')
+
+    def test_jsonify_value(self):
+        form = self.make_form()
+
+        # null field value
+        value = form.jsonify_value(colander.null)
+        self.assertEqual(value, 'null')
+        value = form.jsonify_value(None)
+        self.assertEqual(value, 'null')
+
+        # string value
+        value = form.jsonify_value('blarg')
+        self.assertEqual(value, '"blarg"')
+
+    def test_validate(self):
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+        self.assertFalse(hasattr(form, 'validated'))
+
+        # will not validate unless request is POST
+        self.request.POST = {'foo': 'blarg', 'bar': 'baz'}
+        self.request.method = 'GET'
+        self.assertFalse(form.validate())
+        self.request.method = 'POST'
+        data = form.validate()
+        self.assertEqual(data, {'foo': 'blarg', 'bar': 'baz'})
+
+        # validating a second type updates form.validated
+        self.request.POST = {'foo': 'BLARG', 'bar': 'BAZ'}
+        data = form.validate()
+        self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'})
+        self.assertIs(form.validated, data)
+
+        # bad data does not validate
+        self.request.POST = {'foo': 42, 'bar': None}
+        self.assertFalse(form.validate())
+        dform = form.get_deform()
+        self.assertEqual(len(dform.error.children), 2)
+        self.assertEqual(dform['foo'].errormsg, "Pstruct is not a string")
diff --git a/tests/views/test_base.py b/tests/views/test_base.py
index ef78251..52c717a 100644
--- a/tests/views/test_base.py
+++ b/tests/views/test_base.py
@@ -3,19 +3,31 @@
 from unittest import TestCase
 
 from pyramid import testing
+from pyramid.httpexceptions import HTTPFound
 
 from wuttjamaican.conf import WuttaConfig
 from wuttaweb.views import base
+from wuttaweb.forms import Form
 
 
 class TestView(TestCase):
 
-    def test_basic(self):
-        config = WuttaConfig()
-        request = testing.DummyRequest()
-        request.wutta_config = config
+    def setUp(self):
+        self.config = WuttaConfig()
+        self.app = self.config.get_app()
+        self.request = testing.DummyRequest(wutta_config=self.config)
+        self.view = base.View(self.request)
 
-        view = base.View(request)
-        self.assertIs(view.request, request)
-        self.assertIs(view.config, config)
-        self.assertIs(view.app, config.get_app())
+    def test_basic(self):
+        self.assertIs(self.view.request, self.request)
+        self.assertIs(self.view.config, self.config)
+        self.assertIs(self.view.app, self.app)
+
+    def test_make_form(self):
+        form = self.view.make_form()
+        self.assertIsInstance(form, Form)
+
+    def test_redirect(self):
+        error = self.view.redirect('/')
+        self.assertIsInstance(error, HTTPFound)
+        self.assertEqual(error.location, '/')

From c6f0007908239f739f279f9945fd7b0bff725b16 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 4 Aug 2024 20:39:42 -0500
Subject: [PATCH 04/11] feat: add `wuttaweb.views.essential` module

---
 docs/api/wuttaweb/index.rst           |  1 +
 docs/api/wuttaweb/views.essential.rst |  6 ++++
 src/wuttaweb/views/__init__.py        |  2 +-
 src/wuttaweb/views/essential.py       | 43 +++++++++++++++++++++++++++
 4 files changed, 51 insertions(+), 1 deletion(-)
 create mode 100644 docs/api/wuttaweb/views.essential.rst
 create mode 100644 src/wuttaweb/views/essential.py

diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 6b305cf..5a65f11 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -20,3 +20,4 @@
    views
    views.base
    views.common
+   views.essential
diff --git a/docs/api/wuttaweb/views.essential.rst b/docs/api/wuttaweb/views.essential.rst
new file mode 100644
index 0000000..79c0b57
--- /dev/null
+++ b/docs/api/wuttaweb/views.essential.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.views.essential``
+============================
+
+.. automodule:: wuttaweb.views.essential
+   :members:
diff --git a/src/wuttaweb/views/__init__.py b/src/wuttaweb/views/__init__.py
index 0b62a83..68fdd77 100644
--- a/src/wuttaweb/views/__init__.py
+++ b/src/wuttaweb/views/__init__.py
@@ -33,4 +33,4 @@ from .base import View
 
 
 def includeme(config):
-    config.include('wuttaweb.views.common')
+    config.include('wuttaweb.views.essential')
diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py
new file mode 100644
index 0000000..a9272f4
--- /dev/null
+++ b/src/wuttaweb/views/essential.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  wuttaweb -- Web App for Wutta Framework
+#  Copyright © 2024 Lance Edgar
+#
+#  This file is part of Wutta Framework.
+#
+#  Wutta Framework 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.
+#
+#  Wutta Framework 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
+#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Essential views for convenient includes
+
+Most apps should include this module::
+
+   pyramid_config.include('wuttaweb.views.essential')
+
+That will in turn include the following modules:
+
+* :mod:`wuttaweb.views.common`
+"""
+
+
+def defaults(config, **kwargs):
+    mod = lambda spec: kwargs.get(spec, spec)
+
+    config.include(mod('wuttaweb.views.common'))
+
+
+def includeme(config):
+    defaults(config)

From e296b50aa461d7ec39ad73a1287b9a80c1f1e814 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 4 Aug 2024 21:54:46 -0500
Subject: [PATCH 05/11] feat: add custom security policy, login/logout for
 pyramid

aka. the `wuttaweb.auth` module
---
 docs/api/wuttaweb/auth.rst  |   6 ++
 docs/api/wuttaweb/index.rst |   1 +
 src/wuttaweb/app.py         |   4 +
 src/wuttaweb/auth.py        | 146 ++++++++++++++++++++++++++++++++++++
 tests/test_auth.py          | 139 ++++++++++++++++++++++++++++++++++
 5 files changed, 296 insertions(+)
 create mode 100644 docs/api/wuttaweb/auth.rst
 create mode 100644 src/wuttaweb/auth.py
 create mode 100644 tests/test_auth.py

diff --git a/docs/api/wuttaweb/auth.rst b/docs/api/wuttaweb/auth.rst
new file mode 100644
index 0000000..d645c67
--- /dev/null
+++ b/docs/api/wuttaweb/auth.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.auth``
+=================
+
+.. automodule:: wuttaweb.auth
+   :members:
diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 5a65f11..8ee6ff4 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -8,6 +8,7 @@
    :maxdepth: 1
 
    app
+   auth
    db
    forms
    forms.base
diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py
index a35d00d..f8bfc3a 100644
--- a/src/wuttaweb/app.py
+++ b/src/wuttaweb/app.py
@@ -32,6 +32,7 @@ from wuttjamaican.conf import make_config
 from pyramid.config import Configurator
 
 import wuttaweb.db
+from wuttaweb.auth import WuttaSecurityPolicy
 
 
 class WebAppProvider(AppProvider):
@@ -115,6 +116,9 @@ def make_pyramid_config(settings):
 
     pyramid_config = Configurator(settings=settings)
 
+    # configure user authorization / authentication
+    pyramid_config.set_security_policy(WuttaSecurityPolicy())
+
     pyramid_config.include('pyramid_beaker')
     pyramid_config.include('pyramid_deform')
     pyramid_config.include('pyramid_mako')
diff --git a/src/wuttaweb/auth.py b/src/wuttaweb/auth.py
new file mode 100644
index 0000000..0c2f26d
--- /dev/null
+++ b/src/wuttaweb/auth.py
@@ -0,0 +1,146 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  wuttaweb -- Web App for Wutta Framework
+#  Copyright © 2024 Lance Edgar
+#
+#  This file is part of Wutta Framework.
+#
+#  Wutta Framework 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.
+#
+#  Wutta Framework 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
+#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Auth Utility Logic
+"""
+
+import re
+
+from pyramid.authentication import SessionAuthenticationHelper
+from pyramid.request import RequestLocalCache
+from pyramid.security import remember, forget
+
+from wuttaweb.db import Session
+
+
+def login_user(request, user):
+    """
+    Perform the steps necessary to "login" the given user.  This
+    returns a ``headers`` dict which you should pass to the final
+    redirect, like so::
+
+       from pyramid.httpexceptions import HTTPFound
+
+       headers = login_user(request, user)
+       return HTTPFound(location='/', headers=headers)
+
+    .. warning::
+
+       This logic does not "authenticate" the user!  It assumes caller
+       has already authenticated the user and they are safe to login.
+
+    See also :func:`logout_user()`.
+    """
+    headers = remember(request, user.uuid)
+    return headers
+
+
+def logout_user(request):
+    """
+    Perform the logout action for the given request.  This returns a
+    ``headers`` dict which you should pass to the final redirect, like
+    so::
+
+       from pyramid.httpexceptions import HTTPFound
+
+       headers = logout_user(request)
+       return HTTPFound(location='/', headers=headers)
+
+    See also :func:`login_user()`.
+    """
+    request.session.delete()
+    request.session.invalidate()
+    headers = forget(request)
+    return headers
+
+
+class WuttaSecurityPolicy:
+    """
+    Pyramid :term:`security policy` for WuttaWeb.
+
+    For more on the Pyramid details, see :doc:`pyramid:narr/security`.
+
+    But the idea here is that you should be able to just use this,
+    without thinking too hard::
+
+       from pyramid.config import Configurator
+       from wuttaweb.auth import WuttaSecurityPolicy
+
+       pyramid_config = Configurator()
+       pyramid_config.set_security_policy(WuttaSecurityPolicy())
+
+    This security policy will then do the following:
+
+    * use the request "web session" for auth storage (e.g. current
+      ``user.uuid``)
+    * check permissions as needed, by calling
+      :meth:`~wuttjamaican:wuttjamaican.auth.AuthHandler.has_permission()`
+      for current user
+
+    :param db_session: Optional :term:`db session` to use, instead of
+       :class:`wuttaweb.db.Session`.  Probably only useful for tests.
+    """
+
+    def __init__(self, db_session=None):
+        self.session_helper = SessionAuthenticationHelper()
+        self.identity_cache = RequestLocalCache(self.load_identity)
+        self.db_session = db_session or Session()
+
+    def load_identity(self, request):
+        config = request.registry.settings['wutta_config']
+        app = config.get_app()
+        model = app.model
+
+        # fetch user uuid from current session
+        uuid = self.session_helper.authenticated_userid(request)
+        if not uuid:
+            return
+
+        # fetch user object from db
+        user = self.db_session.get(model.User, uuid)
+        if not user:
+            return
+
+        return user
+
+    def identity(self, request):
+        return self.identity_cache.get_or_create(request)
+
+    def authenticated_userid(self, request):
+        user = self.identity(request)
+        if user is not None:
+            return user.uuid
+
+    def remember(self, request, userid, **kw):
+        return self.session_helper.remember(request, userid, **kw)
+
+    def forget(self, request, **kw):
+        return self.session_helper.forget(request, **kw)
+
+    def permits(self, request, context, permission):
+        config = request.registry.settings['wutta_config']
+        app = config.get_app()
+        auth = app.get_auth_handler()
+
+        user = self.identity(request)
+        return auth.has_permission(self.db_session, user, permission)
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000..a6bea29
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,139 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from pyramid import testing
+
+from wuttjamaican.conf import WuttaConfig
+from wuttaweb import auth as mod
+
+
+class TestLoginUser(TestCase):
+
+    def test_basic(self):
+        config = WuttaConfig()
+        app = config.get_app()
+        model = app.model
+        request = testing.DummyRequest(wutta_config=config)
+        user = model.User(username='barney')
+        headers = mod.login_user(request, user)
+        self.assertEqual(headers, [])
+
+class TestLogoutUser(TestCase):
+
+    def test_basic(self):
+        config = WuttaConfig()
+        request = testing.DummyRequest(wutta_config=config)
+        request.session.delete = MagicMock()
+        headers = mod.logout_user(request)
+        request.session.delete.assert_called_once_with()
+        self.assertEqual(headers, [])
+
+
+class TestWuttaSecurityPolicy(TestCase):
+
+    def setUp(self):
+        self.config = WuttaConfig(defaults={
+            'wutta.db.default.url': 'sqlite://',
+        })
+
+        self.request = testing.DummyRequest()
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+        })
+
+        self.app = self.config.get_app()
+        model = self.app.model
+        model.Base.metadata.create_all(bind=self.config.appdb_engine)
+        self.session = self.app.make_session()
+        self.user = model.User(username='barney')
+        self.session.add(self.user)
+        self.session.commit()
+
+        self.policy = self.make_policy()
+
+    def tearDown(self):
+        testing.tearDown()
+
+    def make_policy(self):
+        return mod.WuttaSecurityPolicy(db_session=self.session)
+
+    def test_remember(self):
+        uuid = self.user.uuid
+        self.assertIsNotNone(uuid)
+        self.assertIsNone(self.policy.session_helper.authenticated_userid(self.request))
+        self.policy.remember(self.request, uuid)
+        self.assertEqual(self.policy.session_helper.authenticated_userid(self.request), uuid)
+
+    def test_forget(self):
+        uuid = self.user.uuid
+        self.policy.remember(self.request, uuid)
+        self.assertEqual(self.policy.session_helper.authenticated_userid(self.request), uuid)
+        self.policy.forget(self.request)
+        self.assertIsNone(self.policy.session_helper.authenticated_userid(self.request))
+
+    def test_identity(self):
+
+        # no identity
+        user = self.policy.identity(self.request)
+        self.assertIsNone(user)
+
+        # identity is remembered (must use new policy to bust cache)
+        self.policy = self.make_policy()
+        uuid = self.user.uuid
+        self.assertIsNotNone(uuid)
+        self.policy.remember(self.request, uuid)
+        user = self.policy.identity(self.request)
+        self.assertIs(user, self.user)
+
+        # invalid identity yields no user
+        self.policy = self.make_policy()
+        self.policy.remember(self.request, 'bogus-user-uuid')
+        user = self.policy.identity(self.request)
+        self.assertIsNone(user)
+
+    def test_authenticated_userid(self):
+
+        # no identity
+        uuid = self.policy.authenticated_userid(self.request)
+        self.assertIsNone(uuid)
+
+        # identity is remembered (must use new policy to bust cache)
+        self.policy = self.make_policy()
+        self.policy.remember(self.request, self.user.uuid)
+        uuid = self.policy.authenticated_userid(self.request)
+        self.assertEqual(uuid, self.user.uuid)
+
+    def test_permits(self):
+        auth = self.app.get_auth_handler()
+        model = self.app.model
+
+        # anon has no perms
+        self.assertFalse(self.policy.permits(self.request, None, 'foo.bar'))
+
+        # but we can grant it
+        anons = auth.get_role_anonymous(self.session)
+        self.user.roles.append(anons)
+        auth.grant_permission(anons, 'foo.bar')
+        self.session.commit()
+
+        # and then perm check is satisfied
+        self.assertTrue(self.policy.permits(self.request, None, 'foo.bar'))
+
+        # now, create a separate role and grant another perm
+        # (but user does not yet belong to this role)
+        role = model.Role(name='whatever')
+        self.session.add(role)
+        auth.grant_permission(role, 'baz.edit')
+        self.session.commit()
+
+        # so far then, user does not have the permission
+        self.policy = self.make_policy()
+        self.policy.remember(self.request, self.user.uuid)
+        self.assertFalse(self.policy.permits(self.request, None, 'baz.edit'))
+
+        # but if we assign user to role, perm check should pass
+        self.user.roles.append(role)
+        self.session.commit()
+        self.assertTrue(self.policy.permits(self.request, None, 'baz.edit'))

From a505ef27fb908459242c56e232958a976dee4b65 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 4 Aug 2024 23:09:29 -0500
Subject: [PATCH 06/11] feat: add auth views, for login/logout

---
 docs/api/wuttaweb/index.rst       |   1 +
 docs/api/wuttaweb/views.auth.rst  |   6 ++
 src/wuttaweb/subscribers.py       |  44 +++++++-
 src/wuttaweb/templates/base.mako  |  13 ++-
 src/wuttaweb/templates/login.mako |  46 ++++++++
 src/wuttaweb/views/auth.py        | 168 ++++++++++++++++++++++++++++++
 src/wuttaweb/views/essential.py   |   2 +
 tests/test_subscribers.py         |  42 ++++++++
 tests/views/test_auth.py          |  72 +++++++++++++
 9 files changed, 390 insertions(+), 4 deletions(-)
 create mode 100644 docs/api/wuttaweb/views.auth.rst
 create mode 100644 src/wuttaweb/templates/login.mako
 create mode 100644 src/wuttaweb/views/auth.py
 create mode 100644 tests/views/test_auth.py

diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 8ee6ff4..204864e 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -19,6 +19,7 @@
    subscribers
    util
    views
+   views.auth
    views.base
    views.common
    views.essential
diff --git a/docs/api/wuttaweb/views.auth.rst b/docs/api/wuttaweb/views.auth.rst
new file mode 100644
index 0000000..9a03e3e
--- /dev/null
+++ b/docs/api/wuttaweb/views.auth.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.views.auth``
+=======================
+
+.. automodule:: wuttaweb.views.auth
+   :members:
diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py
index eea26d2..eebefb4 100644
--- a/src/wuttaweb/subscribers.py
+++ b/src/wuttaweb/subscribers.py
@@ -41,6 +41,7 @@ import logging
 from pyramid import threadlocal
 
 from wuttaweb import helpers
+from wuttaweb.db import Session
 
 
 log = logging.getLogger(__name__)
@@ -48,7 +49,7 @@ log = logging.getLogger(__name__)
 
 def new_request(event):
     """
-    Event hook called when processing a new request.
+    Event hook called when processing a new :term:`request`.
 
     The hook is auto-registered if this module is "included" by
     Pyramid config object.  Or you can explicitly register it::
@@ -56,7 +57,7 @@ def new_request(event):
        pyramid_config.add_subscriber('wuttaweb.subscribers.new_request',
                                      'pyramid.events.NewRequest')
 
-    This will add some things to the request object:
+    This will add to the request object:
 
     .. attribute:: request.wutta_config
 
@@ -66,7 +67,7 @@ def new_request(event):
 
        Flag indicating whether the frontend should be displayed using
        Vue 3 + Oruga (if ``True``), or else Vue 2 + Buefy (if
-       ``False``).
+       ``False``).  This flag is ``False`` by default.
     """
     request = event.request
     config = request.registry.settings['wutta_config']
@@ -84,6 +85,42 @@ def new_request(event):
     request.set_property(use_oruga, reify=True)
 
 
+def new_request_set_user(event, db_session=None):
+    """
+    Event hook called when processing a new :term:`request`, for sake
+    of setting the ``request.user`` property.
+
+    The hook is auto-registered if this module is "included" by
+    Pyramid config object.  Or you can explicitly register it::
+
+       pyramid_config.add_subscriber('wuttaweb.subscribers.new_request_set_user',
+                                     'pyramid.events.NewRequest')
+
+    This will add to the request object:
+
+    .. attribute:: request.user
+
+       Reference to the authenticated
+       :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` instance
+       (if logged in), or ``None``.
+
+    :param db_session: Optional :term:`db session` to use, instead of
+       :class:`wuttaweb.db.Session`.  Probably only useful for tests.
+    """
+    request = event.request
+    config = request.registry.settings['wutta_config']
+    app = config.get_app()
+    model = app.model
+
+    def user(request):
+        uuid = request.authenticated_userid
+        if uuid:
+            session = db_session or Session()
+            return session.get(model.User, uuid)
+
+    request.set_property(user, reify=True)
+
+
 def before_render(event):
     """
     Event hook called just before rendering a template.
@@ -151,4 +188,5 @@ def before_render(event):
 
 def includeme(config):
     config.add_subscriber(new_request, 'pyramid.events.NewRequest')
+    config.add_subscriber(new_request_set_user, 'pyramid.events.NewRequest')
     config.add_subscriber(before_render, 'pyramid.events.BeforeRender')
diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index 5511195..f6b4206 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -309,7 +309,18 @@
   </div>
 </%def>
 
-<%def name="render_user_menu()"></%def>
+<%def name="render_user_menu()">
+  % if request.user:
+      <div class="navbar-item has-dropdown is-hoverable">
+        <a class="navbar-link">${request.user}</a>
+        <div class="navbar-dropdown">
+          ${h.link_to("Logout", url('logout'), class_='navbar-item')}
+        </div>
+      </div>
+  % else:
+      ${h.link_to("Login", url('login'), class_='navbar-item')}
+  % endif
+</%def>
 
 <%def name="render_instance_header_title_extras()"></%def>
 
diff --git a/src/wuttaweb/templates/login.mako b/src/wuttaweb/templates/login.mako
new file mode 100644
index 0000000..b50a863
--- /dev/null
+++ b/src/wuttaweb/templates/login.mako
@@ -0,0 +1,46 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/form.mako" />
+
+<%def name="title()">Login</%def>
+
+<%def name="render_this_page()">
+  ${self.page_content()}
+</%def>
+
+<%def name="page_content()">
+  <div style="height: 100%; display: flex; align-items: center; justify-content: center;">
+    <div class="card">
+      <div class="card-content">
+        ${form.render_vue_tag()}
+      </div>
+    </div>
+  </div>
+</%def>
+
+<%def name="modify_this_page_vars()">
+  <script>
+
+    ${form.vue_component}Data.usernameInput = null
+
+    ${form.vue_component}.mounted = function() {
+        this.$refs.username.focus()
+        this.usernameInput = this.$refs.username.$el.querySelector('input')
+        this.usernameInput.addEventListener('keydown', this.usernameKeydown)
+    }
+
+    ${form.vue_component}.beforeDestroy = function() {
+        this.usernameInput.removeEventListener('keydown', this.usernameKeydown)
+    }
+
+    ${form.vue_component}.methods.usernameKeydown = function(event) {
+        if (event.which == 13) { // ENTER
+            event.preventDefault()
+            this.$refs.password.focus()
+        }
+    }
+
+  </script>
+</%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py
new file mode 100644
index 0000000..981afbd
--- /dev/null
+++ b/src/wuttaweb/views/auth.py
@@ -0,0 +1,168 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  wuttaweb -- Web App for Wutta Framework
+#  Copyright © 2024 Lance Edgar
+#
+#  This file is part of Wutta Framework.
+#
+#  Wutta Framework 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.
+#
+#  Wutta Framework 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
+#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Auth Views
+"""
+
+import colander
+from deform.widget import TextInputWidget, PasswordWidget
+
+from wuttaweb.views import View
+from wuttaweb.db import Session
+from wuttaweb.auth import login_user, logout_user
+
+
+class AuthView(View):
+    """
+    Auth views shared by all apps.
+    """
+
+    def login(self, session=None):
+        """
+        View for user login.
+
+        This view shows the login form, and handles its submission.
+        Upon successful login, user is redirected to home page.
+
+        * route: ``login``
+        * template: ``/login.mako``
+        """
+        auth = self.app.get_auth_handler()
+
+        # TODO: should call request.get_referrer()
+        referrer = self.request.route_url('home')
+
+        # redirect if already logged in
+        if self.request.user:
+            self.request.session.flash(f"{self.request.user} is already logged in", 'error')
+            return self.redirect(referrer)
+
+        form = self.make_form(schema=self.login_make_schema(),
+                              align_buttons_right=True,
+                              show_button_reset=True,
+                              button_label_submit="Login",
+                              button_icon_submit='user')
+
+        # TODO
+        # form.show_cancel = False
+
+        # validate basic form data (sanity check)
+        data = form.validate()
+        if data:
+
+            # truly validate user credentials
+            session = session or Session()
+            user = auth.authenticate_user(session, data['username'], data['password'])
+            if user:
+
+                # okay now they're truly logged in
+                headers = login_user(self.request, user)
+                return self.redirect(referrer, headers=headers)
+
+            else:
+                self.request.session.flash("Invalid user credentials", 'error')
+
+        return {
+            'index_title': self.app.get_title(),
+            'form': form,
+            # TODO
+            # 'referrer': referrer,
+        }
+
+    def login_make_schema(self):
+        schema = colander.Schema()
+
+        # nb. we must explicitly declare the widgets in order to also
+        # specify the ref attribute.  this is needed for autofocus and
+        # keydown behavior for login form.
+
+        schema.add(colander.SchemaNode(
+            colander.String(),
+            name='username',
+            widget=TextInputWidget(attributes={
+                'ref': 'username',
+            })))
+
+        schema.add(colander.SchemaNode(
+            colander.String(),
+            name='password',
+            widget=PasswordWidget(attributes={
+                'ref': 'password',
+            })))
+
+        return schema
+
+    def logout(self):
+        """
+        View for user logout.
+
+        This deletes/invalidates the current user session and then
+        redirects to the login page.
+
+        Note that a simple GET is sufficient; POST is not required.
+
+        * route: ``logout``
+        * template: n/a
+        """
+        # truly logout the user
+        headers = logout_user(self.request)
+
+        # TODO
+        # # redirect to home page after logout, if so configured
+        # if self.config.get_bool('wuttaweb.home_after_logout', default=False):
+        #     return self.redirect(self.request.route_url('home'), headers=headers)
+
+        # otherwise redirect to referrer, with 'login' page as fallback
+        # TODO: should call request.get_referrer()
+        # referrer = self.request.get_referrer(default=self.request.route_url('login'))
+        referrer = self.request.route_url('login')
+        return self.redirect(referrer, headers=headers)
+
+    @classmethod
+    def defaults(cls, config):
+        cls._auth_defaults(config)
+
+    @classmethod
+    def _auth_defaults(cls, config):
+
+        # login
+        config.add_route('login', '/login')
+        config.add_view(cls, attr='login',
+                        route_name='login',
+                        renderer='/login.mako')
+
+        # logout
+        config.add_route('logout', '/logout')
+        config.add_view(cls, attr='logout',
+                        route_name='logout')
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    AuthView = kwargs.get('AuthView', base['AuthView'])
+    AuthView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py
index a9272f4..93c8149 100644
--- a/src/wuttaweb/views/essential.py
+++ b/src/wuttaweb/views/essential.py
@@ -29,6 +29,7 @@ Most apps should include this module::
 
 That will in turn include the following modules:
 
+* :mod:`wuttaweb.views.auth`
 * :mod:`wuttaweb.views.common`
 """
 
@@ -36,6 +37,7 @@ That will in turn include the following modules:
 def defaults(config, **kwargs):
     mod = lambda spec: kwargs.get(spec, spec)
 
+    config.include(mod('wuttaweb.views.auth'))
     config.include(mod('wuttaweb.views.common'))
 
 
diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py
index 804eb1a..63b6640 100644
--- a/tests/test_subscribers.py
+++ b/tests/test_subscribers.py
@@ -7,9 +7,11 @@ from unittest.mock import MagicMock
 from wuttjamaican.conf import WuttaConfig
 
 from pyramid import testing
+from pyramid.security import remember
 
 from wuttaweb import subscribers
 from wuttaweb import helpers
+from wuttaweb.auth import WuttaSecurityPolicy
 
 
 class TestNewRequest(TestCase):
@@ -56,6 +58,46 @@ def custom_oruga_detector(request):
     return True
 
 
+class TestNewRequestSetUser(TestCase):
+
+    def setUp(self):
+        self.config = WuttaConfig(defaults={
+            'wutta.db.default.url': 'sqlite://',
+        })
+
+        self.request = testing.DummyRequest()
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+        })
+
+        self.app = self.config.get_app()
+        model = self.app.model
+        model.Base.metadata.create_all(bind=self.config.appdb_engine)
+        self.session = self.app.make_session()
+        self.user = model.User(username='barney')
+        self.session.add(self.user)
+        self.session.commit()
+
+        self.pyramid_config.set_security_policy(WuttaSecurityPolicy(db_session=self.session))
+
+    def tearDown(self):
+        testing.tearDown()
+
+    def test_anonymous(self):
+        self.assertFalse(hasattr(self.request, 'user'))
+        event = MagicMock(request=self.request)
+        subscribers.new_request_set_user(event)
+        self.assertIsNone(self.request.user)
+
+    def test_authenticated(self):
+        uuid = self.user.uuid
+        self.assertIsNotNone(uuid)
+        remember(self.request, uuid)
+        event = MagicMock(request=self.request)
+        subscribers.new_request_set_user(event, db_session=self.session)
+        self.assertIs(self.request.user, self.user)
+
+
 class TestBeforeRender(TestCase):
 
     def setUp(self):
diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py
new file mode 100644
index 0000000..495dac1
--- /dev/null
+++ b/tests/views/test_auth.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from pyramid import testing
+from pyramid.httpexceptions import HTTPFound
+
+from wuttjamaican.conf import WuttaConfig
+from wuttaweb.views import auth as mod
+from wuttaweb.auth import WuttaSecurityPolicy
+
+
+class TestAuthView(TestCase):
+
+    def setUp(self):
+        self.config = WuttaConfig(defaults={
+            'wutta.db.default.url': 'sqlite://',
+        })
+
+        self.request = testing.DummyRequest(wutta_config=self.config, user=None)
+        self.pyramid_config = testing.setUp(request=self.request)
+
+        self.app = self.config.get_app()
+        auth = self.app.get_auth_handler()
+        model = self.app.model
+        model.Base.metadata.create_all(bind=self.config.appdb_engine)
+        self.session = self.app.make_session()
+        self.user = model.User(username='barney')
+        self.session.add(self.user)
+        auth.set_user_password(self.user, 'testpass')
+        self.session.commit()
+
+        self.pyramid_config.set_security_policy(WuttaSecurityPolicy(db_session=self.session))
+        self.pyramid_config.include('wuttaweb.views.auth')
+        self.pyramid_config.include('wuttaweb.views.common')
+
+    def tearDown(self):
+        testing.tearDown()
+
+    def test_login(self):
+        view = mod.AuthView(self.request)
+        context = view.login()
+        self.assertIn('form', context)
+
+        # redirect if user already logged in
+        self.request.user = self.user
+        view = mod.AuthView(self.request)
+        redirect = view.login(session=self.session)
+        self.assertIsInstance(redirect, HTTPFound)
+
+        # login fails w/ wrong password
+        self.request.user = None
+        self.request.method = 'POST'
+        self.request.POST = {'username': 'barney', 'password': 'WRONG'}
+        view = mod.AuthView(self.request)
+        context = view.login(session=self.session)
+        self.assertIn('form', context)
+
+        # redirect if login succeeds
+        self.request.method = 'POST'
+        self.request.POST = {'username': 'barney', 'password': 'testpass'}
+        view = mod.AuthView(self.request)
+        redirect = view.login(session=self.session)
+        self.assertIsInstance(redirect, HTTPFound)
+
+    def test_logout(self):
+        view = mod.AuthView(self.request)
+        self.request.session.delete = MagicMock()
+        redirect = view.logout()
+        self.request.session.delete.assert_called_once_with()
+        self.assertIsInstance(redirect, HTTPFound)

From 70d13ee1e756ab115b3ab24b12b88181f9424fe4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 08:44:02 -0500
Subject: [PATCH 07/11] feat: add basic logo, favicon images

definitely should replace these at some point..
---
 src/wuttaweb/static/img/favicon.ico   | Bin 0 -> 5694 bytes
 src/wuttaweb/static/img/logo.png      | Bin 0 -> 20687 bytes
 src/wuttaweb/templates/base.mako      |   8 +++++---
 src/wuttaweb/templates/base_meta.mako |   8 ++++++--
 src/wuttaweb/templates/home.mako      |   8 +++-----
 src/wuttaweb/templates/login.mako     |   4 +++-
 6 files changed, 17 insertions(+), 11 deletions(-)
 create mode 100644 src/wuttaweb/static/img/favicon.ico
 create mode 100644 src/wuttaweb/static/img/logo.png

diff --git a/src/wuttaweb/static/img/favicon.ico b/src/wuttaweb/static/img/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..2b7edf1d739b0f1b0cbd6d9ec186d18843a54c4c
GIT binary patch
literal 5694
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

literal 0
HcmV?d00001

diff --git a/src/wuttaweb/static/img/logo.png b/src/wuttaweb/static/img/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..96fae964432932828436639579f10d04ff4dc5e2
GIT binary patch
literal 20687
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
GLSTYr$$`)S

literal 0
HcmV?d00001

diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index f6b4206..7aba37c 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -151,9 +151,11 @@
 
             <div class="navbar-brand">
               <a class="navbar-item" href="${url('home')}">
-                ${base_meta.header_logo()}
-                <div id="global-header-title">
-                  ${base_meta.global_title()}
+                <div style="display: flex; gap: 0.3rem; align-items: center;">
+                  ${base_meta.header_logo()}
+                  <div id="global-header-title">
+                    ${base_meta.global_title()}
+                  </div>
                 </div>
               </a>
               <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false">
diff --git a/src/wuttaweb/templates/base_meta.mako b/src/wuttaweb/templates/base_meta.mako
index 4e62198..c65e68c 100644
--- a/src/wuttaweb/templates/base_meta.mako
+++ b/src/wuttaweb/templates/base_meta.mako
@@ -7,11 +7,15 @@
 <%def name="extra_styles()"></%def>
 
 <%def name="favicon()">
-  ## <link rel="icon" type="image/x-icon" href="${config.get('tailbone', 'favicon_url', default=request.static_url('wuttaweb:static/img/favicon.ico'))}" />
+  <link rel="icon" type="image/x-icon" href="${config.get('wuttaweb.favicon_url', default=request.static_url('wuttaweb:static/img/favicon.ico'))}" />
 </%def>
 
 <%def name="header_logo()">
-  ## ${h.image(config.get('wuttaweb.header_image_url', default=request.static_url('wuttaweb:static/img/logo.png')), "Header Logo", style="height: 49px;")}
+  ${h.image(config.get('wuttaweb.header_logo_url', default=request.static_url('wuttaweb:static/img/favicon.ico')), "Header Logo", style="height: 49px;")}
+</%def>
+
+<%def name="full_logo()">
+  ${h.image(config.get('wuttaweb.logo_url', default=request.static_url('wuttaweb:static/img/logo.png')), f"{app.get_title()} logo")}
 </%def>
 
 <%def name="footer()">
diff --git a/src/wuttaweb/templates/home.mako b/src/wuttaweb/templates/home.mako
index 61a4eb2..1bb5f0d 100644
--- a/src/wuttaweb/templates/home.mako
+++ b/src/wuttaweb/templates/home.mako
@@ -9,11 +9,9 @@
 </%def>
 
 <%def name="page_content()">
-  <div style="height: 100%; display: flex; align-items: center; justify-content: center;">
-    <div class="logo">
-      ## ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
-      <h1 class="is-size-1">Welcome to ${base_meta.app_title()}</h1>
-    </div>
+  <div style="height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
+    <div>${base_meta.full_logo()}</div>
+    <h1 class="is-size-1">Welcome to ${app.get_title()}</h1>
   </div>
 </%def>
 
diff --git a/src/wuttaweb/templates/login.mako b/src/wuttaweb/templates/login.mako
index b50a863..6f07542 100644
--- a/src/wuttaweb/templates/login.mako
+++ b/src/wuttaweb/templates/login.mako
@@ -1,5 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
+<%namespace name="base_meta" file="/base_meta.mako" />
 
 <%def name="title()">Login</%def>
 
@@ -8,7 +9,8 @@
 </%def>
 
 <%def name="page_content()">
-  <div style="height: 100%; display: flex; align-items: center; justify-content: center;">
+  <div style="height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
+    <div>${base_meta.full_logo()}</div>
     <div class="card">
       <div class="card-content">
         ${form.render_vue_tag()}

From a2ba88ca8f221f34774ca62459b9a4b9d20ef0df Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 11:45:00 -0500
Subject: [PATCH 08/11] feat: add view to change current user password

---
 pyproject.toml                                |  1 +
 src/wuttaweb/app.py                           |  1 +
 src/wuttaweb/forms/base.py                    | 57 ++++++++++++++-
 .../templates/auth/change_password.mako       |  7 ++
 src/wuttaweb/templates/{ => auth}/login.mako  |  0
 src/wuttaweb/templates/base.mako              |  1 +
 .../templates/deform/checked_password.pt      | 13 ++++
 src/wuttaweb/templates/form.mako              |  6 ++
 .../templates/forms/vue_template.mako         |  2 +-
 src/wuttaweb/util.py                          |  2 +-
 src/wuttaweb/views/auth.py                    | 72 ++++++++++++++++++-
 tests/forms/test_base.py                      | 32 ++++++++-
 tests/views/test_auth.py                      | 72 +++++++++++++++++++
 13 files changed, 259 insertions(+), 7 deletions(-)
 create mode 100644 src/wuttaweb/templates/auth/change_password.mako
 rename src/wuttaweb/templates/{ => auth}/login.mako (100%)
 create mode 100644 src/wuttaweb/templates/deform/checked_password.pt

diff --git a/pyproject.toml b/pyproject.toml
index ce72e00..52f460a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,6 +34,7 @@ dependencies = [
         "pyramid_beaker",
         "pyramid_deform",
         "pyramid_mako",
+        "pyramid_tm",
         "waitress",
         "WebHelpers2",
         "WuttJamaican[db]>=0.7.0",
diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py
index f8bfc3a..6aadc0c 100644
--- a/src/wuttaweb/app.py
+++ b/src/wuttaweb/app.py
@@ -122,6 +122,7 @@ def make_pyramid_config(settings):
     pyramid_config.include('pyramid_beaker')
     pyramid_config.include('pyramid_deform')
     pyramid_config.include('pyramid_mako')
+    pyramid_config.include('pyramid_tm')
 
     return pyramid_config
 
diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py
index b8c4a40..0974a50 100644
--- a/src/wuttaweb/forms/base.py
+++ b/src/wuttaweb/forms/base.py
@@ -337,6 +337,16 @@ class Form:
         with label and containing a widget.
 
         Actual output will depend on the field attributes etc.
+        Typical output might look like:
+
+        .. code-block:: html
+
+           <b-field label="Foo"
+                    horizontal
+                    type="is-danger"
+                    message="something went wrong!">
+              <!-- widget element(s) -->
+           </b-field>
         """
         dform = self.get_deform()
         field = dform[fieldname]
@@ -354,8 +364,50 @@ class Form:
             'label': label,
         }
 
+        # next we will build array of messages to display..some
+        # fields always show a "helptext" msg, and some may have
+        # validation errors..
+        field_type = None
+        messages = []
+
+        # show errors if present
+        errors = self.get_field_errors(fieldname)
+        if errors:
+            field_type = 'is-danger'
+            messages.extend(errors)
+
+        # ..okay now we can declare the field messages and type
+        if field_type:
+            attrs['type'] = field_type
+        if messages:
+            if len(messages) == 1:
+                msg = messages[0]
+                if msg.startswith('`') and msg.endswith('`'):
+                    attrs[':message'] = msg
+                else:
+                    attrs['message'] = msg
+            # TODO
+            # else:
+            #     # nb. must pass an array as JSON string
+            #     attrs[':message'] = '[{}]'.format(', '.join([
+            #         "'{}'".format(msg.replace("'", r"\'"))
+            #         for msg in messages]))
+
         return HTML.tag('b-field', c=[html], **attrs)
 
+    def get_field_errors(self, field):
+        """
+        Return a list of error messages for the given field.
+
+        Not useful unless a call to :meth:`validate()` failed.
+        """
+        dform = self.get_deform()
+        if field in dform:
+            error = dform[field].errormsg
+            if error:
+                return [error]
+        return []
+
     def get_vue_field_value(self, field):
         """
         This method returns a JSON string which will be assigned as
@@ -400,7 +452,10 @@ class Form:
         the :attr:`validated` attribute.
 
         However if the data is not valid, ``False`` is returned, and
-        there will be no :attr:`validated` attribute.
+        there will be no :attr:`validated` attribute.  In that case
+        you should inspect the form errors to learn/display what went
+        wrong for the user's sake.  See also
+        :meth:`get_field_errors()`.
 
         :returns: Data dict, or ``False``.
         """
diff --git a/src/wuttaweb/templates/auth/change_password.mako b/src/wuttaweb/templates/auth/change_password.mako
new file mode 100644
index 0000000..c64aceb
--- /dev/null
+++ b/src/wuttaweb/templates/auth/change_password.mako
@@ -0,0 +1,7 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/form.mako" />
+
+<%def name="title()">Change Password</%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/templates/login.mako b/src/wuttaweb/templates/auth/login.mako
similarity index 100%
rename from src/wuttaweb/templates/login.mako
rename to src/wuttaweb/templates/auth/login.mako
diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index 7aba37c..dd51690 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -316,6 +316,7 @@
       <div class="navbar-item has-dropdown is-hoverable">
         <a class="navbar-link">${request.user}</a>
         <div class="navbar-dropdown">
+          ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
           ${h.link_to("Logout", url('logout'), class_='navbar-item')}
         </div>
       </div>
diff --git a/src/wuttaweb/templates/deform/checked_password.pt b/src/wuttaweb/templates/deform/checked_password.pt
new file mode 100644
index 0000000..624f8a8
--- /dev/null
+++ b/src/wuttaweb/templates/deform/checked_password.pt
@@ -0,0 +1,13 @@
+<div tal:define="name name|field.name;
+                 vmodel vmodel|'model_'+name;">
+  ${field.start_mapping()}
+  <b-input name="${name}"
+           value="${field.widget.redisplay and cstruct or ''}"
+           type="password"
+           placeholder="Password" />
+  <b-input name="${name}-confirm"
+           value="${field.widget.redisplay and confirm or ''}"
+           type="password"
+           placeholder="Confirm Password" />
+  ${field.end_mapping()}
+</div>
diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako
index efa320f..81b6ece 100644
--- a/src/wuttaweb/templates/form.mako
+++ b/src/wuttaweb/templates/form.mako
@@ -1,6 +1,12 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/page.mako" />
 
+<%def name="page_content()">
+  <div style="margin-top: 2rem; width: 50%;">
+    ${form.render_vue_tag()}
+  </div>
+</%def>
+
 <%def name="render_this_page_template()">
   ${parent.render_this_page_template()}
   ${form.render_vue_template()}
diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako
index 0151632..11767fd 100644
--- a/src/wuttaweb/templates/forms/vue_template.mako
+++ b/src/wuttaweb/templates/forms/vue_template.mako
@@ -9,7 +9,7 @@
       % endfor
     </section>
 
-    <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: end; width: 100%;">
+    <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: ${'end' if form.align_buttons_right else 'start'}; width: 100%; padding-left: 10rem;">
 
       % if form.show_button_reset:
           <b-button native-type="reset">
diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py
index 1cf7804..a8d059f 100644
--- a/src/wuttaweb/util.py
+++ b/src/wuttaweb/util.py
@@ -43,7 +43,7 @@ def get_form_data(request):
     # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
     if not request.POST and (
             getattr(request, 'is_xhr', False)
-            or request.content_type == 'application/json'):
+            or getattr(request, 'content_type', None) == 'application/json'):
         return request.json_body
     return request.POST
 
diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py
index 981afbd..9fc838c 100644
--- a/src/wuttaweb/views/auth.py
+++ b/src/wuttaweb/views/auth.py
@@ -25,7 +25,7 @@ Auth Views
 """
 
 import colander
-from deform.widget import TextInputWidget, PasswordWidget
+from deform.widget import TextInputWidget, PasswordWidget, CheckedPasswordWidget
 
 from wuttaweb.views import View
 from wuttaweb.db import Session
@@ -45,7 +45,7 @@ class AuthView(View):
         Upon successful login, user is redirected to home page.
 
         * route: ``login``
-        * template: ``/login.mako``
+        * template: ``/auth/login.mako``
         """
         auth = self.app.get_auth_handler()
 
@@ -138,6 +138,66 @@ class AuthView(View):
         referrer = self.request.route_url('login')
         return self.redirect(referrer, headers=headers)
 
+    def change_password(self):
+        """
+        View allowing a user to change their own password.
+
+        This view shows a change-password form, and handles its
+        submission.  If successful, user is redirected to home page.
+
+        If current user is not authenticated, no form is shown and
+        user is redirected to home page.
+
+        * route: ``change_password``
+        * template: ``/auth/change_password.mako``
+        """
+        if not self.request.user:
+            return self.redirect(self.request.route_url('home'))
+
+        form = self.make_form(schema=self.change_password_make_schema(),
+                              show_button_reset=True)
+
+        data = form.validate()
+        if data:
+            auth = self.app.get_auth_handler()
+            auth.set_user_password(self.request.user, data['new_password'])
+            self.request.session.flash("Your password has been changed.")
+            # TODO: should use request.get_referrer() instead
+            referrer = self.request.route_url('home')
+            return self.redirect(referrer)
+
+        return {'index_title': str(self.request.user),
+                'form': form}
+
+    def change_password_make_schema(self):
+        schema = colander.Schema()
+
+        schema.add(colander.SchemaNode(
+            colander.String(),
+            name='current_password',
+            widget=PasswordWidget(),
+            validator=self.change_password_validate_current_password))
+
+        schema.add(colander.SchemaNode(
+            colander.String(),
+            name='new_password',
+            widget=CheckedPasswordWidget(),
+            validator=self.change_password_validate_new_password))
+
+        return schema
+
+    def change_password_validate_current_password(self, node, value):
+        auth = self.app.get_auth_handler()
+        user = self.request.user
+        if not auth.check_user_password(user, value):
+            node.raise_invalid("Current password is incorrect.")
+
+    def change_password_validate_new_password(self, node, value):
+        auth = self.app.get_auth_handler()
+        user = self.request.user
+        if auth.check_user_password(user, value):
+            node.raise_invalid("New password must be different from old password.")
+
     @classmethod
     def defaults(cls, config):
         cls._auth_defaults(config)
@@ -149,13 +209,19 @@ class AuthView(View):
         config.add_route('login', '/login')
         config.add_view(cls, attr='login',
                         route_name='login',
-                        renderer='/login.mako')
+                        renderer='/auth/login.mako')
 
         # logout
         config.add_route('logout', '/logout')
         config.add_view(cls, attr='logout',
                         route_name='logout')
 
+        # change password
+        config.add_route('change_password', '/change-password')
+        config.add_view(cls, attr='change_password',
+                        route_name='change_password',
+                        renderer='/auth/change_password.mako')
+
 
 def defaults(config, **kwargs):
     base = globals()
diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py
index 96a0805..27e2109 100644
--- a/tests/forms/test_base.py
+++ b/tests/forms/test_base.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8; -*-
 
 from unittest import TestCase
+from unittest.mock import MagicMock
 
 import colander
 import deform
@@ -179,12 +180,41 @@ class TestForm(TestCase):
 
     def test_render_vue_field(self):
         self.pyramid_config.include('pyramid_deform')
-
         schema = self.make_schema()
         form = self.make_form(schema=schema)
+        dform = form.get_deform()
+
+        # typical
         html = form.render_vue_field('foo')
         self.assertIn('<b-field :horizontal="true" label="Foo">', html)
         self.assertIn('<b-input name="foo"', html)
+        # nb. no error message
+        self.assertNotIn('message', html)
+
+        # with single "static" error
+        dform['foo'].error = MagicMock(msg="something is wrong")
+        html = form.render_vue_field('foo')
+        self.assertIn(' message="something is wrong"', html)
+
+        # with single "dynamic" error
+        dform['foo'].error = MagicMock(msg="`something is wrong`")
+        html = form.render_vue_field('foo')
+        self.assertIn(':message="`something is wrong`"', html)
+
+    def test_get_field_errors(self):
+        schema = self.make_schema()
+        form = self.make_form(schema=schema)
+        dform = form.get_deform()
+
+        # no error
+        errors = form.get_field_errors('foo')
+        self.assertEqual(len(errors), 0)
+
+        # simple error
+        dform['foo'].error = MagicMock(msg="something is wrong")
+        errors = form.get_field_errors('foo')
+        self.assertEqual(len(errors), 1)
+        self.assertEqual(errors[0], "something is wrong")
 
     def test_get_vue_field_value(self):
         schema = self.make_schema()
diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py
index 495dac1..b22989f 100644
--- a/tests/views/test_auth.py
+++ b/tests/views/test_auth.py
@@ -70,3 +70,75 @@ class TestAuthView(TestCase):
         redirect = view.logout()
         self.request.session.delete.assert_called_once_with()
         self.assertIsInstance(redirect, HTTPFound)
+
+    def test_change_password(self):
+        view = mod.AuthView(self.request)
+        auth = self.app.get_auth_handler()
+
+        # unauthenticated user is redirected
+        redirect = view.change_password()
+        self.assertIsInstance(redirect, HTTPFound)
+
+        # now "login" the user, and set initial password
+        self.request.user = self.user
+        auth.set_user_password(self.user, 'foo')
+        self.session.commit()
+
+        # view should now return context w/ form
+        context = view.change_password()
+        self.assertIn('form', context)
+
+        # submit valid form, ensure password is changed
+        # (nb. this also would redirect user to home page)
+        self.request.method = 'POST'
+        self.request.POST = {
+            'current_password': 'foo',
+            # nb. new_password requires colander mapping structure
+            '__start__': 'new_password:mapping',
+            'new_password': 'bar',
+            'new_password-confirm': 'bar',
+            '__end__': 'new_password:mapping',
+        }
+        redirect = view.change_password()
+        self.assertIsInstance(redirect, HTTPFound)
+        self.session.commit()
+        self.session.refresh(self.user)
+        self.assertFalse(auth.check_user_password(self.user, 'foo'))
+        self.assertTrue(auth.check_user_password(self.user, 'bar'))
+
+        # at this point 'foo' is the password, now let's submit some
+        # invalid forms and make sure we get back a context w/ form
+
+        # first try empty data
+        self.request.POST = {}
+        context = view.change_password()
+        self.assertIn('form', context)
+        dform = context['form'].get_deform()
+        self.assertEqual(dform['current_password'].errormsg, "Required")
+        self.assertEqual(dform['new_password'].errormsg, "Required")
+
+        # now try bad current password
+        self.request.POST = {
+            'current_password': 'blahblah',
+            '__start__': 'new_password:mapping',
+            'new_password': 'baz',
+            'new_password-confirm': 'baz',
+            '__end__': 'new_password:mapping',
+        }
+        context = view.change_password()
+        self.assertIn('form', context)
+        dform = context['form'].get_deform()
+        self.assertEqual(dform['current_password'].errormsg, "Current password is incorrect.")
+
+        # now try bad new password
+        self.request.POST = {
+            'current_password': 'bar',
+            '__start__': 'new_password:mapping',
+            'new_password': 'bar',
+            'new_password-confirm': 'bar',
+            '__end__': 'new_password:mapping',
+        }
+        context = view.change_password()
+        self.assertIn('form', context)
+        dform = context['form'].get_deform()
+        self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.")

From fc339ba81bbacca8490841c4e2a98836422ebfec Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 14:21:54 -0500
Subject: [PATCH 09/11] feat: add support for admin user to become / stop being
 root

---
 src/wuttaweb/auth.py             |   6 +-
 src/wuttaweb/subscribers.py      |  51 ++++++++++-
 src/wuttaweb/templates/base.mako |  35 +++++++-
 src/wuttaweb/views/auth.py       |  54 ++++++++++++
 src/wuttaweb/views/base.py       |   8 ++
 tests/test_auth.py               |   6 ++
 tests/test_subscribers.py        | 141 ++++++++++++++++++++++++++++---
 tests/views/test_auth.py         |  50 ++++++++++-
 tests/views/test_base.py         |   6 +-
 9 files changed, 335 insertions(+), 22 deletions(-)

diff --git a/src/wuttaweb/auth.py b/src/wuttaweb/auth.py
index 0c2f26d..de9b868 100644
--- a/src/wuttaweb/auth.py
+++ b/src/wuttaweb/auth.py
@@ -138,9 +138,13 @@ class WuttaSecurityPolicy:
         return self.session_helper.forget(request, **kw)
 
     def permits(self, request, context, permission):
+
+        # nb. root user can do anything
+        if getattr(request, 'is_root', False):
+            return True
+
         config = request.registry.settings['wutta_config']
         app = config.get_app()
         auth = app.get_auth_handler()
-
         user = self.identity(request)
         return auth.has_permission(self.db_session, user, permission)
diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py
index eebefb4..1b711e3 100644
--- a/src/wuttaweb/subscribers.py
+++ b/src/wuttaweb/subscribers.py
@@ -63,6 +63,16 @@ def new_request(event):
 
        Reference to the app :term:`config object`.
 
+    .. method:: request.get_referrer(default=None)
+
+       Request method to get the "canonical" HTTP referrer value.
+       This has logic to check for referrer in the request params,
+       user session etc.
+
+       :param default: Optional default URL if none is found in
+          request params/session.  If no default is specified,
+          the ``'home'`` route is used.
+
     .. attribute:: request.use_oruga
 
        Flag indicating whether the frontend should be displayed using
@@ -75,6 +85,19 @@ def new_request(event):
 
     request.wutta_config = config
 
+    def get_referrer(default=None):
+        if request.params.get('referrer'):
+            return request.params['referrer']
+        if request.session.get('referrer'):
+            return request.session.pop('referrer')
+        referrer = getattr(request, 'referrer', None)
+        if (not referrer or referrer == request.current_route_url()
+            or not referrer.startswith(request.host_url)):
+            referrer = default or request.route_url('home')
+        return referrer
+
+    request.get_referrer = get_referrer
+
     def use_oruga(request):
         spec = config.get('wuttaweb.oruga_detector.spec')
         if spec:
@@ -104,22 +127,44 @@ def new_request_set_user(event, db_session=None):
        :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` instance
        (if logged in), or ``None``.
 
-    :param db_session: Optional :term:`db session` to use, instead of
-       :class:`wuttaweb.db.Session`.  Probably only useful for tests.
+    .. attribute:: request.is_admin
+
+       Flag indicating whether current user is a member of the
+       Administrator role.
+
+    .. attribute:: request.is_root
+
+       Flag indicating whether user is currently elevated to root
+       privileges.  This is only possible if :attr:`request.is_admin`
+       is also true.
     """
     request = event.request
     config = request.registry.settings['wutta_config']
     app = config.get_app()
-    model = app.model
 
     def user(request):
         uuid = request.authenticated_userid
         if uuid:
             session = db_session or Session()
+            model = app.model
             return session.get(model.User, uuid)
 
     request.set_property(user, reify=True)
 
+    def is_admin(request):
+        auth = app.get_auth_handler()
+        return auth.user_is_admin(request.user)
+
+    request.set_property(is_admin, reify=True)
+
+    def is_root(request):
+        if request.is_admin:
+            if request.session.get('is_root', False):
+                return True
+        return False
+
+    request.set_property(is_root, reify=True)
+
 
 def before_render(event):
     """
diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index dd51690..b04c980 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -314,8 +314,29 @@
 <%def name="render_user_menu()">
   % if request.user:
       <div class="navbar-item has-dropdown is-hoverable">
-        <a class="navbar-link">${request.user}</a>
+        <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}</a>
         <div class="navbar-dropdown">
+          % if request.is_root:
+              ${h.form(url('stop_root'), ref='stopBeingRootForm')}
+              ## TODO
+              ## ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="stopBeingRoot()"
+                 class="navbar-item has-background-danger has-text-white">
+                Stop being root
+              </a>
+              ${h.end_form()}
+          % elif request.is_admin:
+              ${h.form(url('become_root'), ref='startBeingRootForm')}
+              ## TODO
+              ## ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="startBeingRoot()"
+                 class="navbar-item has-background-danger has-text-white">
+                Become root
+              </a>
+              ${h.end_form()}
+          % endif
           ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
           ${h.link_to("Logout", url('logout'), class_='navbar-item')}
         </div>
@@ -359,6 +380,18 @@
                 const key = 'menu_' + hash + '_shown'
                 this[key] = !this[key]
             },
+
+            % if request.is_admin:
+
+                startBeingRoot() {
+                    this.$refs.startBeingRootForm.submit()
+                },
+
+                stopBeingRoot() {
+                    this.$refs.stopBeingRootForm.submit()
+                },
+
+            % endif
         },
     }
 
diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py
index 9fc838c..389271b 100644
--- a/src/wuttaweb/views/auth.py
+++ b/src/wuttaweb/views/auth.py
@@ -198,6 +198,48 @@ class AuthView(View):
         if auth.check_user_password(user, value):
             node.raise_invalid("New password must be different from old password.")
 
+    def become_root(self):
+        """
+        Elevate the current request to 'root' for full system access.
+
+        This is only allowed if current (authenticated) user is a
+        member of the Administrator role.  Also note that GET is not
+        allowed for this view, only POST.
+
+        See also :meth:`stop_root()`.
+        """
+        if self.request.method != 'POST':
+            raise self.forbidden()
+
+        if not self.request.is_admin:
+            raise self.forbidden()
+
+        self.request.session['is_root'] = True
+        self.request.session.flash("You have been elevated to 'root' and now have full system access")
+
+        url = self.request.get_referrer()
+        return self.redirect(url)
+
+    def stop_root(self):
+        """
+        Lower the current request from 'root' back to normal access.
+
+        Also note that GET is not allowed for this view, only POST.
+
+        See also :meth:`become_root()`.
+        """
+        if self.request.method != 'POST':
+            raise self.forbidden()
+
+        if not self.request.is_admin:
+            raise self.forbidden()
+
+        self.request.session['is_root'] = False
+        self.request.session.flash("Your normal system access has been restored")
+
+        url = self.request.get_referrer()
+        return self.redirect(url)
+
     @classmethod
     def defaults(cls, config):
         cls._auth_defaults(config)
@@ -222,6 +264,18 @@ class AuthView(View):
                         route_name='change_password',
                         renderer='/auth/change_password.mako')
 
+        # become root
+        config.add_route('become_root', '/root/yes',
+                         request_method='POST')
+        config.add_view(cls, attr='become_root',
+                        route_name='become_root')
+
+        # stop root
+        config.add_route('stop_root', '/root/no',
+                         request_method='POST')
+        config.add_view(cls, attr='stop_root',
+                        route_name='stop_root')
+
 
 def defaults(config, **kwargs):
     base = globals()
diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py
index e7bfea3..e412ed2 100644
--- a/src/wuttaweb/views/base.py
+++ b/src/wuttaweb/views/base.py
@@ -55,6 +55,14 @@ class View:
         self.config = self.request.wutta_config
         self.app = self.config.get_app()
 
+    def forbidden(self):
+        """
+        Convenience method, to raise a HTTP 403 Forbidden exception::
+
+           raise self.forbidden()
+        """
+        return httpexceptions.HTTPForbidden()
+
     def make_form(self, **kwargs):
         """
         Make and return a new :class:`~wuttaweb.forms.base.Form`
diff --git a/tests/test_auth.py b/tests/test_auth.py
index a6bea29..5d6c406 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -137,3 +137,9 @@ class TestWuttaSecurityPolicy(TestCase):
         self.user.roles.append(role)
         self.session.commit()
         self.assertTrue(self.policy.permits(self.request, None, 'baz.edit'))
+
+        # now let's try another perm - we won't grant it, but will
+        # confirm user is denied access unless they become root
+        self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm'))
+        self.request.is_root = True
+        self.assertTrue(self.policy.permits(self.request, None, 'some-root-perm'))
diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py
index 63b6640..27c85c3 100644
--- a/tests/test_subscribers.py
+++ b/tests/test_subscribers.py
@@ -18,40 +18,78 @@ class TestNewRequest(TestCase):
 
     def setUp(self):
         self.config = WuttaConfig()
+        self.request = self.make_request()
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+        })
+
+    def tearDown(self):
+        testing.tearDown()
 
     def make_request(self):
         request = testing.DummyRequest()
-        request.registry.settings = {'wutta_config': self.config}
+        # request.registry.settings = {'wutta_config': self.config}
         return request
 
     def test_wutta_config(self):
-        request = self.make_request()
-        event = MagicMock(request=request)
+        event = MagicMock(request=self.request)
 
         # request gets a new attr
-        self.assertFalse(hasattr(request, 'wutta_config'))
+        self.assertFalse(hasattr(self.request, 'wutta_config'))
         subscribers.new_request(event)
-        self.assertTrue(hasattr(request, 'wutta_config'))
-        self.assertIs(request.wutta_config, self.config)
+        self.assertTrue(hasattr(self.request, 'wutta_config'))
+        self.assertIs(self.request.wutta_config, self.config)
 
     def test_use_oruga_default(self):
-        request = self.make_request()
-        event = MagicMock(request=request)
+        event = MagicMock(request=self.request)
 
         # request gets a new attr, false by default
-        self.assertFalse(hasattr(request, 'use_oruga'))
+        self.assertFalse(hasattr(self.request, 'use_oruga'))
         subscribers.new_request(event)
-        self.assertFalse(request.use_oruga)
+        self.assertFalse(self.request.use_oruga)
 
     def test_use_oruga_custom(self):
         self.config.setdefault('wuttaweb.oruga_detector.spec', 'tests.test_subscribers:custom_oruga_detector')
-        request = self.make_request()
-        event = MagicMock(request=request)
+        event = MagicMock(request=self.request)
 
         # request gets a new attr, which should be true
-        self.assertFalse(hasattr(request, 'use_oruga'))
+        self.assertFalse(hasattr(self.request, 'use_oruga'))
         subscribers.new_request(event)
-        self.assertTrue(request.use_oruga)
+        self.assertTrue(self.request.use_oruga)
+
+    def test_get_referrer(self):
+        event = MagicMock(request=self.request)
+
+        def home(request):
+            pass
+
+        self.pyramid_config.add_route('home', '/')
+        self.pyramid_config.add_view(home, route_name='home')
+
+        self.assertFalse(hasattr(self.request, 'get_referrer'))
+        subscribers.new_request(event)
+        self.assertTrue(hasattr(self.request, 'get_referrer'))
+
+        # default if no referrer, is home route
+        url = self.request.get_referrer()
+        self.assertEqual(url, self.request.route_url('home'))
+
+        # can specify another default
+        url = self.request.get_referrer(default='https://wuttaproject.org')
+        self.assertEqual(url, 'https://wuttaproject.org')
+
+        # or referrer can come from user session
+        self.request.session['referrer'] = 'https://rattailproject.org'
+        self.assertIn('referrer', self.request.session)
+        url = self.request.get_referrer()
+        self.assertEqual(url, 'https://rattailproject.org')
+        # nb. referrer should also have been removed from user session
+        self.assertNotIn('referrer', self.request.session)
+
+        # or referrer can come from request params
+        self.request.params['referrer'] = 'https://kernel.org'
+        url = self.request.get_referrer()
+        self.assertEqual(url, 'https://kernel.org')
 
 
 def custom_oruga_detector(request):
@@ -97,6 +135,81 @@ class TestNewRequestSetUser(TestCase):
         subscribers.new_request_set_user(event, db_session=self.session)
         self.assertIs(self.request.user, self.user)
 
+    def test_is_admin(self):
+        event = MagicMock(request=self.request)
+
+        # anonymous user
+        self.assertFalse(hasattr(self.request, 'user'))
+        self.assertFalse(hasattr(self.request, 'is_admin'))
+        subscribers.new_request_set_user(event, db_session=self.session)
+        self.assertIsNone(self.request.user)
+        self.assertFalse(self.request.is_admin)
+
+        # reset
+        del self.request.is_admin
+
+        # authenticated user, but still not an admin
+        self.request.user = self.user
+        subscribers.new_request_set_user(event, db_session=self.session)
+        self.assertIs(self.request.user, self.user)
+        self.assertFalse(self.request.is_admin)
+
+        # reset
+        del self.request.is_admin
+
+        # but if we make them an admin, it changes
+        auth = self.app.get_auth_handler()
+        admin = auth.get_role_administrator(self.session)
+        self.user.roles.append(admin)
+        self.session.commit()
+        subscribers.new_request_set_user(event, db_session=self.session)
+        self.assertIs(self.request.user, self.user)
+        self.assertTrue(self.request.is_admin)
+
+    def test_is_root(self):
+        event = MagicMock(request=self.request)
+
+        # anonymous user
+        self.assertFalse(hasattr(self.request, 'user'))
+        self.assertFalse(hasattr(self.request, 'is_root'))
+        subscribers.new_request_set_user(event, db_session=self.session)
+        self.assertIsNone(self.request.user)
+        self.assertFalse(self.request.is_root)
+
+        # reset
+        del self.request.is_admin
+        del self.request.is_root
+
+        # authenticated user, but still not an admin
+        self.request.user = self.user
+        subscribers.new_request_set_user(event, db_session=self.session)
+        self.assertIs(self.request.user, self.user)
+        self.assertFalse(self.request.is_root)
+
+        # reset
+        del self.request.is_admin
+        del self.request.is_root
+
+        # even if we make them an admin, still not yet root
+        auth = self.app.get_auth_handler()
+        admin = auth.get_role_administrator(self.session)
+        self.user.roles.append(admin)
+        self.session.commit()
+        subscribers.new_request_set_user(event, db_session=self.session)
+        self.assertIs(self.request.user, self.user)
+        self.assertTrue(self.request.is_admin)
+        self.assertFalse(self.request.is_root)
+
+        # reset
+        del self.request.is_admin
+        del self.request.is_root
+
+        # root status flag lives in user session
+        self.request.session['is_root'] = True
+        subscribers.new_request_set_user(event, db_session=self.session)
+        self.assertTrue(self.request.is_admin)
+        self.assertTrue(self.request.is_root)
+
 
 class TestBeforeRender(TestCase):
 
diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py
index b22989f..d10e759 100644
--- a/tests/views/test_auth.py
+++ b/tests/views/test_auth.py
@@ -4,11 +4,12 @@ from unittest import TestCase
 from unittest.mock import MagicMock
 
 from pyramid import testing
-from pyramid.httpexceptions import HTTPFound
+from pyramid.httpexceptions import HTTPFound, HTTPForbidden
 
 from wuttjamaican.conf import WuttaConfig
 from wuttaweb.views import auth as mod
 from wuttaweb.auth import WuttaSecurityPolicy
+from wuttaweb.subscribers import new_request
 
 
 class TestAuthView(TestCase):
@@ -19,7 +20,9 @@ class TestAuthView(TestCase):
         })
 
         self.request = testing.DummyRequest(wutta_config=self.config, user=None)
-        self.pyramid_config = testing.setUp(request=self.request)
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+        })
 
         self.app = self.config.get_app()
         auth = self.app.get_auth_handler()
@@ -142,3 +145,46 @@ class TestAuthView(TestCase):
         self.assertIn('form', context)
         dform = context['form'].get_deform()
         self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.")
+
+    def test_become_root(self):
+        event = MagicMock(request=self.request)
+        new_request(event)      # add request.get_referrer()
+        view = mod.AuthView(self.request)
+
+        # GET not allowed
+        self.request.method = 'GET'
+        self.assertRaises(HTTPForbidden, view.become_root)
+
+        # non-admin users also not allowed
+        self.request.method = 'POST'
+        self.request.is_admin = False
+        self.assertRaises(HTTPForbidden, view.become_root)
+
+        # but admin users can become root
+        self.request.is_admin = True
+        self.assertNotIn('is_root', self.request.session)
+        redirect = view.become_root()
+        self.assertIsInstance(redirect, HTTPFound)
+        self.assertTrue(self.request.session['is_root'])
+
+    def test_stop_root(self):
+        event = MagicMock(request=self.request)
+        new_request(event)      # add request.get_referrer()
+        view = mod.AuthView(self.request)
+
+        # GET not allowed
+        self.request.method = 'GET'
+        self.assertRaises(HTTPForbidden, view.stop_root)
+
+        # non-admin users also not allowed
+        self.request.method = 'POST'
+        self.request.is_admin = False
+        self.assertRaises(HTTPForbidden, view.stop_root)
+
+        # but admin users can stop being root
+        # (nb. there is no check whether user is currently root)
+        self.request.is_admin = True
+        self.assertNotIn('is_root', self.request.session)
+        redirect = view.stop_root()
+        self.assertIsInstance(redirect, HTTPFound)
+        self.assertFalse(self.request.session['is_root'])
diff --git a/tests/views/test_base.py b/tests/views/test_base.py
index 52c717a..103e005 100644
--- a/tests/views/test_base.py
+++ b/tests/views/test_base.py
@@ -3,7 +3,7 @@
 from unittest import TestCase
 
 from pyramid import testing
-from pyramid.httpexceptions import HTTPFound
+from pyramid.httpexceptions import HTTPFound, HTTPForbidden
 
 from wuttjamaican.conf import WuttaConfig
 from wuttaweb.views import base
@@ -23,6 +23,10 @@ class TestView(TestCase):
         self.assertIs(self.view.config, self.config)
         self.assertIs(self.view.app, self.app)
 
+    def test_forbidden(self):
+        error = self.view.forbidden()
+        self.assertIsInstance(error, HTTPForbidden)
+
     def test_make_form(self):
         form = self.view.make_form()
         self.assertIsInstance(form, Form)

From 0e0460b83170d1c5ce58bf83a4c9043472f67bb7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 15:06:55 -0500
Subject: [PATCH 10/11] fix: allow custom user getter for
 `new_request_set_user()` hook

---
 src/wuttaweb/subscribers.py | 50 ++++++++++++++++++++++++++++---------
 tests/test_subscribers.py   |  2 +-
 2 files changed, 39 insertions(+), 13 deletions(-)

diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py
index 1b711e3..63fe428 100644
--- a/src/wuttaweb/subscribers.py
+++ b/src/wuttaweb/subscribers.py
@@ -35,6 +35,7 @@ However some custom apps may need to supplement or replace the event
 hooks contained here, depending on the circumstance.
 """
 
+import functools
 import json
 import logging
 
@@ -108,10 +109,29 @@ def new_request(event):
     request.set_property(use_oruga, reify=True)
 
 
-def new_request_set_user(event, db_session=None):
+def default_user_getter(request, db_session=None):
+    """
+    This is the default function used to retrieve user object from
+    database.  Result of this is then assigned to :attr:`request.user`
+    as part of the :func:`new_request_set_user()` hook.
+    """
+    uuid = request.authenticated_userid
+    if uuid:
+        config = request.wutta_config
+        app = config.get_app()
+        model = app.model
+        session = db_session or Session()
+        return session.get(model.User, uuid)
+
+
+def new_request_set_user(
+        event,
+        user_getter=default_user_getter,
+        db_session=None,
+):
     """
     Event hook called when processing a new :term:`request`, for sake
-    of setting the ``request.user`` property.
+    of setting the :attr:`request.user` and similar properties.
 
     The hook is auto-registered if this module is "included" by
     Pyramid config object.  Or you can explicitly register it::
@@ -137,32 +157,38 @@ def new_request_set_user(event, db_session=None):
        Flag indicating whether user is currently elevated to root
        privileges.  This is only possible if :attr:`request.is_admin`
        is also true.
+
+    You may wish to "supplement" this hook by registering your own
+    custom hook and then invoking this one as needed.  You can then
+    pass certain params to override only parts of the logic:
+
+    :param user_getter: Optional getter function to retrieve the user
+       from database, instead of :func:`default_user_getter()`.
+
+    :param db_session: Optional :term:`db session` to use,
+       instead of :class:`wuttaweb.db.Session`.
     """
     request = event.request
     config = request.registry.settings['wutta_config']
     app = config.get_app()
 
-    def user(request):
-        uuid = request.authenticated_userid
-        if uuid:
-            session = db_session or Session()
-            model = app.model
-            return session.get(model.User, uuid)
-
-    request.set_property(user, reify=True)
+    # request.user
+    if db_session:
+        user_getter = functools.partial(user_getter, db_session=db_session)
+    request.set_property(user_getter, name='user', reify=True)
 
+    # request.is_admin
     def is_admin(request):
         auth = app.get_auth_handler()
         return auth.user_is_admin(request.user)
-
     request.set_property(is_admin, reify=True)
 
+    # request.is_root
     def is_root(request):
         if request.is_admin:
             if request.session.get('is_root', False):
                 return True
         return False
-
     request.set_property(is_root, reify=True)
 
 
diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py
index 27c85c3..419e130 100644
--- a/tests/test_subscribers.py
+++ b/tests/test_subscribers.py
@@ -103,7 +103,7 @@ class TestNewRequestSetUser(TestCase):
             'wutta.db.default.url': 'sqlite://',
         })
 
-        self.request = testing.DummyRequest()
+        self.request = testing.DummyRequest(wutta_config=self.config)
         self.pyramid_config = testing.setUp(request=self.request, settings={
             'wutta_config': self.config,
         })

From 17df2c0f5691fe0553414ec83d347b00293b5a22 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 15:32:46 -0500
Subject: [PATCH 11/11] =?UTF-8?q?bump:=20version=200.2.0=20=E2=86=92=200.3?=
 =?UTF-8?q?.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 18 ++++++++++++++++++
 pyproject.toml |  2 +-
 2 files changed, 19 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba15691..90fc273 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,24 @@ All notable changes to wuttaweb 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.3.0 (2024-08-05)
+
+### Feat
+
+- add support for admin user to become / stop being root
+- add view to change current user password
+- add basic logo, favicon images
+- add auth views, for login/logout
+- add custom security policy, login/logout for pyramid
+- add `wuttaweb.views.essential` module
+- add initial/basic forms support
+- add `wuttaweb.db` module, with `Session`
+- add `util.get_form_data()` convenience function
+
+### Fix
+
+- allow custom user getter for `new_request_set_user()` hook
+
 ## v0.2.0 (2024-07-14)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 52f460a..ce0044d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "WuttaWeb"
-version = "0.2.0"
+version = "0.3.0"
 description = "Web App for Wutta Framework"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]