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