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/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 204864e..5afd18b 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -23,3 +23,5 @@
views.base
views.common
views.essential
+ views.master
+ views.settings
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/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/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"}]
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()">
+
+
+
+
+
+%def>
+
+<%def name="modify_this_page_vars()">
+ ${parent.modify_this_page_vars()}
+
+%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/templates/master/index.mako b/src/wuttaweb/templates/master/index.mako
new file mode 100644
index 0000000..fd3d573
--- /dev/null
+++ b/src/wuttaweb/templates/master/index.mako
@@ -0,0 +1,13 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="title()">${index_title}%def>
+
+<%def name="content_title()">%def>
+
+<%def name="page_content()">
+
TODO: index page content
+%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/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/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/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 .
+#
+################################################################################
+"""
+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/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 .
+#
+################################################################################
+"""
+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 27e2109..5e29ad6 100644
--- a/tests/forms/test_base.py
+++ b/tests/forms/test_base.py
@@ -46,8 +46,10 @@ class TestFieldList(TestCase):
class TestForm(TestCase):
def setUp(self):
- self.config = WuttaConfig()
- self.request = testing.DummyRequest(wutta_config=self.config)
+ self.config = WuttaConfig(defaults={
+ 'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
+ })
+ 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 419e130..c991383 100644
--- a/tests/test_subscribers.py
+++ b/tests/test_subscribers.py
@@ -214,10 +214,12 @@ 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()
+ request = testing.DummyRequest(use_oruga=False)
request.registry.settings = {'wutta_config': self.config}
request.wutta_config = self.config
return request
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_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)
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
new file mode 100644
index 0000000..8fe4c47
--- /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, use_oruga=False)
+ 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
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()