diff --git a/tailbone/app.py b/tailbone/app.py index 80cce0f6..6896ea4d 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -156,19 +156,33 @@ def make_pyramid_config(settings, configure_csrf=True): config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') - # and some similar magic for config views + # and some similar magic for certain master views + config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page') return config -def add_config_page(config, route_name, label): +def add_index_page(config, route_name, label, permission): + """ + Register a config page for the app. + """ + def action(): + pages = config.get_settings().get('tailbone_index_pages', []) + pages.append({'label': label, 'route': route_name, + 'permission': permission}) + config.add_settings({'tailbone_index_pages': pages}) + config.action(None, action) + + +def add_config_page(config, route_name, label, permission): """ Register a config page for the app. """ def action(): pages = config.get_settings().get('tailbone_config_pages', []) - pages.append({'label': label, 'route': route_name}) + pages.append({'label': label, 'route': route_name, + 'permission': permission}) config.add_settings({'tailbone_config_pages': pages}) config.action(None, action) diff --git a/tailbone/menus.py b/tailbone/menus.py index e2c025c1..464f081c 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -28,22 +28,28 @@ from __future__ import unicode_literals, absolute_import import re -from rattail.util import import_module_path +from rattail.util import import_module_path, prettify + +from tailbone.db import Session def make_simple_menus(request): """ Build the main menu list for the app. """ - menus_module = import_module_path( - request.rattail_config.require('tailbone', 'menus')) + # first try to make menus from config + raw_menus = make_menus_from_config(request) + if not raw_menus: - if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus): - raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module)) + # no config, so import/invoke function to build them + menus_module = import_module_path( + request.rattail_config.require('tailbone', 'menus')) + if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus): + raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module)) + raw_menus = menus_module.simple_menus(request) - # collect "simple" menus definition, but must refine that somewhat to - # produce our final menus - raw_menus = menus_module.simple_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: @@ -51,7 +57,7 @@ def make_simple_menus(request): if topitem['allowed']: if topitem.get('type') == 'link': - final_menus.append(make_menu_entry(topitem)) + final_menus.append(make_menu_entry(request, topitem)) else: # assuming 'menu' type @@ -65,7 +71,7 @@ def make_simple_menus(request): submenu_items = [] for subitem in item['items']: if subitem['allowed']: - submenu_items.append(make_menu_entry(subitem)) + submenu_items.append(make_menu_entry(request, subitem)) menu_items.append({ 'type': 'submenu', 'title': item['title'], @@ -79,10 +85,10 @@ def make_simple_menus(request): # 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(item)) + menu_items.append(make_menu_entry(request, item)) else: # standard menu item - menu_items.append(make_menu_entry(item)) + menu_items.append(make_menu_entry(request, item)) # remove final separator if present if menu_items and menu_items[-1]['is_sep']: @@ -105,14 +111,201 @@ def make_simple_menus(request): # 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'] = re.sub(r'\W', '', topitem['title'].lower()) + group['key'] = make_menu_key(request.rattail_config, + topitem['title']) final_menus.append(group) return final_menus -def make_menu_entry(item): +def make_menus_from_config(request): + """ + Try to build a complete menu set from config/settings. + + This essentially checks for the top-level menu list in config; if + found then it will build a full menu set from config. If this + 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 + main_keys = config.getlist('tailbone.menu', 'menus') + if not main_keys: + return + + menus = [] + + # menu definition can come either from config file or db settings, + # but if the latter then we want to optimize with one big query + if config.getbool('tailbone.menu', 'from_settings', + default=False): + app = config.get_app() + model = config.get_model() + + # fetch all menu-related settings at once + query = Session().query(model.Setting)\ + .filter(model.Setting.name.like('tailbone.menu.%')) + settings = app.cache_model(Session(), model.Setting, + query=query, key='name', + normalizer=lambda s: s.value) + for key in main_keys: + menus.append(make_single_menu_from_settings(request, key, settings)) + + else: # read from config file only + for key in main_keys: + menus.append(make_single_menu_from_config(request, key)) + + return menus + + +def make_single_menu_from_config(request, key): + """ + Makes a single top-level menu dict from config file. Note that + this will read from config file(s) *only* and avoids querying the + database, for efficiency. + """ + config = request.rattail_config + menu = { + 'key': key, + 'type': 'menu', + 'items': [], + } + + # title + title = config.get('tailbone.menu', + 'menu.{}.label'.format(key), + usedb=False) + menu['title'] = title or prettify(key) + + # items + item_keys = config.getlist('tailbone.menu', + 'menu.{}.items'.format(key), + usedb=False) + for item_key in item_keys: + item = {} + + if item_key == 'SEP': + item['type'] = 'sep' + + else: + item['type'] = 'item' + item['key'] = item_key + + # title + title = config.get('tailbone.menu', + 'menu.{}.item.{}.label'.format(key, item_key), + usedb=False) + item['title'] = title or prettify(item_key) + + # route + route = config.get('tailbone.menu', + 'menu.{}.item.{}.route'.format(key, item_key), + usedb=False) + if route: + item['route'] = route + item['url'] = request.route_url(route) + + else: + + # url + url = config.get('tailbone.menu', + 'menu.{}.item.{}.url'.format(key, item_key), + usedb=False) + if not url: + url = request.route_url(item_key) + elif url.startswith('route:'): + url = request.route_url(url[6:]) + item['url'] = url + + # perm + perm = config.get('tailbone.menu', + 'menu.{}.item.{}.perm'.format(key, item_key), + usedb=False) + item['perm'] = perm or '{}.list'.format(item_key) + + menu['items'].append(item) + + return menu + + +def make_single_menu_from_settings(request, key, settings): + """ + Makes a single top-level menu dict from DB settings. + """ + config = request.rattail_config + menu = { + 'key': key, + 'type': 'menu', + 'items': [], + } + + # title + title = settings.get('tailbone.menu.menu.{}.label'.format(key)) + menu['title'] = title or prettify(key) + + # items + item_keys = config.parse_list( + settings.get('tailbone.menu.menu.{}.items'.format(key))) + for item_key in item_keys: + item = {} + + if item_key == 'SEP': + item['type'] = 'sep' + + else: + item['type'] = 'item' + item['key'] = item_key + + # title + title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format( + key, item_key)) + item['title'] = title or prettify(item_key) + + # route + route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format( + key, item_key)) + if route: + item['route'] = route + item['url'] = request.route_url(route) + + else: + + # url + url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format( + key, item_key)) + if not url: + url = request.route_url(item_key) + if url.startswith('route:'): + url = request.route_url(url[6:]) + item['url'] = url + + # perm + perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format( + key, item_key)) + item['perm'] = perm or '{}.list'.format(item_key) + + menu['items'].append(item) + + return menu + + +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. @@ -126,15 +319,24 @@ def make_menu_entry(item): } # standard menu item - return { + entry = { 'type': 'item', 'title': item['title'], - 'url': item['url'], + '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'] + entry['url'] = request.route_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): diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako new file mode 100644 index 00000000..495b5c65 --- /dev/null +++ b/tailbone/templates/configure-menus.mako @@ -0,0 +1,424 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + +%def> + +<%def name="form_content()"> + ${h.hidden('menus', **{':value': 'JSON.stringify(allMenuData)'})} + +
Click on a menu to edit. Drag things around to rearrange.
+ +{{ editingMenuItem.isNew ? "Add" : "Edit" }} Item
+