Add home_page and menu_page concepts
authorKarl O. Pinc <kop@karlpinc.com>
Tue, 1 Dec 2020 03:41:14 +0000 (21:41 -0600)
committerKarl O. Pinc <kop@karlpinc.com>
Tue, 1 Dec 2020 03:41:14 +0000 (21:41 -0600)
13 files changed:
README.rst
setup.py
src/pgwui_common/checkset.py
src/pgwui_common/exceptions.py
src/pgwui_common/pgwui_common.py
src/pgwui_common/templates/auth_base.mak
src/pgwui_common/templates/base.mak
src/pgwui_common/views/__init__.py [new file with mode: 0644]
src/pgwui_common/views/ex_views.py [new file with mode: 0644]
src/pgwui_common/views/page_views.py [new file with mode: 0644]
tests/test_pgwui_common.py
tests/views/test_ex_views.py [new file with mode: 0644]
tests/views/test_page_views.py [new file with mode: 0644]

index 589842b8818000c270f29d562d5c7e6aee97f742..03b24060e85c8871f739eecd1bd2598d92e6c6e3 100644 (file)
@@ -58,24 +58,35 @@ experience with the Mako HTML templating system and/or the Python
 programming language.
 
 
+Configuration Settings Common to All PGWUI Components
+-----------------------------------------------------
+
+PGWUI components all have the following configuration settings:
+
+ menu_label
+   The label for PGWUI_Menu to display, when different from the default
+
+Note that the ``menu_label`` setting appears within the settings for
+the component, not at top-level.
+
+
 Usage
 -----
 
+This section is of interest to application developers.
+
 When utilizing PGWUI modules in your own `Pyramid`_ application,
 modules which require the PGWUI_Common module, PGWUI_Common must be
-explicitly configured.  This can be done with any of `Pyramid's
-<Pyramid_>`_ configuration mechanisms, ``pyramid_includes =
-pgwui_common`` in a ``.ini`` file or
-``config.include('pgwui_common')`` in Python code.
+explicitly configured.  This can be done with any of
 
+`Pyramid's`_ configuration mechanisms,
 
-Configuration Settings Common to All PGWUI Components
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-PGWUI modules all have the following configuration settings:
+``pyramid_includes = pgwui_common`` in a ``.ini`` file or
+``config.include('pgwui_common')`` in Python code.
 
- menu_label
-   The label for PGWUI_Menu to display, when different from the default
+Unless PGWUI_Server is used, PGWUI_Common (and PGWUI_Core) expect all
+required settings exist, as well as those which have defaults
+established by PGWUI_Server.
 
 
 Common Template Variables
@@ -84,24 +95,28 @@ Common Template Variables
 The `@base_view` decorator, and it's decendents, makes the following variables
 available in templates:
 
- pgwui::  A dict, containing the following keys:
+ pgwui
+   A dict, containing the following keys:
  
-   routes:: A dict, keyed by PGWUI component name.  Each value is the
-   URL used to reach the component.  There are the following special
-   component names:
+   urls
+     A dict, keyed by PGWUI component name.  Each value is the
+     URL used to reach the component.  There are the following special
+     component names:
 
-     pgwui_home:: The URL to the pgwui.home_page setting.  This key
-     is always available.
+     pgwui_home
+       The URL to the pgwui.home_page setting.  This key
+       is always available.
 
-     pgwui_menu:: The URL to the menu of PGWUI components.  This
-     obtains its value from the ``pgwui.menu_page`` configuration
-     setting, if present.  Otherwise it is the URL to the PGWUI_Menu
-     component, if the component is present.  Otherwise the key
-     does not exist.
+     pgwui_menu
+       The URL to the menu of PGWUI components.  This
+       obtains its value from the ``pgwui.menu_page`` configuration
+       setting, if present.  Otherwise it is the URL to the PGWUI_Menu
+       component, if the component is present.  Otherwise the key
+       does not exist.
 
 
 Configuration By Python Code
-----------------------------
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 If you are writing a complete `Pyramid`_ application, or simply want full
 control over configuration you will need to make a Python
@@ -161,3 +176,4 @@ provided by `The Dian Fossey Gorilla Fund
 
 .. _PostgreSQL: https://www.postgresql.org/
 .. _Pyramid: https://trypyramid.com/
+.. _Pyramid's: `Pyramid`_
index 0afd2c9dd9239423cf44bef92abffda276b7b16d..0168cf7a6dbf5617aa0f78b77c05e363f1a7e9dc 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -145,6 +145,7 @@ setup(
         'pyramid',
         'pyramid_beaker',
         'pyramid_mako',
+        'attrs',
     ],
 
     # List additional groups of dependencies here (e.g. development
index 679dca967ea52b4edd876a4be07db5a85c457c18..2be79abc586379ddc9f084ae906102b857c3cc13 100644 (file)
@@ -32,7 +32,7 @@ def require_settings(component, required_settings, conf):
     for setting in required_settings:
         if setting not in conf:
             errors.append(exceptions.MissingSettingError(
-                '{}.{}'.format(component, setting)))
+                '{}:{}'.format(component, setting)))
     return errors
 
 
@@ -41,7 +41,7 @@ def unknown_settings(component, settings, conf):
     for setting in conf:
         if setting not in settings:
             errors.append(exceptions.UnknownSettingKeyError(
-                '{}.{}'.format(component, setting)))
+                '{}:{}'.format(component, setting)))
     return errors
 
 
@@ -56,5 +56,5 @@ def boolean_settings(component, booleans, conf):
             if (val is not True
                     and val is not False):
                 errors.append(exceptions.NotBooleanSettingError(
-                    '{}.{}'.format(component, setting), conf[setting]))
+                    '{}:{}'.format(component, setting), conf[setting]))
     return errors
index 95a5022416dc31af5ce9e8bb1191c1f17dd8fe61..02122f0fb0d90e6f908a0cd3577af86c90c2aebd 100644 (file)
@@ -42,8 +42,106 @@ class MissingSettingError(Error):
         super().__init__('Missing PGWUI setting: {}'.format(key))
 
 
+class BadPageTypeError(Error):
+    def __init__(self, key, val):
+        super().__init__(f'Bad {key} setting ({val})')
+
+
+class BadPageSourceError(Error):
+    def __init__(self, msg):
+        super().__init__(msg)
+
+
+class BadURLSourceError(BadPageSourceError):
+    def __init__(self, key, val):
+        super().__init__(
+            f'Bad {key} setting for the "URL" type, ({val}) '
+            'does not look like an URL')
+
+
+class BadFileSourceError(BadPageSourceError):
+    def __init__(self, key, val):
+        super().__init__(
+            f'Bad {key} setting for a "file" type, ({val}) '
+            'does not look like a file system path beginning with "/"')
+
+
+class BadFileURLPathError(BadPageSourceError):
+    def __init__(self, key, val):
+        super().__init__(
+            f'Bad {key} setting for a "file" type, ({val}) '
+            'does not look like a "path" component of an URL '
+            'that begins with "/"')
+
+
+class BadRouteSourceError(BadPageSourceError):
+    def __init__(self, key, val):
+        super().__init__(
+            f'Bad {key} setting for a "route" type, ({val}) '
+            'does not look like a Pyramid route name')
+
+
+class BadAssetSourceError(BadPageSourceError):
+    def __init__(self, key, val):
+        super().__init__(
+            f'Bad {key} setting for an "asset" type, ({val}) '
+            'does not look like a Pyramid asset specification')
+
+
 class NotBooleanSettingError(Error):
     def __init__(self, key, value):
         super().__init__(
             'The "{}" PGWUI setting must be "True" or "False"'
             .format(key))
+
+
+class ViewError(Error):
+    pass
+
+
+class BadPageError(ViewError):
+    def __init__(self, page, ex, msg):
+        self.page = page
+        self.ex = ex
+        super().__init__(msg)
+
+
+class BadPageFileNotFoundError(BadPageError):
+    def __init__(self, page, ex):
+        super().__init__(
+            page, ex,
+            f'The "pgwui:{page}:source" configuration setting refers to '
+            f'a file ({ex.filename}) that does not exist')
+
+
+class BadPageFilePermissionError(BadPageError):
+    def __init__(self, page, ex):
+        super().__init__(
+            page, ex,
+            f'The "pgwui:{page}:source" configuration setting refers to '
+            f'a file ({ex.filename}) which cannot be read due to file '
+            'system permissions')
+
+
+class BadPageIsADirectoryError(BadPageError):
+    def __init__(self, page, ex):
+        super().__init__(
+            page, ex,
+            f'The "pgwui:{page}:source" configuration setting refers to '
+            f'a directory ({ex.filename}), not a file')
+
+
+class BadRouteError(BadPageError):
+    def __init__(self, page, ex):
+        super().__init__(
+            page, ex,
+            f'The "pgwui:{page}:source" configuration setting refers to '
+            'a route that does not exist')
+
+
+class BadAssetError(BadPageError):
+    def __init__(self, page, ex):
+        super().__init__(
+            page, ex,
+            f'The "pgwui:{page}:source" configuration setting refers to '
+            'an asset that does not exist')
index cba4c906acfb158f1d5fdf3672a0f8bf19c315b3..edbd819b119c154e25d93873d2f942242c4bca38 100644 (file)
 
 # Karl O. Pinc <kop@karlpinc.com>
 
-'''Provide a way to configure PGWUI.
+'''Configure supporting modules and other common elements
+View decorators to expose useful variables to templates
 '''
 
+import pgwui_common.views.page_views
 from .plugin import find_pgwui_components
+from . import exceptions as ex
 
 
-DEFAULT_HOME_ROUTE = '/'
+def route_path(request, page_name, source):
+    '''Return the route path of the page's "source"
+    '''
+    try:
+        return request.route_path(source)
+    except KeyError as old_ex:
+        raise ex.BadRouteError(page_name, old_ex)
 
 
-def set_menu_route(request, routes):
-    '''Add routes for pgwui_menu, return non-menu components
+def asset_path(request, page_name, source):
+    '''Return the static path to the asset's "source"
+    '''
+    try:
+        return request.static_path(source)
+    except ValueError as old_ex:
+        raise ex.BadAssetError(page_name, old_ex)
+
+
+def url_of_page(request, page_name):
+    '''Return a url to the page.  This may or may not be fully
+    qualified, depending on what the user specifies in the settings.
+    '''
+    page_conf = request.registry.settings['pgwui'][page_name]
+    type = page_conf['type']
+    source = page_conf['source']
+    if type == 'URL':
+        return source
+    if type == 'file':
+        return request.route_path(f'pgwui_common.{page_name}')
+    if type == 'route':
+        return route_path(request, page_name, source)
+    if type == 'asset':
+        return asset_path(request, page_name, source)
+
+
+def set_menu_url(request, urls):
+    '''Add urls for pgwui_menu, return non-menu components
     '''
     try:
-        menu_url = request.route_url('pgwui_menu')
+        menu_url = url_of_page(request, 'menu_page')
     except KeyError:
-        pass
-    else:
-        if menu_url != request.route_url('pgwui_home'):
-            routes['pgwui_menu'] = request.route_path('pgwui_menu')
+        try:
+            menu_url = request.route_path('pgwui_menu')
+        except KeyError:
+            return
+    if menu_url != urls['pgwui_home']:
+        urls['pgwui_menu'] = menu_url
 
 
-def set_component_routes(request, routes):
-    '''Add routes for each pgwui component to the 'routes' dict
+def set_component_urls(request, urls):
+    '''Add urls for each pgwui component to the 'urls' dict
     '''
-    set_menu_route(request, routes)
+    set_menu_url(request, urls)
     components = find_pgwui_components()
     if 'pgwui_menu' in components:
         components.remove('pgwui_menu')
 
     for component in components:
         try:
-            route = request.route_path(component)
+            url = request.route_path(component)
         except KeyError:
             pass         # In case a component has no route
         else:
-            routes.setdefault(component, route)
+            urls.setdefault(component, url)
 
 
-def set_routes(request, routes):
-    '''Build 'routes' dict with all the routes
+def set_urls(request, urls):
+    '''Build 'urls' dict with all the urls
     '''
-    home_route = request.route_path('pgwui_home')
-    routes.setdefault('pgwui_home', home_route)
-    set_component_routes(request, routes)
+    home_url = url_of_page(request, 'home_page')
+    urls.setdefault('pgwui_home', home_url)
+    set_component_urls(request, urls)
 
 
 def base_view(wrapped):
@@ -73,8 +110,8 @@ def base_view(wrapped):
         '''
         response = wrapped(request)
         pgwui = response.get('pgwui', {})
-        routes = pgwui.setdefault('routes', dict())
-        set_routes(request, routes)
+        urls = pgwui.setdefault('urls', dict())
+        set_urls(request, urls)
         response['pgwui'] = pgwui
         return response
     return wrapper
@@ -90,6 +127,32 @@ def auth_base_view(wrapped):
     return wrapper
 
 
+def configure_page(config, pgwui_settings, page_name):
+    '''Setup route and view for a file given in pgwui."page_name" setting,
+    which is the name of the new route.
+
+    Only files need anything done.  URLs are used as written into the
+    config, and routes and assets already exist.
+    '''
+    if page_name in pgwui_settings:
+        page_settings = pgwui_settings[page_name]
+        type = page_settings['type']
+        if type == 'file':
+            route_name = f'pgwui_common.{page_name}'
+            with config.route_prefix_context(None):
+                config.add_route(route_name, page_settings['url_path'])
+            config.add_view(pgwui_common.views.page_views.PageViewer,
+                            attr=page_name, route_name=route_name)
+
+
+def configure_pages(config):
+    '''Setup routes and views for "pgwui.xxxx_page" settings
+    '''
+    pgwui_settings = config.get_settings()['pgwui']
+    configure_page(config, pgwui_settings, 'home_page')
+    configure_page(config, pgwui_settings, 'menu_page')
+
+
 def includeme(config):
     '''Pyramid configuration for PGWUI_Common
     '''
@@ -99,4 +162,5 @@ def includeme(config):
         'static/pgwui_common',
         'pgwui_common:static/',
         cache_max_age=3600)
-    config.add_route('pgwui_home', DEFAULT_HOME_ROUTE)
+    configure_pages(config)
+    config.scan()
index 0837aba0dfcdaf3ab4a0be4895d24fee0fdcf6c7..243030e81ebac8400e5a108c32d7ac479ccfe779 100644 (file)
@@ -52,8 +52,8 @@
 
 <%def name="navbar_content()">
   ${parent.navbar_content()}
-  % if 'pgwui_logout' in pgwui['routes']:
-    | <a href="${pgwui['routes']['pgwui_logout']}">Logout</a>
+  % if 'pgwui_logout' in pgwui['urls']:
+    | <a href="${pgwui['urls']['pgwui_logout']}">Logout</a>
   % endif
 </%def>
 
index a66324ca277f73cbbf234f28694bab43f9149dbc..51c4ee4988bef758a2babc042928be908578ece1 100644 (file)
   This template uses the following variables in it's context:
 
     pgwui    Dict
-      routes   Dict of routes, keyed by pgwui component name
+      urls   Dict of urls, keyed by pgwui component name
 
 </%doc>
 
 <%def name="navbar_content()">
-  <a href="${pgwui['routes']['pgwui_home']}">HOME</a>
-  % if 'pgwui_menu' in pgwui['routes']:
-    | <a href="${pgwui['routes']['pgwui_menu']}">Menu</a>
+  <a href="${pgwui['urls']['pgwui_home']}">HOME</a>
+  % if 'pgwui_menu' in pgwui['urls']:
+    | <a href="${pgwui['urls']['pgwui_menu']}">Menu</a>
   % endif
 </%def>
 
diff --git a/src/pgwui_common/views/__init__.py b/src/pgwui_common/views/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pgwui_common/views/ex_views.py b/src/pgwui_common/views/ex_views.py
new file mode 100644 (file)
index 0000000..a7ecfc6
--- /dev/null
@@ -0,0 +1,36 @@
+# Copyright (C) 2020 The Meme Factory, Inc.  http://www.karlpinc.com/
+
+# This file is part of PGWUI_Common.
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program 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
+# Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public
+# License along with this program.  If not, see
+# <http://www.gnu.org/licenses/>.
+#
+
+# Karl O. Pinc <kop@karlpinc.com>
+
+'''Views for exceptions that are raised
+'''
+
+from pyramid.view import exception_view_config
+
+from pgwui_common import exceptions as ex
+
+NOT_FOUND = '404 Not Found'
+
+
+@exception_view_config(ex.BadPageError, renderer='string')
+def bad_config_view(ex, request):
+    request.response.status_code = 404
+    request.response.status = NOT_FOUND
+    return f'PGWUI Configuration Error:\n{ex}:\n{ex.ex}'
diff --git a/src/pgwui_common/views/page_views.py b/src/pgwui_common/views/page_views.py
new file mode 100644 (file)
index 0000000..956c17c
--- /dev/null
@@ -0,0 +1,55 @@
+# Copyright (C) 2020 The Meme Factory, Inc.  http://www.karlpinc.com/
+
+# This file is part of PGWUI_Common.
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program 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
+# Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public
+# License along with this program.  If not, see
+# <http://www.gnu.org/licenses/>.
+#
+
+# Karl O. Pinc <kop@karlpinc.com>
+
+'''Return a page that's a file in the file system
+'''
+
+import attr
+from pyramid.response import FileResponse
+
+from pgwui_common import exceptions as ex
+
+
+@attr.s
+class PageViewer():
+    '''A class of views that return file content
+    '''
+    request = attr.ib()
+
+    def page(self, page_name):
+        try:
+            return FileResponse(
+                self.request.registry.settings['pgwui'][page_name]['source'],
+                request=self.request,
+                content_type='text/html',
+                cache_max_age=3600)
+        except FileNotFoundError as old_ex:
+            raise ex.BadPageFileNotFoundError(page_name, old_ex)
+        except PermissionError as old_ex:
+            raise ex.BadPageFilePermissionError(page_name, old_ex)
+        except IsADirectoryError as old_ex:
+            raise ex.BadPageIsADirectoryError(page_name, old_ex)
+
+    def menu_page(self):
+        return self.page('menu_page')
+
+    def home_page(self):
+        return self.page('home_page')
index d735ee015bf22ca82c576a8f9ba4ff6a0bc704c3..ab95852ea717572f5c60fa656a464ba3748a6748 100644 (file)
 
 import pytest
 import pyramid.config
+import pyramid.testing
 from pyramid.threadlocal import get_current_request
 
 import pgwui_common.pgwui_common as pgwui_common
+import pgwui_common.exceptions as common_ex
 
 from pgwui_testing import testing
 
@@ -38,11 +40,19 @@ FOO_URL = 'foo://bar/'
 mock_find_pgwui_components = testing.make_mock_fixture(
     pgwui_common, 'find_pgwui_components')
 
-mock_route_path = testing.instance_method_mock_fixture('route_path')
+mock_method_route_path = testing.instance_method_mock_fixture('route_path')
 mock_route_url = testing.instance_method_mock_fixture('route_url')
+mock_include = testing.instance_method_mock_fixture('include')
+mock_add_static_view = testing.instance_method_mock_fixture('add_static_view')
+mock_add_route = testing.instance_method_mock_fixture('add_route')
+mock_add_view = testing.instance_method_mock_fixture('add_view')
+mock_static_path = testing.instance_method_mock_fixture('static_path')
 
 
 def mock_view(request):
+    if (hasattr(request, 'registry')
+            and 'pgwui' in request.registry.settings):
+        return request.registry.settings
     return {'pgwui': {'foo': FOO_URL}}
 
 
@@ -52,129 +62,249 @@ def check_base_view_results(request, pgwui):
 
 # Unit tests
 
-# set_menu_route()
+# route_path()
 
+@pytest.mark.unittest
+def test_route_path_with_path(pyramid_request_config, mock_method_route_path):
+    '''static_path() result is returned
+    '''
+    expected = 'route'
+
+    request = get_current_request()
+    mocked_route_path = mock_method_route_path(request)
+    mocked_route_path.return_value = expected
+
+    result = pgwui_common.route_path(request, None, None)
+
+    assert result == expected
+
+
+@pytest.mark.unittest
+def test_route_path_no_path(pyramid_request_config, mock_method_route_path):
+    '''BadRouteError() raised when there's no path
+    '''
+    request = get_current_request()
+    mocked_route_path = mock_method_route_path(request)
+    mocked_route_path.side_effect = KeyError
+
+    with pytest.raises(common_ex.BadRouteError):
+        pgwui_common.route_path(request, None, None)
+
+    assert True
+
+
+mock_route_path = testing.make_mock_fixture(
+    pgwui_common, 'route_path')
+
+
+# asset_path()
+
+@pytest.mark.unittest
+def test_asset_path_with_path(pyramid_request_config, mock_static_path):
+    '''static_path() result is returned
+    '''
+    expected = 'static'
+
+    request = get_current_request()
+    mocked_static_path = mock_static_path(request)
+    mocked_static_path.return_value = expected
+
+    result = pgwui_common.asset_path(request, None, None)
+
+    assert result == expected
+
+
+@pytest.mark.unittest
+def test_asset_path_no_path(pyramid_request_config, mock_static_path):
+    '''BadAssetError() raised when there's no path
+    '''
+    request = get_current_request()
+    mocked_static_path = mock_static_path(request)
+    mocked_static_path.side_effect = ValueError
+
+    with pytest.raises(common_ex.BadAssetError):
+        pgwui_common.asset_path(request, None, None)
+
+    assert True
+
+
+mock_asset_path = testing.make_mock_fixture(
+    pgwui_common, 'asset_path')
+
+
+# url_of_page()
+
+@pytest.mark.parametrize(
+    ('pgwui', 'page_name', 'expected'), [
+        ({'test_page': {'type': 'URL',
+                        'source': 'somesource'}},
+         'test_page',
+         'somesource'),
+        ({'test_page': {'type': 'file',
+                        'source': 'somesource'}},
+         'test_page',
+         'pgwui_common.test_page'),
+        ({'test_page': {'type': 'route',
+                        'source': 'somesource'}},
+         'test_page',
+         'routepath'),
+        ({'test_page': {'type': 'asset',
+                        'source': 'somesource'}},
+         'test_page',
+         'static'),
+        ({'test_page': {'type': 'impossible',
+                        'source': 'somesource'}},
+         'test_page',
+         None)])
+@pytest.mark.unittest
+def test_url_of_page(
+        pyramid_request_config, mock_method_route_path,
+        mock_route_path, mock_asset_path, pgwui, page_name, expected):
+    '''The right results and calls are made
+    '''
+    mock_asset_path.return_value = 'static'
+    mock_route_path.return_value = 'routepath'
+
+    request = get_current_request()
+    mocked_route_path = mock_method_route_path(request)
+    mocked_route_path.side_effect = lambda x: x
+
+    request.registry.settings['pgwui'] = pgwui
+    result = pgwui_common.url_of_page(request, page_name)
+
+    assert result == expected
+
+
+mock_url_of_page = testing.make_mock_fixture(
+    pgwui_common, 'url_of_page')
+
+
+# set_menu_url()
 
 @pytest.mark.unittest
 @pytest.mark.parametrize(
-    "test_routes,expected",
+    "test_urls,expected",
     [
-        # menu and home have identical routes, no route is added for menu
+        # menu and home have identical urls, no url is added for menu
         ({'pgwui_menu': '/', 'pgwui_home': '/'},
          {}),
-        # No menu route, no route is added for menu
+        # No menu url, no url is added for menu
         ({'pgwui_home': '/'},
          {}),
-        # menu and home have different urls, route is added for menu
+        # menu and home have different urls, url is added for menu
         ({'pgwui_menu': '/menu', 'pgwui_home': '/'},
          {'pgwui_menu': '/menu'})])
-def test_set_menu_route(
-        pyramid_request_config, mock_route_path, mock_route_url,
-        test_routes, expected):
-    '''The expected routes are returned
+def test_set_menu_url(
+        pyramid_request_config, mock_method_route_path, mock_url_of_page,
+        test_urls, expected):
+    '''The expected urls are returned
     '''
     def path_func(name):
-        return test_routes[name]
-
-    def url_func(name):
-        return f'{request.application_url}{test_routes[name]}'
+        return test_urls[name]
 
+    mock_url_of_page.side_effect = lambda *args: test_urls['pgwui_menu']
     request = get_current_request()
-    mocked_route_path = mock_route_path(request)
+    mocked_route_path = mock_method_route_path(request)
     mocked_route_path.side_effect = path_func
-    mocked_route_url = mock_route_url(request)
-    mocked_route_url.side_effect = url_func
 
-    routes = dict()
-    pgwui_common.set_menu_route(request, routes)
+    urls = {'pgwui_home': test_urls['pgwui_home']}
+    expected.update(urls)
+    pgwui_common.set_menu_url(request, urls)
 
-    assert routes == expected
+    assert urls == expected
 
 
-mock_set_menu_route = testing.make_mock_fixture(
-    pgwui_common, 'set_menu_route')
+mock_set_menu_url = testing.make_mock_fixture(
+    pgwui_common, 'set_menu_url')
 
 
-# set_component_routes()
+# set_component_urls()
 
+@pytest.mark.parametrize(
+    'test_urls', [
+        # With a pgwui_menu
+        {'pgwui_menu': '/menu',
+         'pgwui_logout': '/logout',
+         'pgwui_foo': '/foo',
+         'pgwui_home': '/'},
+        # Without a pgwui_menu
+        {'pgwui_logout': '/logout',
+         'pgwui_foo': '/foo',
+         'pgwui_home': '/'}])
 @pytest.mark.unittest
-def test_set_component_routes(
-        pyramid_request_config, mock_route_path, mock_set_menu_route,
-        mock_find_pgwui_components):
-    '''Routes are set for every component which has a route, except for
+def test_set_component_urls(
+        pyramid_request_config, mock_method_route_path, mock_set_menu_url,
+        mock_find_pgwui_components, test_urls):
+    '''Urls are set for every component which has a route, except for
     pgwui_menu
     '''
-    test_routes = {'pgwui_menu': '/menu',
-                   'pgwui_logout': '/logout',
-                   'pgwui_foo': '/foo',
-                   'pgwui_home': '/'}
-    test_components = list(test_routes) + ['pgwui_noroute']
+    test_components = list(test_urls) + ['pgwui_noroute']
 
-    def route_func(route):
-        return test_routes[route]
+    def url_func(url):
+        return test_urls[url]
 
     request = get_current_request()
-    mocked_route_path = mock_route_path(request)
-    mocked_route_path.side_effect = route_func
+    mocked_route_path = mock_method_route_path(request)
+    mocked_route_path.side_effect = url_func
     mock_find_pgwui_components.return_value = test_components
 
-    routes = dict()
-    pgwui_common.set_component_routes(request, routes)
+    urls = dict()
+    pgwui_common.set_component_urls(request, urls)
 
-    expected_routes = test_routes.copy()
-    del expected_routes['pgwui_menu']
+    expected_urls = test_urls.copy()
+    if 'pgwui_menu' in expected_urls:
+        del expected_urls['pgwui_menu']
 
-    assert routes == expected_routes
+    mock_set_menu_url.assert_called_once()
+    assert urls == expected_urls
 
 
-mock_set_component_routes = testing.make_mock_fixture(
-    pgwui_common, 'set_component_routes')
+mock_set_component_urls = testing.make_mock_fixture(
+    pgwui_common, 'set_component_urls')
 
 
-# set_routes()
+# set_urls()
 
 @pytest.mark.unittest
-def test_set_routes(
-        pyramid_request_config, mock_set_component_routes, mock_route_path):
-    '''The 'home' route is added and set_component_routes() called
+def test_set_urls(
+        pyramid_request_config, mock_url_of_page, mock_set_component_urls):
+    '''The 'home' url is added and set_component_urls() called
     '''
+    test_home_route = '/'
     request = get_current_request()
 
-    mocked_route_path = mock_route_path(request)
-    mocked_route_path.return_value = pgwui_common.DEFAULT_HOME_ROUTE
+    mock_url_of_page.return_value = test_home_route
 
-    routes = dict()
-    pgwui_common.set_routes(request, routes)
+    urls = dict()
+    pgwui_common.set_urls(request, urls)
 
-    assert routes['pgwui_home'] == pgwui_common.DEFAULT_HOME_ROUTE
-    mock_set_component_routes.assert_called_once()
+    assert urls['pgwui_home'] == test_home_route
+    mock_set_component_urls.assert_called_once()
 
 
-mock_set_routes = testing.make_mock_fixture(
-    pgwui_common, 'set_routes')
+mock_set_urls = testing.make_mock_fixture(
+    pgwui_common, 'set_urls')
 
 
 # base_view()
 @pytest.mark.unittest
-def test_base_view_routes(pyramid_request_config, mock_set_routes):
-    '''The response has the 'pgwui['routes']' dict added to it'''
+def test_base_view_urls(mock_set_urls):
+    '''The response has the 'pgwui['urls']' dict added to it'''
     def mock_view(request):
         return {}
 
-    pgwui_common.includeme(pyramid_request_config)
     wrapper = pgwui_common.base_view(mock_view)
     response = wrapper(get_current_request())
 
     assert 'pgwui' in response
     pgwui = response['pgwui']
-    assert 'routes' in pgwui
-    assert isinstance(pgwui['routes'], dict)
+    assert 'urls' in pgwui
+    assert isinstance(pgwui['urls'], dict)
 
 
 @pytest.mark.unittest
-def test_base_view_default(pyramid_request_config):
+def test_base_view_default(mock_set_urls):
     '''The response retains the mock view's variables'''
-    pgwui_common.includeme(pyramid_request_config)
     wrapper = pgwui_common.base_view(mock_view)
     request = get_current_request()
     response = wrapper(request)
@@ -188,11 +318,9 @@ mock_base_view = testing.make_mock_fixture(pgwui_common, 'base_view')
 # auth_base_view()
 
 @pytest.mark.unittest
-def test_auth_base_view(pyramid_request_config, mock_base_view):
+def test_auth_base_view(mock_base_view):
     '''Wrapper calls base_view()
     '''
-    pgwui_common.includeme(pyramid_request_config)
-
     wrapper = pgwui_common.auth_base_view(mock_view)
     request = get_current_request()
     wrapper(request)
@@ -200,32 +328,75 @@ def test_auth_base_view(pyramid_request_config, mock_base_view):
     mock_base_view.assert_called_once()
 
 
-# includeme()
+# configure_page()
 
 @pytest.mark.unittest
-def test_includeme_configurecalled():
-    '''Pyramid Configure() methods are called'''
-    class MockConfig():
-        def __init__(self):
-            self.include_called = False
-            self.add_static_view_called = False
-            self.home_route = None
+def test_configure_page_no_page():
+    '''When there's no setting for the page, nothing is done
+    '''
+    pgwui_common.configure_page(None, {}, 'test_page')
 
-        def include(self, *args):
-            self.include_called = True
 
-        def add_static_view(self, *args, **kwargs):
-            self.add_static_view_called = True
+@pytest.mark.unittest
+def test_configure_page_not_file():
+    '''When the type of the page is not "file",nothing is done
+    '''
+    pgwui_common.configure_page(
+        None, {'test_page': {'type': 'other'}}, 'test_page')
 
-        def add_route(self, name, route):
-            if name == 'pgwui_home':
-                self.home_route = route
 
-    config = MockConfig()
-    pgwui_common.includeme(config)
-    assert config.include_called
-    assert config.add_static_view_called
-    assert config.home_route == '/'
+@pytest.mark.unittest
+def test_configure_page_file(
+        pyramid_request_config, mock_add_route, mock_add_view):
+    '''When the type of the page is "file", a route and view are added
+    '''
+    mocked_add_route = mock_add_route(pyramid_request_config)
+    mocked_add_view = mock_add_view(pyramid_request_config)
+    pgwui_common.configure_page(
+        pyramid_request_config,
+        {'test_page': {'type': 'file', 'url_path': 'somepath'}},
+        'test_page')
+
+    mocked_add_route.assert_called_once()
+    mocked_add_view.assert_called_once()
+
+
+mock_configure_page = testing.make_mock_fixture(
+    pgwui_common, 'configure_page')
+
+
+# configure_pages()
+
+@pytest.mark.unittest
+def test_configure_pages(pyramid_request_config, mock_configure_page):
+    '''Calls configure_page() with all the pages
+    '''
+    pgwui = 'pgwui'
+    pyramid_request_config.get_settings()['pgwui'] = pgwui
+    pgwui_common.configure_pages(pyramid_request_config)
+
+    assert (set([call[0] for call in mock_configure_page.call_args_list])
+            == set([(pyramid_request_config, pgwui, 'home_page'),
+                    (pyramid_request_config, pgwui, 'menu_page')]))
+
+
+mock_configure_pages = testing.make_mock_fixture(
+    pgwui_common, 'configure_pages')
+
+
+# includeme()
+
+@pytest.mark.unittest
+def test_includeme_configurecalled(
+        mock_add_static_view, mock_include, mock_configure_pages):
+    '''Pyramid Configure() methods are called'''
+    with pyramid.testing.testConfig() as config:
+        mocked_include = mock_include(config)
+        mocked_add_static_view = mock_add_static_view(config)
+        pgwui_common.includeme(config)
+        assert mocked_include.call_count == 2
+        mocked_add_static_view.assert_called_once()
+    mock_configure_pages.assert_called_once()
 
 
 # Integration tests
@@ -235,29 +406,36 @@ def test_includeme_configurecalled():
 @pytest.mark.integrationtest
 def test_auth_base_view_integration(
         pyramid_request_config, mock_find_pgwui_components):
-    '''There are routes for every component
+    '''There are urls for every component
     '''
-    test_routes = {
+    test_pgwui = {'home_page': {'type': 'URL', 'source': '/'}}
+
+    test_urls = {
         'pgwui_menu': '/menu',
         'pgwui_logout': '/logout',
         'pgwui_foo': '/foo'}
 
-    mock_find_pgwui_components.return_value = list(test_routes)
+    mock_find_pgwui_components.return_value = list(test_urls)
 
+    pyramid_request_config.add_settings(pgwui=test_pgwui)
     pgwui_common.includeme(pyramid_request_config)
-    for name, route in test_routes.items():
-        pyramid_request_config.add_route(name, route)
+    for name, url in test_urls.items():
+        pyramid_request_config.add_route(name, url)
 
     wrapper = pgwui_common.auth_base_view(mock_view)
     request = get_current_request()
     result = wrapper(request)
 
-    assert result['pgwui']['routes'] == dict(test_routes, pgwui_home='/')
+    assert result['pgwui']['urls'] == dict(test_urls, pgwui_home='/')
 
 
 # includeme()
 
 @pytest.mark.integrationtest
 def test_includeme():
+    pgwui = {'home_page': {'type': 'file',
+                           'url_path': '/'}}
+
     config = pyramid.config.Configurator()
+    config.registry.settings['pgwui'] = pgwui
     pgwui_common.includeme(config)
diff --git a/tests/views/test_ex_views.py b/tests/views/test_ex_views.py
new file mode 100644 (file)
index 0000000..030073c
--- /dev/null
@@ -0,0 +1,52 @@
+# Copyright (C) 2020 The Meme Factory, Inc.  http://www.karlpinc.com/
+
+# This file is part of PGWUI_Common.
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program 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
+# Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public
+# License along with this program.  If not, see
+# <http://www.gnu.org/licenses/>.
+#
+
+# Karl O. Pinc <kop@karlpinc.com>
+
+import pytest
+from pyramid.threadlocal import get_current_request
+
+import pgwui_common.views.ex_views as ex_views
+import pgwui_common.exceptions as common_ex
+
+import pgwui_testing.testing as testing
+
+# Activiate our pytest plugin
+# pytest_plugins = ("pgwui",)
+
+
+mock_exception_view_config = testing.make_mock_fixture(
+    ex_views, 'exception_view_config')
+
+
+# Unit tests
+
+# bad_config_view()
+
+@pytest.mark.unittest
+def test_bad_config_view(pyramid_request_config, mock_exception_view_config):
+    '''Modifies the request, returns an expected response
+    '''
+    request = get_current_request()
+    result = ex_views.bad_config_view(
+        common_ex.BadPageError(None, None, None), request)
+
+    assert isinstance(result, str)
+    assert request.response.status_code == 404
+    assert request.response.status == ex_views.NOT_FOUND
diff --git a/tests/views/test_page_views.py b/tests/views/test_page_views.py
new file mode 100644 (file)
index 0000000..dcd0512
--- /dev/null
@@ -0,0 +1,107 @@
+# Copyright (C) 2020 The Meme Factory, Inc.  http://www.karlpinc.com/
+
+# This file is part of PGWUI_Common.
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program 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
+# Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public
+# License along with this program.  If not, see
+# <http://www.gnu.org/licenses/>.
+#
+
+# Karl O. Pinc <kop@karlpinc.com>
+
+import pytest
+from pyramid.threadlocal import get_current_request
+
+import pgwui_common.exceptions as common_ex
+import pgwui_common.views.page_views as page_views
+
+from pgwui_testing import testing
+
+# Activiate our pytest plugin
+# pytest_plugins = ("pgwui",)
+
+
+# Helper functions and constants
+
+mock_page = testing.instance_method_mock_fixture('page')
+mock_FileResponse = testing.make_mock_fixture(
+    page_views, 'FileResponse')
+
+
+# Unit tests
+
+# PageViewer.home_page()
+
+@pytest.mark.unittest
+def test_home_page(pyramid_request_config, mock_page):
+    '''Called with correct name
+    '''
+    request = get_current_request()
+    view = page_views.PageViewer(request)
+    mocked_page = mock_page(view)
+    view.home_page()
+    assert mocked_page.call_args[0][0] == 'home_page'
+
+
+# PageViewer.menu_page()
+
+@pytest.mark.unittest
+def test_menu_page(pyramid_request_config, mock_page):
+    '''Called with correct name
+    '''
+    request = get_current_request()
+    view = page_views.PageViewer(request)
+    mocked_page = mock_page(view)
+    view.menu_page()
+    assert mocked_page.call_args[0][0] == 'menu_page'
+
+
+# PageViewer.page()
+
+@pytest.mark.unittest
+def test_page_success(pyramid_request_config, mock_FileResponse):
+    '''FileResponse() called
+    '''
+    expected = 'some value'
+    mock_FileResponse.return_value = expected
+
+    pgwui = {'test_page': {'source': 'anything'}}
+    request = get_current_request()
+    request.registry.settings['pgwui'] = pgwui
+    view = page_views.PageViewer(request)
+    result = view.page('test_page')
+
+    mock_FileResponse.assert_called_once()
+    assert result == expected
+
+
+@pytest.mark.parametrize(
+    ('ex', 'expected'), [
+        (FileNotFoundError, common_ex.BadPageFileNotFoundError),
+        (PermissionError, common_ex.BadPageFilePermissionError),
+        (IsADirectoryError, common_ex.BadPageIsADirectoryError)])
+@pytest.mark.unittest
+def test_page_exception(pyramid_request_config, mock_FileResponse,
+                        ex, expected):
+    '''The correct exception is raised
+    '''
+    mock_FileResponse.side_effect = ex
+
+    pgwui = {'test_page': {'source': 'anything'}}
+    request = get_current_request()
+    request.registry.settings['pgwui'] = pgwui
+    view = page_views.PageViewer(request)
+    with pytest.raises(expected):
+        view.page('test_page')
+
+    mock_FileResponse.assert_called_once()