From d0881cbd097eedde5e0af719228e4e32c7ce22a1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Jan 2023 12:38:04 -0600 Subject: [PATCH 001/744] Keep aspect ratio for product images in new custorder --- tailbone/templates/custorders/create.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 77e72244..45d4a510 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -552,7 +552,7 @@
+ style="max-height: 150px; max-width: 150px; "/> ##

{{ productKey }}

@@ -716,7 +716,7 @@
+ style="max-height: 150px; max-width: 150px; "/>
From 31b213610f646955b3deeed70e8515379229d03e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Jan 2023 15:31:51 -0600 Subject: [PATCH 002/744] Fix template bug for generating report --- tailbone/templates/reports/generated/choose.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index 7f24bfde..31aa3cd5 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -98,8 +98,8 @@ % endif -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + +<%def name="declare_vars()"> + + + +<%def name="make_component()"> + + diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 9f4a6c3b..faa13a24 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -24,7 +24,16 @@ v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" native-value="true" @input="settingsNeedSaved = true"> - From Invoice + From Single Invoice + + + + + + From Multiple (Combined) Invoices diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index dca71c35..8c397c4f 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -81,12 +81,12 @@
+ ${form.render_field_readonly('item_entry')} % if row.product: ${form.render_field_readonly(product_key_field)} ${form.render_field_readonly('product')} % else: ${form.render_field_readonly(product_key_field)} - ${form.render_field_readonly('item_entry')} % if product_key_field != 'upc': ${form.render_field_readonly('upc')} % endif diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 654f61df..3cfa00a2 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -4,6 +4,7 @@ <%namespace name="base_meta" file="/base_meta.mako" /> <%namespace file="/formposter.mako" import="declare_formposter_mixin" /> <%namespace name="page_help" file="/page_help.mako" /> +<%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> @@ -577,6 +578,7 @@ ${tailbone_autocomplete_template()} + ${multi_file_upload.render_template()} <%def name="render_this_page_component()"> @@ -764,6 +766,7 @@ <%def name="declare_whole_page_vars()"> ${page_help.declare_vars()} + ${multi_file_upload.declare_vars()} ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + % endif <%def name="extra_styles()"> ${parent.extra_styles()} + % if not use_buefy: + % endif - <%def name="context_menu_items()"> + ${parent.context_menu_items()}
  • ${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}
  • +<%def name="render_form()"> + % if use_buefy: -
      - ${self.context_menu_items()} -
    + + + + + % else: + ## not buefy + +
    + ${h.form(form.action_url, id='inventory-form')} + ${h.csrf_token(request)} + +
    + +
    + ${h.hidden('product')} +
    ${h.text('upc', autocomplete='off')}
    +
    +

    please ENTER a scancode

    +
    +
    please confirm UPC and provide more details
    +
    product already exists in batch, please confirm count
    +
    pack item scanned, but must count units instead
    +
    +
    +
    + + + + % if allow_cases: +
    + +
    ${h.text('cases', autocomplete='off')}
    +
    + % endif + +
    + +
    ${h.text('units', autocomplete='off')}
    +
    + +
    + ${h.submit('submit', "Submit")} +
    + + ${h.end_form()}
    -
    -
    - - - % if allow_cases: -
    - -
    ${h.text('cases', autocomplete='off')}
    -
    % endif + -
    - -
    ${h.text('units', autocomplete='off')}
    -
    -
    - ${h.submit('submit', "Submit")} -
    - - ${h.end_form()} -
    +${parent.body()} diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 19e5a4a7..a00b8d97 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -57,7 +57,7 @@ <%def name="before_object_helpers()"> <%def name="render_this_page_template()"> - % if form is not Underined: + % if form is not Undefined: ${self.render_form()} % endif ${parent.render_this_page_template()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index efb61b20..1c35169a 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -705,9 +705,15 @@ class BatchMasterView(MasterView): if self.rows_creatable and not batch.executed and not batch.complete: permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.create_row'.format(permission_prefix)): - link = tags.link_to("Create a new {}".format(self.get_row_model_title()), - self.get_action_url('create_row', batch)) - return HTML.tag('p', c=[link]) + url = self.get_action_url('create_row', batch) + if self.get_use_buefy(): + return self.make_buefy_button("New Row", url=url, + is_primary=True, + icon_left='plus') + else: + text = "Create a new {}".format(self.get_row_model_title()) + link = tags.link_to(text, url) + return HTML.tag('p', c=[link]) def make_batch_row_grid_tools(self, batch): if self.get_use_buefy(): diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index f8699725..48bc9267 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -234,40 +234,47 @@ class InventoryBatchView(BatchMasterView): if batch.executed: return self.redirect(self.get_action_url('view', batch)) + use_buefy = self.get_use_buefy() schema = DesktopForm().bind(session=self.Session()) - form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + if self.request.method == 'POST': + if form.validate(newstyle=True): - product = self.Session.query(model.Product).get(form.validated['product']) + product = self.Session.query(model.Product).get(form.validated['product']) - row = None - if self.should_aggregate_products(batch): - row = self.find_row_for_product(batch, product) - if row: + row = None + if self.should_aggregate_products(batch): + row = self.find_row_for_product(batch, product) + if row: + row.cases = form.validated['cases'] + row.units = form.validated['units'] + self.handler.refresh_row(row) + + if not row: + row = model.InventoryBatchRow() + row.product = product + row.upc = form.validated['upc'] + row.brand_name = form.validated['brand_name'] + row.description = form.validated['description'] + row.size = form.validated['size'] + row.case_quantity = form.validated['case_quantity'] row.cases = form.validated['cases'] row.units = form.validated['units'] - self.handler.refresh_row(row) + self.handler.capture_current_units(row) + self.handler.add_row(batch, row) - if not row: - row = model.InventoryBatchRow() - row.product = product - row.upc = form.validated['upc'] - row.brand_name = form.validated['brand_name'] - row.description = form.validated['description'] - row.size = form.validated['size'] - row.case_quantity = form.validated['case_quantity'] - row.cases = form.validated['cases'] - row.units = form.validated['units'] - self.handler.capture_current_units(row) - self.handler.add_row(batch, row) + description = make_full_description(form.validated['brand_name'], + form.validated['description'], + form.validated['size']) + self.request.session.flash("{} cases, {} units: {} {}".format( + form.validated['cases'] or 0, form.validated['units'] or 0, + form.validated['upc'].pretty(), description)) + return self.redirect(self.request.current_route_url()) - description = make_full_description(form.validated['brand_name'], - form.validated['description'], - form.validated['size']) - self.request.session.flash("{} cases, {} units: {} {}".format( - form.validated['cases'] or 0, form.validated['units'] or 0, - form.validated['upc'].pretty(), description)) - return self.redirect(self.request.current_route_url()) + else: + dform = form.make_deform_form() + msg = "Form did not validate: {}".format(six.text_type(dform.error)) + self.request.session.flash(msg, 'error') title = self.get_instance_title(batch) return self.render_to_response('desktop_form', { From fa1cf353b86ff1979363d597d4168a0e0f94fc57 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Jan 2023 19:55:52 -0600 Subject: [PATCH 014/744] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4d628263..722886ea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.279 (2023-01-11) +-------------------- + +* Add basic support for receiving from multiple invoice files. + +* Add support for per-item default discount, for new custorder. + +* Fix panel header icon behavior for new custorder. + +* Refactor inventory batch "add row" page, per new theme. + + 0.8.278 (2023-01-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 43ec4886..13c8abaa 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.278' +__version__ = '0.8.279' From 225e13f43bedc853ef6deed42adda660b941c003 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Jan 2023 23:29:28 -0600 Subject: [PATCH 015/744] Allow all external dependency URLs to be set in config so can host all files locally if needed. we also now assume all themes support buefy unless otherwise configured --- tailbone/templates/themes/falafel/base.mako | 20 ++++++++------------ tailbone/util.py | 11 +++-------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 3cfa00a2..888b3bb6 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -107,25 +107,21 @@ <%def name="jquery()"> - ## jQuery 1.12.4 - ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} + ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.jquery', default='https://code.jquery.com/jquery-1.12.4.min.js'))} <%def name="vuejs()"> - ${h.javascript_link('https://unpkg.com/vue@{}/dist/vue.min.js'.format(vue_version))} - - ## vue-resource - ## (needed for e.g. this.$http.get() calls, used by grid at least) - ## TODO: make this configurable also - ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-resource@1.5.1')} + ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue', default='https://unpkg.com/vue@{}/dist/vue.min.js'.format(vue_version)))} + ## TODO: make this version configurable also + ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue_resource', default='https://cdn.jsdelivr.net/npm/vue-resource@1.5.1'))} <%def name="buefy()"> - ${h.javascript_link('https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version))} + ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.buefy', default='https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version)))} <%def name="fontawesome()"> - + <%def name="extra_javascript()"> @@ -163,14 +159,14 @@ ${h.stylesheet_link(buefy_css)} % else: ## upstream Buefy CSS - ${h.stylesheet_link('https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version))} + ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.buefy.css', default='https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version)))} % endif ## TODO: this is only being referenced by the progress template i think? ## (so, should make a Buefy progress page at least) <%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')} + ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.jquery.css', default='https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css'))} <%def name="extra_styles()"> diff --git a/tailbone/util.py b/tailbone/util.py index c1d39eac..a9aa3bf3 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -109,13 +109,8 @@ def should_use_buefy(request): if buefy is not None: return buefy - # TODO: should not hard-code this surely, but works for now... - if theme == 'falafel': - return True - - # TODO: probably should not use this fallback? it was the first setting - # i tested with, but is poorly named to say the least - return request.rattail_config.getbool('tailbone', 'grids.use_buefy', default=False) + # otherwise assume buefy is in effect + return True def pretty_datetime(config, value): From 2163522e7c9baf9d00e039f3d00ce98f0709344e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Jan 2023 23:31:09 -0600 Subject: [PATCH 016/744] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 722886ea..b08f2e9f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.280 (2023-01-11) +-------------------- + +* Allow all external dependency URLs to be set in config. + + 0.8.279 (2023-01-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 13c8abaa..5716918c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.279' +__version__ = '0.8.280' From d842a3d8e0c4f875fe78599d4342dd0082d2db39 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Jan 2023 15:19:46 -0600 Subject: [PATCH 017/744] Add new views for App Info, and Configure App and a way to specify version/url overrides for buefy, vue etc. also, begin logic for "standard" admin menu --- tailbone/config.py | 14 +- tailbone/grids/core.py | 12 +- tailbone/helpers.py | 5 +- tailbone/menus.py | 102 ++++++++- tailbone/subscribers.py | 13 +- tailbone/templates/appinfo/configure.mako | 242 ++++++++++++++++++++ tailbone/templates/appinfo/index.mako | 114 +++++++++ tailbone/templates/themes/falafel/base.mako | 15 +- tailbone/util.py | 100 ++++++++ tailbone/views/master.py | 4 + tailbone/views/settings.py | 157 ++++++++++++- 11 files changed, 752 insertions(+), 26 deletions(-) create mode 100644 tailbone/templates/appinfo/configure.mako create mode 100644 tailbone/templates/appinfo/index.mako diff --git a/tailbone/config.py b/tailbone/config.py index 1cb6236e..bcdde8a6 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +26,7 @@ Rattail config extension for Tailbone from __future__ import unicode_literals, absolute_import +import warnings from pkg_resources import parse_version from rattail.config import ConfigExtension as BaseExtension @@ -64,7 +65,16 @@ def csrf_header_name(config): def get_buefy_version(config): - return config.get('tailbone', 'buefy_version') or '0.8.17' + warnings.warn("get_buefy_version() is deprecated; please use " + "tailbone.util.get_libver() instead", + DeprecationWarning, stacklevel=2) + + version = config.get('tailbone', 'libver.buefy') + if version: + return version + + return config.get('tailbone', 'buefy_version', + default='latest') def get_buefy_0_8(config, version=None): diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 78fd2cc6..59ab6018 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -453,10 +453,18 @@ class Grid(object): return pretty_boolean(value) def obtain_value(self, obj, column_name): + """ + Try to obtain and return the value from the given object, for + the given column name. + + :returns: The value, or ``None`` if no value was found. + """ try: return obj[column_name] + except KeyError: + pass except TypeError: - return getattr(obj, column_name) + return getattr(obj, column_name, None) def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 750d3f39..aeb6aa01 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -41,7 +41,8 @@ from webhelpers2.html.tags import * from tailbone.util import (csrf_token, get_csrf_token, pretty_datetime, raw_datetime, render_markdown, - route_exists) + route_exists, + get_liburl) def pretty_date(date): diff --git a/tailbone/menus.py b/tailbone/menus.py index 8b432879..7da22696 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -403,3 +403,103 @@ def mark_allowed(request, menus): 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 + """ + 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, + } diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 4aed36cd..cbbcb95a 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -41,9 +41,9 @@ import tailbone from tailbone import helpers from tailbone.db import Session from tailbone.config import (csrf_header_name, should_expose_websockets, - get_buefy_version, get_buefy_0_8) + get_buefy_0_8) from tailbone.menus import make_simple_menus -from tailbone.util import should_use_buefy, get_global_search_options +from tailbone.util import should_use_buefy, get_global_search_options, get_libver def new_request(event): @@ -160,13 +160,8 @@ def before_render(event): # buefy themes get some extra treatment if should_use_buefy(request): - # declare vue.js and buefy versions to use. the default - # values here are "quite conservative" as of this writing, - # perhaps too much so, but at least they should work fine. - renderer_globals['vue_version'] = request.rattail_config.get( - 'tailbone', 'vue_version') or '2.6.10' - version = get_buefy_version(rattail_config) - renderer_globals['buefy_version'] = version + # TODO: remove this hack once all nodes safely on buefy 0.9 + version = get_libver(request, 'buefy') renderer_globals['buefy_0_8'] = get_buefy_0_8(rattail_config, version=version) diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako new file mode 100644 index 00000000..821f937f --- /dev/null +++ b/tailbone/templates/appinfo/configure.mako @@ -0,0 +1,242 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + +

    Basics

    +
    + + + + + + + + + + ## TODO: should be a dropdown, app handler defines choices + + + + + + + + + + + + + + Production Mode + + + +
    + +

    Display

    +
    + + + + + + + + + + +
    + +

    Grids

    +
    + + + + + + + + + + +
    + +

    Web Libraries

    +
    + + + + % if buefy_0_8: + + % endif + + + + % for weblib in weblibs: + ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})} + ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})} + % endfor + + + + + +
    + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako new file mode 100644 index 00000000..4bf70354 --- /dev/null +++ b/tailbone/templates/appinfo/index.mako @@ -0,0 +1,114 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="render_grid_component()"> + + + + + +
    +
    + + + % if buefy_0_8: + + % endif + + +
    +
    +
    + + + + + +
    +
    + ${parent.render_grid_component()} +
    +
    +
    + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 888b3bb6..adbcd893 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -107,21 +107,20 @@ <%def name="jquery()"> - ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.jquery', default='https://code.jquery.com/jquery-1.12.4.min.js'))} + ${h.javascript_link(h.get_liburl(request, 'jquery'))} <%def name="vuejs()"> - ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue', default='https://unpkg.com/vue@{}/dist/vue.min.js'.format(vue_version)))} - ## TODO: make this version configurable also - ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue_resource', default='https://cdn.jsdelivr.net/npm/vue-resource@1.5.1'))} + ${h.javascript_link(h.get_liburl(request, 'vue'))} + ${h.javascript_link(h.get_liburl(request, 'vue_resource'))} <%def name="buefy()"> - ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.buefy', default='https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version)))} + ${h.javascript_link(h.get_liburl(request, 'buefy'))} <%def name="fontawesome()"> - + <%def name="extra_javascript()"> @@ -159,14 +158,14 @@ ${h.stylesheet_link(buefy_css)} % else: ## upstream Buefy CSS - ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.buefy.css', default='https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version)))} + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} % endif ## TODO: this is only being referenced by the progress template i think? ## (so, should make a Buefy progress page at least) <%def name="jquery_theme()"> - ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.jquery.css', default='https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css'))} + ${h.stylesheet_link(h.get_liburl(request, 'jquery_ui'))} <%def name="extra_styles()"> diff --git a/tailbone/util.py b/tailbone/util.py index a9aa3bf3..ccab81c6 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -98,6 +98,106 @@ def get_global_search_options(request): return options +def get_libver(request, key, fallback=True, default_only=False): + """ + Return the appropriate URL for the library identified by ``key``. + """ + config = request.rattail_config + + if not default_only: + version = config.get('tailbone', 'libver.{}'.format(key)) + if version: + return version + + if not fallback and not default_only: + + if key == 'buefy': + version = config.get('tailbone', 'buefy_version') + if version: + return version + + elif key == 'buefy.css': + version = get_libver(request, 'buefy', fallback=False) + if version: + return version + + elif key == 'vue': + version = config.get('tailbone', 'vue_version') + if version: + return version + + return + + if key == 'buefy': + if not default_only: + version = config.get('tailbone', 'buefy_version') + if version: + return version + return 'latest' + + elif key == 'buefy.css': + version = get_libver(request, 'buefy', default_only=default_only) + if version: + return version + return 'latest' + + elif key == 'vue': + if not default_only: + version = config.get('tailbone', 'vue_version') + if version: + return version + return '2.6.14' + + elif key == 'vue_resource': + return 'latest' + + elif key == 'fontawesome': + return '5.3.1' + + elif key == 'jquery': + return '1.12.4' + + elif key == 'jquery_ui': + return '1.11.4' + + +def get_liburl(request, key, fallback=True): + """ + Return the appropriate URL for the library identified by ``key``. + """ + config = request.rattail_config + + url = config.get('tailbone', 'liburl.{}'.format(key)) + if url: + return url + + if not fallback: + return + + version = get_libver(request, key) + + if key == 'buefy': + return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version) + + elif key == 'buefy.css': + return 'https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(version) + + elif key == 'vue': + return 'https://unpkg.com/vue@{}/dist/vue.min.js'.format(version) + + elif key == 'vue_resource': + return 'https://cdn.jsdelivr.net/npm/vue-resource@{}'.format(version) + + elif key == 'fontawesome': + return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version) + + elif key == 'jquery': + return 'https://code.jquery.com/jquery-{}.min.js'.format(version) + + elif key == 'jquery_ui': + return 'https://code.jquery.com/ui/{}/themes/dark-hive/jquery-ui.css'.format(version) + + def should_use_buefy(request): """ Returns a flag indicating whether or not the current theme supports (and diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1771a3b7..a80b6c26 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2248,6 +2248,8 @@ class MasterView(View): route = self.get_route_prefix() return self.request.route_url(route, **kwargs) + # TODO: this should not be class method, if possible + # (pretty sure overriding as instance method works fine) @classmethod def get_index_title(cls): """ @@ -4822,6 +4824,8 @@ class MasterView(View): value = six.text_type(bool(value)).lower() elif simple.get('type') is int: value = six.text_type(int(value or '0')) + elif value is None: + value = '' else: value = six.text_type(value) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 9a1e8620..f4a213c0 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,14 +26,17 @@ Settings Views from __future__ import unicode_literals, absolute_import +import os import re +import subprocess +import sys import json import six from rattail.db import model from rattail.settings import Setting -from rattail.util import import_module_path +from rattail.util import import_module_path, OrderedDict import colander from webhelpers2.html import tags @@ -41,6 +44,153 @@ from webhelpers2.html import tags from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView, View +from tailbone.util import get_libver, get_liburl + + +class AppInfoView(MasterView): + """ + Master view for the overall app, to show/edit config etc. + """ + route_prefix = 'appinfo' + model_key = 'UNUSED' + model_title = "UNUSED" + model_title_plural = "App Info" + creatable = False + viewable = False + editable = False + deletable = False + filterable = False + pageable = False + configurable = True + + grid_columns = [ + 'name', + 'version', + 'editable_project_location', + ] + + def get_index_title(self): + return "App Info for {}".format(self.rattail_config.app_title()) + + def get_data(self, session=None): + pip = os.path.join(sys.prefix, 'bin', 'pip') + output = subprocess.check_output([pip, 'list', '--format=json']) + data = json.loads(output.decode('utf_8').strip()) + + for pkg in data: + pkg.setdefault('editable_project_location', '') + + return data + + def configure_grid(self, g): + super(AppInfoView, self).configure_grid(g) + + g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) + g.set_sort_defaults('name') + g.set_searchable('name') + + g.sorters['version'] = g.make_simple_sorter('version', foldcase=True) + + g.sorters['editable_project_location'] = g.make_simple_sorter( + 'editable_project_location', foldcase=True) + g.set_searchable('editable_project_location') + + def configure_get_context(self, **kwargs): + context = super(AppInfoView, self).configure_get_context(**kwargs) + + weblibs = OrderedDict([ + ('vue', "Vue"), + ('vue_resource', "vue-resource"), + ('buefy', "Buefy"), + ('buefy.css', "Buefy CSS"), + ('fontawesome', "FontAwesome"), + ('jquery', "jQuery"), + ('jquery_ui', "jQuery UI"), + ]) + + for key in weblibs: + title = weblibs[key] + weblibs[key] = { + 'key': key, + 'title': title, + + # nb. these values are exactly as configured, and are + # used for editing the settings + 'configured_version': get_libver(self.request, key, fallback=False), + 'configured_url': get_liburl(self.request, key, fallback=False), + + # these are for informational purposes only + 'default_version': get_libver(self.request, key, default_only=True), + 'live_url': get_liburl(self.request, key), + } + + context['weblibs'] = list(weblibs.values()) + return context + + def configure_get_simple_settings(self): + return [ + + # basics + {'section': 'rattail', + 'option': 'app_title'}, + {'section': 'rattail', + 'option': 'node_type'}, + {'section': 'rattail', + 'option': 'node_title'}, + {'section': 'rattail', + 'option': 'production', + 'type': bool}, + + # display + {'section': 'tailbone', + 'option': 'background_color'}, + + # grids + {'section': 'tailbone', + 'option': 'grid.default_pagesize', + # TODO: seems like should enforce this, but validation is + # not setup yet + # 'type': int + }, + + # web libs + {'section': 'tailbone', + 'option': 'libver.vue'}, + {'section': 'tailbone', + 'option': 'liburl.vue'}, + {'section': 'tailbone', + 'option': 'libver.vue_resource'}, + {'section': 'tailbone', + 'option': 'liburl.vue_resource'}, + {'section': 'tailbone', + 'option': 'libver.buefy'}, + {'section': 'tailbone', + 'option': 'liburl.buefy'}, + {'section': 'tailbone', + 'option': 'libver.buefy.css'}, + {'section': 'tailbone', + 'option': 'liburl.buefy.css'}, + {'section': 'tailbone', + 'option': 'libver.fontawesome'}, + {'section': 'tailbone', + 'option': 'liburl.fontawesome'}, + {'section': 'tailbone', + 'option': 'libver.jquery'}, + {'section': 'tailbone', + 'option': 'liburl.jquery'}, + {'section': 'tailbone', + 'option': 'libver.jquery_ui'}, + {'section': 'tailbone', + 'option': 'liburl.jquery_ui'}, + + # nb. these are no longer used (deprecated), but we keep + # them defined here so the tool auto-deletes them + {'section': 'tailbone', + 'option': 'buefy_version'}, + {'section': 'tailbone', + 'option': 'vue_version'}, + + ] class SettingView(MasterView): @@ -322,6 +472,9 @@ class AppSettingsView(View): def defaults(config, **kwargs): base = globals() + AppInfoView = kwargs.get('AppInfoView', base['AppInfoView']) + AppInfoView.defaults(config) + AppSettingsView = kwargs.get('AppSettingsView', base['AppSettingsView']) AppSettingsView.defaults(config) From 38f88407ff8a096158153a5aec4ed7f2f9de2977 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Jan 2023 15:33:56 -0600 Subject: [PATCH 018/744] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index b08f2e9f..b530a0d2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.281 (2023-01-12) +-------------------- + +* Add new views for App Info, and Configure App. + + 0.8.280 (2023-01-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5716918c..1bdfa062 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.280' +__version__ = '0.8.281' From fb7368993c71a9a8bcc9497b2424f153854bb465 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Jan 2023 22:56:12 -0600 Subject: [PATCH 019/744] Show basic column info as row grid when viewing Table --- tailbone/views/tables.py | 65 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 49d9e7a5..db045a73 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -29,8 +29,11 @@ from __future__ import unicode_literals, absolute_import import sys import warnings +import six + import colander from deform import widget as dfwidget +from webhelpers2.html import HTML from tailbone.views import MasterView @@ -60,6 +63,19 @@ class TableView(MasterView): 'row_count', ] + has_rows = True + rows_pageable = False + rows_filterable = False + rows_viewable = False + + row_grid_columns = [ + 'sequence', + 'column_name', + 'data_type', + 'nullable', + 'description', + ] + def __init__(self, request): super(TableView, self).__init__(request) app = self.get_rattail_app() @@ -117,6 +133,7 @@ class TableView(MasterView): } table = model.Base.metadata.tables.get(table_name) + data['table'] = table if table is not None: try: mapper = get_mapper(table) @@ -198,6 +215,52 @@ class TableView(MasterView): # def save_create_form(self, form): # return form.validated + def get_row_data(self, table): + data = [] + for i, column in enumerate(table['table'].columns, 1): + + data.append({ + 'column': column, + 'sequence': i, + 'column_name': column.name, + 'data_type': six.text_type(repr(column.type)), + 'nullable': column.nullable, + 'description': column.doc, + }) + return data + + def configure_row_grid(self, g): + super(TableView, self).configure_row_grid(g) + + g.sorters['sequence'] = g.make_simple_sorter('sequence') + g.set_sort_defaults('sequence') + g.set_label('sequence', "Seq.") + + g.sorters['column_name'] = g.make_simple_sorter('column_name', + foldcase=True) + g.set_searchable('column_name') + + g.sorters['data_type'] = g.make_simple_sorter('data_type', + foldcase=True) + g.set_searchable('data_type') + + g.set_type('nullable', 'boolean') + g.sorters['nullable'] = g.make_simple_sorter('nullable') + + g.set_renderer('description', self.render_column_description) + + def render_column_description(self, column, field): + text = column[field] + if not text: + return + + max_length = 80 + + if len(text) < max_length: + return text + + return HTML.tag('span', title=text, c="{} ...".format(text[:max_length])) + @classmethod def defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') From cac005f993529c0f5422d48ec6d39e449a73e7df Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Jan 2023 03:51:12 -0600 Subject: [PATCH 020/744] Semi-finish logic for writing new table model class to file definitely needs more polish and features, but the gist.. --- tailbone/templates/tables/create.mako | 363 ++++++++++++++++++++++++-- tailbone/views/tables.py | 86 +++--- 2 files changed, 387 insertions(+), 62 deletions(-) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 90d9d26f..4d46273a 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -10,7 +10,7 @@ -<%def name="render_buefy_form()"> +<%def name="render_this_page()"> Enter Details - ${parent.render_buefy_form()} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Record version data for this table + + + +
    + +
    +
    +

    Columns

    +
    +
    + + New + +
    +
    + + + % if buefy_0_8: + + % endif + + + + + + +
    + +
    + + Details are complete + +
    + + label="Write Model">

    Write Model

    + + + {{ tableBranch }} + + + + {{ tableName }} + + + + {{ tableModelName }} + + + + + +
    - - TODO: poser_widget - - - TODO: PoserWidget - - - TODO: ~/src/poser/poser/db/model/widgets.py -
    - Write model class to file + @click="writeModelFile()" + :disabled="writingModelFile"> + {{ writingModelFile ? "Working, please wait..." : "Write model class to file" }}
    @@ -206,6 +438,107 @@ diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index db045a73..196e70f5 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -26,6 +26,7 @@ Views with info about the underlying Rattail tables from __future__ import unicode_literals, absolute_import +import os import sys import warnings @@ -160,65 +161,36 @@ class TableView(MasterView): def make_form_schema(self): return TableSchema() - def configure_form(self, f): - super(TableView, self).configure_form(f) + def template_kwargs_create(self, **kwargs): + kwargs = super(TableView, self).template_kwargs_create(**kwargs) app = self.get_rattail_app() + model = self.model - # exclude some fields when creating - if self.creating: - f.remove('row_count', - 'module_name', - 'module_file') + kwargs['branch_name_options'] = self.db_handler.get_alembic_branch_names() - # branch_name - if self.creating: + branch_name = app.get_table_prefix() + if branch_name not in kwargs['branch_name_options']: + branch_name = None + kwargs['branch_name'] = branch_name - # move this field to top of form, as it's more fundamental - # when creating new table - f.remove('branch_name') - f.insert(0, 'branch_name') + kwargs['model_dir'] = (os.path.dirname(model.__file__) + + os.sep) - # define options for dropdown - branches = self.db_handler.get_alembic_branch_names() - values = [(branch, branch) for branch in branches] - f.set_widget('branch_name', dfwidget.SelectWidget(values=values)) + return kwargs - # default to custom app branch, if applicable - table_prefix = app.get_table_prefix() - if table_prefix in branches: - f.set_default('branch_name', table_prefix) - f.set_helptext('branch_name', "Leave this set to your custom app branch, unless you know what you're doing.") + def write_model_file(self): + data = self.request.json_body + path = data['module_file'] - # table_name - if self.creating: - f.set_default('table_name', '{}_widget'.format(app.get_table_prefix())) - f.set_helptext('table_name', "Should be singular in nature, i.e. 'widget' not 'widgets'") + if os.path.exists(path): + return {'error': "File already exists"} - # model_name - if self.creating: - f.set_default('model_name', '{}Widget'.format(app.get_class_prefix())) - f.set_helptext('model_name', "Should be singular in nature, i.e. 'Widget' not 'Widgets'") - - # model_title* - if self.creating: - f.set_default('model_title', 'Widget') - f.set_helptext('model_title', "Human-friendly singular model title.") - f.set_default('model_title_plural', 'Widgets') - f.set_helptext('model_title_plural', "Human-friendly plural model title.") - - # description - if self.creating: - f.set_default('description', "Represents a cool widget.") - f.set_helptext('description', "Brief description of what a record in this table represents.") - - # TODO: not sure yet how to handle "save" action - # def save_create_form(self, form): - # return form.validated + self.db_handler.write_table_model(data, path) + return {'ok': True} def get_row_data(self, table): data = [] for i, column in enumerate(table['table'].columns, 1): - data.append({ 'column': column, 'sequence': i, @@ -269,8 +241,26 @@ class TableView(MasterView): if not rattail_config.production(): cls.creatable = True + cls._table_defaults(config) cls._defaults(config) + @classmethod + def _table_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + if cls.creatable: + + # write model class to file + config.add_route('{}.write_model_file'.format(route_prefix), + '{}/write-model-file'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='write_model_file', + route_name='{}.write_model_file'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + class TablesView(TableView): @@ -303,6 +293,8 @@ class TableSchema(colander.Schema): module_file = colander.SchemaNode(colander.String(), missing=colander.null) + versioned = colander.SchemaNode(colander.Bool()) + def defaults(config, **kwargs): base = globals() From 83f9a3faa7be4ed4572238d87bfc99c2c04047bb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Jan 2023 16:49:16 -0600 Subject: [PATCH 021/744] Fix "toggle batch complete" for Chrome browser --- tailbone/templates/batch/view.mako | 4 ++++ tailbone/views/batch/core.py | 17 +++++++++-------- tailbone/views/master.py | 22 +++++++++++++--------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 66a6881a..4288f6e2 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -426,6 +426,10 @@ }) } + % if not batch.executed and master.has_perm('edit'): + ${form.component_studly}Data.togglingBatchComplete = false + % endif + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): ThisPageData.showUploadDialog = false diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 1c35169a..56bfa2f1 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -431,11 +431,10 @@ class BatchMasterView(MasterView): return dict(batch.params or {}) def render_complete(self, batch, field): - permission_prefix = self.get_permission_prefix() use_buefy = self.get_use_buefy() text = "Yes" if batch.complete else "No" - if batch.executed or not self.request.has_perm('{}.edit'.format(permission_prefix)): + if batch.executed or not self.has_perm('edit'): return text if batch.complete: @@ -445,16 +444,18 @@ class BatchMasterView(MasterView): label = "Mark Complete" value = 'true' - kwargs = {} + url = self.get_action_url('toggle_complete', batch) + kwargs = {'@submit': 'togglingBatchComplete = true'} if not use_buefy: kwargs['class_'] = 'autodisable' - begin_form = tags.form(self.get_action_url('toggle_complete', batch), **kwargs) + begin_form = tags.form(url, **kwargs) if use_buefy: - submit = HTML.tag('once-button', - type='is-primary', - native_type='submit', - text=label) + label = HTML.literal( + '{{{{ togglingBatchComplete ? "Working, please wait..." : "{}" }}}}'.format(label)) + submit = self.make_buefy_button(label, is_primary=True, + native_type='submit', + **{':disabled': 'togglingBatchComplete'}) else: submit = tags.submit('submit', label) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a80b6c26..d01bb462 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2660,31 +2660,35 @@ class MasterView(View): normal.append(button) return normal - def make_buefy_button(self, label, is_primary=False, - url=None, is_external=False, + def make_buefy_button(self, label, + type=None, is_primary=False, + url=None, target=None, is_external=False, + icon_left=None, **kwargs): """ Make and return a HTML ```` literal. """ - btn_kw = dict(c=label, icon_pack='fas') + btn_kw = kwargs + btn_kw.setdefault('c', label) + btn_kw.setdefault('icon_pack', 'fas') - if 'type' in kwargs: - btn_kw['type'] = kwargs['type'] + if type: + btn_kw['type'] = type elif is_primary: btn_kw['type'] = 'is-primary' if url: btn_kw['href'] = url - if 'icon_left' in kwargs: - btn_kw['icon_left'] = kwargs['icon_left'] + if icon_left: + btn_kw['icon_left'] = icon_left elif is_external: btn_kw['icon_left'] = 'external-link-alt' else: btn_kw['icon_left'] = 'eye' - if 'target' in kwargs: - btn_kw['target'] = kwargs['target'] + if target: + btn_kw['target'] = target elif is_external: btn_kw['target'] = '_blank' From 0753e956f9272e176466d974e05184f23d7f6b6c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Jan 2023 18:10:28 -0600 Subject: [PATCH 022/744] Revert logic that assumes all themes use buefy that just isn't a safe assumption yet..alas --- tailbone/util.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index ccab81c6..9eae1740 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -209,8 +209,13 @@ def should_use_buefy(request): if buefy is not None: return buefy - # otherwise assume buefy is in effect - return True + # TODO: should not hard-code this surely, but works for now... + if theme == 'falafel': + return True + + # TODO: probably should not use this fallback? it was the first setting + # i tested with, but is poorly named to say the least + return request.rattail_config.getbool('tailbone', 'grids.use_buefy', default=False) def pretty_datetime(config, value): From f18f24962ef5d957fb01d8a3bb309ca6225cf3d6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Jan 2023 20:18:42 -0600 Subject: [PATCH 023/744] Refactor tempmon dashboard view, for buefy themes --- .../templates/tempmon/appliances/view.mako | 9 ++ tailbone/templates/tempmon/clients/view.mako | 10 ++ tailbone/templates/tempmon/dashboard.mako | 112 ++++++++++++++++++ tailbone/views/tempmon/appliances.py | 10 +- tailbone/views/tempmon/clients.py | 10 +- tailbone/views/tempmon/core.py | 57 ++++++++- tailbone/views/tempmon/dashboard.py | 36 +++--- 7 files changed, 225 insertions(+), 19 deletions(-) diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako index bbaa0e3f..07a524b8 100644 --- a/tailbone/templates/tempmon/appliances/view.mako +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -8,5 +8,14 @@ % endif +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + ${parent.body()} diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index ab65bac6..2141d977 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -40,4 +40,14 @@ % endif +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + ${parent.body()} diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako index 815eb89e..214ff480 100644 --- a/tailbone/templates/tempmon/dashboard.mako +++ b/tailbone/templates/tempmon/dashboard.mako @@ -83,6 +83,57 @@ <%def name="render_this_page()"> + % if use_buefy: + + ${h.form(request.current_route_url(), ref='applianceForm')} + ${h.csrf_token(request)} +
    + +
    + + + + + +
    + + % if appliance: + + % endif + +
    + ${h.end_form()} + + % if appliance and appliance.probes: + % for probe in appliance.probes: +

    + Probe:  ${h.link_to(probe.description, url('tempmon.probes.graph', uuid=probe.uuid))} + (status: ${enum.TEMPMON_PROBE_STATUS[probe.status]}) +

    + % if probe.enabled: + + % else: +

    This probe is not enabled.

    + % endif + % endfor + % elif appliance: +

    This appliance has no probes configured!

    + % else: +

    Please choose an appliance.

    + % endif + + % else: + ## not buefy
    @@ -129,6 +180,67 @@ % else:

    This appliance has no probes configured!

    % endif + % endif + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index 6b8ee036..c523ae78 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -121,6 +121,14 @@ class TempmonApplianceView(MasterView): elif self.creating or self.editing: f.remove_field('probes') + def template_kwargs_view(self, **kwargs): + kwargs = super(TempmonApplianceView, self).template_kwargs_view(**kwargs) + appliance = kwargs['instance'] + + kwargs['probes_data'] = self.normalize_probes(appliance.probes) + + return kwargs + def unique_name(self, node, value): query = self.Session.query(tempmon.Appliance)\ .filter(tempmon.Appliance.name == value) diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index a3fdb31b..9edbd2ba 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -159,6 +159,14 @@ class TempmonClientView(MasterView): # archived f.set_helptext('archived', tempmon.Client.archived.__doc__) + def template_kwargs_view(self, **kwargs): + kwargs = super(TempmonClientView, self).template_kwargs_view(**kwargs) + client = kwargs['instance'] + + kwargs['probes_data'] = self.normalize_probes(client.probes) + + return kwargs + def objectify(self, form, data=None): # this is a hack to prevent updates to the 'enabled' timestamp, when diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 6665f50e..3f16860d 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -42,6 +42,28 @@ class MasterView(views.MasterView): from rattail_tempmon.db import Session return Session() + def normalize_probes(self, probes): + data = [] + for probe in probes: + view_url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) + edit_url = self.request.route_url('tempmon.probes.edit', uuid=probe.uuid) + data.append({ + 'uuid': probe.uuid, + 'url': view_url, + '_action_url_view': view_url, + '_action_url_edit': edit_url, + 'description': probe.description, + 'critical_temp_min': probe.critical_temp_min, + 'good_temp_min': probe.good_temp_min, + 'good_temp_max': probe.good_temp_max, + 'critical_temp_max': probe.critical_temp_max, + 'status': self.enum.TEMPMON_PROBE_STATUS[probe.status], + 'enabled': "Yes" if probe.enabled else "No", + }) + app = self.get_rattail_app() + data = app.json_friendly(data) + return data + def render_probes(self, obj, field): """ This method is used by Appliance and Client views. @@ -50,6 +72,39 @@ class MasterView(views.MasterView): return "" route_prefix = self.get_route_prefix() + use_buefy = self.get_use_buefy() + if use_buefy: + + actions = [self.make_grid_action_view()] + if self.request.has_perm('tempmon.probes.edit'): + actions.append(self.make_grid_action_edit()) + + factory = self.get_grid_factory() + g = factory( + key='{}.probes'.format(route_prefix), + data=[], + columns=[ + 'description', + 'critical_temp_min', + 'good_temp_min', + 'good_temp_max', + 'critical_temp_max', + 'status', + 'enabled', + ], + labels={ + 'critical_temp_min': "Crit. Min", + 'good_temp_min': "Good Min", + 'good_temp_max': "Good Max", + 'critical_temp_max': "Crit. Max", + }, + linked_columns=['description'], + main_actions=actions, + ) + return HTML.literal( + g.render_buefy_table_element(data_prop='probesData')) + + # not buefy! view_url = lambda p, i: self.request.route_url('tempmon.probes.view', uuid=p.uuid) actions = [ grids.GridAction('view', icon='zoomin', url=view_url), diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py index 954acf94..c2b925a8 100644 --- a/tailbone/views/tempmon/dashboard.py +++ b/tailbone/views/tempmon/dashboard.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -74,27 +74,31 @@ class TempmonDashboardView(View): selected_appliance = TempmonSession.query(tempmon.Appliance)\ .get(selected_uuid) - appliances = TempmonSession.query(tempmon.Appliance)\ - .order_by(tempmon.Appliance.name)\ - .all() - appliance_options = tags.Options([ - tags.Option(appliance.name, appliance.uuid) - for appliance in appliances]) - - if use_buefy: - appliance_select = None - raise NotImplementedError - else: - appliance_select = tags.select('appliance_uuid', selected_uuid, appliance_options) - - return { + context = { 'index_url': self.request.route_url('tempmon.appliances'), 'index_title': "TempMon Appliances", 'use_buefy': use_buefy, - 'appliance_select': appliance_select, 'appliance': selected_appliance, } + appliances = TempmonSession.query(tempmon.Appliance)\ + .order_by(tempmon.Appliance.name)\ + .all() + + if use_buefy: + context['appliances_data'] = [{'uuid': a.uuid, + 'name': a.name} + for a in appliances] + + else: + appliance_options = tags.Options([ + tags.Option(appliance.name, appliance.uuid) + for appliance in appliances]) + context['appliance_select'] = tags.select( + 'appliance_uuid', selected_uuid, appliance_options) + + return context + def readings(self): # track down the requested appliance From d8bd4bd847063df00b76d97dc7c9b93c57768a26 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Jan 2023 20:28:00 -0600 Subject: [PATCH 024/744] Prevent listing for top-level Messages view user must access inbox, archive etc. directly instead --- tailbone/views/messages.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index f483d03b..29766b6b 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -52,6 +52,7 @@ class MessageView(MasterView): checkboxes = True replying = False reply_header_sent_format = '%a %d %b %Y at %I:%M %p' + listable = False grid_columns = [ 'subject', From 80989cc84fcb71a022adcf62db50b9553438fe37 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Jan 2023 20:53:26 -0600 Subject: [PATCH 025/744] Update changelog --- CHANGES.rst | 16 ++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index b530a0d2..3323bc18 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.282 (2023-01-13) +-------------------- + +* Show basic column info as row grid when viewing Table. + +* Semi-finish logic for writing new table model class to file. + +* Fix "toggle batch complete" for Chrome browser. + +* Revert logic that assumes all themes use buefy. + +* Refactor tempmon dashboard view, for buefy themes. + +* Prevent listing for top-level Messages view. + + 0.8.281 (2023-01-12) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1bdfa062..fa24a9ed 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.281' +__version__ = '0.8.282' From 23358d9c5d3ed15e271e79a0921aae4d34eb0d4f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Jan 2023 02:20:21 -0600 Subject: [PATCH 026/744] Tweak how backfill task is launched per upstream changes --- tailbone/views/luigi.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index d340bfee..d3bd7b43 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -121,7 +121,10 @@ class LuigiTaskView(MasterView): start_date = app.parse_date(data['start_date']) end_date = app.parse_date(data['end_date']) try: - self.luigi_handler.launch_backfill_task(task, start_date, end_date) + self.luigi_handler.launch_backfill_task(task, start_date, end_date, + keep_config=False, + email_if_empty=True, + wait=False) except Exception as error: log.warning("failed to launch backfill task: %s", task, exc_info=True) From e82e27acd752907c6107bdb232fed21afec92b09 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Jan 2023 08:40:08 -0600 Subject: [PATCH 027/744] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3323bc18..18d9fef1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.283 (2023-01-14) +-------------------- + +* Tweak how backfill task is launched. + + 0.8.282 (2023-01-13) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fa24a9ed..de749230 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.282' +__version__ = '0.8.283' From dec0ebba3035b916ed4de1a302cd0a9790faf96d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Jan 2023 10:31:31 -0600 Subject: [PATCH 028/744] Let the API "rawbytes" response be just that, w/ no file --- tailbone/api/master.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 97426214..3d21cfbe 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -479,13 +479,16 @@ class APIMasterView(APIView): """ obj = self.get_object() - filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() - path = self.download_path(obj, filename) + # TODO: is this really needed? + # filename = self.request.GET.get('filename', None) + # if filename: + # path = self.download_path(obj, filename) + # return self.file_response(path, attachment=False) - response = self.file_response(path, attachment=False) - return response + return self.rawbytes_response(obj) + + def rawbytes_response(self, obj): + raise NotImplementedError ############################## # autocomplete From aef679c030baa3987202a3dec23f24a2a5989955 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Jan 2023 11:51:22 -0600 Subject: [PATCH 029/744] Fix bug when adding new profile via datasync configure --- tailbone/templates/datasync/configure.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 63769ee8..f65d69a5 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -693,7 +693,7 @@ } ThisPage.methods.newProfile = function() { - this.editingProfile = {} + this.editingProfile = {watcher_kwargs_data: []} this.editingConsumer = null this.editingWatcherKwargs = false From cfdaa1e92713bce29055345ec23b34294c90bb9e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Jan 2023 12:17:05 -0600 Subject: [PATCH 030/744] Add default logic to get merge data for object --- tailbone/views/master.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d01bb462..c53dac60 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2094,7 +2094,8 @@ class MasterView(View): if self.merge_handler: return self.merge_handler.get_merge_preview_data(obj) - raise NotImplementedError("please implement `{}.get_merge_data()`".format(self.__class__.__name__)) + return dict([(f, getattr(obj, f)) + for f in self.get_merge_fields()]) def get_merge_resulting_data(self, remove, keep): result = dict(keep) From 39d53617bd70d16ed54f43b7ecd5847f144573d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Jan 2023 16:01:26 -0600 Subject: [PATCH 031/744] 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) From 9d2bcff96bcb9d71250af55ebace4e50b81132ad Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Jan 2023 18:48:56 -0600 Subject: [PATCH 032/744] Add full set of default menus plus dynamic set of integration menus, from providers --- tailbone/handler.py | 9 ++ tailbone/menus.py | 350 ++++++++++++++++++++++++++++++------------ tailbone/providers.py | 5 +- 3 files changed, 269 insertions(+), 95 deletions(-) diff --git a/tailbone/handler.py b/tailbone/handler.py index c665545a..cb78dc82 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.py @@ -26,8 +26,12 @@ Tailbone Handler from __future__ import unicode_literals, absolute_import +import six + from rattail.app import GenericHandler +from tailbone.providers import get_all_providers + class TailboneHandler(GenericHandler): """ @@ -46,4 +50,9 @@ class TailboneHandler(GenericHandler): default='tailbone.menus:MenuHandler') Handler = self.app.load_object(spec) self.menu_handler = Handler(self.config) + self.menu_handler.tb = self return self.menu_handler + + def iter_providers(self): + providers = get_all_providers(self.config) + return six.itervalues(providers) diff --git a/tailbone/menus.py b/tailbone/menus.py index d3a2a4aa..a9de79dc 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -258,107 +258,269 @@ class MenuHandler(GenericHandler): 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), + menus = [ + self.make_people_menu(request), + self.make_products_menu(request), + self.make_vendors_menu(request), ] - def make_admin_menu(self, request, include_stores=False, **kwargs): + integration_menus = self.make_integration_menus(request) + if integration_menus: + menus.extend(integration_menus) + + menus.extend([ + # TODO: add reporting menu + self.make_batches_menu(request), + self.make_admin_menu(request), + ]) + + return menus + + def make_integration_menus(self, request, **kwargs): + """ + Make a set of menus for all registered system integrations. + """ + menus = [] + for provider in self.tb.iter_providers(): + menu = provider.make_integration_menu(request) + if menu: + menus.append(menu) + menus.sort(key=lambda menu: menu['title'].lower()) + return menus + + def make_people_menu(self, request, **kwargs): + """ + Generate a typical People menu + """ + return { + 'title': "People", + 'type': 'menu', + 'items': [ + { + 'title': "Members", + 'route': 'members', + 'perm': 'members.list', + }, + { + 'title': "Customers", + 'route': 'customers', + 'perm': 'customers.list', + }, + { + 'title': "Customer Groups", + 'route': 'customergroups', + 'perm': 'customergroups.list', + }, + { + 'title': "Employees", + 'route': 'employees', + 'perm': 'employees.list', + }, + { + 'title': "All People", + 'route': 'people', + 'perm': 'people.list', + }, + ], + } + + def make_products_menu(self, request, **kwargs): + """ + Generate a typical Products menu + """ + return { + 'title': "Products", + 'type': 'menu', + 'items': [ + { + 'title': "Products", + 'route': 'products', + 'perm': 'products.list', + }, + { + 'title': "Departments", + 'route': 'departments', + 'perm': 'departments.list', + }, + { + 'title': "Subdepartments", + 'route': 'subdepartments', + 'perm': 'subdepartments.list', + }, + { + 'title': "Brands", + 'route': 'brands', + 'perm': 'brands.list', + }, + { + 'title': "Families", + 'route': 'families', + 'perm': 'families.list', + }, + { + 'title': "Report Codes", + 'route': 'reportcodes', + 'perm': 'reportcodes.list', + }, + ], + } + + def make_vendors_menu(self, request, **kwargs): + """ + Generate a typical Vendors menu + """ + return { + 'title': "Vendors", + 'type': 'menu', + 'items': [ + { + 'title': "Vendors", + 'route': 'vendors', + 'perm': 'vendors.list', + }, + {'type': 'sep'}, + { + 'title': "Ordering", + 'route': 'ordering', + 'perm': 'ordering.list', + }, + { + 'title': "Receiving", + 'route': 'receiving', + 'perm': 'receiving.list', + }, + {'type': 'sep'}, + { + 'title': "Purchases", + 'route': 'purchases', + 'perm': 'purchases.list', + }, + { + 'title': "Credits", + 'route': 'purchases.credits', + 'perm': 'purchases.credits.list', + }, + # {'type': 'sep'}, + # { + # 'title': "Catalogs", + # 'route': 'vendorcatalogs', + # 'perm': 'vendorcatalogs.list', + # }, + ], + } + + def make_batches_menu(self, request, **kwargs): + """ + Generate a typical Batches menu + """ + return { + 'title': "Batches", + 'type': 'menu', + 'items': [ + { + 'title': "Handheld", + 'route': 'batch.handheld', + 'perm': 'batch.handheld.list', + }, + # { + # 'title': "Inventory", + # 'route': 'batch.inventory', + # 'perm': 'batch.inventory.list', + # }, + ], + } + + def make_admin_menu(self, request, **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, + 'items': [ + { + 'title': "Stores", + 'route': 'stores', + 'perm': 'stores.list', + }, + { + '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', + }, + ], } @@ -478,7 +640,7 @@ def make_menu_entry(request, item): 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) + log.debug("invalid route name for menu entry: %s", entry) entry['url'] = entry['route'] entry['key'] = entry['route'] else: diff --git a/tailbone/providers.py b/tailbone/providers.py index baa2a15d..a538fa73 100644 --- a/tailbone/providers.py +++ b/tailbone/providers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -48,6 +48,9 @@ class TailboneProvider(object): def get_provided_views(self): return {} + def make_integration_menu(self, request, **kwargs): + pass + def get_all_providers(config): """ From 68ed5942e650423eb74e8dc8c4658952ea288ff8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Jan 2023 23:23:21 -0600 Subject: [PATCH 033/744] Add basic "Review Model" step for new table wizard --- tailbone/templates/tables/create.mako | 132 +++++++++++++++++++++++++- tailbone/views/tables.py | 28 +++++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 4d46273a..9cf4a112 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -275,6 +275,12 @@ + + + Overwrite file if it exists + + +
    {{ writingModelFile ? "Working, please wait..." : "Write model class to file" }} + + Skip +
    @@ -299,7 +310,87 @@

    Review Model

    -

    TODO: review model class here

    + +

    + Model code was generated to file: +

    + +

    + {{ tableModelFile }} +

    + +

    + First, review that code and adjust to your liking. +

    + +

    + Next be sure to import the new model. Typically this is done + by editing the file... +

    + +

    + ${model_dir}__init__.py +

    + +

    + ...and adding a line such as: +

    + +

    + from .{{ tableModelFileModuleName }} import {{ tableModelName }} +

    + +

    + Once you've done all that, the web app must be restarted. + This may happen automatically depending on your setup. + Test the model import status below. +

    + +
    +
    +

    + Model Import Status +

    +
    +
    +
    +
    +
    + +
    + + import not yet attempted + + + imported okay + + + import failed: {{ modelImportStatus }} + +
    +
    +
    +
    + + + +
    +
    + + Refresh / Test Import + +
    +
    +
    +
    +
    +
    +
    + @click="activeStep = 'write-revision'" + :disabled="!modelImported"> Model class looks good!
    @@ -515,11 +607,17 @@ } ThisPageData.tableModelFile = '${model_dir}widget.py' + ThisPageData.tableModelFileOverwrite = false ThisPageData.writingModelFile = false ThisPage.methods.writeModelFile = function() { this.writingModelFile = true + this.modelImportName = this.tableModelName + this.modelImported = false + this.modelImportStatus = "import not yet attempted" + this.modelImportProblem = false + let url = '${url('{}.write_model_file'.format(route_prefix))}' let params = { branch_name: this.tableBranch, @@ -529,8 +627,9 @@ model_title_plural: this.tableModelTitlePlural, description: this.tableDescription, versioned: this.tableVersioned, - module_file: this.tableModelFile, columns: this.tableColumns, + module_file: this.tableModelFile, + overwrite: this.tableModelFileOverwrite, } this.submitForm(url, params, response => { this.writingModelFile = false @@ -540,6 +639,33 @@ }) } + ThisPageData.modelImportName = '${rattail_app.get_class_prefix()}Widget' + ThisPageData.modelImportStatus = "import not yet attempted" + ThisPageData.modelImported = false + ThisPageData.modelImportProblem = false + + ThisPage.computed.tableModelFileModuleName = function() { + let path = this.tableModelFile + path = path.replace(/^.*\//, '') + path = path.replace(/\.py$/, '') + return path + } + + ThisPage.methods.modelImportTest = function() { + let url = '${url('{}.check_model'.format(route_prefix))}' + let params = {model_name: this.modelImportName} + this.submitForm(url, params, response => { + if (response.data.problem) { + this.modelImportProblem = true + this.modelImported = false + this.modelImportStatus = response.data.problem + } else { + this.modelImportProblem = false + this.modelImported = true + } + }) + } + diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 196e70f5..d398733c 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -183,11 +183,28 @@ class TableView(MasterView): path = data['module_file'] if os.path.exists(path): - return {'error': "File already exists"} + if data['overwrite']: + os.remove(path) + else: + return {'error': "File already exists"} self.db_handler.write_table_model(data, path) return {'ok': True} + def check_model(self): + model = self.model + data = self.request.json_body + model_name = data['model_name'] + + if not hasattr(model, model_name): + return {'ok': True, + 'problem': "class not found in primary model contents", + 'model': self.model.__name__} + + # TODO: probably should inspect closer before assuming ok..? + + return {'ok': True} + def get_row_data(self, table): data = [] for i, column in enumerate(table['table'].columns, 1): @@ -261,6 +278,15 @@ class TableView(MasterView): renderer='json', permission='{}.create'.format(permission_prefix)) + # check model + config.add_route('{}.check_model'.format(route_prefix), + '{}/check-model'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='check_model', + route_name='{}.check_model'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + class TablesView(TableView): From f4bc280da7e3869ece64b28b365d61598f8753e6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Jan 2023 22:52:01 -0600 Subject: [PATCH 034/744] Wrap up steps for new table wizard it actually works.. :) needs more polish, but will let usage drive that --- tailbone/templates/tables/create.mako | 219 ++++++++++++++++++++++---- tailbone/views/tables.py | 68 ++++++++ 2 files changed, 258 insertions(+), 29 deletions(-) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 9cf4a112..6c88bb7a 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -26,10 +26,10 @@ Enter Details - - -
    + label="Write Revision" + clickable>

    Write Revision

    @@ -417,9 +424,25 @@ You said the model class looked good, so next we will generate a revision script, used to modify DB schema.

    -

    - TODO: write revision script here -

    + + + + + + + + + + + +
    +
    + {{ writingRevisionScript ? "Working, please wait..." : "Generate revision script" }} + + - Write revision script to file + Skip
    @@ -441,7 +470,19 @@

    Review Revision

    -

    TODO: review revision script here

    + +

    + Revision script was generated to file: +

    + +

    + {{ revisionScript }} +

    + +

    + Please review that code and adjust to your liking. +

    +
    + label="Upgrade DB" + clickable>

    Upgrade DB

    -

    TODO: upgrade DB here

    +

    + You said the revision script looked good, so next we will use + it to upgrade your actual database. +

    +
    - Upgrade database + icon-left="arrow-up" + @click="upgradeDB()" + :disabled="upgradingDB"> + {{ upgradingDB ? "Working, please wait..." : "Upgrade database" }}
    + label="Review DB" + clickable>

    Review DB

    -

    TODO: review DB here

    + +

    + At this point your new table should be present in the DB. + Test below. +

    + +
    +
    +

    + Table Status +

    +
    +
    +
    +
    +
    + +
    + + check not yet attempted + + + table exists! + + + {{ tableCheckProblem }} + +
    +
    +
    +
    + + + +
    +
    + + Test for Table + +
    +
    +
    +
    +
    +
    +
    + @click="activeStep = 'commit-code'" + :disabled="!tableCheckAttempted || tableCheckProblem"> DB looks good!
    @@ -507,19 +606,26 @@

    Commit Code

    -

    TODO: commit changes here

    + +

    + Hope you're having a great day. +

    + +

    + Don't forget to commit code changes to your source repo. +

    +
    Back - - Code changes are committed! - + +
    @@ -530,9 +636,9 @@ diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index d398733c..b9213d9d 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -32,6 +32,8 @@ import warnings import six +from rattail.util import simple_error + import colander from deform import widget as dfwidget from webhelpers2.html import HTML @@ -111,6 +113,13 @@ class TableView(MasterView): # row_count g.sorters['row_count'] = g.make_simple_sorter('row_count') + def configure_form(self, f): + super(TableView, self).configure_form(f) + + # TODO: should render this instead, by inspecting table + if not self.creating: + f.remove('versioned') + def get_instance(self): from sqlalchemy_utils import get_mapper @@ -205,6 +214,37 @@ class TableView(MasterView): return {'ok': True} + def write_revision_script(self): + data = self.request.json_body + script = self.db_handler.generate_revision_script(data['branch'], + message=data['message']) + return {'ok': True, + 'script': script.path} + + def upgrade_db(self): + self.db_handler.upgrade_db() + return {'ok': True} + + def check_table(self): + model = self.model + data = self.request.json_body + table_name = data['table_name'] + + table = model.Base.metadata.tables.get(table_name) + if table is None: + return {'ok': True, + 'problem': "Table does not exist in model metadata!"} + + try: + count = self.Session.query(table).count() + except Exception as error: + return {'ok': True, + 'problem': simple_error(error)} + + url = self.request.route_url('{}.view'.format(self.get_route_prefix()), + table_name=table_name) + return {'ok': True, 'url': url} + def get_row_data(self, table): data = [] for i, column in enumerate(table['table'].columns, 1): @@ -237,6 +277,7 @@ class TableView(MasterView): g.sorters['nullable'] = g.make_simple_sorter('nullable') g.set_renderer('description', self.render_column_description) + g.set_searchable('description') def render_column_description(self, column, field): text = column[field] @@ -287,6 +328,33 @@ class TableView(MasterView): renderer='json', permission='{}.create'.format(permission_prefix)) + # generate revision script + config.add_route('{}.write_revision_script'.format(route_prefix), + '{}/write-revision-script'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='write_revision_script', + route_name='{}.write_revision_script'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # upgrade db + config.add_route('{}.upgrade_db'.format(route_prefix), + '{}/upgrade-db'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='upgrade_db', + route_name='{}.upgrade_db'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # check table + config.add_route('{}.check_table'.format(route_prefix), + '{}/check-table'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='check_table', + route_name='{}.check_table'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + class TablesView(TableView): From 00548a259b5a5a6be46f5c0c71f6f787c342b852 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Jan 2023 13:50:27 -0600 Subject: [PATCH 035/744] Add basic "new model view" wizard --- tailbone/app.py | 22 +- tailbone/handler.py | 22 ++ tailbone/templates/appinfo/configure.mako | 22 ++ tailbone/templates/configure.mako | 9 + tailbone/templates/page.mako | 1 + tailbone/templates/views/model/create.mako | 339 +++++++++++++++++++++ tailbone/views/master.py | 25 +- tailbone/views/settings.py | 5 + tailbone/views/tables.py | 22 ++ tailbone/views/views.py | 219 +++++++++++++ 10 files changed, 681 insertions(+), 5 deletions(-) create mode 100644 tailbone/templates/views/model/create.mako create mode 100644 tailbone/views/views.py diff --git a/tailbone/app.py b/tailbone/app.py index 1cfae6b2..9e8348bc 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -177,6 +177,7 @@ def make_pyramid_config(settings, configure_csrf=True): # 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') + config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view') config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement') config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket') @@ -240,6 +241,25 @@ def add_config_page(config, route_name, label, permission): config.action(None, action) +def add_model_view(config, model_name, label, route_prefix, permission_prefix): + """ + Register a model view for the app. + """ + def action(): + all_views = config.get_settings().get('tailbone_model_views', {}) + + model_views = all_views.setdefault(model_name, []) + model_views.append({ + 'label': label, + 'route_prefix': route_prefix, + 'permission_prefix': permission_prefix, + }) + + config.add_settings({'tailbone_model_views': all_views}) + + config.action(None, action) + + def add_view_supplement(config, route_prefix, cls): """ Register a master view supplement for the app. diff --git a/tailbone/handler.py b/tailbone/handler.py index cb78dc82..db95bc71 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.py @@ -27,8 +27,10 @@ Tailbone Handler from __future__ import unicode_literals, absolute_import import six +from mako.lookup import TemplateLookup from rattail.app import GenericHandler +from rattail.files import resource_path from tailbone.providers import get_all_providers @@ -38,6 +40,13 @@ class TailboneHandler(GenericHandler): Base class and default implementation for Tailbone handler. """ + def __init__(self, *args, **kwargs): + super(TailboneHandler, self).__init__(*args, **kwargs) + + # TODO: make templates dir configurable? + templates = [resource_path('rattail:templates/web')] + self.templates = TemplateLookup(directories=templates) + def get_menu_handler(self, **kwargs): """ Get the configured "menu" handler. @@ -54,5 +63,18 @@ class TailboneHandler(GenericHandler): return self.menu_handler def iter_providers(self): + """ + Returns an iterator over all registered Tailbone providers. + """ providers = get_all_providers(self.config) return six.itervalues(providers) + + def write_model_view(self, data, path, **kwargs): + """ + Write code for a new model view, based on the given data dict, + to the given path. + """ + template = self.templates.get_template('/new-model-view.mako') + content = template.render(**data) + with open(path, 'wt') as f: + f.write(content) diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 821f937f..bb932148 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -41,6 +41,28 @@ +
    +
    + + + Running from Source + + +
    +
    + + + + +
    +
    +

    Display

    diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 2fe8ee72..3aa60f31 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -3,6 +3,15 @@ <%def name="title()">Configure ${config_title} +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + <%def name="save_undo_buttons()">
    diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 9f497268..c1e07db3 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -37,6 +37,7 @@ configureFieldsHelp: Boolean, }, computed: {}, + watch: {}, methods: {}, } diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako new file mode 100644 index 00000000..6a542c52 --- /dev/null +++ b/tailbone/templates/views/model/create.mako @@ -0,0 +1,339 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%def name="render_this_page()"> + + + +

    + Enter Details +

    + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + Details are complete + +
    + +
    + + +

    + Write View +

    + + + {{ modelName }} + + + + {{ viewClassName }} + + + + + + + + + Overwrite file if it exists + + + +
    +
    + + Back + + + {{ writingViewFile ? "Working, please wait..." : "Write view class to file" }} + + + Skip + +
    +
    +
    + + +

    + Review View +

    + +

    + View code was generated to file: +

    + +

    + {{ viewFile }} +

    + +

    + First, review that code and adjust to your liking. +

    + +

    + Next be sure to include the new view in your config. + Typically this is done by editing the file... +

    + +

    + ${view_dir}__init__.py +

    + +

    + ...and adding a line to the includeme() block such as: +

    + +
    +def includeme(config):
    +
    +    # ...existing config includes here...
    +
    +    ## TODO: stop hard-coding widgets
    +    config.include('${pkgroot}.web.views.widgets')
    +      
    + +

    + Once you've done all that, the web app must be restarted. + This may happen automatically depending on your setup. + Test the view status below. +

    + +
    +
    +

    + View Status +

    +
    +
    +
    +
    +
    + +
    + + check not yet attempted + + + route found! + + + {{ viewImportProblem }} + +
    +
    +
    +
    + + + +
    +
    + + Test View + +
    +
    +
    +
    +
    +
    + +
    + + Back + + + View class looks good! + + + Skip + +
    +
    + + +

    + Commit Code +

    + +

    + Hope you're having a great day. +

    + +

    + Don't forget to commit code changes to your source repo. +

    + +
    + + Back + + + +
    +
    + +
    + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c53dac60..1afbc639 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2841,9 +2841,12 @@ class MasterView(View): def make_grid_action_view(self): use_buefy = self.get_use_buefy() - url = self.get_view_index_url if self.use_index_links else None icon = 'eye' if use_buefy else 'zoomin' - return self.make_action('view', icon=icon, url=url) + return self.make_action('view', icon=icon, url=self.default_view_url()) + + def default_view_url(self): + if self.use_index_links: + return self.get_view_index_url def get_view_index_url(self, row, i): route = '{}.view_index'.format(self.get_route_prefix()) @@ -4978,6 +4981,22 @@ class MasterView(View): # list/search if cls.listable: + + # master views which represent a typical model class, and + # allow for an index view, are registered specially so the + # admin may browse the full list of such views + modclass = cls.get_model_class(error=False) + if modclass: + config.add_tailbone_model_view(modclass.__name__, + model_title_plural, + route_prefix, + permission_prefix) + + # but regardless we register the index view, for similar reasons + config.add_tailbone_index_page(route_prefix, model_title_plural, + '{}.list'.format(permission_prefix)) + + # index view config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix), "List / search {}".format(model_title_plural)) config.add_route(route_prefix, '{}/'.format(url_prefix)) @@ -4985,8 +5004,6 @@ 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 diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index f4a213c0..72ee704e 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -140,6 +140,11 @@ class AppInfoView(MasterView): {'section': 'rattail', 'option': 'production', 'type': bool}, + {'section': 'rattail', + 'option': 'running_from_source', + 'type': bool}, + {'section': 'rattail', + 'option': 'running_from_source.rootpkg'}, # display {'section': 'tailbone', diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index b9213d9d..6f717a58 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -67,6 +67,7 @@ class TableView(MasterView): ] has_rows = True + rows_title = "Columns" rows_pageable = False rows_filterable = False rows_viewable = False @@ -170,6 +171,27 @@ class TableView(MasterView): def make_form_schema(self): return TableSchema() + def get_xref_buttons(self, table): + buttons = super(TableView, self).get_xref_buttons(table) + + if table.get('model_name'): + all_views = self.request.registry.settings['tailbone_model_views'] + model_views = all_views.get(table['model_name'], []) + for view in model_views: + url = self.request.route_url(view['route_prefix']) + buttons.append(self.make_xref_button(url=url, text=view['label'], + internal=True)) + + if self.request.has_perm('model_views.create'): + url = self.request.route_url('model_views.create', + _query={'model_name': table['model_name']}) + buttons.append(self.make_buefy_button("New View", + is_primary=True, + url=url, + icon_left='plus')) + + return buttons + def template_kwargs_create(self, **kwargs): kwargs = super(TableView, self).template_kwargs_create(**kwargs) app = self.get_rattail_app() diff --git a/tailbone/views/views.py b/tailbone/views/views.py new file mode 100644 index 00000000..64f94112 --- /dev/null +++ b/tailbone/views/views.py @@ -0,0 +1,219 @@ +# -*- 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 . +# +################################################################################ +""" +Views for views +""" + +from __future__ import unicode_literals, absolute_import + +import os +import sys + +from rattail.db.util import get_fieldnames +from rattail.util import simple_error + +import colander +from deform import widget as dfwidget + +from tailbone.views import MasterView + + +class ModelViewView(MasterView): + """ + Master view for views + """ + normalized_model_name = 'model_view' + model_key = 'route_prefix' + model_title = "Model View" + url_prefix = '/views/model' + viewable = True + creatable = True + editable = False + deletable = False + filterable = False + pageable = False + + grid_columns = [ + 'label', + 'model_name', + 'route_prefix', + 'permission_prefix', + ] + + def get_data(self, **kwargs): + """ + Fetch existing model views from app registry + """ + data = [] + + all_views = self.request.registry.settings['tailbone_model_views'] + for model_name in sorted(all_views): + model_views = all_views[model_name] + for view in model_views: + data.append({ + 'model_name': model_name, + 'label': view['label'], + 'route_prefix': view['route_prefix'], + 'permission_prefix': view['permission_prefix'], + }) + + return data + + def configure_grid(self, g): + super(ModelViewView, self).configure_grid(g) + + # label + g.sorters['label'] = g.make_simple_sorter('label') + g.set_sort_defaults('label') + g.set_link('label') + + # model_name + g.sorters['model_name'] = g.make_simple_sorter('model_name', foldcase=True) + g.set_searchable('model_name') + + # route + g.sorters['route'] = g.make_simple_sorter('route') + + # permission + g.sorters['permission'] = g.make_simple_sorter('permission') + + def default_view_url(self, view, i=None): + return self.request.route_url(view['route_prefix']) + + def make_form_schema(self): + return ModelViewSchema() + + def template_kwargs_create(self, **kwargs): + kwargs = super(ModelViewView, self).template_kwargs_create(**kwargs) + app = self.get_rattail_app() + db_handler = app.get_db_handler() + + model_classes = db_handler.get_model_classes() + kwargs['model_names'] = [cls.__name__ for cls in model_classes] + + pkg = self.rattail_config.get('rattail', 'running_from_source.rootpkg') + if pkg: + kwargs['pkgroot'] = pkg + pkg = sys.modules[pkg] + pkgdir = os.path.dirname(pkg.__file__) + kwargs['view_dir'] = os.path.join(pkgdir, 'web', 'views') + os.sep + else: + kwargs['pkgroot'] = 'poser' + kwargs['view_dir'] = '??' + os.sep + + return kwargs + + def write_view_file(self): + data = self.request.json_body + path = data['view_file'] + + if os.path.exists(path): + if data['overwrite']: + os.remove(path) + else: + return {'error': "File already exists"} + + app = self.get_rattail_app() + tb = app.get_tailbone_handler() + model_class = getattr(self.model, data['model_name']) + + data['model_module_name'] = self.model.__name__ + data['model_title_plural'] = getattr(model_class, + 'model_title_plural', + # TODO + model_class.__name__) + + data['model_versioned'] = hasattr(model_class, '__versioned__') + + fieldnames = get_fieldnames(self.rattail_config, + model_class) + fieldnames.remove('uuid') + data['model_fieldnames'] = fieldnames + + tb.write_model_view(data, path) + + return {'ok': True} + + def check_view(self): + data = self.request.json_body + + try: + url = self.request.route_url(data['route_prefix']) + except Exception as error: + return {'ok': True, + 'problem': simple_error(error)} + + return {'ok': True, 'url': url} + + @classmethod + def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + + # allow creating views only if *not* production + if not rattail_config.production(): + cls.creatable = True + + cls._model_view_defaults(config) + cls._defaults(config) + + @classmethod + def _model_view_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + if cls.creatable: + + # write view class to file + config.add_route('{}.write_view_file'.format(route_prefix), + '{}/write-view-file'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='write_view_file', + route_name='{}.write_view_file'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # check view + config.add_route('{}.check_view'.format(route_prefix), + '{}/check-view'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='check_view', + route_name='{}.check_view'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + +class ModelViewSchema(colander.Schema): + + model_name = colander.SchemaNode(colander.String()) + + +def defaults(config, **kwargs): + base = globals() + + ModelViewView = kwargs.get('ModelViewView', base['ModelViewView']) + ModelViewView.defaults(config) + + +def includeme(config): + defaults(config) From 9b21d52206de478d5466d768984aeacaf59db731 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Jan 2023 18:44:54 -0600 Subject: [PATCH 036/744] Update changelog --- CHANGES.rst | 18 ++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 18d9fef1..179b9a33 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.284 (2023-01-15) +-------------------- + +* Let the API "rawbytes" response be just that, w/ no file. + +* Fix bug when adding new profile via datasync configure. + +* Add default logic to get merge data for object. + +* Add new handlers, TailboneHandler and MenuHandler. + +* Add full set of default menus. + +* Wrap up steps for new table wizard. + +* Add basic "new model view" wizard. + + 0.8.283 (2023-01-14) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index de749230..b1eb871d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.283' +__version__ = '0.8.284' From 98fa6eea05a95fe690cc09659af9837042b2e08f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Jan 2023 21:55:52 -0600 Subject: [PATCH 037/744] Misc. tweaks for App Details / Configure Menus --- tailbone/menus.py | 12 +--------- tailbone/templates/appinfo/index.mako | 25 +++++++++++++++++++++ tailbone/templates/configure-menus.mako | 24 ++++++++++++++++++++ tailbone/templates/themes/falafel/base.mako | 2 +- tailbone/views/menus.py | 10 ++++----- tailbone/views/settings.py | 10 +++++++-- tailbone/views/views.py | 7 ++++-- 7 files changed, 68 insertions(+), 22 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index a9de79dc..e956685d 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -501,20 +501,10 @@ class MenuHandler(GenericHandler): 'perm': 'luigi.list', }, { - 'title': "Tables", - 'route': 'tables', - 'perm': 'tables.list', - }, - { - 'title': "App Info", + 'title': "App Details", 'route': 'appinfo', 'perm': 'appinfo.list', }, - { - 'title': "Configure App", - 'route': 'appinfo.configure', - 'perm': 'appinfo.configure', - }, { 'title': "Upgrades", 'route': 'upgrades', diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 4bf70354..9b50b8a9 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -3,6 +3,31 @@ <%def name="render_grid_component()"> +
    + + + + + + + + + + +
    +