Add basic support for Poser reports, list/create
This commit is contained in:
parent
a3195267c9
commit
72177aef0a
|
@ -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.
|
||||
|
|
77
tailbone/templates/poser/reports/view.mako
Normal file
77
tailbone/templates/poser/reports/view.mako
Normal file
|
@ -0,0 +1,77 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/master/view.mako" />
|
||||
|
||||
<%def name="render_form_buttons()">
|
||||
<div v-if="!showUploadForm" class="buttons">
|
||||
<b-button type="is-primary"
|
||||
@click="heckYeah()">
|
||||
Upload Replacement Module
|
||||
</b-button>
|
||||
<once-button type="is-primary"
|
||||
tag="a"
|
||||
% if instance.get('error'):
|
||||
href="#" disabled
|
||||
% else:
|
||||
href="${url('generate_specific_report', type_key=instance['report'].type_key)}"
|
||||
% endif
|
||||
text="Generate this Report">
|
||||
</once-button>
|
||||
</div>
|
||||
<div v-if="showUploadForm">
|
||||
${h.form(master.get_action_url('replace', instance), enctype='multipart/form-data', **{'@submit': 'uploadSubmitting = true'})}
|
||||
${h.csrf_token(request)}
|
||||
<b-field label="New Module File" horizontal>
|
||||
|
||||
<b-field class="file is-primary"
|
||||
:class="{'has-name': !!uploadFile}"
|
||||
>
|
||||
<b-upload name="replacement_module"
|
||||
v-model="uploadFile"
|
||||
class="file-label">
|
||||
<span class="file-cta">
|
||||
<b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
|
||||
<span class="file-label">Click to upload</span>
|
||||
</span>
|
||||
</b-upload>
|
||||
<span v-if="uploadFile"
|
||||
class="file-name">
|
||||
{{ uploadFile.name }}
|
||||
</span>
|
||||
</b-field>
|
||||
|
||||
<div class="buttons">
|
||||
<b-button @click="showUploadForm = false">
|
||||
Cancel
|
||||
</b-button>
|
||||
<b-button type="is-primary"
|
||||
native-type="submit"
|
||||
:disabled="uploadSubmitting || !uploadFile"
|
||||
icon-pack="fas"
|
||||
icon-left="save">
|
||||
{{ uploadSubmitting ? "Working, please wait..." : "Save" }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
</b-field>
|
||||
${h.end_form()}
|
||||
</div>
|
||||
</%def>
|
||||
|
||||
<%def name="modify_this_page_vars()">
|
||||
${parent.modify_this_page_vars()}
|
||||
<script type="text/javascript">
|
||||
|
||||
${form.component_studly}Data.showUploadForm = false
|
||||
|
||||
${form.component_studly}Data.uploadFile = null
|
||||
|
||||
${form.component_studly}Data.uploadSubmitting = false
|
||||
|
||||
${form.component_studly}.methods.heckYeah = function() {
|
||||
this.showUploadForm = true
|
||||
}
|
||||
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
57
tailbone/templates/poser/setup.mako
Normal file
57
tailbone/templates/poser/setup.mako
Normal file
|
@ -0,0 +1,57 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/page.mako" />
|
||||
|
||||
<%def name="title()">Poser Setup</%def>
|
||||
|
||||
<%def name="page_content()">
|
||||
<br />
|
||||
|
||||
<p class="block">
|
||||
Before you can use Poser features, ${app_title} must create the
|
||||
file structure for it.
|
||||
</p>
|
||||
|
||||
<p class="block">
|
||||
A new folder will be created at this location:
|
||||
<span class="is-family-monospace has-text-weight-bold">
|
||||
${poser_dir}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p class="block">
|
||||
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}.
|
||||
</p>
|
||||
|
||||
<p class="block">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p class="block">
|
||||
Proceed?
|
||||
</p>
|
||||
|
||||
${h.form(request.current_route_url(), **{'@submit': 'setupSubmitting = true'})}
|
||||
${h.csrf_token(request)}
|
||||
<b-button type="is-primary"
|
||||
native-type="submit"
|
||||
:disabled="setupSubmitting">
|
||||
{{ setupSubmitting ? "Working, please wait..." : "Go for it!" }}
|
||||
</b-button>
|
||||
${h.end_form()}
|
||||
</%def>
|
||||
|
||||
<%def name="modify_this_page_vars()">
|
||||
${parent.modify_this_page_vars()}
|
||||
<script type="text/javascript">
|
||||
|
||||
ThisPageData.setupSubmitting = false
|
||||
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
|
||||
${parent.body()}
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
if hasattr(obj, field):
|
||||
filename = getattr(obj, field)
|
||||
else:
|
||||
filename = obj[field]
|
||||
if not filename:
|
||||
return ""
|
||||
path = self.download_path(obj, filename)
|
||||
|
|
31
tailbone/views/poser/__init__.py
Normal file
31
tailbone/views/poser/__init__.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Poser Views
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
|
||||
def includeme(config):
|
||||
config.include('tailbone.views.poser.reports')
|
294
tailbone/views/poser/reports.py
Normal file
294
tailbone/views/poser/reports.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
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)
|
|
@ -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?
|
||||
|
|
Loading…
Reference in a new issue