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 case the validator pertains to the form at large instead of
one of the fields. one of the fields.
TODO: what should the validator look like?
:param validator: Callable validator for the node. :param validator: Callable validator for the node.
""" """
self.validators[key] = validator 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): def set_required(self, key, required=True):
""" """
Set whether or not value is required for a given field. 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.db import model
from rattail.batch import consume_batch_id from rattail.batch import consume_batch_id
from rattail.mail import send_email 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 rattail.files import resource_path
from pyramid import httpexceptions from pyramid import httpexceptions
@ -188,6 +188,32 @@ class CommonView(View):
""" """
raise Exception("Congratulations, you have triggered a bogus error.") 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 @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._defaults(config) cls._defaults(config)
@ -249,6 +275,14 @@ class CommonView(View):
config.add_route('bogus_error', '/bogus-error') config.add_route('bogus_error', '/bogus-error')
config.add_view(cls, attr='bogus_error', route_name='bogus_error', permission='errors.bogus') 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): def includeme(config):
CommonView.defaults(config) CommonView.defaults(config)

View file

@ -638,7 +638,7 @@ class MasterView(View):
""" """
self.creating = True self.creating = True
if form is None: if form is None:
form = self.make_form(self.get_model_class()) form = self.make_create_form()
if self.request.method == 'POST': if self.request.method == 'POST':
if self.validate_form(form): if self.validate_form(form):
# let save_create_form() return alternate object if necessary # let save_create_form() return alternate object if necessary
@ -651,6 +651,9 @@ class MasterView(View):
context['dform'] = form.make_deform_form() context['dform'] = form.make_deform_form()
return self.render_to_response(template, context) 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): def save_create_form(self, form):
uploads = self.normalize_uploads(form) uploads = self.normalize_uploads(form)
self.before_create(form) self.before_create(form)
@ -3618,7 +3621,10 @@ class MasterView(View):
raise NotImplementedError raise NotImplementedError
def render_downloadable_file(self, obj, field): def render_downloadable_file(self, obj, field):
if hasattr(obj, field):
filename = getattr(obj, field) filename = getattr(obj, field)
else:
filename = obj[field]
if not filename: if not filename:
return "" return ""
path = self.download_path(obj, filename) 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, form = forms.Form(schema=schema, request=self.request,
use_buefy=use_buefy, helptext=helptext) use_buefy=use_buefy, helptext=helptext)
form.submit_label = "Generate this Report" 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 # must declare jquery support for date fields, ugh
# TODO: obviously would be nice for this to be automatic? # TODO: obviously would be nice for this to be automatic?