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 name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${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 name="content_title()"> + +<%def name="page_content()"> +

TODO: index page content

+ + + +${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()