Add basic "new model view" wizard

This commit is contained in:
Lance Edgar 2023-01-16 13:50:27 -06:00
parent f4bc280da7
commit 00548a259b
10 changed files with 681 additions and 5 deletions

View file

@ -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.

View file

@ -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)

View file

@ -41,6 +41,28 @@
</b-checkbox>
</b-field>
<div class="level-left">
<div class="level-item">
<b-field>
<b-checkbox name="rattail.running_from_source"
v-model="simpleSettings['rattail.running_from_source']"
native-value="true"
@input="settingsNeedSaved = true">
Running from Source
</b-checkbox>
</b-field>
</div>
<div class="level-item">
<b-field label="Top-Level Package" horizontal
v-if="simpleSettings['rattail.running_from_source']">
<b-input name="rattail.running_from_source.rootpkg"
v-model="simpleSettings['rattail.running_from_source.rootpkg']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</div>
</div>
</div>
<h3 class="block is-size-3">Display</h3>

View file

@ -3,6 +3,15 @@
<%def name="title()">Configure ${config_title}</%def>
<%def name="extra_styles()">
${parent.extra_styles()}
<style type="text/css">
.label {
white-space: nowrap;
}
</style>
</%def>
<%def name="save_undo_buttons()">
<div class="buttons"
v-if="settingsNeedSaved">

View file

@ -37,6 +37,7 @@
configureFieldsHelp: Boolean,
},
computed: {},
watch: {},
methods: {},
}

View file

@ -0,0 +1,339 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/create.mako" />
<%def name="extra_styles()">
${parent.extra_styles()}
<style type="text/css">
.label {
white-space: nowrap;
}
</style>
</%def>
<%def name="render_this_page()">
<b-steps v-model="activeStep"
:animated="false"
rounded
:has-navigation="false"
vertical
icon-pack="fas">
<b-step-item step="1"
value="enter-details"
label="Enter Details"
clickable>
<h3 class="is-size-3 block">
Enter Details
</h3>
<b-field grouped>
<b-field label="Model Name">
<b-select v-model="modelName">
<option v-for="name in modelNames"
:key="name"
:value="name">
{{ name }}
</option>
</b-select>
</b-field>
<b-field label="View Class Name">
<b-input v-model="viewClassName">
</b-input>
</b-field>
<b-field label="View Route Prefix">
<b-input v-model="viewRoutePrefix">
</b-input>
</b-field>
</b-field>
<br />
<div class="buttons">
<b-button type="is-primary"
icon-pack="fas"
icon-left="check"
@click="activeStep = 'write-view'">
Details are complete
</b-button>
</div>
</b-step-item>
<b-step-item step="2"
value="write-view"
label="Write View">
<h3 class="is-size-3 block">
Write View
</h3>
<b-field label="Model Name" horizontal>
{{ modelName }}
</b-field>
<b-field label="View Class" horizontal>
{{ viewClassName }}
</b-field>
<b-field horizontal label="File">
<b-input v-model="viewFile"></b-input>
</b-field>
<b-field horizontal>
<b-checkbox v-model="viewFileOverwrite">
Overwrite file if it exists
</b-checkbox>
</b-field>
<div class="form">
<div class="buttons">
<b-button icon-pack="fas"
icon-left="arrow-left"
@click="activeStep = 'enter-details'">
Back
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="save"
@click="writeViewFile()"
:disabled="writingViewFile">
{{ writingViewFile ? "Working, please wait..." : "Write view class to file" }}
</b-button>
<b-button icon-pack="fas"
icon-left="arrow-right"
@click="activeStep = 'review-view'">
Skip
</b-button>
</div>
</div>
</b-step-item>
<b-step-item step="3"
value="review-view"
label="Review View"
## clickable
>
<h3 class="is-size-3 block">
Review View
</h3>
<p class="block">
View code was generated to file:
</p>
<p class="block is-family-code" style="padding-left: 3rem;">
{{ viewFile }}
</p>
<p class="block">
First, review that code and adjust to your liking.
</p>
<p class="block">
Next be sure to include the new view in your config.
Typically this is done by editing the file...
</p>
<p class="block is-family-code" style="padding-left: 3rem;">
${view_dir}__init__.py
</p>
<p class="block">
...and adding a line to the includeme() block such as:
</p>
<pre class="block">
def includeme(config):
# ...existing config includes here...
## TODO: stop hard-coding widgets
config.include('${pkgroot}.web.views.widgets')
</pre>
<p class="block">
Once you&apos;ve done all that, the web app must be restarted.
This may happen automatically depending on your setup.
Test the view status below.
</p>
<div class="card block">
<header class="card-header">
<p class="card-header-title">
View Status
</p>
</header>
<div class="card-content">
<div class="content">
<div class="level">
<div class="level-left">
<div class="level-item">
<span v-if="!viewImportAttempted">
check not yet attempted
</span>
<span v-if="viewImported"
class="has-text-success has-text-weight-bold">
route found!
</span>
<span v-if="viewImportAttempted && viewImportProblem"
class="has-text-danger">
{{ viewImportProblem }}
</span>
</div>
</div>
<div class="level-right">
<div class="level-item">
<b-field horizontal label="Route Prefix">
<b-input v-model="viewRoutePrefix"></b-input>
</b-field>
</div>
<div class="level-item">
<b-button type="is-primary"
icon-pack="fas"
icon-left="redo"
@click="testView()">
Test View
</b-button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="buttons">
<b-button icon-pack="fas"
icon-left="arrow-left"
@click="activeStep = 'write-view'">
Back
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="check"
@click="activeStep = 'commit-code'"
:disabled="!viewImported">
View class looks good!
</b-button>
<b-button icon-pack="fas"
icon-left="arrow-right"
@click="activeStep = 'commit-code'">
Skip
</b-button>
</div>
</b-step-item>
<b-step-item step="4"
value="commit-code"
label="Commit Code">
<h3 class="is-size-3 block">
Commit Code
</h3>
<p class="block">
Hope you're having a great day.
</p>
<p class="block">
Don't forget to commit code changes to your source repo.
</p>
<div class="buttons">
<b-button icon-pack="fas"
icon-left="arrow-left"
@click="activeStep = 'review-view'">
Back
</b-button>
<once-button type="is-primary"
tag="a" :href="viewURL"
icon-left="arrow-right"
:disabled="!viewURL"
:text="`Show me my new view: ${'$'}{viewClassName}`">
</once-button>
</div>
</b-step-item>
</b-steps>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
ThisPageData.activeStep = null
ThisPageData.modelNames = ${json.dumps(model_names)|n}
ThisPageData.modelName = null
ThisPageData.viewClassName = null
ThisPageData.viewRoutePrefix = null
ThisPage.watch.modelName = function(newName, oldName) {
this.viewClassName = `${'$'}{newName}View`
this.viewRoutePrefix = newName.toLowerCase()
}
ThisPage.mounted = function() {
let params = new URLSearchParams(location.search)
if (params.has('model_name')) {
this.modelName = params.get('model_name')
}
}
ThisPageData.viewFile = '${view_dir}widgets.py'
ThisPageData.viewFileOverwrite = false
ThisPageData.writingViewFile = false
ThisPage.methods.writeViewFile = function() {
this.writingViewFile = true
let url = '${url('{}.write_view_file'.format(route_prefix))}'
let params = {
view_file: this.viewFile,
overwrite: this.viewFileOverwrite,
view_class_name: this.viewClassName,
model_name: this.modelName,
route_prefix: this.viewRoutePrefix,
}
this.submitForm(url, params, response => {
this.writingViewFile = false
this.activeStep = 'review-view'
}, response => {
this.writingViewFile = false
})
}
ThisPageData.viewImported = false
ThisPageData.viewImportAttempted = false
ThisPageData.viewImportProblem = null
ThisPage.methods.testView = function() {
this.viewImported = false
this.viewImportProblem = null
let url = '${url('{}.check_view'.format(route_prefix))}'
let params = {
route_prefix: this.viewRoutePrefix,
}
this.submitForm(url, params, response => {
this.viewImportAttempted = true
if (response.data.problem) {
this.viewImportProblem = response.data.problem
} else {
this.viewImported = true
this.viewURL = response.data.url
}
})
}
ThisPageData.viewURL = null
</script>
</%def>
${parent.body()}

View file

@ -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

View file

@ -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',

View file

@ -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()

219
tailbone/views/views.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)