diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index b64846b..673a6df 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -9,6 +9,7 @@ app helpers + menus static subscribers util diff --git a/docs/api/wuttaweb/menus.rst b/docs/api/wuttaweb/menus.rst new file mode 100644 index 0000000..5b0b5c9 --- /dev/null +++ b/docs/api/wuttaweb/menus.rst @@ -0,0 +1,6 @@ + +``wuttaweb.menus`` +================== + +.. automodule:: wuttaweb.menus + :members: diff --git a/pyproject.toml b/pyproject.toml index 9eaf8fb..a623b4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,10 @@ tests = ["pytest-cov", "tox"] main = "wuttaweb.app:main" +[project.entry-points."wutta.providers"] +wuttaweb = "wuttaweb.app:WebAppProvider" + + [project.urls] Homepage = "https://rattailproject.org/" Repository = "https://kallithea.rattailproject.org/rattail-project/wuttaweb" diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 0970710..ff9d37f 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -26,11 +26,38 @@ Application import os +from wuttjamaican.app import AppProvider from wuttjamaican.conf import make_config from pyramid.config import Configurator +class WebAppProvider(AppProvider): + """ + The :term:`app provider` for WuttaWeb. This adds + some methods to get web-specific :term:`handlers`. + """ + + def get_web_menu_handler(self, **kwargs): + """ + Get the configured "menu" handler for the web app. + + Specify a custom handler in your config file like this: + + .. code-block:: ini + + [wuttaweb] + menus.handler_spec = poser.web.menus:PoserMenuHandler + + :returns: Instance of :class:`~wuttaweb.menus.MenuHandler`. + """ + if 'web_menu_handler' not in self.__dict__: + spec = self.config.get('wuttaweb.menus.handler_spec', + default='wuttaweb.menus:MenuHandler') + self.web_menu_handler = self.app.load_object(spec)(self.config) + return self.web_menu_handler + + def make_wutta_config(settings): """ Make a WuttaConfig object from the given settings. diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py new file mode 100644 index 0000000..37625a5 --- /dev/null +++ b/src/wuttaweb/menus.py @@ -0,0 +1,297 @@ +# -*- 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 . +# +################################################################################ +""" +Main Menu +""" + +import re +import logging + +from wuttjamaican.app import GenericHandler + + +log = logging.getLogger(__name__) + + +class MenuHandler(GenericHandler): + """ + Base class and default implementation for menu handler. + + It is assumed that most apps will override the menu handler with + their own subclass. In particular the subclass will override + :meth:`make_menus()` and/or :meth:`make_admin_menu()`. + + The app should normally not instantiate the menu handler directly, + but instead call + :meth:`~wuttaweb.app.WebAppProvider.get_web_menu_handler()` on the + :term:`app handler`. + + To configure your menu handler to be used, do this within your + :term:`config extension`:: + + config.setdefault('wuttaweb.menus.handler_spec', 'poser.web.menus:PoserMenuHandler') + + The core web app will call :meth:`do_make_menus()` to get the + final (possibly filtered) menu set for the current user. The + menu set should be a list of dicts, for example:: + + menus = [ + { + 'title': "First Dropdown", + 'type': 'menu', + 'items': [ + { + 'title': "Foo", + 'route': 'foo', + }, + {'type': 'sep'}, # horizontal line + { + 'title': "Bar", + 'route': 'bar', + }, + ], + }, + { + 'title': "Second Dropdown", + 'type': 'menu', + 'items': [ + { + 'title': "Wikipedia", + 'url': 'https://en.wikipedia.org', + 'target': '_blank', + }, + ], + }, + ] + """ + + ############################## + # default menu definitions + ############################## + + def make_menus(self, request, **kwargs): + """ + Generate the full set of menus for the app. + + This method provides a semi-sane menu set by default, but it + is expected for most apps to override it. + + The return value should be a list of dicts as described above. + """ + return [ + self.make_admin_menu(request), + ] + + def make_admin_menu(self, request, **kwargs): + """ + Generate a typical Admin menu. + + This method provides a semi-sane menu set by default, but it + is expected for most apps to override it. + + The return value for this method should be a *single* dict, + which will ultimately be one element of the final list of + dicts as described above. + """ + return { + 'title': "Admin", + 'type': 'menu', + 'items': [ + { + 'title': "TODO!", + 'url': '#', + }, + ], + } + + ############################## + # default internal logic + ############################## + + def do_make_menus(self, request, **kwargs): + """ + This method is responsible for constructing the final menu + set. It first calls :meth:`make_menus()` to get the basic + set, and then it prunes entries as needed based on current + user permissions. + + The web app calls this method but you normally should not need + to override it; you can override :meth:`make_menus()` instead. + """ + raw_menus = self.make_menus(request, **kwargs) + + # now we have "simple" (raw) menus definition, but must refine + # that somewhat to produce our final menus + self._mark_allowed(request, raw_menus) + final_menus = [] + for topitem in raw_menus: + + if topitem['allowed']: + + if topitem.get('type') == 'link': + final_menus.append(self._make_menu_entry(request, topitem)) + + else: # assuming 'menu' type + + menu_items = [] + for item in topitem['items']: + if not item['allowed']: + continue + + # nested submenu + if item.get('type') == 'menu': + submenu_items = [] + for subitem in item['items']: + if subitem['allowed']: + submenu_items.append(self._make_menu_entry(request, subitem)) + menu_items.append({ + 'type': 'submenu', + 'title': item['title'], + 'items': submenu_items, + 'is_menu': True, + 'is_sep': False, + }) + + elif item.get('type') == 'sep': + # we only want to add a sep, *if* we already have some + # menu items (i.e. there is something to separate) + # *and* the last menu item is not a sep (avoid doubles) + if menu_items and not menu_items[-1]['is_sep']: + menu_items.append(self._make_menu_entry(request, item)) + + else: # standard menu item + menu_items.append(self._make_menu_entry(request, item)) + + # remove final separator if present + if menu_items and menu_items[-1]['is_sep']: + menu_items.pop() + + # only add if we wound up with something + assert menu_items + if menu_items: + group = { + 'type': 'menu', + 'key': topitem.get('key'), + 'title': topitem['title'], + 'items': menu_items, + 'is_menu': True, + 'is_link': False, + } + + # topitem w/ no key likely means it did not come + # from config but rather explicit definition in + # code. so we are free to "invent" a (safe) key + # for it, since that is only for editing config + if not group['key']: + group['key'] = self._make_menu_key(topitem['title']) + + final_menus.append(group) + + return final_menus + + def _is_allowed(self, request, item): + """ + Logic to determine if a given menu item is "allowed" for + current user. + """ + perm = item.get('perm') + # TODO + # if perm: + # return request.has_perm(perm) + return True + + def _mark_allowed(self, request, menus): + """ + Traverse the menu set, and mark each item as "allowed" (or + not) based on current user permissions. + """ + for topitem in menus: + + if topitem.get('type', 'menu') == 'link': + topitem['allowed'] = True + + elif topitem.get('type', 'menu') == 'menu': + topitem['allowed'] = False + + for item in topitem['items']: + + if item.get('type') == 'menu': + for subitem in item['items']: + subitem['allowed'] = self._is_allowed(request, subitem) + + item['allowed'] = False + for subitem in item['items']: + if subitem['allowed'] and subitem.get('type') != 'sep': + item['allowed'] = True + break + + else: + item['allowed'] = self._is_allowed(request, item) + + for item in topitem['items']: + if item['allowed'] and item.get('type') != 'sep': + topitem['allowed'] = True + break + + def _make_menu_entry(self, request, item): + """ + Convert a simple menu entry dict, into a proper menu-related + object, for use in constructing final menu. + """ + # separator + if item.get('type') == 'sep': + return { + 'type': 'sep', + 'is_menu': False, + 'is_sep': True, + } + + # standard menu item + entry = { + 'type': 'item', + 'title': item['title'], + 'perm': item.get('perm'), + 'target': item.get('target'), + 'is_link': True, + 'is_menu': False, + 'is_sep': False, + } + if item.get('route'): + entry['route'] = item['route'] + try: + entry['url'] = request.route_url(entry['route']) + except KeyError: # happens if no such route + log.warning("invalid route name for menu entry: %s", entry) + entry['url'] = entry['route'] + entry['key'] = entry['route'] + else: + if item.get('url'): + entry['url'] = item['url'] + entry['key'] = self._make_menu_key(entry['title']) + return entry + + def _make_menu_key(self, value): + """ + Generate a normalized menu key for the given value. + """ + return re.sub(r'\W', '', value.lower()) diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py index 2777caa..860942f 100644 --- a/src/wuttaweb/subscribers.py +++ b/src/wuttaweb/subscribers.py @@ -115,6 +115,12 @@ def before_render(event): Reference to the built-in module, :mod:`python:json`. + .. data:: 'menus' + + Set of entries to be shown in the main menu. This is obtained + by calling :meth:`~wuttaweb.menus.MenuHandler.do_make_menus()` + on the configured :class:`~wuttaweb.menus.MenuHandler`. + .. data:: 'url' Reference to the request method, @@ -131,8 +137,8 @@ def before_render(event): context['url'] = request.route_url context['json'] = json - # TODO - context['menus'] = [] + menus = app.get_web_menu_handler() + context['menus'] = menus.do_make_menus(request) def includeme(config): diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 9b6d82e..5511195 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -103,6 +103,11 @@ } % endif + /* nb. this refers to the "menu-sized" app title in far left of main menu */ + #global-header-title { + font-weight: bold; + } + #current-context { padding-left: 0.5rem; } diff --git a/src/wuttaweb/templates/base_meta.mako b/src/wuttaweb/templates/base_meta.mako index 7aa2901..4e62198 100644 --- a/src/wuttaweb/templates/base_meta.mako +++ b/src/wuttaweb/templates/base_meta.mako @@ -16,6 +16,6 @@ <%def name="footer()">

- powered by ${h.link_to("Wutta Framework", 'https://pypi.org/project/WuttJamaican/', target='_blank')} + powered by ${h.link_to("WuttaWeb", 'https://wuttaproject.org/', target='_blank')}

diff --git a/tests/test_menus.py b/tests/test_menus.py new file mode 100644 index 0000000..68cec31 --- /dev/null +++ b/tests/test_menus.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from wuttjamaican.conf import WuttaConfig + +from pyramid import testing + +from wuttaweb import menus as mod + + +class TestMenuHandler(TestCase): + + def setUp(self): + self.config = WuttaConfig() + self.app = self.config.get_app() + self.handler = mod.MenuHandler(self.config) + self.request = testing.DummyRequest() + + def test_make_admin_menu(self): + menus = self.handler.make_admin_menu(self.request) + self.assertIsInstance(menus, dict) + + def test_make_menus(self): + menus = self.handler.make_menus(self.request) + self.assertIsInstance(menus, list) + + def test_is_allowed(self): + # TODO: this should test auth/perm handling + item = {} + self.assertTrue(self.handler._is_allowed(self.request, item)) + + def test_mark_allowed(self): + + def make_menus(): + return [ + { + 'type': 'menu', + 'items': [ + {'title': "Foo", 'url': '#'}, + {'title': "Bar", 'url': '#'}, + ], + }, + ] + + mock_is_allowed = MagicMock() + with patch.object(self.handler, '_is_allowed', new=mock_is_allowed): + + # all should be allowed + mock_is_allowed.return_value = True + menus = make_menus() + self.handler._mark_allowed(self.request, menus) + menu = menus[0] + self.assertTrue(menu['allowed']) + foo, bar = menu['items'] + self.assertTrue(foo['allowed']) + self.assertTrue(bar['allowed']) + + # none should be allowed + mock_is_allowed.return_value = False + menus = make_menus() + self.handler._mark_allowed(self.request, menus) + menu = menus[0] + self.assertFalse(menu['allowed']) + foo, bar = menu['items'] + self.assertFalse(foo['allowed']) + self.assertFalse(bar['allowed']) + + def test_mark_allowed_submenu(self): + + def make_menus(): + return [ + { + 'type': 'menu', + 'items': [ + {'title': "Foo", 'url': '#'}, + { + 'type': 'menu', + 'items': [ + {'title': "Bar", 'url': '#'}, + ], + }, + ], + }, + ] + + mock_is_allowed = MagicMock() + with patch.object(self.handler, '_is_allowed', new=mock_is_allowed): + + # all should be allowed + mock_is_allowed.return_value = True + menus = make_menus() + self.handler._mark_allowed(self.request, menus) + menu = menus[0] + self.assertTrue(menu['allowed']) + foo, submenu = menu['items'] + self.assertTrue(foo['allowed']) + self.assertTrue(submenu['allowed']) + subitem = submenu['items'][0] + self.assertTrue(subitem['allowed']) + + # none should be allowed + mock_is_allowed.return_value = False + menus = make_menus() + self.handler._mark_allowed(self.request, menus) + menu = menus[0] + self.assertFalse(menu['allowed']) + foo, submenu = menu['items'] + self.assertFalse(foo['allowed']) + self.assertFalse(submenu['allowed']) + subitem = submenu['items'][0] + self.assertFalse(subitem['allowed']) + + def test_make_menu_key(self): + self.assertEqual(self.handler._make_menu_key('foo'), 'foo') + self.assertEqual(self.handler._make_menu_key('FooBar'), 'foobar') + self.assertEqual(self.handler._make_menu_key('Foo - $#Bar'), 'foobar') + self.assertEqual(self.handler._make_menu_key('Foo__Bar'), 'foo__bar') + + def test_make_menu_entry_item(self): + item = {'title': "Foo", 'url': '#'} + entry = self.handler._make_menu_entry(self.request, item) + self.assertEqual(entry['type'], 'item') + self.assertEqual(entry['title'], "Foo") + self.assertEqual(entry['url'], '#') + self.assertTrue(entry['is_link']) + + def test_make_menu_entry_item_with_no_url(self): + item = {'title': "Foo"} + entry = self.handler._make_menu_entry(self.request, item) + self.assertEqual(entry['type'], 'item') + self.assertEqual(entry['title'], "Foo") + self.assertNotIn('url', entry) + # nb. still sets is_link = True; basically it's with no href + self.assertTrue(entry['is_link']) + + def test_make_menu_entry_item_with_known_route(self): + item = {'title': "Foo", 'route': 'home'} + with patch.object(self.request, 'route_url', return_value='/something'): + entry = self.handler._make_menu_entry(self.request, item) + self.assertEqual(entry['type'], 'item') + self.assertEqual(entry['url'], '/something') + self.assertTrue(entry['is_link']) + + def test_make_menu_entry_item_with_unknown_route(self): + item = {'title': "Foo", 'route': 'home'} + with patch.object(self.request, 'route_url', side_effect=KeyError): + entry = self.handler._make_menu_entry(self.request, item) + self.assertEqual(entry['type'], 'item') + # nb. fake url is used, based on (bad) route name + self.assertEqual(entry['url'], 'home') + self.assertTrue(entry['is_link']) + + def test_make_menu_entry_sep(self): + item = {'type': 'sep'} + entry = self.handler._make_menu_entry(self.request, item) + self.assertEqual(entry['type'], 'sep') + self.assertTrue(entry['is_sep']) + self.assertFalse(entry['is_menu']) + + def test_do_make_menus_prune_unallowed_item(self): + test_menus = [ + { + 'title': "First Menu", + 'type': 'menu', + 'items': [ + {'title': "Foo", 'url': '#'}, + {'title': "Bar", 'url': '#'}, + ], + }, + ] + + def is_allowed(request, item): + if item.get('title') == 'Bar': + return False + return True + + with patch.object(self.handler, 'make_menus', return_value=test_menus): + with patch.object(self.handler, '_is_allowed', side_effect=is_allowed): + menus = self.handler.do_make_menus(self.request) + + # Foo remains but Bar is pruned + menu = menus[0] + self.assertEqual(len(menu['items']), 1) + item = menu['items'][0] + self.assertEqual(item['title'], 'Foo') + + def test_do_make_menus_prune_unallowed_menu(self): + test_menus = [ + { + 'title': "First Menu", + 'type': 'menu', + 'items': [ + {'title': "Foo", 'url': '#'}, + {'title': "Bar", 'url': '#'}, + ], + }, + { + 'title': "Second Menu", + 'type': 'menu', + 'items': [ + {'title': "Baz", 'url': '#'}, + ], + }, + ] + + def is_allowed(request, item): + if item.get('title') == 'Baz': + return True + return False + + with patch.object(self.handler, 'make_menus', return_value=test_menus): + with patch.object(self.handler, '_is_allowed', side_effect=is_allowed): + menus = self.handler.do_make_menus(self.request) + + # Second/Baz remains but First/Foo/Bar are pruned + self.assertEqual(len(menus), 1) + menu = menus[0] + self.assertEqual(menu['title'], 'Second Menu') + self.assertEqual(len(menu['items']), 1) + item = menu['items'][0] + self.assertEqual(item['title'], 'Baz') + + def test_do_make_menus_with_top_link(self): + test_menus = [ + { + 'title': "First Menu", + 'type': 'menu', + 'items': [ + {'title': "Foo", 'url': '#'}, + {'title': "Bar", 'url': '#'}, + ], + }, + { + 'title': "Second Link", + 'type': 'link', + }, + ] + + with patch.object(self.handler, 'make_menus', return_value=test_menus): + with patch.object(self.handler, '_is_allowed', return_value=True): + menus = self.handler.do_make_menus(self.request) + + # ensure top link remains + self.assertEqual(len(menus), 2) + menu = menus[1] + self.assertEqual(menu['title'], "Second Link") + + def test_do_make_menus_with_trailing_sep(self): + test_menus = [ + { + 'title': "First Menu", + 'type': 'menu', + 'items': [ + {'title': "Foo", 'url': '#'}, + {'title': "Bar", 'url': '#'}, + {'type': 'sep'}, + ], + }, + ] + + with patch.object(self.handler, 'make_menus', return_value=test_menus): + with patch.object(self.handler, '_is_allowed', return_value=True): + menus = self.handler.do_make_menus(self.request) + + # ensure trailing sep was pruned + menu = menus[0] + self.assertEqual(len(menu['items']), 2) + foo, bar = menu['items'] + self.assertEqual(foo['title'], 'Foo') + self.assertEqual(bar['title'], 'Bar') + + def test_do_make_menus_with_submenu(self): + test_menus = [ + { + 'title': "First Menu", + 'type': 'menu', + 'items': [ + { + 'title': "First Submenu", + 'type': 'menu', + 'items': [ + {'title': "Foo", 'url': '#'}, + ], + }, + { + 'title': "Second Submenu", + 'type': 'menu', + 'items': [ + {'title': "Bar", 'url': '#'}, + ], + }, + ], + }, + ] + + def is_allowed(request, item): + if item.get('title') == 'Bar': + return False + return True + + with patch.object(self.handler, 'make_menus', return_value=test_menus): + with patch.object(self.handler, '_is_allowed', side_effect=is_allowed): + menus = self.handler.do_make_menus(self.request) + + # first submenu remains, second is pruned + menu = menus[0] + self.assertEqual(len(menu['items']), 1) + submenu = menu['items'][0] + self.assertEqual(submenu['type'], 'submenu') + self.assertEqual(submenu['title'], 'First Submenu') + self.assertEqual(len(submenu['items']), 1) + item = submenu['items'][0] + self.assertEqual(item['title'], 'Foo')