Add new handlers, TailboneHandler and MenuHandler

This commit is contained in:
Lance Edgar 2023-01-14 16:01:26 -06:00
parent cfdaa1e927
commit 39d53617bd
2 changed files with 384 additions and 297 deletions

49
tailbone/handler.py Normal file
View file

@ -0,0 +1,49 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail 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.
#
# Rattail 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
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Handler
"""
from __future__ import unicode_literals, absolute_import
from rattail.app import GenericHandler
class TailboneHandler(GenericHandler):
"""
Base class and default implementation for Tailbone handler.
"""
def get_menu_handler(self, **kwargs):
"""
Get the configured "menu" handler.
:returns: The :class:`~tailbone.menus.MenuHandler` instance
for the app.
"""
if not hasattr(self, 'menu_handler'):
spec = self.config.get('tailbone.menus', 'handler',
default='tailbone.menus:MenuHandler')
Handler = self.app.load_object(spec)
self.menu_handler = Handler(self.config)
return self.menu_handler

View file

@ -28,7 +28,9 @@ from __future__ import unicode_literals, absolute_import
import re import re
import logging import logging
import warnings
from rattail.app import GenericHandler
from rattail.util import import_module_path, prettify, simple_error from rattail.util import import_module_path, prettify, simple_error
from webhelpers2.html import tags, HTML from webhelpers2.html import tags, HTML
@ -39,16 +41,26 @@ from tailbone.db import Session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def make_simple_menus(request): class MenuHandler(GenericHandler):
""" """
Build the main menu list for the app. Base class and default implementation for menu handler.
"""
def make_raw_menus(self, request, **kwargs):
"""
Generate a full set of "raw" menus for the app.
The "raw" menus are basically just a set of dicts to represent
the final menus.
""" """
# first try to make menus from config, but this is highly # first try to make menus from config, but this is highly
# susceptible to failure, so try to warn user of problems # susceptible to failure, so try to warn user of problems
raw_menus = None
try: try:
raw_menus = make_menus_from_config(request) menus = self.make_menus_from_config(request)
if menus:
return menus
except Exception as error: except Exception as error:
# TODO: these messages show up multiple times on some pages?! # TODO: these messages show up multiple times on some pages?!
# that must mean the BeforeRender event is firing multiple # that must mean the BeforeRender event is firing multiple
# times..but why?? seems like there is only 1 request... # times..but why?? seems like there is only 1 request...
@ -60,141 +72,64 @@ def make_simple_menus(request):
tags.link_to("Menu Config", request.route_url('configure_menus')))) tags.link_to("Menu Config", request.route_url('configure_menus'))))
request.session.flash(msg, 'warning') request.session.flash(msg, 'warning')
if not raw_menus: # okay, no config, so menus must be built from code..
# no config, so import/invoke code function to build them # first check for a "simple menus" module; use that if defined
menus_module = import_module_path( menumod = self.config.get('tailbone', 'menus')
request.rattail_config.require('tailbone', 'menus')) if menumod:
if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus): menumod = import_module_path(menumod)
raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module)) if (not hasattr(menumod, 'simple_menus')
raw_menus = menus_module.simple_menus(request) or not callable(menumod.simple_menus)):
raise RuntimeError("module does not have a simple_menus() "
"callable: {}".format(menumod))
return menumod.simple_menus(request)
# now we have "simple" (raw) menus definition, but must refine # now we fallback to menu handler method
# that somewhat to produce our final menus return self.make_menus(request)
mark_allowed(request, raw_menus)
final_menus = []
for topitem in raw_menus:
if topitem['allowed']: def make_menus_from_config(self, request, **kwargs):
if topitem.get('type') == 'link':
final_menus.append(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(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(make_menu_entry(request, item))
else: # standard menu item
menu_items.append(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'] = make_menu_key(request.rattail_config,
topitem['title'])
final_menus.append(group)
return final_menus
def make_menus_from_config(request):
""" """
Try to build a complete menu set from config/settings. Try to build a complete menu set from config/settings.
This essentially checks for the top-level menu list in config; if This will look in the DB settings table, or config file, for
found then it will build a full menu set from config. If this menu data. If found, it constructs menus from that data.
top-level list is not present in config then menus will be built
purely from code instead. An example of this top-level list:
.. code-hightlight:: ini
[tailbone.menu]
menus = first, second, third, admin
Obviously much more config would be needed to define those menus
etc. but that is the option that determines whether the rest of
menu config is even read, or not.
""" """
config = request.rattail_config # bail unless config defines top-level menu keys
main_keys = config.getlist('tailbone.menu', 'menus') main_keys = self.config.getlist('tailbone.menu', 'menus')
if not main_keys: if not main_keys:
return return
model = self.model
menus = [] menus = []
# menu definition can come either from config file or db settings, # menu definition can come either from config file or db
# but if the latter then we want to optimize with one big query # settings, but if the latter then we want to optimize with
if config.getbool('tailbone.menu', 'from_settings', # one big query
if self.config.getbool('tailbone.menu', 'from_settings',
default=False): default=False):
app = config.get_app()
model = config.get_model()
# fetch all menu-related settings at once # fetch all menu-related settings at once
query = Session().query(model.Setting)\ query = Session().query(model.Setting)\
.filter(model.Setting.name.like('tailbone.menu.%')) .filter(model.Setting.name.like('tailbone.menu.%'))
settings = app.cache_model(Session(), model.Setting, settings = self.app.cache_model(Session(), model.Setting,
query=query, key='name', query=query, key='name',
normalizer=lambda s: s.value) normalizer=lambda s: s.value)
for key in main_keys: for key in main_keys:
menus.append(make_single_menu_from_settings(request, key, settings)) menus.append(self.make_single_menu_from_settings(request, key,
settings))
else: # read from config file only else: # read from config file only
for key in main_keys: for key in main_keys:
menus.append(make_single_menu_from_config(request, key)) menus.append(self.make_single_menu_from_config(request, key))
return menus return menus
def make_single_menu_from_config(self, request, key, **kwargs):
def make_single_menu_from_config(request, key):
""" """
Makes a single top-level menu dict from config file. Note that Makes a single top-level menu dict from config file. Note
this will read from config file(s) *only* and avoids querying the that this will read from config file(s) *only* and avoids
database, for efficiency. querying the database, for efficiency.
""" """
config = request.rattail_config
menu = { menu = {
'key': key, 'key': key,
'type': 'menu', 'type': 'menu',
@ -202,13 +137,13 @@ def make_single_menu_from_config(request, key):
} }
# title # title
title = config.get('tailbone.menu', title = self.config.get('tailbone.menu',
'menu.{}.label'.format(key), 'menu.{}.label'.format(key),
usedb=False) usedb=False)
menu['title'] = title or prettify(key) menu['title'] = title or prettify(key)
# items # items
item_keys = config.getlist('tailbone.menu', item_keys = self.config.getlist('tailbone.menu',
'menu.{}.items'.format(key), 'menu.{}.items'.format(key),
usedb=False) usedb=False)
for item_key in item_keys: for item_key in item_keys:
@ -222,13 +157,13 @@ def make_single_menu_from_config(request, key):
item['key'] = item_key item['key'] = item_key
# title # title
title = config.get('tailbone.menu', title = self.config.get('tailbone.menu',
'menu.{}.item.{}.label'.format(key, item_key), 'menu.{}.item.{}.label'.format(key, item_key),
usedb=False) usedb=False)
item['title'] = title or prettify(item_key) item['title'] = title or prettify(item_key)
# route # route
route = config.get('tailbone.menu', route = self.config.get('tailbone.menu',
'menu.{}.item.{}.route'.format(key, item_key), 'menu.{}.item.{}.route'.format(key, item_key),
usedb=False) usedb=False)
if route: if route:
@ -238,7 +173,7 @@ def make_single_menu_from_config(request, key):
else: else:
# url # url
url = config.get('tailbone.menu', url = self.config.get('tailbone.menu',
'menu.{}.item.{}.url'.format(key, item_key), 'menu.{}.item.{}.url'.format(key, item_key),
usedb=False) usedb=False)
if not url: if not url:
@ -248,7 +183,7 @@ def make_single_menu_from_config(request, key):
item['url'] = url item['url'] = url
# perm # perm
perm = config.get('tailbone.menu', perm = self.config.get('tailbone.menu',
'menu.{}.item.{}.perm'.format(key, item_key), 'menu.{}.item.{}.perm'.format(key, item_key),
usedb=False) usedb=False)
item['perm'] = perm or '{}.list'.format(item_key) item['perm'] = perm or '{}.list'.format(item_key)
@ -257,12 +192,10 @@ def make_single_menu_from_config(request, key):
return menu return menu
def make_single_menu_from_settings(self, request, key, settings, **kwargs):
def make_single_menu_from_settings(request, key, settings):
""" """
Makes a single top-level menu dict from DB settings. Makes a single top-level menu dict from DB settings.
""" """
config = request.rattail_config
menu = { menu = {
'key': key, 'key': key,
'type': 'menu', 'type': 'menu',
@ -274,7 +207,7 @@ def make_single_menu_from_settings(request, key, settings):
menu['title'] = title or prettify(key) menu['title'] = title or prettify(key)
# items # items
item_keys = config.parse_list( item_keys = self.config.parse_list(
settings.get('tailbone.menu.menu.{}.items'.format(key))) settings.get('tailbone.menu.menu.{}.items'.format(key)))
for item_key in item_keys: for item_key in item_keys:
item = {} item = {}
@ -318,94 +251,18 @@ def make_single_menu_from_settings(request, key, settings):
return menu return menu
def make_menus(self, request, **kwargs):
def make_menu_key(config, value):
""" """
Generate a normalized menu key for the given value. Make 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.
""" """
return re.sub(r'\W', '', value.lower()) return [
self.make_admin_menu(request),
]
def make_admin_menu(self, request, include_stores=False, **kwargs):
def make_menu_entry(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'] = make_menu_key(request.rattail_config, entry['title'])
return entry
def is_allowed(request, item):
"""
Logic to determine if a given menu item is "allowed" for current user.
"""
perm = item.get('perm')
if perm:
return request.has_perm(perm)
return True
def mark_allowed(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') == 'menu':
topitem['allowed'] = False
for item in topitem['items']:
if item.get('type') == 'menu':
for subitem in item['items']:
subitem['allowed'] = 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'] = is_allowed(request, item)
for item in topitem['items']:
if item['allowed'] and item.get('type') != 'sep':
topitem['allowed'] = True
break
def make_admin_menu(request, include_stores=False):
""" """
Generate a typical Admin menu Generate a typical Admin menu
""" """
@ -503,3 +360,184 @@ def make_admin_menu(request, include_stores=False):
'type': 'menu', 'type': 'menu',
'items': items, 'items': items,
} }
def make_simple_menus(request):
"""
Build the main menu list for the app.
"""
app = request.rattail_config.get_app()
tailbone_handler = app.get_tailbone_handler()
menu_handler = tailbone_handler.get_menu_handler()
raw_menus = menu_handler.make_raw_menus(request)
# now we have "simple" (raw) menus definition, but must refine
# that somewhat to produce our final menus
mark_allowed(request, raw_menus)
final_menus = []
for topitem in raw_menus:
if topitem['allowed']:
if topitem.get('type') == 'link':
final_menus.append(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(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(make_menu_entry(request, item))
else: # standard menu item
menu_items.append(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'] = make_menu_key(request.rattail_config,
topitem['title'])
final_menus.append(group)
return final_menus
def make_menu_key(config, value):
"""
Generate a normalized menu key for the given value.
"""
return re.sub(r'\W', '', value.lower())
def make_menu_entry(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'] = make_menu_key(request.rattail_config, entry['title'])
return entry
def is_allowed(request, item):
"""
Logic to determine if a given menu item is "allowed" for current user.
"""
perm = item.get('perm')
if perm:
return request.has_perm(perm)
return True
def mark_allowed(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') == 'menu':
topitem['allowed'] = False
for item in topitem['items']:
if item.get('type') == 'menu':
for subitem in item['items']:
subitem['allowed'] = 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'] = is_allowed(request, item)
for item in topitem['items']:
if item['allowed'] and item.get('type') != 'sep':
topitem['allowed'] = True
break
def make_admin_menu(request, **kwargs):
"""
Generate a typical Admin menu
"""
warnings.warn("make_admin_menu() function is deprecated; please use "
"MenuHandler.make_admin_menu() instead",
DeprecationWarning, stacklevel=2)
app = request.rattail_config.get_app()
tailbone_handler = app.get_tailbone_handler()
menu_handler = tailbone_handler.get_menu_handler()
return menu_handler.make_admin_menu(request, **kwargs)