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