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>
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+
+%def>
+
<%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>
+
+<%def name="render_this_page()">
+
+
+
+
+ Enter Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Details are complete
+
+
+
+
+
+
+
+ Write View
+
+
+
+ {{ modelName }}
+
+
+
+ {{ viewClassName }}
+
+
+
+
+
+
+
+
+ Overwrite file if it exists
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+ 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>
+
+<%def name="modify_this_page_vars()">
+ ${parent.modify_this_page_vars()}
+
+%def>
+
+
+${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)