1
0
Fork 0

feat: add initial MasterView support

very minimal, index view only with empty page content
This commit is contained in:
Lance Edgar 2024-08-05 19:21:58 -05:00
parent f5891d36fa
commit 9ac4f7525e
9 changed files with 760 additions and 2 deletions

View file

@ -23,3 +23,4 @@
views.base views.base
views.common views.common
views.essential views.essential
views.master

View file

@ -0,0 +1,6 @@
``wuttaweb.views.master``
=========================
.. automodule:: wuttaweb.views.master
:members:

View file

@ -0,0 +1,9 @@
## -*- coding: utf-8; -*-
<%inherit file="/page.mako" />
<%def name="page_content()">
<p>TODO: index page content</p>
</%def>
${parent.body()}

View file

@ -27,9 +27,11 @@ For convenience, from this ``wuttaweb.views`` namespace you can access
the following: the following:
* :class:`~wuttaweb.views.base.View` * :class:`~wuttaweb.views.base.View`
* :class:`~wuttaweb.views.master.MasterView`
""" """
from .base import View from .base import View
from .master import MasterView
def includeme(config): def includeme(config):

View file

@ -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)

View file

@ -46,7 +46,9 @@ class TestFieldList(TestCase):
class TestForm(TestCase): class TestForm(TestCase):
def setUp(self): 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.request = testing.DummyRequest(wutta_config=self.config)
self.pyramid_config = testing.setUp(request=self.request, settings={ self.pyramid_config = testing.setUp(request=self.request, settings={

View file

@ -214,7 +214,9 @@ class TestNewRequestSetUser(TestCase):
class TestBeforeRender(TestCase): class TestBeforeRender(TestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig() self.config = WuttaConfig(defaults={
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
})
def make_request(self): def make_request(self):
request = testing.DummyRequest() request = testing.DummyRequest()

11
tests/utils.py Normal file
View file

@ -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 []

282
tests/views/test_master.py Normal file
View file

@ -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