Add basic support for Poser reports, list/create

This commit is contained in:
Lance Edgar 2022-03-01 23:00:11 -06:00
parent a3195267c9
commit 72177aef0a
8 changed files with 512 additions and 4 deletions

View file

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

View 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()}

View 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:&nbsp; &nbsp;
<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.&nbsp; 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.&nbsp;
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()}

View file

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

View file

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

View 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')

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

View file

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