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 name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + +${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 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 name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${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?