From 30f95e2f08884d605f8ddac5c2ced67287bfd130 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 17 Dec 2021 19:22:48 -0600 Subject: [PATCH] Add common configuration logic for "input file templates" just used in one batch so far but should be useful for many more..once can get around to migrating them had to rework the configuration logic to use HTML form instead of JSON, to allow for the file uploads --- tailbone/templates/configure.mako | 146 ++++++++--- tailbone/templates/datasync/configure.mako | 14 +- tailbone/templates/importing/configure.mako | 10 +- tailbone/templates/master/index.mako | 5 + tailbone/templates/products/configure.mako | 12 +- tailbone/templates/receiving/configure.mako | 33 ++- .../reports/generated/configure.mako | 6 +- .../templates/settings/email/configure.mako | 6 +- tailbone/templates/vendors/configure.mako | 6 +- tailbone/views/batch/core.py | 6 + tailbone/views/datasync.py | 15 +- tailbone/views/importing.py | 2 +- tailbone/views/master.py | 239 +++++++++++++++++- 13 files changed, 405 insertions(+), 95 deletions(-) diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 336ea67c..de2b4e78 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -53,6 +53,79 @@ +<%def name="input_file_template_field(key)"> + <% tmpl = input_file_templates[key] %> + + + + + + + + + + + + + + + + + + + + + + + + Click to upload + + + + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + + + + + + + + + + + + + +<%def name="input_file_templates_section()"> +

Input File Templates

+
+ % for key in input_file_templates: + ${self.input_file_template_field(key)} + % endfor +
+ + +<%def name="form_content()"> + <%def name="page_content()"> ${parent.page_content()} @@ -106,6 +179,11 @@ + + ${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm')} + ${h.csrf_token(request)} + ${self.form_content()} + ${h.end_form()} <%def name="modify_this_page_vars()"> @@ -116,6 +194,16 @@ ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} % endif + % if input_file_template_settings is not Undefined: + ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} + ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} + ThisPageData.inputFileTemplateUploads = { + % for key in input_file_templates: + '${key}': null, + % endfor + } + % endif + ThisPageData.purgeSettingsShowDialog = false ThisPageData.purgingSettings = false @@ -127,41 +215,41 @@ this.purgeSettingsShowDialog = true } - ThisPage.methods.settingsCollectParams = function() { - % if simple_settings is not Undefined: - return {simple_settings: this.simpleSettings} - % else: - return {} + % if input_file_template_settings is not Undefined: + ThisPage.methods.validateInputFileTemplateSettings = function() { + % for tmpl in six.itervalues(input_file_templates): + if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.inputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + % endif + + ThisPage.methods.validateSettings = function() { + let msg + + % if input_file_template_settings is not Undefined: + msg = this.validateInputFileTemplateSettings() + if (msg) { + return msg + } % endif } ThisPage.methods.saveSettings = function() { - this.savingSettings = true - - let url = ${json.dumps(request.current_route_url())|n} - let params = this.settingsCollectParams() - let headers = { - 'X-CSRF-TOKEN': this.csrftoken, + let msg = this.validateSettings() + if (msg) { + alert(msg) + return } - this.$http.post(url, params, {headers: headers}).then((response) => { - if (response.data.success) { - this.settingsNeedSaved = false - location.href = url // reload page - } else { - this.$buefy.toast.open({ - message: "Save failed: " + (response.data.error || "(unknown error)"), - type: 'is-danger', - duration: 4000, // 4 seconds - }) - } - }).catch((error) => { - this.$buefy.toast.open({ - message: "Save failed: (unknown error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) - }) + this.savingSettings = true + this.settingsNeedSaved = false + this.$refs.saveSettingsForm.submit() } // cf. https://stackoverflow.com/a/56551646 diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 0bed21e3..ca57a468 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -44,8 +44,8 @@ -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()"> + ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})} @@ -401,7 +401,8 @@ - @@ -675,13 +676,6 @@ } } - ThisPage.methods.settingsCollectParams = function() { - return { - profiles: this.profilesData, - restart_command: this.restartCommand, - } - } - % if request.has_perm('datasync.restart'): ThisPageData.restartingDatasync = false ThisPageData.restartDatasyncFormButtonText = "Restart Datasync" diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index ac215e1c..cbe8463c 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -1,8 +1,8 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()"> + ${h.hidden('handlers', **{':value': 'JSON.stringify(handlersData)'})}

Designated Handlers

@@ -180,12 +180,6 @@ this.editHandlerShowDialog = false } - ThisPage.methods.settingsCollectParams = function() { - return { - handlers: this.handlersData, - } - } - diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index f58a59d1..de58af83 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -165,6 +165,11 @@ % if master.configurable and master.has_perm('configure'):
  • ${h.link_to("Configure {}".format(config_title), url('{}.configure'.format(route_prefix)))}
  • % endif + % if master.has_input_file_templates and master.has_perm('download_template'): + % for template in six.itervalues(input_file_templates): +
  • ${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}
  • + % endfor + % endif <%def name="grid_tools()"> diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 045e5904..e3c21307 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -1,8 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()">

    Key Field

    @@ -10,7 +9,8 @@ - @@ -19,7 +19,8 @@ - @@ -32,7 +33,8 @@
    - Show "POD" Images as fallback diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 3b2a93e1..06ab3769 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -1,42 +1,46 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()">

    Supported Workflows

    - From Scratch - From Invoice - From Purchase Order - From Purchase Order, with Invoice - Truck Dump @@ -48,14 +52,16 @@
    - Allow Cases - Allow "Expired" Credits @@ -67,21 +73,24 @@
    - Show Product Images - Allow "Quick Receive" - Allow "Quick Receive All" diff --git a/tailbone/templates/reports/generated/configure.mako b/tailbone/templates/reports/generated/configure.mako index 27e60afa..e8224f28 100644 --- a/tailbone/templates/reports/generated/configure.mako +++ b/tailbone/templates/reports/generated/configure.mako @@ -1,14 +1,14 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()">

    Generating

    - Show report chooser as form, with dropdown diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index f212f635..228eb1a4 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -1,14 +1,14 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()">

    Sending

    - Make record of all attempts to send email diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index e1a47644..0bcb4a9e 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -1,14 +1,14 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()">

    Display

    - Show vendor chooser as autocomplete field diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 985f5502..0eb956cf 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -83,6 +83,8 @@ class BatchMasterView(MasterView): has_worksheet = False has_worksheet_file = False + input_file_template_config_section = 'rattail.batch' + grid_columns = [ 'id', 'description', @@ -157,6 +159,10 @@ class BatchMasterView(MasterView): factory = self.get_handler_factory(self.rattail_config) return factory(self.rattail_config) + @property + def input_file_template_config_prefix(self): + return '{}.input_file_template'.format(self.batch_handler.batch_key) + def download_path(self, batch, filename): return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 03be846e..6c6db9f1 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -27,6 +27,7 @@ DataSync Views from __future__ import unicode_literals, absolute_import import getpass +import json import subprocess import logging @@ -132,7 +133,7 @@ class DataSyncThreadView(MasterView): settings = [] watch = [] - for profile in data['profiles']: + for profile in json.loads(data['profiles']): pkey = profile['key'] if profile['enabled']: watch.append(pkey) @@ -181,12 +182,12 @@ class DataSyncThreadView(MasterView): 'value': ', '.join(consumers)}, ]) - settings.extend([ - {'name': 'rattail.datasync.watch', - 'value': ', '.join(watch)}, - {'name': 'tailbone.datasync.restart', - 'value': data['restart_command']}, - ]) + if watch: + settings.append({'name': 'rattail.datasync.watch', + 'value': ', '.join(watch)}) + + settings.append({'name': 'tailbone.datasync.restart', + 'value': data['restart_command']}) return settings diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index b63e4d43..d93e4cfd 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -560,7 +560,7 @@ cd {prefix} def configure_gather_settings(self, data): settings = [] - for handler in data['handlers']: + for handler in json.loads(data['handlers']): key = handler['key'] settings.extend([ diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a9d11377..2146ff97 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -29,6 +29,7 @@ from __future__ import unicode_literals, absolute_import import os import csv import datetime +import shutil import tempfile import logging @@ -36,11 +37,9 @@ import json import six import sqlalchemy as sa from sqlalchemy import orm - import sqlalchemy_continuum as continuum from sqlalchemy_utils.functions import get_primary_keys, get_columns - from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query from rattail.util import prettify, OrderedDict, simple_error @@ -57,6 +56,7 @@ from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render from pyramid.response import FileResponse from webhelpers2.html import HTML, tags +from webob.compat import cgi_FieldStorage from tailbone import forms, grids, diffs from tailbone.views import View @@ -114,6 +114,7 @@ class MasterView(View): execute_progress_initial_msg = None supports_prev_next = False supports_import_batch_from_file = False + has_input_file_templates = False configurable = False # set to True to add "View *global* Objects" permission, and @@ -1467,6 +1468,26 @@ class MasterView(View): Return a content type for a file download, if known. """ + def download_input_file_template(self): + """ + View for downloading an input file template. + """ + key = self.request.GET['key'] + filespec = self.request.GET['file'] + + matches = [tmpl for tmpl in self.get_input_file_templates() + if tmpl['key'] == key] + if not matches: + raise self.notfound() + + template = matches[0] + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'input_files', + self.get_route_prefix()) + basedir = os.path.join(templatesdir, template['key']) + path = os.path.join(basedir, filespec) + return self.file_response(path) + def edit(self): """ View for editing an existing model record. @@ -2230,6 +2251,90 @@ class MasterView(View): kwargs['db_picker_options'] = [tags.Option(k) for k in engines] kwargs['db_picker_selected'] = selected + # add info for downloadable input file templates, if any + if self.has_input_file_templates: + templates = self.normalize_input_file_templates() + kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl) + for tmpl in templates]) + + return kwargs + + def get_input_file_templates(self): + return [] + + def normalize_input_file_templates(self, templates=None, + include_file_options=False): + if templates is None: + templates = self.get_input_file_templates() + + route_prefix = self.get_route_prefix() + + if include_file_options: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'input_files', + route_prefix) + + for template in templates: + + if 'config_section' not in template: + template['config_section'] = self.input_file_template_config_section + section = template['config_section'] + + if 'config_prefix' not in template: + template['config_prefix'] = '{}.{}'.format( + self.input_file_template_config_prefix, + template['key']) + prefix = template['config_prefix'] + + for key in ('mode', 'file', 'url'): + + if 'option_{}'.format(key) not in template: + template['option_{}'.format(key)] = '{}.{}'.format(prefix, key) + + if 'setting_{}'.format(key) not in template: + template['setting_{}'.format(key)] = '{}.{}'.format( + section, + template['option_{}'.format(key)]) + + if key not in template: + value = self.rattail_config.get( + section, + template['option_{}'.format(key)]) + if value is not None: + template[key] = value + + template.setdefault('mode', 'default') + template.setdefault('file', None) + template.setdefault('url', template['default_url']) + + if include_file_options: + options = [] + basedir = os.path.join(templatesdir, template['key']) + if os.path.exists(basedir): + for name in sorted(os.listdir(basedir)): + if len(name) == 4 and name.isdigit(): + files = os.listdir(os.path.join(basedir, name)) + if len(files) == 1: + options.append(os.path.join(name, files[0])) + template['file_options'] = options + template['file_options_dir'] = basedir + + if template['mode'] == 'external': + template['effective_url'] = template['url'] + elif template['mode'] == 'hosted': + template['effective_url'] = self.request.route_url( + '{}.download_input_file_template'.format(route_prefix), + _query={'key': template['key'], + 'file': template['file']}) + else: + template['effective_url'] = template['default_url'] + + return templates + + def template_kwargs_index(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ return kwargs def template_kwargs_create(self, **kwargs): @@ -4043,16 +4148,71 @@ class MasterView(View): self.request.session.flash("Settings have been removed.") return self.redirect(self.request.current_route_url()) else: - data = self.request.json_body + data = self.request.POST + + # collect any uploaded files + uploads = {} + for key, value in six.iteritems(data): + if isinstance(value, cgi_FieldStorage): + tempdir = tempfile.mkdtemp() + filename = os.path.basename(value.filename) + filepath = os.path.join(tempdir, filename) + with open(filepath, 'wb') as f: + f.write(value.file.read()) + uploads[key] = { + 'filedir': tempdir, + 'filename': filename, + 'filepath': filepath, + } + + # process any uploads first + if uploads: + self.configure_process_uploads(uploads, data) + + # then gather/save settings settings = self.configure_gather_settings(data) self.configure_remove_settings() self.configure_save_settings(settings) self.request.session.flash("Settings have been saved.") - return self.json_response({'success': True}) + return self.redirect(self.request.current_route_url()) context = self.configure_get_context() return self.render_to_response('configure', context) + def configure_process_uploads(self, uploads, data): + if self.has_input_file_templates: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'input_files', + self.get_route_prefix()) + + def get_next_filedir(basedir): + nextid = 1 + while True: + path = os.path.join(basedir, '{:04d}'.format(nextid)) + if not os.path.exists(path): + # this should fail if there happens to be a race + # condition and someone else got to this id first + os.mkdir(path) + return path + nextid += 1 + + for template in self.normalize_input_file_templates(): + key = '{}.upload'.format(template['setting_file']) + if key in uploads: + assert self.request.POST[template['setting_mode']] == 'hosted' + assert not self.request.POST[template['setting_file']] + info = uploads[key] + basedir = os.path.join(templatesdir, template['key']) + if not os.path.exists(basedir): + os.makedirs(basedir) + filedir = get_next_filedir(basedir) + filepath = os.path.join(filedir, info['filename']) + shutil.copyfile(info['filepath'], filepath) + shutil.rmtree(info['filedir']) + numdir = os.path.basename(filedir) + data[template['setting_file']] = os.path.join(numdir, + info['filename']) + def configure_get_simple_settings(self): """ If you have some "simple" settings, each of which basically @@ -4120,22 +4280,34 @@ class MasterView(View): context['simple_settings'] = settings + # add settings for downloadable input file templates, if any + if self.has_input_file_templates: + settings = {} + file_options = {} + file_option_dirs = {} + for template in self.normalize_input_file_templates( + include_file_options=True): + settings[template['setting_mode']] = template['mode'] + settings[template['setting_file']] = template['file'] + settings[template['setting_url']] = template['url'] + file_options[template['key']] = template['file_options'] + file_option_dirs[template['key']] = template['file_options_dir'] + context['input_file_template_settings'] = settings + context['input_file_options'] = file_options + context['input_file_option_dirs'] = file_option_dirs + return context def configure_gather_settings(self, data): settings = [] + # maybe collect "simple" settings simple_settings = self.configure_get_simple_settings() - if simple_settings and 'simple_settings' in data: - - data_settings = data['simple_settings'] + if simple_settings: for simple in simple_settings: name = self.configure_get_name_for_simple_setting(simple) - value = None - - if name in data_settings: - value = data_settings[name] + value = data.get(name) if simple.get('type') is bool: value = six.text_type(bool(value)).lower() @@ -4145,14 +4317,45 @@ class MasterView(View): settings.append({'name': name, 'value': value}) + # maybe also collect input file template settings + if self.has_input_file_templates: + for template in self.normalize_input_file_templates(): + + # mode + settings.append({'name': template['setting_mode'], + 'value': data.get(template['setting_mode'])}) + + # file + value = data.get(template['setting_file']) + if value: + # nb. avoid saving if empty, so can remain "null" + settings.append({'name': template['setting_file'], + 'value': value}) + + # url + settings.append({'name': template['setting_url'], + 'value': data.get(template['setting_url'])}) + return settings def configure_remove_settings(self): + model = self.model + names = [] + simple_settings = self.configure_get_simple_settings() if simple_settings: - model = self.model - names = [self.configure_get_name_for_simple_setting(simple) - for simple in simple_settings] + names.extend([self.configure_get_name_for_simple_setting(simple) + for simple in simple_settings]) + + if self.has_input_file_templates: + for template in self.normalize_input_file_templates(): + names.extend([ + template['setting_mode'], + template['setting_file'], + template['setting_url'], + ]) + + if names: self.Session.query(model.Setting)\ .filter(model.Setting.name.in_(names))\ .delete(synchronize_session=False) @@ -4365,6 +4568,14 @@ class MasterView(View): config.add_tailbone_permission(permission_prefix, '{}.merge'.format(permission_prefix), "Merge 2 {}".format(model_title_plural)) + # download input file template + if cls.has_input_file_templates and cls.creatable: + config.add_route('{}.download_input_file_template'.format(route_prefix), + '{}/download-input-file-template'.format(url_prefix)) + config.add_view(cls, attr='download_input_file_template', + route_name='{}.download_input_file_template'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + # view if cls.viewable: config.add_tailbone_permission(permission_prefix, '{}.view'.format(permission_prefix),