From 9ac4f7525eb76a1dd3be2a6cff4b0c1d7455ac77 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 19:21:58 -0500
Subject: [PATCH 1/4] feat: add initial `MasterView` support

very minimal, index view only with empty page content
---
 docs/api/wuttaweb/index.rst              |   1 +
 docs/api/wuttaweb/views.master.rst       |   6 +
 src/wuttaweb/templates/master/index.mako |   9 +
 src/wuttaweb/views/__init__.py           |   2 +
 src/wuttaweb/views/master.py             | 443 +++++++++++++++++++++++
 tests/forms/test_base.py                 |   4 +-
 tests/test_subscribers.py                |   4 +-
 tests/utils.py                           |  11 +
 tests/views/test_master.py               | 282 +++++++++++++++
 9 files changed, 760 insertions(+), 2 deletions(-)
 create mode 100644 docs/api/wuttaweb/views.master.rst
 create mode 100644 src/wuttaweb/templates/master/index.mako
 create mode 100644 src/wuttaweb/views/master.py
 create mode 100644 tests/utils.py
 create mode 100644 tests/views/test_master.py

diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 204864e..057ce36 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -23,3 +23,4 @@
    views.base
    views.common
    views.essential
+   views.master
diff --git a/docs/api/wuttaweb/views.master.rst b/docs/api/wuttaweb/views.master.rst
new file mode 100644
index 0000000..a1e5d5b
--- /dev/null
+++ b/docs/api/wuttaweb/views.master.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.views.master``
+=========================
+
+.. automodule:: wuttaweb.views.master
+   :members:
diff --git a/src/wuttaweb/templates/master/index.mako b/src/wuttaweb/templates/master/index.mako
new file mode 100644
index 0000000..ce8273f
--- /dev/null
+++ b/src/wuttaweb/templates/master/index.mako
@@ -0,0 +1,9 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="page_content()">
+  <p>TODO: index page content</p>
+</%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/views/__init__.py b/src/wuttaweb/views/__init__.py
index 68fdd77..845ff49 100644
--- a/src/wuttaweb/views/__init__.py
+++ b/src/wuttaweb/views/__init__.py
@@ -27,9 +27,11 @@ For convenience, from this ``wuttaweb.views`` namespace you can access
 the following:
 
 * :class:`~wuttaweb.views.base.View`
+* :class:`~wuttaweb.views.master.MasterView`
 """
 
 from .base import View
+from .master import MasterView
 
 
 def includeme(config):
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
new file mode 100644
index 0000000..9ba1572
--- /dev/null
+++ b/src/wuttaweb/views/master.py
@@ -0,0 +1,443 @@
+# -*- 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 Logic for Master Views
+"""
+
+from pyramid.renderers import render_to_response
+
+from wuttaweb.views import View
+
+
+class MasterView(View):
+    """
+    Base class for "master" views.
+
+    Master views typically map to a table in a DB, though not always.
+    They essentially are a set of CRUD views for a certain type of
+    data record.
+
+    Many attributes may be overridden in subclass.  For instance to
+    define :attr:`model_class`::
+
+       from wuttaweb.views import MasterView
+       from wuttjamaican.db.model import Person
+
+       class MyPersonView(MasterView):
+           model_class = Person
+
+       def includeme(config):
+           MyPersonView.defaults(config)
+
+    .. note::
+
+       Many of these attributes will only exist if they have been
+       explicitly defined in a subclass.  There are corresponding
+       ``get_xxx()`` methods which should be used instead of accessing
+       these attributes directly.
+
+    .. attribute:: model_class
+
+       Optional reference to a data model class.  While not strictly
+       required, most views will set this to a SQLAlchemy mapped
+       class,
+       e.g. :class:`wuttjamaican:wuttjamaican.db.model.auth.User`.
+
+       Code should not access this directly but instead call
+       :meth:`get_model_class()`.
+
+    .. attribute:: model_name
+
+       Optional override for the view's data model name,
+       e.g. ``'WuttaWidget'``.
+
+       Code should not access this directly but instead call
+       :meth:`get_model_name()`.
+
+    .. attribute:: model_name_normalized
+
+       Optional override for the view's "normalized" data model name,
+       e.g. ``'wutta_widget'``.
+
+       Code should not access this directly but instead call
+       :meth:`get_model_name_normalized()`.
+
+    .. attribute:: model_title
+
+       Optional override for the view's "humanized" (singular) model
+       title, e.g. ``"Wutta Widget"``.
+
+       Code should not access this directly but instead call
+       :meth:`get_model_title()`.
+
+    .. attribute:: model_title_plural
+
+       Optional override for the view's "humanized" (plural) model
+       title, e.g. ``"Wutta Widgets"``.
+
+       Code should not access this directly but instead call
+       :meth:`get_model_title_plural()`.
+
+    .. attribute:: route_prefix
+
+       Optional override for the view's route prefix,
+       e.g. ``'wutta_widgets'``.
+
+       Code should not access this directly but instead call
+       :meth:`get_route_prefix()`.
+
+    .. attribute:: url_prefix
+
+       Optional override for the view's URL prefix,
+       e.g. ``'/widgets'``.
+
+       Code should not access this directly but instead call
+       :meth:`get_url_prefix()`.
+
+    .. attribute:: template_prefix
+
+       Optional override for the view's template prefix,
+       e.g. ``'/widgets'``.
+
+       Code should not access this directly but instead call
+       :meth:`get_template_prefix()`.
+
+    .. attribute:: listable
+
+       Boolean indicating whether the view model supports "listing" -
+       i.e. it should have an :meth:`index()` view.
+    """
+
+    ##############################
+    # attributes
+    ##############################
+
+    listable = True
+
+    ##############################
+    # view methods
+    ##############################
+
+    def index(self):
+        """
+        View to "list" (filter/browse) the model data.
+
+        This is the "default" view for the model and is what user sees
+        when visiting the "root" path under the :attr:`url_prefix`,
+        e.g. ``/widgets/``.
+        """
+        return self.render_to_response('index', {})
+
+    ##############################
+    # support methods
+    ##############################
+
+    def get_index_title(self):
+        """
+        Returns the main index title for the master view.
+
+        By default this returns the value from
+        :meth:`get_model_title_plural()`.  Subclass may override as
+        needed.
+        """
+        return self.get_model_title_plural()
+
+    def render_to_response(self, template, context):
+        """
+        Locate and render an appropriate template, with the given
+        context, and return a :term:`response`.
+
+        The specified ``template`` should be only the "base name" for
+        the template - e.g.  ``'index'`` or ``'edit'``.  This method
+        will then try to locate a suitable template file, based on
+        values from :meth:`get_template_prefix()` and
+        :meth:`get_fallback_templates()`.
+
+        In practice this *usually* means two different template paths
+        will be attempted, e.g. if ``template`` is ``'edit'`` and
+        :attr:`template_prefix` is ``'/widgets'``:
+
+        * ``/widgets/edit.mako``
+        * ``/master/edit.mako``
+
+        The first template found to exist will be used for rendering.
+        It then calls
+        :func:`pyramid:pyramid.renderers.render_to_response()` and
+        returns the result.
+
+        :param template: Base name for the template.
+
+        :param context: Data dict to be used as template context.
+
+        :returns: Response object containing the rendered template.
+        """
+        defaults = {
+            'index_title': self.get_index_title(),
+        }
+
+        # merge defaults + caller-provided context
+        defaults.update(context)
+        context = defaults
+
+        # first try the template path most specific to this view
+        template_prefix = self.get_template_prefix()
+        mako_path = f'{template_prefix}/{template}.mako'
+        try:
+            return render_to_response(mako_path, context, request=self.request)
+        except IOError:
+
+            # failing that, try one or more fallback templates
+            for fallback in self.get_fallback_templates(template):
+                try:
+                    return render_to_response(fallback, context, request=self.request)
+                except IOError:
+                    pass
+
+            # if we made it all the way here, then we found no
+            # templates at all, in which case re-attempt the first and
+            # let that error raise on up
+            return render_to_response(mako_path, context, request=self.request)
+
+    def get_fallback_templates(self, template):
+        """
+        Returns a list of "fallback" template paths which may be
+        attempted for rendering a view.  This is used within
+        :meth:`render_to_response()` if the "first guess" template
+        file was not found.
+
+        :param template: Base name for a template (without prefix), e.g.
+           ``'custom'``.
+
+        :returns: List of full template paths to be tried, based on
+           the specified template.  For instance if ``template`` is
+           ``'custom'`` this will (by default) return::
+
+              ['/master/custom.mako']
+        """
+        return [f'/master/{template}.mako']
+
+    ##############################
+    # class methods
+    ##############################
+
+    @classmethod
+    def get_model_class(cls):
+        """
+        Returns the model class for the view (if defined).
+
+        A model class will *usually* be a SQLAlchemy mapped class,
+        e.g. :class:`wuttjamaican:wuttjamaican.db.model.base.Person`.
+
+        There is no default value here, but a subclass may override by
+        assigning :attr:`model_class`.
+
+        Note that the model class is not *required* - however if you
+        do not set the :attr:`model_class`, then you *must* set the
+        :attr:`model_name`.
+        """
+        if hasattr(cls, 'model_class'):
+            return cls.model_class
+
+    @classmethod
+    def get_model_name(cls):
+        """
+        Returns the model name for the view.
+
+        A model name should generally be in the format of a Python
+        class name, e.g. ``'WuttaWidget'``.  (Note this is
+        *singular*, not plural.)
+
+        The default logic will call :meth:`get_model_class()` and
+        return that class name as-is.  A subclass may override by
+        assigning :attr:`model_name`.
+        """
+        if hasattr(cls, 'model_name'):
+            return cls.model_name
+
+        return cls.get_model_class().__name__
+
+    @classmethod
+    def get_model_name_normalized(cls):
+        """
+        Returns the "normalized" model name for the view.
+
+        A normalized model name should generally be in the format of a
+        Python variable name, e.g. ``'wutta_widget'``.  (Note this is
+        *singular*, not plural.)
+
+        The default logic will call :meth:`get_model_name()` and
+        simply lower-case the result.  A subclass may override by
+        assigning :attr:`model_name_normalized`.
+        """
+        if hasattr(cls, 'model_name_normalized'):
+            return cls.model_name_normalized
+
+        return cls.get_model_name().lower()
+
+    @classmethod
+    def get_model_title(cls):
+        """
+        Returns the "humanized" (singular) model title for the view.
+
+        The model title will be displayed to the user, so should have
+        proper grammar and capitalization, e.g. ``"Wutta Widget"``.
+        (Note this is *singular*, not plural.)
+
+        The default logic will call :meth:`get_model_name()` and use
+        the result as-is.  A subclass may override by assigning
+        :attr:`model_title`.
+        """
+        if hasattr(cls, 'model_title'):
+            return cls.model_title
+
+        return cls.get_model_name()
+
+    @classmethod
+    def get_model_title_plural(cls):
+        """
+        Returns the "humanized" (plural) model title for the view.
+
+        The model title will be displayed to the user, so should have
+        proper grammar and capitalization, e.g. ``"Wutta Widgets"``.
+        (Note this is *plural*, not singular.)
+
+        The default logic will call :meth:`get_model_title()` and
+        simply add a ``'s'`` to the end.  A subclass may override by
+        assigning :attr:`model_title_plural`.
+        """
+        if hasattr(cls, 'model_title_plural'):
+            return cls.model_title_plural
+
+        model_title = cls.get_model_title()
+        return f"{model_title}s"
+
+    @classmethod
+    def get_route_prefix(cls):
+        """
+        Returns the "route prefix" for the master view.  This prefix
+        is used for all named routes defined by the view class.
+
+        For instance if route prefix is ``'widgets'`` then a view
+        might have these routes:
+
+        * ``'widgets'``
+        * ``'widgets.create'``
+        * ``'widgets.edit'``
+        * ``'widgets.delete'``
+
+        The default logic will call
+        :meth:`get_model_name_normalized()` and simply add an ``'s'``
+        to the end, making it plural.  A subclass may override by
+        assigning :attr:`route_prefix`.
+        """
+        if hasattr(cls, 'route_prefix'):
+            return cls.route_prefix
+
+        model_name = cls.get_model_name_normalized()
+        return f'{model_name}s'
+
+    @classmethod
+    def get_url_prefix(cls):
+        """
+        Returns the "URL prefix" for the master view.  This prefix is
+        used for all URLs defined by the view class.
+
+        Using the same example as in :meth:`get_route_prefix()`, the
+        URL prefix would be ``'/widgets'`` and the view would have
+        defined routes for these URLs:
+
+        * ``/widgets/``
+        * ``/widgets/new``
+        * ``/widgets/XXX/edit``
+        * ``/widgets/XXX/delete``
+
+        The default logic will call :meth:`get_route_prefix()` and
+        simply add a ``'/'`` to the beginning.  A subclass may
+        override by assigning :attr:`url_prefix`.
+        """
+        if hasattr(cls, 'url_prefix'):
+            return cls.url_prefix
+
+        route_prefix = cls.get_route_prefix()
+        return f'/{route_prefix}'
+
+    @classmethod
+    def get_template_prefix(cls):
+        """
+        Returns the "template prefix" for the master view.  This
+        prefix is used to guess which template path to render for a
+        given view.
+
+        Using the same example as in :meth:`get_url_prefix()`, the
+        template prefix would also be ``'/widgets'`` and the templates
+        assumed for those routes would be:
+
+        * ``/widgets/index.mako``
+        * ``/widgets/create.mako``
+        * ``/widgets/edit.mako``
+        * ``/widgets/delete.mako``
+
+        The default logic will call :meth:`get_url_prefix()` and
+        return that value as-is.  A subclass may override by assigning
+        :attr:`template_prefix`.
+        """
+        if hasattr(cls, 'template_prefix'):
+            return cls.template_prefix
+
+        return cls.get_url_prefix()
+
+    ##############################
+    # configuration
+    ##############################
+
+    @classmethod
+    def defaults(cls, config):
+        """
+        Provide default Pyramid configuration for a master view.
+
+        This is generally called from within the module's
+        ``includeme()`` function, e.g.::
+
+           from wuttaweb.views import MasterView
+
+           class WidgetView(MasterView):
+               model_name = 'Widget'
+
+           def includeme(config):
+               WidgetView.defaults(config)
+
+        :param config: Reference to the app's
+           :class:`pyramid:pyramid.config.Configurator` instance.
+        """
+        cls._defaults(config)
+
+    @classmethod
+    def _defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+
+        # index view
+        if cls.listable:
+            config.add_route(route_prefix, f'{url_prefix}/')
+            config.add_view(cls, attr='index',
+                            route_name=route_prefix)
diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py
index 27e2109..95ff122 100644
--- a/tests/forms/test_base.py
+++ b/tests/forms/test_base.py
@@ -46,7 +46,9 @@ class TestFieldList(TestCase):
 class TestForm(TestCase):
 
     def setUp(self):
-        self.config = WuttaConfig()
+        self.config = WuttaConfig(defaults={
+            'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
+        })
         self.request = testing.DummyRequest(wutta_config=self.config)
 
         self.pyramid_config = testing.setUp(request=self.request, settings={
diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py
index 419e130..e2fa7fd 100644
--- a/tests/test_subscribers.py
+++ b/tests/test_subscribers.py
@@ -214,7 +214,9 @@ class TestNewRequestSetUser(TestCase):
 class TestBeforeRender(TestCase):
 
     def setUp(self):
-        self.config = WuttaConfig()
+        self.config = WuttaConfig(defaults={
+            'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
+        })
 
     def make_request(self):
         request = testing.DummyRequest()
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..ed66cc1
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8; -*-
+
+from wuttaweb.menus import MenuHandler
+
+
+class NullMenuHandler(MenuHandler):
+    """
+    Dummy menu handler for testing.
+    """
+    def make_menus(self, request, **kwargs):
+        return []
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
new file mode 100644
index 0000000..b786129
--- /dev/null
+++ b/tests/views/test_master.py
@@ -0,0 +1,282 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from pyramid import testing
+from pyramid.response import Response
+
+from wuttjamaican.conf import WuttaConfig
+from wuttaweb.views import master
+from wuttaweb.subscribers import new_request_set_user
+
+
+class TestMasterView(TestCase):
+
+    def setUp(self):
+        self.config = WuttaConfig(defaults={
+            'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
+        })
+        self.app = self.config.get_app()
+        self.request = testing.DummyRequest(wutta_config=self.config)
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+            'mako.directories': ['wuttaweb:templates'],
+        })
+        self.pyramid_config.include('pyramid_mako')
+        self.pyramid_config.include('wuttaweb.static')
+        self.pyramid_config.include('wuttaweb.views.essential')
+        self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
+                                           'pyramid.events.BeforeRender')
+
+        event = MagicMock(request=self.request)
+        new_request_set_user(event)
+
+    def tearDown(self):
+        testing.tearDown()
+
+    def test_defaults(self):
+        master.MasterView.model_name = 'Widget'
+        # TODO: should inspect pyramid routes after this, to be certain
+        master.MasterView.defaults(self.pyramid_config)
+        del master.MasterView.model_name
+
+    ##############################
+    # class methods
+    ##############################
+
+    def test_get_model_class(self):
+        
+        # no model class by default
+        self.assertIsNone(master.MasterView.get_model_class())
+
+        # subclass may specify
+        MyModel = MagicMock()
+        master.MasterView.model_class = MyModel
+        self.assertIs(master.MasterView.get_model_class(), MyModel)
+        del master.MasterView.model_class
+
+    def test_get_model_name(self):
+        
+        # error by default (since no model class)
+        self.assertRaises(AttributeError, master.MasterView.get_model_name)
+
+        # subclass may specify model name
+        master.MasterView.model_name = 'Widget'
+        self.assertEqual(master.MasterView.get_model_name(), 'Widget')
+        del master.MasterView.model_name
+
+        # or it may specify model class
+        MyModel = MagicMock(__name__='Blaster')
+        master.MasterView.model_class = MyModel
+        self.assertEqual(master.MasterView.get_model_name(), 'Blaster')
+        del master.MasterView.model_class
+
+    def test_get_model_name_normalized(self):
+        
+        # error by default (since no model class)
+        self.assertRaises(AttributeError, master.MasterView.get_model_name_normalized)
+
+        # subclass may specify *normalized* model name
+        master.MasterView.model_name_normalized = 'widget'
+        self.assertEqual(master.MasterView.get_model_name_normalized(), 'widget')
+        del master.MasterView.model_name_normalized
+
+        # or it may specify *standard* model name
+        master.MasterView.model_name = 'Blaster'
+        self.assertEqual(master.MasterView.get_model_name_normalized(), 'blaster')
+        del master.MasterView.model_name
+
+        # or it may specify model class
+        MyModel = MagicMock(__name__='Dinosaur')
+        master.MasterView.model_class = MyModel
+        self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur')
+        del master.MasterView.model_class
+
+    def test_get_model_title(self):
+        
+        # error by default (since no model class)
+        self.assertRaises(AttributeError, master.MasterView.get_model_title)
+
+        # subclass may specify  model title
+        master.MasterView.model_title = 'Wutta Widget'
+        self.assertEqual(master.MasterView.get_model_title(), "Wutta Widget")
+        del master.MasterView.model_title
+
+        # or it may specify model name
+        master.MasterView.model_name = 'Blaster'
+        self.assertEqual(master.MasterView.get_model_title(), "Blaster")
+        del master.MasterView.model_name
+
+        # or it may specify model class
+        MyModel = MagicMock(__name__='Dinosaur')
+        master.MasterView.model_class = MyModel
+        self.assertEqual(master.MasterView.get_model_title(), "Dinosaur")
+        del master.MasterView.model_class
+
+    def test_get_model_title_plural(self):
+        
+        # error by default (since no model class)
+        self.assertRaises(AttributeError, master.MasterView.get_model_title_plural)
+
+        # subclass may specify *plural* model title
+        master.MasterView.model_title_plural = 'People'
+        self.assertEqual(master.MasterView.get_model_title_plural(), "People")
+        del master.MasterView.model_title_plural
+
+        # or it may specify *singular* model title
+        master.MasterView.model_title = 'Wutta Widget'
+        self.assertEqual(master.MasterView.get_model_title_plural(), "Wutta Widgets")
+        del master.MasterView.model_title
+
+        # or it may specify model name
+        master.MasterView.model_name = 'Blaster'
+        self.assertEqual(master.MasterView.get_model_title_plural(), "Blasters")
+        del master.MasterView.model_name
+
+        # or it may specify model class
+        MyModel = MagicMock(__name__='Dinosaur')
+        master.MasterView.model_class = MyModel
+        self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs")
+        del master.MasterView.model_class
+
+    def test_get_route_prefix(self):
+        
+        # error by default (since no model class)
+        self.assertRaises(AttributeError, master.MasterView.get_route_prefix)
+
+        # subclass may specify route prefix
+        master.MasterView.route_prefix = 'widgets'
+        self.assertEqual(master.MasterView.get_route_prefix(), 'widgets')
+        del master.MasterView.route_prefix
+
+        # subclass may specify *normalized* model name
+        master.MasterView.model_name_normalized = 'blaster'
+        self.assertEqual(master.MasterView.get_route_prefix(), 'blasters')
+        del master.MasterView.model_name_normalized
+
+        # or it may specify *standard* model name
+        master.MasterView.model_name = 'Dinosaur'
+        self.assertEqual(master.MasterView.get_route_prefix(), 'dinosaurs')
+        del master.MasterView.model_name
+
+        # or it may specify model class
+        MyModel = MagicMock(__name__='Truck')
+        master.MasterView.model_class = MyModel
+        self.assertEqual(master.MasterView.get_route_prefix(), 'trucks')
+        del master.MasterView.model_class
+
+    def test_get_url_prefix(self):
+        
+        # error by default (since no model class)
+        self.assertRaises(AttributeError, master.MasterView.get_url_prefix)
+
+        # subclass may specify url prefix
+        master.MasterView.url_prefix = '/widgets'
+        self.assertEqual(master.MasterView.get_url_prefix(), '/widgets')
+        del master.MasterView.url_prefix
+
+        # or it may specify route prefix
+        master.MasterView.route_prefix = 'trucks'
+        self.assertEqual(master.MasterView.get_url_prefix(), '/trucks')
+        del master.MasterView.route_prefix
+
+        # or it may specify *normalized* model name
+        master.MasterView.model_name_normalized = 'blaster'
+        self.assertEqual(master.MasterView.get_url_prefix(), '/blasters')
+        del master.MasterView.model_name_normalized
+
+        # or it may specify *standard* model name
+        master.MasterView.model_name = 'Dinosaur'
+        self.assertEqual(master.MasterView.get_url_prefix(), '/dinosaurs')
+        del master.MasterView.model_name
+
+        # or it may specify model class
+        MyModel = MagicMock(__name__='Machine')
+        master.MasterView.model_class = MyModel
+        self.assertEqual(master.MasterView.get_url_prefix(), '/machines')
+        del master.MasterView.model_class
+
+    def test_get_template_prefix(self):
+        
+        # error by default (since no model class)
+        self.assertRaises(AttributeError, master.MasterView.get_template_prefix)
+
+        # subclass may specify template prefix
+        master.MasterView.template_prefix = '/widgets'
+        self.assertEqual(master.MasterView.get_template_prefix(), '/widgets')
+        del master.MasterView.template_prefix
+
+        # or it may specify url prefix
+        master.MasterView.url_prefix = '/trees'
+        self.assertEqual(master.MasterView.get_template_prefix(), '/trees')
+        del master.MasterView.url_prefix
+
+        # or it may specify route prefix
+        master.MasterView.route_prefix = 'trucks'
+        self.assertEqual(master.MasterView.get_template_prefix(), '/trucks')
+        del master.MasterView.route_prefix
+
+        # or it may specify *normalized* model name
+        master.MasterView.model_name_normalized = 'blaster'
+        self.assertEqual(master.MasterView.get_template_prefix(), '/blasters')
+        del master.MasterView.model_name_normalized
+
+        # or it may specify *standard* model name
+        master.MasterView.model_name = 'Dinosaur'
+        self.assertEqual(master.MasterView.get_template_prefix(), '/dinosaurs')
+        del master.MasterView.model_name
+
+        # or it may specify model class
+        MyModel = MagicMock(__name__='Machine')
+        master.MasterView.model_class = MyModel
+        self.assertEqual(master.MasterView.get_template_prefix(), '/machines')
+        del master.MasterView.model_class
+
+    ##############################
+    # support methods
+    ##############################
+
+    def test_get_index_title(self):
+        master.MasterView.model_title_plural = "Wutta Widgets"
+        view = master.MasterView(self.request)
+        self.assertEqual(view.get_index_title(), "Wutta Widgets")
+        del master.MasterView.model_title_plural
+
+    def test_render_to_response(self):
+
+        # basic sanity check using /master/index.mako
+        # (nb. it skips /widgets/index.mako since that doesn't exist)
+        master.MasterView.model_name = 'Widget'
+        view = master.MasterView(self.request)
+        response = view.render_to_response('index', {})
+        self.assertIsInstance(response, Response)
+        del master.MasterView.model_name
+
+        # basic sanity check using /appinfo/index.mako
+        master.MasterView.model_name = 'AppInfo'
+        master.MasterView.template_prefix = '/appinfo'
+        view = master.MasterView(self.request)
+        response = view.render_to_response('index', {})
+        self.assertIsInstance(response, Response)
+        del master.MasterView.model_name
+        del master.MasterView.template_prefix
+
+        # bad template name causes error
+        master.MasterView.model_name = 'Widget'
+        self.assertRaises(IOError, view.render_to_response, 'nonexistent', {})
+        del master.MasterView.model_name
+
+    ##############################
+    # view methods
+    ##############################
+
+    def test_index(self):
+        
+        # basic sanity check using /appinfo
+        master.MasterView.model_name = 'AppInfo'
+        master.MasterView.template_prefix = '/appinfo'
+        view = master.MasterView(self.request)
+        response = view.index()
+        del master.MasterView.model_name
+        del master.MasterView.template_prefix

From 9a739381aebc4ff20ad65359f3a4043709caa518 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 21:49:18 -0500
Subject: [PATCH 2/4] feat: add basic App Info view (index only)

more to come!
---
 docs/api/wuttaweb/index.rst               |  1 +
 docs/api/wuttaweb/views.settings.rst      |  6 +++
 src/wuttaweb/menus.py                     |  5 +-
 src/wuttaweb/subscribers.py               |  1 +
 src/wuttaweb/templates/appinfo/index.mako | 56 ++++++++++++++++++++++
 src/wuttaweb/templates/master/index.mako  |  4 ++
 src/wuttaweb/views/essential.py           |  1 +
 src/wuttaweb/views/settings.py            | 47 +++++++++++++++++++
 tests/forms/test_base.py                  |  2 +-
 tests/test_subscribers.py                 |  2 +-
 tests/views/test_master.py                |  2 +-
 tests/views/test_settings.py              | 13 ++++++
 tests/views/utils.py                      | 57 +++++++++++++++++++++++
 13 files changed, 192 insertions(+), 5 deletions(-)
 create mode 100644 docs/api/wuttaweb/views.settings.rst
 create mode 100644 src/wuttaweb/templates/appinfo/index.mako
 create mode 100644 src/wuttaweb/views/settings.py
 create mode 100644 tests/views/test_settings.py
 create mode 100644 tests/views/utils.py

diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 057ce36..5afd18b 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -24,3 +24,4 @@
    views.common
    views.essential
    views.master
+   views.settings
diff --git a/docs/api/wuttaweb/views.settings.rst b/docs/api/wuttaweb/views.settings.rst
new file mode 100644
index 0000000..7923331
--- /dev/null
+++ b/docs/api/wuttaweb/views.settings.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.views.settings``
+===========================
+
+.. automodule:: wuttaweb.views.settings
+   :members:
diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py
index c0e7ae1..0fe780d 100644
--- a/src/wuttaweb/menus.py
+++ b/src/wuttaweb/menus.py
@@ -118,8 +118,9 @@ class MenuHandler(GenericHandler):
             'type': 'menu',
             'items': [
                 {
-                    'title': "TODO!",
-                    'url': '#',
+                    'title': "App Info",
+                    'route': 'appinfo',
+                    'perm': 'appinfo.list',
                 },
             ],
         }
diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py
index 63fe428..92d40d9 100644
--- a/src/wuttaweb/subscribers.py
+++ b/src/wuttaweb/subscribers.py
@@ -249,6 +249,7 @@ def before_render(event):
     context['h'] = helpers
     context['url'] = request.route_url
     context['json'] = json
+    context['b'] = 'o' if request.use_oruga else 'b' # for buefy
 
     # TODO: this should be avoided somehow, for non-traditional web
     # apps, esp. "API" web apps.  (in the meantime can configure the
diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako
new file mode 100644
index 0000000..7354514
--- /dev/null
+++ b/src/wuttaweb/templates/appinfo/index.mako
@@ -0,0 +1,56 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="page_content()">
+
+  <nav class="panel item-panel">
+    <p class="panel-heading">Application</p>
+    <div class="panel-block">
+      <div style="width: 100%;">
+        <b-field horizontal label="Distribution">
+          <span>${app.get_distribution(obj=app.get_web_handler()) or f'?? - set config for `{app.appname}.app_dist`'}</span>
+        </b-field>
+        <b-field horizontal label="Version">
+          <span>${app.get_version(obj=app.get_web_handler()) or f'?? - set config for `{app.appname}.app_dist`'}</span>
+        </b-field>
+        <b-field horizontal label="App Title">
+          <span>${app.get_title()}</span>
+        </b-field>
+      </div>
+    </div>
+  </nav>
+
+  <nav class="panel item-panel">
+    <p class="panel-heading">Configuration Files</p>
+    <div class="panel-block">
+      <div style="width: 100%;">
+        <${b}-table :data="configFiles">
+
+          <${b}-table-column field="priority"
+                          label="Priority"
+                          v-slot="props">
+            {{ props.row.priority }}
+          </${b}-table-column>
+
+          <${b}-table-column field="path"
+                          label="File Path"
+                          v-slot="props">
+            {{ props.row.path }}
+          </${b}-table-column>
+
+        </${b}-table>
+      </div>
+    </div>
+  </nav>
+
+</%def>
+
+<%def name="modify_this_page_vars()">
+  ${parent.modify_this_page_vars()}
+  <script>
+    ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(config.get_prioritized_files(), 1)])|n}
+  </script>
+</%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/templates/master/index.mako b/src/wuttaweb/templates/master/index.mako
index ce8273f..fd3d573 100644
--- a/src/wuttaweb/templates/master/index.mako
+++ b/src/wuttaweb/templates/master/index.mako
@@ -1,6 +1,10 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/page.mako" />
 
+<%def name="title()">${index_title}</%def>
+
+<%def name="content_title()"></%def>
+
 <%def name="page_content()">
   <p>TODO: index page content</p>
 </%def>
diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py
index 93c8149..0d4ec35 100644
--- a/src/wuttaweb/views/essential.py
+++ b/src/wuttaweb/views/essential.py
@@ -39,6 +39,7 @@ def defaults(config, **kwargs):
 
     config.include(mod('wuttaweb.views.auth'))
     config.include(mod('wuttaweb.views.common'))
+    config.include(mod('wuttaweb.views.settings'))
 
 
 def includeme(config):
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py
new file mode 100644
index 0000000..42ce834
--- /dev/null
+++ b/src/wuttaweb/views/settings.py
@@ -0,0 +1,47 @@
+# -*- 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/>.
+#
+################################################################################
+"""
+Views for app settings
+"""
+
+from wuttaweb.views import MasterView
+
+
+class AppInfoView(MasterView):
+    """
+    Master view for the overall app, to show/edit config etc.
+    """
+    model_name = 'AppInfo'
+    model_title_plural = "App Info"
+    route_prefix = 'appinfo'
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    AppInfoView = kwargs.get('AppInfoView', base['AppInfoView'])
+    AppInfoView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py
index 95ff122..5e29ad6 100644
--- a/tests/forms/test_base.py
+++ b/tests/forms/test_base.py
@@ -49,7 +49,7 @@ class TestForm(TestCase):
         self.config = WuttaConfig(defaults={
             'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
         })
-        self.request = testing.DummyRequest(wutta_config=self.config)
+        self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
 
         self.pyramid_config = testing.setUp(request=self.request, settings={
             'mako.directories': ['wuttaweb:templates'],
diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py
index e2fa7fd..c991383 100644
--- a/tests/test_subscribers.py
+++ b/tests/test_subscribers.py
@@ -219,7 +219,7 @@ class TestBeforeRender(TestCase):
         })
 
     def make_request(self):
-        request = testing.DummyRequest()
+        request = testing.DummyRequest(use_oruga=False)
         request.registry.settings = {'wutta_config': self.config}
         request.wutta_config = self.config
         return request
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index b786129..8fe4c47 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -18,7 +18,7 @@ class TestMasterView(TestCase):
             'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
         })
         self.app = self.config.get_app()
-        self.request = testing.DummyRequest(wutta_config=self.config)
+        self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
         self.pyramid_config = testing.setUp(request=self.request, settings={
             'wutta_config': self.config,
             'mako.directories': ['wuttaweb:templates'],
diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py
new file mode 100644
index 0000000..321364b
--- /dev/null
+++ b/tests/views/test_settings.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8; -*-
+
+from tests.views.utils import WebTestCase
+
+from wuttaweb.views import settings
+
+
+class TestAppInfoView(WebTestCase):
+
+    def test_index(self):
+        # just a sanity check
+        view = settings.AppInfoView(self.request)
+        response = view.index()
diff --git a/tests/views/utils.py b/tests/views/utils.py
new file mode 100644
index 0000000..495f5cb
--- /dev/null
+++ b/tests/views/utils.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from pyramid import testing
+
+from wuttjamaican.conf import WuttaConfig
+from wuttaweb import subscribers
+
+
+class WebTestCase(TestCase):
+    """
+    Base class for test suites requiring a full (typical) web app.
+    """
+
+    def setUp(self):
+        self.setup_web()
+
+    def setup_web(self):
+        self.config = WuttaConfig(defaults={
+            'wutta.db.default.url': 'sqlite://',
+            'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
+        })
+
+        self.request = testing.DummyRequest()
+
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+            'mako.directories': ['wuttaweb:templates'],
+        })
+
+        # init db
+        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()
+
+        # init web
+        self.pyramid_config.include('pyramid_mako')
+        self.pyramid_config.include('wuttaweb.static')
+        self.pyramid_config.include('wuttaweb.views.essential')
+        self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
+                                           'pyramid.events.BeforeRender')
+
+        # setup new request w/ anonymous user
+        event = MagicMock(request=self.request)
+        subscribers.new_request(event)
+        def user_getter(request, **kwargs): pass
+        subscribers.new_request_set_user(event, db_session=self.session,
+                                         user_getter=user_getter)
+
+    def tearDown(self):
+        self.teardown_web()
+
+    def teardown_web(self):
+        testing.tearDown()

From 7766ca6b122403251258073dfff3f3c14d0468bb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 21:58:31 -0500
Subject: [PATCH 3/4] fix: add `notfound()` View method; auto-append trailing
 slash

the latter provides auto-redirect to `/widgets/` when user visits
`/widgets` for example
---
 src/wuttaweb/views/base.py   | 8 ++++++++
 src/wuttaweb/views/common.py | 5 ++++-
 tests/views/test_base.py     | 6 +++++-
 3 files changed, 17 insertions(+), 2 deletions(-)

diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py
index e412ed2..94e22b2 100644
--- a/src/wuttaweb/views/base.py
+++ b/src/wuttaweb/views/base.py
@@ -73,6 +73,14 @@ class View:
         """
         return forms.Form(self.request, **kwargs)
 
+    def notfound(self):
+        """
+        Convenience method, to raise a HTTP 404 Not Found exception::
+
+           raise self.notfound()
+        """
+        return httpexceptions.HTTPNotFound()
+
     def redirect(self, url, **kwargs):
         """
         Convenience method to return a HTTP 302 response.
diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py
index 153bc5c..b5c108e 100644
--- a/src/wuttaweb/views/common.py
+++ b/src/wuttaweb/views/common.py
@@ -53,7 +53,10 @@ class CommonView(View):
     @classmethod
     def _defaults(cls, config):
 
-        # home
+        # auto-correct URLs which require trailing slash
+        config.add_notfound_view(cls, attr='notfound', append_slash=True)
+
+        # home page
         config.add_route('home', '/')
         config.add_view(cls, attr='home',
                         route_name='home',
diff --git a/tests/views/test_base.py b/tests/views/test_base.py
index 103e005..6e2f126 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, HTTPForbidden
+from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound
 
 from wuttjamaican.conf import WuttaConfig
 from wuttaweb.views import base
@@ -31,6 +31,10 @@ class TestView(TestCase):
         form = self.view.make_form()
         self.assertIsInstance(form, Form)
 
+    def test_notfound(self):
+        error = self.view.notfound()
+        self.assertIsInstance(error, HTTPNotFound)
+
     def test_redirect(self):
         error = self.view.redirect('/')
         self.assertIsInstance(error, HTTPFound)

From 01aa08b33d62187e63773abc8f0aafaee7376188 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 22:55:09 -0500
Subject: [PATCH 4/4] =?UTF-8?q?bump:=20version=200.3.0=20=E2=86=92=200.4.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90fc273..69b842c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ 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.4.0 (2024-08-05)
+
+### Feat
+
+- add basic App Info view (index only)
+- add initial `MasterView` support
+
+### Fix
+
+- add `notfound()` View method; auto-append trailing slash
+- bump min version for wuttjamaican
+
 ## v0.3.0 (2024-08-05)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 2c921be..1708f3b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "WuttaWeb"
-version = "0.3.0"
+version = "0.4.0"
 description = "Web App for Wutta Framework"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]