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 name="form_content()"> + ${h.hidden('menus', **{':value': 'JSON.stringify(allMenuData)'})} + +

Top-Level Menus

+

Click on a menu to edit.  Drag things around to rearrange.

+ + + + + +   + + + {{ allMenus[key].title }} + + + +
+   +
+ + Add + + +
+ +
+ + + + + + + + + +
+ + Revert / Cancel + + + Save + + + Delete + +
+
+ +
+ + + + + + + + + + +
+ + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index f05b24c0..2fe8ee72 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -190,7 +190,7 @@ - ${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm')} + ${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm', **{'@submit': 'saveSettingsFormSubmit'})} ${h.csrf_token(request)} ${self.form_content()} ${h.end_form()} @@ -262,6 +262,14 @@ this.$refs.saveSettingsForm.submit() } + // nb. this is here to avoid auto-submitting form when user + // presses ENTER while some random input field has focus + ThisPage.methods.saveSettingsFormSubmit = function(event) { + if (!this.savingSettings) { + event.preventDefault() + } + } + // cf. https://stackoverflow.com/a/56551646 ThisPage.methods.beforeWindowUnload = function(e) { if (this.settingsNeedSaved && !this.undoChanges) { diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 0bc0aca8..61471aaa 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -295,9 +295,15 @@ % endif % elif index_title: - - ${index_title} - + % if index_url: + + ${h.link_to(index_title, index_url)} + + % else: + + ${index_title} + + % endif % endif @@ -430,6 +436,14 @@ % endfor % endif + % if request.session.peek_flash('warning'): + % for msg in request.session.pop_flash('warning'): + + ${msg} + + % endfor + % endif + % if request.session.peek_flash(): % for msg in request.session.pop_flash(): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6c174f06..b4d76d80 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4209,7 +4209,9 @@ class MasterView(View): if self.request.method == 'POST': if self.request.POST.get('remove_settings'): self.configure_remove_settings() - self.request.session.flash("Settings have been removed.") + self.request.session.flash("All settings for {} have been " + "removed.".format(self.get_config_title()), + 'warning') return self.redirect(self.request.current_route_url()) else: data = self.request.POST @@ -4517,6 +4519,8 @@ class MasterView(View): config.add_view(cls, attr='index', route_name=route_prefix, permission='{}.list'.format(permission_prefix), **kwargs) + config.add_tailbone_index_page(route_prefix, model_title_plural, + '{}.list'.format(permission_prefix)) # download results # this is the "new" more flexible approach, but we only want to @@ -4572,7 +4576,8 @@ class MasterView(View): route_name='{}.configure'.format(route_prefix), permission='{}.configure'.format(permission_prefix)) config.add_tailbone_config_page('{}.configure'.format(route_prefix), - config_title) + config_title, + '{}.configure'.format(permission_prefix)) # quickie (search) if cls.supports_quickie_search: diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py new file mode 100644 index 00000000..37c2536c --- /dev/null +++ b/tailbone/views/menus.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 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 . +# +################################################################################ +""" +Base class for Config Views +""" + +from __future__ import unicode_literals, absolute_import + +import json + +import sqlalchemy as sa + +from tailbone.views import View +from tailbone.db import Session +from tailbone.menus import make_menu_key + + +class MenuConfigView(View): + """ + View for configuring the main menu. + """ + + def configure(self): + """ + Main entry point to menu config views. + """ + if self.request.method == 'POST': + if self.request.POST.get('remove_settings'): + self.configure_remove_settings() + self.request.session.flash("All settings for Menus have been removed.", + 'warning') + return self.redirect(self.request.current_route_url()) + else: + data = self.request.POST + + # gather/save settings + settings = self.configure_gather_settings(data) + self.configure_remove_settings() + self.configure_save_settings(settings) + self.request.session.flash("Settings have been saved.") + return self.redirect(self.request.current_route_url()) + + context = { + 'config_title': "Menus", + 'use_buefy': True, + 'index_title': "App Settings", + 'index_url': self.request.route_url('appsettings'), + } + + possible_index_options = sorted( + self.request.registry.settings['tailbone_index_pages'], + key=lambda p: p['label']) + + index_options = [] + for option in possible_index_options: + perm = option['permission'] + option['perm'] = perm + option['url'] = self.request.route_url(option['route']) + index_options.append(option) + + context['index_route_options'] = index_options + return context + + def configure_gather_settings(self, data): + settings = [{'name': 'tailbone.menu.from_settings', + 'value': 'true'}] + + main_keys = [] + for topitem in json.loads(data['menus']): + key = make_menu_key(self.rattail_config, topitem['title']) + main_keys.append(key) + + settings.extend([ + {'name': 'tailbone.menu.menu.{}.label'.format(key), + 'value': topitem['title']}, + ]) + + item_keys = [] + for item in topitem['items']: + item_type = item.get('type', 'item') + if item_type == 'item': + if item.get('route'): + item_key = item['route'] + else: + item_key = make_menu_key(self.rattail_config, item['title']) + item_keys.append(item_key) + + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.label'.format(key, item_key), + 'value': item['title']}, + ]) + + if item.get('route'): + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.route'.format(key, item_key), + 'value': item['route']}, + ]) + + elif item.get('url'): + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.url'.format(key, item_key), + 'value': item['url']}, + ]) + + if item.get('perm'): + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.perm'.format(key, item_key), + 'value': item['perm']}, + ]) + + elif item_type == 'sep': + item_keys.append('SEP') + + settings.extend([ + {'name': 'tailbone.menu.menu.{}.items'.format(key), + 'value': ' '.join(item_keys)}, + ]) + + settings.append({'name': 'tailbone.menu.menus', + 'value': ' '.join(main_keys)}) + return settings + + def configure_remove_settings(self): + model = self.model + Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name == 'tailbone.menu.from_settings', + model.Setting.name == 'tailbone.menu.menus', + model.Setting.name.like('tailbone.menu.menu.%.label'), + model.Setting.name.like('tailbone.menu.menu.%.items'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.label'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.route'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.perm'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.url')))\ + .delete(synchronize_session=False) + + def configure_save_settings(self, settings): + model = self.model + session = Session() + for setting in settings: + session.add(model.Setting(name=setting['name'], + value=setting['value'])) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + + # configure menus + config.add_route('configure_menus', + '/configure-menus') + config.add_view(cls, attr='configure', + route_name='configure_menus', + # nb. must be root to configure menus! b/c + # otherwise some route options may be hidden + permission='admin', + renderer='/configure-menus.mako') + config.add_tailbone_config_page('configure_menus', "Menus", 'admin') + + +def defaults(config, **kwargs): + base = globals() + + MenuConfigView = kwargs.get('MenuConfigView', base['MenuConfigView']) + MenuConfigView.defaults(config) + + +def includeme(config): + defaults(config)