diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index c2398fdb..1088ca9b 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -674,10 +674,18 @@ class Form(object):
case the validator pertains to the form at large instead of
one of the fields.
+ TODO: what should the validator look like?
+
:param validator: Callable validator for the node.
"""
self.validators[key] = validator
+ # we normally apply the validator when creating the schema, so
+ # if this form already has a schema, then go ahead and apply
+ # the validator to it
+ if self.schema and key in self.schema:
+ self.schema[key].validator = validator
+
def set_required(self, key, required=True):
"""
Set whether or not value is required for a given field.
diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako
new file mode 100644
index 00000000..990a35af
--- /dev/null
+++ b/tailbone/templates/poser/reports/view.mako
@@ -0,0 +1,77 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="render_form_buttons()">
+
+
+ Upload Replacement Module
+
+
+
+
+
+ ${h.form(master.get_action_url('replace', instance), enctype='multipart/form-data', **{'@submit': 'uploadSubmitting = true'})}
+ ${h.csrf_token(request)}
+
+
+
+
+
+
+ Click to upload
+
+
+
+ {{ uploadFile.name }}
+
+
+
+
+
+ Cancel
+
+
+ {{ uploadSubmitting ? "Working, please wait..." : "Save" }}
+
+
+
+
+ ${h.end_form()}
+
+%def>
+
+<%def name="modify_this_page_vars()">
+ ${parent.modify_this_page_vars()}
+
+%def>
+
+${parent.body()}
diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako
new file mode 100644
index 00000000..18fda0d7
--- /dev/null
+++ b/tailbone/templates/poser/setup.mako
@@ -0,0 +1,57 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="title()">Poser Setup%def>
+
+<%def name="page_content()">
+
+
+
+ Before you can use Poser features, ${app_title} must create the
+ file structure for it.
+
+
+
+ A new folder will be created at this location:
+
+ ${poser_dir}
+
+
+
+
+ Once set up, ${app_title} can generate code for certain features,
+ in the Poser folder. You can then access these features from
+ within ${app_title}.
+
+
+
+ You are free to edit most files in the Poser folder as well.
+ When you do so ${app_title} should pick up on the changes with no
+ need for app restart.
+
+
+
+ Proceed?
+
+
+ ${h.form(request.current_route_url(), **{'@submit': 'setupSubmitting = true'})}
+ ${h.csrf_token(request)}
+
+ {{ setupSubmitting ? "Working, please wait..." : "Go for it!" }}
+
+ ${h.end_form()}
+%def>
+
+<%def name="modify_this_page_vars()">
+ ${parent.modify_this_page_vars()}
+
+%def>
+
+
+${parent.body()}
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index c3e40547..b4a947fa 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -32,7 +32,7 @@ import rattail
from rattail.db import model
from rattail.batch import consume_batch_id
from rattail.mail import send_email
-from rattail.util import OrderedDict
+from rattail.util import OrderedDict, simple_error
from rattail.files import resource_path
from pyramid import httpexceptions
@@ -188,6 +188,32 @@ class CommonView(View):
"""
raise Exception("Congratulations, you have triggered a bogus error.")
+ def poser_setup(self):
+ if not self.request.is_root:
+ raise self.forbidden()
+
+ use_buefy = self.get_use_buefy()
+ app = self.get_rattail_app()
+ app_title = self.rattail_config.app_title()
+ poser_handler = app.get_poser_handler()
+
+ if self.request.method == 'POST':
+ try:
+ path = poser_handler.make_poser_dir()
+ except Exception as error:
+ self.request.session.flash(simple_error(error), 'error')
+ else:
+ self.request.session.flash("Poser folder created at: {}".format(path))
+ self.request.session.flash("Please restart the web app!", 'warning')
+ return self.redirect(self.request.route_url('home'))
+
+ return {
+ 'use_buefy': use_buefy,
+ 'app_title': app_title,
+ 'index_title': app_title,
+ 'poser_dir': poser_handler.get_default_poser_dir(),
+ }
+
@classmethod
def defaults(cls, config):
cls._defaults(config)
@@ -249,6 +275,14 @@ class CommonView(View):
config.add_route('bogus_error', '/bogus-error')
config.add_view(cls, attr='bogus_error', route_name='bogus_error', permission='errors.bogus')
+ # make poser dir
+ config.add_route('poser_setup', '/poser-setup')
+ config.add_view(cls, attr='poser_setup',
+ route_name='poser_setup',
+ renderer='/poser/setup.mako',
+ # nb. root only
+ permission='admin')
+
def includeme(config):
CommonView.defaults(config)
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index b36f18b3..1214d8aa 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -638,7 +638,7 @@ class MasterView(View):
"""
self.creating = True
if form is None:
- form = self.make_form(self.get_model_class())
+ form = self.make_create_form()
if self.request.method == 'POST':
if self.validate_form(form):
# let save_create_form() return alternate object if necessary
@@ -651,6 +651,9 @@ class MasterView(View):
context['dform'] = form.make_deform_form()
return self.render_to_response(template, context)
+ def make_create_form(self):
+ return self.make_form(self.get_model_class())
+
def save_create_form(self, form):
uploads = self.normalize_uploads(form)
self.before_create(form)
@@ -3618,7 +3621,10 @@ class MasterView(View):
raise NotImplementedError
def render_downloadable_file(self, obj, field):
- filename = getattr(obj, field)
+ if hasattr(obj, field):
+ filename = getattr(obj, field)
+ else:
+ filename = obj[field]
if not filename:
return ""
path = self.download_path(obj, filename)
diff --git a/tailbone/views/poser/__init__.py b/tailbone/views/poser/__init__.py
new file mode 100644
index 00000000..e721c862
--- /dev/null
+++ b/tailbone/views/poser/__init__.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2022 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 .
+#
+################################################################################
+"""
+Poser Views
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+
+def includeme(config):
+ config.include('tailbone.views.poser.reports')
diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py
new file mode 100644
index 00000000..146398f8
--- /dev/null
+++ b/tailbone/views/poser/reports.py
@@ -0,0 +1,294 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2022 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 .
+#
+################################################################################
+"""
+Poser Report Views
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+import os
+
+import six
+
+from rattail.util import simple_error
+
+import colander
+from deform import widget as dfwidget
+from webhelpers2.html import HTML, tags
+
+from tailbone.views import MasterView
+
+
+class PoserReportView(MasterView):
+ """
+ Master view for Poser reports
+ """
+ normalized_model_name = 'poserreport'
+ model_title = "Poser Report"
+ model_key = 'report_key'
+ route_prefix = 'poser.reports'
+ url_prefix = '/poser/reports'
+ filterable = False
+ pageable = False
+ editable = False # TODO: should allow this somehow?
+ downloadable = True
+
+ labels = {
+ 'report_key': "Poser Key",
+ }
+
+ grid_columns = [
+ 'report_key',
+ 'report_name',
+ 'description',
+ 'error',
+ ]
+
+ form_fields = [
+ 'report_key',
+ 'report_name',
+ 'description',
+ 'flavor',
+ 'include_comments',
+ 'module_file',
+ 'module_file_path',
+ 'error',
+ ]
+
+ def __init__(self, request):
+ super(PoserReportView, self).__init__(request)
+ app = self.get_rattail_app()
+ self.poser_handler = app.get_poser_handler()
+
+ # nb. pre-load all reports b/c all views potentially need
+ # access to the data set
+ self.data = self.get_data()
+
+ def get_data(self, session=None):
+ if hasattr(self, 'data'):
+ return self.data
+
+ try:
+ return self.poser_handler.get_all_reports()
+
+ except Exception as error:
+ self.request.session.flash(simple_error(error), 'error')
+
+ if not self.request.is_root:
+ self.request.session.flash("You must become root in order "
+ "to do Poser Setup.", 'error')
+ else:
+ link = tags.link_to("Poser Setup",
+ self.request.route_url('poser_setup'))
+ msg = HTML.literal("Please see the {} page.".format(link))
+ self.request.session.flash(msg, 'error')
+ return []
+
+ def configure_grid(self, g):
+ super(PoserReportView, self).configure_grid(g)
+
+ g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True)
+ g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True)
+
+ g.set_renderer('error', self.render_report_error)
+
+ g.set_sort_defaults('report_name')
+
+ g.set_link('report_key')
+ g.set_link('report_name')
+ g.set_link('description')
+ g.set_link('error')
+
+ g.set_searchable('report_key')
+ g.set_searchable('report_name')
+ g.set_searchable('description')
+
+ if self.request.has_perm('report_output.create'):
+ g.more_actions.append(self.make_action(
+ 'generate', icon='arrow-circle-right',
+ url=self.get_generate_url))
+
+ def get_generate_url(self, report, i=None):
+ if not report.get('error'):
+ return self.request.route_url('generate_specific_report',
+ type_key=report['report'].type_key)
+
+ def render_report_error(self, report, field):
+ error = report.get('error')
+ if error:
+ return HTML.tag('span', class_='has-background-warning', c=[error])
+
+ def get_instance(self):
+ report_key = self.request.matchdict['report_key']
+ for report in self.get_data():
+ if report['report_key'] == report_key:
+ return report
+ raise self.notfound()
+
+ def get_instance_title(self, report):
+ return report['report_name']
+
+ def make_form_schema(self):
+ return PoserReportSchema()
+
+ def make_create_form(self):
+ return self.make_form({})
+
+ def save_create_form(self, form):
+ self.before_create(form)
+
+ report = self.poser_handler.make_report(
+ form.validated['report_key'],
+ form.validated['report_name'],
+ form.validated['description'],
+ flavor=form.validated['flavor'],
+ include_comments=form.validated['include_comments'])
+
+ return report
+
+ def configure_form(self, f):
+ super(PoserReportView, self).configure_form(f)
+ report = f.model_instance
+
+ # report_key
+ f.set_default('report_key', 'cool_widgets')
+ f.set_helptext('report_key', "Unique computer-friendly key for the report type.")
+ if self.creating:
+ f.set_validator('report_key', self.unique_report_key)
+
+ # report_name
+ f.set_default('report_name', "Cool Widgets Weekly")
+ f.set_helptext('report_name', "Human-friendly display name for the report.")
+
+ # description
+ f.set_default('description', "How many cool widgets we come across each week")
+ f.set_helptext('description', "Brief description of the report.")
+
+ # flavor
+ if self.creating:
+ f.set_helptext('flavor', "Determines the type of sample code to generate.")
+ flavors = self.poser_handler.get_supported_report_flavors()
+ values = [(key, flavor['description'])
+ for key, flavor in six.iteritems(flavors)]
+ f.set_widget('flavor', dfwidget.SelectWidget(values=values))
+ f.set_validator('flavor', colander.OneOf(flavors))
+ if flavors:
+ f.set_default('flavor', list(flavors)[0])
+ else:
+ f.remove('flavor')
+
+ # include_comments
+ if not self.creating:
+ f.remove('include_comments')
+
+ # module_file
+ if self.creating:
+ f.remove('module_file')
+ else:
+ # nb. set this key as workaround for render method, which
+ # expects object to have this field
+ report['module_file'] = os.path.basename(report['module_file_path'])
+ f.set_renderer('module_file', self.render_downloadable_file)
+
+ # error
+ if self.creating or not report.get('error'):
+ f.remove('error')
+ else:
+ f.set_renderer('error', self.render_report_error)
+
+ def unique_report_key(self, node, value):
+ for report in self.get_data():
+ if report['report_key'] == value:
+ raise node.raise_invalid("Poser report key must be unique")
+
+ def download_path(self, report, filename):
+ return report['module_file_path']
+
+ def delete_instance(self, report):
+ self.poser_handler.delete_report(report['report_key'])
+
+ def replace(self):
+ app = self.get_rattail_app()
+ report = self.get_instance()
+
+ value = self.request.POST['replacement_module']
+ tempdir = app.make_temp_dir()
+ filepath = os.path.join(tempdir, os.path.basename(value.filename))
+ with open(filepath, 'wb') as f:
+ f.write(value.file.read())
+
+ try:
+ newreport = self.poser_handler.replace_report(report['report_key'],
+ filepath)
+ except Exception as error:
+ self.request.session.flash(simple_error(error), 'error')
+ else:
+ report = newreport
+ finally:
+ os.remove(filepath)
+ os.rmdir(tempdir)
+
+ return self.redirect(self.get_action_url('view', report))
+
+ @classmethod
+ def defaults(cls, config):
+ cls._poser_report_defaults(config)
+ cls._defaults(config)
+
+ @classmethod
+ def _poser_report_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ instance_url_prefix = cls.get_instance_url_prefix()
+
+ # replace module
+ config.add_route('{}.replace'.format(route_prefix),
+ '{}/replace'.format(instance_url_prefix),
+ request_method='POST')
+ config.add_view(cls, attr='replace',
+ route_name='{}.replace'.format(route_prefix),
+ # TODO: requires root, should add custom permission?
+ permission='admin')
+
+
+class PoserReportSchema(colander.MappingSchema):
+
+ report_key = colander.SchemaNode(colander.String())
+
+ report_name = colander.SchemaNode(colander.String())
+
+ description = colander.SchemaNode(colander.String())
+
+ flavor = colander.SchemaNode(colander.String())
+
+ include_comments = colander.SchemaNode(colander.Bool())
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ PoserReportView = kwargs.get('PoserReportView', base['PoserReportView'])
+ PoserReportView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py
index 12957934..cb0b718b 100644
--- a/tailbone/views/reports.py
+++ b/tailbone/views/reports.py
@@ -400,7 +400,8 @@ class ReportOutputView(ExportMasterView):
form = forms.Form(schema=schema, request=self.request,
use_buefy=use_buefy, helptext=helptext)
form.submit_label = "Generate this Report"
- form.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
+ form.cancel_url = self.request.get_referrer(
+ default=self.request.route_url('{}.create'.format(route_prefix)))
# must declare jquery support for date fields, ugh
# TODO: obviously would be nice for this to be automatic?