From 39d53617bd70d16ed54f43b7ecd5847f144573d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Jan 2023 16:01:26 -0600 Subject: [PATCH] Add new handlers, TailboneHandler and MenuHandler --- tailbone/handler.py | 49 ++++ tailbone/menus.py | 632 +++++++++++++++++++++++--------------------- 2 files changed, 384 insertions(+), 297 deletions(-) create mode 100644 tailbone/handler.py diff --git a/tailbone/handler.py b/tailbone/handler.py new file mode 100644 index 00000000..c665545a --- /dev/null +++ b/tailbone/handler.py @@ -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 . +# +################################################################################ +""" +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 diff --git a/tailbone/menus.py b/tailbone/menus.py index 7da22696..d3a2a4aa 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -28,7 +28,9 @@ from __future__ import unicode_literals, absolute_import import re import logging +import warnings +from rattail.app import GenericHandler from rattail.util import import_module_path, prettify, simple_error from webhelpers2.html import tags, HTML @@ -39,35 +41,336 @@ from tailbone.db import Session log = logging.getLogger(__name__) +class MenuHandler(GenericHandler): + """ + 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 + # susceptible to failure, so try to warn user of problems + try: + menus = self.make_menus_from_config(request) + if menus: + return menus + except Exception as error: + + # TODO: these messages show up multiple times on some pages?! + # that must mean the BeforeRender event is firing multiple + # times..but why?? seems like there is only 1 request... + log.warning("failed to make menus from config", exc_info=True) + request.session.flash(simple_error(error), 'error') + request.session.flash("Menu config is invalid! Reverting to menus " + "defined in code!", 'warning') + msg = HTML.literal('Please edit your {} ASAP.'.format( + tags.link_to("Menu Config", request.route_url('configure_menus')))) + request.session.flash(msg, 'warning') + + # okay, no config, so menus must be built from code.. + + # first check for a "simple menus" module; use that if defined + menumod = self.config.get('tailbone', 'menus') + if menumod: + menumod = import_module_path(menumod) + if (not hasattr(menumod, 'simple_menus') + 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 fallback to menu handler method + return self.make_menus(request) + + def make_menus_from_config(self, request, **kwargs): + """ + Try to build a complete menu set from config/settings. + + This will look in the DB settings table, or config file, for + menu data. If found, it constructs menus from that data. + """ + # bail unless config defines top-level menu keys + main_keys = self.config.getlist('tailbone.menu', 'menus') + if not main_keys: + return + + model = self.model + 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 self.config.getbool('tailbone.menu', 'from_settings', + default=False): + + # fetch all menu-related settings at once + query = Session().query(model.Setting)\ + .filter(model.Setting.name.like('tailbone.menu.%')) + settings = self.app.cache_model(Session(), model.Setting, + query=query, key='name', + normalizer=lambda s: s.value) + for key in main_keys: + menus.append(self.make_single_menu_from_settings(request, key, + settings)) + + else: # read from config file only + for key in main_keys: + menus.append(self.make_single_menu_from_config(request, key)) + + return menus + + def make_single_menu_from_config(self, request, key, **kwargs): + """ + 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. + """ + menu = { + 'key': key, + 'type': 'menu', + 'items': [], + } + + # title + title = self.config.get('tailbone.menu', + 'menu.{}.label'.format(key), + usedb=False) + menu['title'] = title or prettify(key) + + # items + item_keys = self.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 = self.config.get('tailbone.menu', + 'menu.{}.item.{}.label'.format(key, item_key), + usedb=False) + item['title'] = title or prettify(item_key) + + # route + route = self.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 = self.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 = self.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(self, request, key, settings, **kwargs): + """ + Makes a single top-level menu dict from DB settings. + """ + 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 = self.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_menus(self, request, **kwargs): + """ + 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 [ + self.make_admin_menu(request), + ] + + def make_admin_menu(self, request, include_stores=False, **kwargs): + """ + Generate a typical Admin menu + """ + items = [] + + if include_stores: + items.append({ + 'title': "Stores", + 'route': 'stores', + 'perm': 'stores.list', + }) + + items.extend([ + { + 'title': "Users", + 'route': 'users', + 'perm': 'users.list', + }, + { + 'title': "User Events", + 'route': 'userevents', + 'perm': 'userevents.list', + }, + { + 'title': "Roles", + 'route': 'roles', + 'perm': 'roles.list', + }, + {'type': 'sep'}, + { + 'title': "App Settings", + 'route': 'appsettings', + 'perm': 'settings.list', + }, + { + 'title': "Email Settings", + 'route': 'emailprofiles', + 'perm': 'emailprofiles.list', + }, + { + 'title': "Email Attempts", + 'route': 'email_attempts', + 'perm': 'email_attempts.list', + }, + { + 'title': "Raw Settings", + 'route': 'settings', + 'perm': 'settings.list', + }, + {'type': 'sep'}, + { + 'title': "DataSync Changes", + 'route': 'datasyncchanges', + 'perm': 'datasync_changes.list', + }, + { + 'title': "DataSync Status", + 'route': 'datasync.status', + 'perm': 'datasync.status', + }, + { + 'title': "Importing / Exporting", + 'route': 'importing', + 'perm': 'importing.list', + }, + { + 'title': "Luigi Tasks", + 'route': 'luigi', + 'perm': 'luigi.list', + }, + { + 'title': "Tables", + 'route': 'tables', + 'perm': 'tables.list', + }, + { + 'title': "App Info", + 'route': 'appinfo', + 'perm': 'appinfo.list', + }, + { + 'title': "Configure App", + 'route': 'appinfo.configure', + 'perm': 'appinfo.configure', + }, + { + 'title': "Upgrades", + 'route': 'upgrades', + 'perm': 'upgrades.list', + }, + ]) + + return { + 'title': "Admin", + 'type': 'menu', + 'items': items, + } + + def make_simple_menus(request): """ Build the main menu list for the app. """ - # first try to make menus from config, but this is highly - # susceptible to failure, so try to warn user of problems - raw_menus = None - try: - raw_menus = make_menus_from_config(request) - except Exception as error: - # TODO: these messages show up multiple times on some pages?! - # that must mean the BeforeRender event is firing multiple - # times..but why?? seems like there is only 1 request... - log.warning("failed to make menus from config", exc_info=True) - request.session.flash(simple_error(error), 'error') - request.session.flash("Menu config is invalid! Reverting to menus " - "defined in code!", 'warning') - msg = HTML.literal('Please edit your {} ASAP.'.format( - tags.link_to("Menu Config", request.route_url('configure_menus')))) - request.session.flash(msg, 'warning') + app = request.rattail_config.get_app() + tailbone_handler = app.get_tailbone_handler() + menu_handler = tailbone_handler.get_menu_handler() - if not raw_menus: - - # no config, so import/invoke code 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) + 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 @@ -140,185 +443,6 @@ def make_simple_menus(request): return final_menus -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. @@ -405,101 +529,15 @@ def mark_allowed(request, menus): break -def make_admin_menu(request, include_stores=False): +def make_admin_menu(request, **kwargs): """ Generate a typical Admin menu """ - items = [] + warnings.warn("make_admin_menu() function is deprecated; please use " + "MenuHandler.make_admin_menu() instead", + DeprecationWarning, stacklevel=2) - if include_stores: - items.append({ - 'title': "Stores", - 'route': 'stores', - 'perm': 'stores.list', - }) - - items.extend([ - { - 'title': "Users", - 'route': 'users', - 'perm': 'users.list', - }, - { - 'title': "User Events", - 'route': 'userevents', - 'perm': 'userevents.list', - }, - { - 'title': "Roles", - 'route': 'roles', - 'perm': 'roles.list', - }, - {'type': 'sep'}, - { - 'title': "App Settings", - 'route': 'appsettings', - 'perm': 'settings.list', - }, - { - 'title': "Email Settings", - 'route': 'emailprofiles', - 'perm': 'emailprofiles.list', - }, - { - 'title': "Email Attempts", - 'route': 'email_attempts', - 'perm': 'email_attempts.list', - }, - { - 'title': "Raw Settings", - 'route': 'settings', - 'perm': 'settings.list', - }, - {'type': 'sep'}, - { - 'title': "DataSync Changes", - 'route': 'datasyncchanges', - 'perm': 'datasync_changes.list', - }, - { - 'title': "DataSync Status", - 'route': 'datasync.status', - 'perm': 'datasync.status', - }, - { - 'title': "Importing / Exporting", - 'route': 'importing', - 'perm': 'importing.list', - }, - { - 'title': "Luigi Tasks", - 'route': 'luigi', - 'perm': 'luigi.list', - }, - { - 'title': "Tables", - 'route': 'tables', - 'perm': 'tables.list', - }, - { - 'title': "App Info", - 'route': 'appinfo', - 'perm': 'appinfo.list', - }, - { - 'title': "Configure App", - 'route': 'appinfo.configure', - 'perm': 'appinfo.configure', - }, - { - 'title': "Upgrades", - 'route': 'upgrades', - 'perm': 'upgrades.list', - }, - ]) - - return { - 'title': "Admin", - 'type': 'menu', - 'items': items, - } + 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)