From 00548a259b5a5a6be46f5c0c71f6f787c342b852 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Jan 2023 13:50:27 -0600 Subject: [PATCH] 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)