1
0
Fork 0

add basic support for menu handler

default menu is not yet useful, but the handler mostly works.  except
for actual auth/perm checks since we have no users yet!
This commit is contained in:
Lance Edgar 2024-07-13 20:03:44 -05:00
parent 001179c87f
commit 60e8303d29
9 changed files with 664 additions and 3 deletions

View file

@ -9,6 +9,7 @@
app
helpers
menus
static
subscribers
util

View file

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

View file

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

View file

@ -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<app provider>` for WuttaWeb. This adds
some methods to get web-specific :term:`handlers<handler>`.
"""
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.

297
src/wuttaweb/menus.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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())

View file

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

View file

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

View file

@ -16,6 +16,6 @@
<%def name="footer()">
<p class="has-text-centered">
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')}
</p>
</%def>

315
tests/test_menus.py Normal file
View file

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