tailbone/tailbone/views/importing.py
2023-05-09 20:25:05 -05:00

687 lines
21 KiB
Python

# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 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/>.
#
################################################################################
"""
View for running arbitrary import/export jobs
"""
import getpass
import socket
import sys
import logging
import subprocess
import time
import json
import sqlalchemy as sa
from rattail.exceptions import ConfigurationError
from rattail.threads import Thread
import colander
import markdown
from deform import widget as dfwidget
from webhelpers2.html import HTML
from tailbone.views import MasterView
log = logging.getLogger(__name__)
class ImportingView(MasterView):
"""
View for running arbitrary import/export jobs
"""
normalized_model_name = 'importhandler'
model_title = "Import / Export Handler"
model_key = 'key'
route_prefix = 'importing'
url_prefix = '/importing'
index_title = "Importing / Exporting"
creatable = False
editable = False
deletable = False
filterable = False
pageable = False
configurable = True
config_title = "Import / Export"
labels = {
'host_title': "Data Source",
'local_title': "Data Target",
'direction_display': "Direction",
}
grid_columns = [
'host_title',
'local_title',
'direction_display',
'handler_spec',
]
form_fields = [
'key',
'local_key',
'host_key',
'handler_spec',
'host_title',
'local_title',
'direction_display',
'models',
]
runjob_form_fields = [
'handler_spec',
'host_title',
'local_title',
'models',
'create',
'update',
'delete',
# 'runas',
'versioning',
'dry_run',
'warnings',
]
def get_data(self, session=None):
app = self.get_rattail_app()
data = []
for handler in app.get_designated_import_handlers(
ignore_errors=True, sort=True):
data.append(self.normalize(handler))
return data
def normalize(self, handler, keep_handler=True):
data = {
'key': handler.get_key(),
'generic_title': handler.get_generic_title(),
'host_key': handler.host_key,
'host_title': handler.get_generic_host_title(),
'local_key': handler.local_key,
'local_title': handler.get_generic_local_title(),
'handler_spec': handler.get_spec(),
'direction': handler.direction,
'direction_display': handler.direction.capitalize(),
'safe_for_web_app': handler.safe_for_web_app,
}
if keep_handler:
data['_handler'] = handler
alternates = getattr(handler, 'alternate_handlers', None)
if alternates:
data['alternates'] = []
for alternate in alternates:
data['alternates'].append(self.normalize(
alternate, keep_handler=keep_handler))
cmd = self.get_cmd_for_handler(handler, ignore_errors=True)
if cmd:
data['cmd'] = ' '.join(cmd)
data['command'] = cmd[0]
data['subcommand'] = cmd[1]
runas = self.get_runas_for_handler(handler)
if runas:
data['default_runas'] = runas
return data
def configure_grid(self, g):
super(ImportingView, self).configure_grid(g)
g.set_link('host_title')
g.set_link('local_title')
def get_instance(self):
"""
Fetch the current model instance by inspecting the route kwargs and
doing a database lookup. If the instance cannot be found, raises 404.
"""
key = self.request.matchdict['key']
app = self.get_rattail_app()
handler = app.get_import_handler(key, ignore_errors=True)
if handler:
return self.normalize(handler)
raise self.notfound()
def get_instance_title(self, handler_info):
handler = handler_info['_handler']
return handler.get_generic_title()
def make_form_schema(self):
return ImportHandlerSchema()
def make_form_kwargs(self, **kwargs):
kwargs = super(ImportingView, self).make_form_kwargs(**kwargs)
# nb. this is set as sort of a hack, to prevent SA model
# inspection logic
kwargs['renderers'] = {}
return kwargs
def configure_form(self, f):
super(ImportingView, self).configure_form(f)
f.set_renderer('models', self.render_models)
def render_models(self, handler, field):
handler = handler['_handler']
items = []
for key in handler.get_importer_keys():
items.append(HTML.tag('li', c=[key]))
return HTML.tag('ul', c=items)
def template_kwargs_view(self, **kwargs):
kwargs = super(ImportingView, self).template_kwargs_view(**kwargs)
handler_info = kwargs['instance']
kwargs['handler'] = handler_info['_handler']
return kwargs
def runjob(self):
"""
View for running an import / export job
"""
handler_info = self.get_instance()
handler = handler_info['_handler']
form = self.make_runjob_form(handler_info)
if self.request.method == 'POST':
if self.validate_form(form):
self.cache_runjob_form_values(handler, form)
try:
return self.do_runjob(handler_info, form)
except Exception as error:
self.request.session.flash(str(error), 'error')
return self.redirect(self.request.current_route_url())
return self.render_to_response('runjob', {
'handler_info': handler_info,
'handler': handler,
'form': form,
})
def cache_runjob_form_values(self, handler, form):
handler_key = handler.get_key()
def make_key(field):
return 'rattail.importing.{}.{}'.format(handler_key, field)
for field in form.fields:
key = make_key(field)
self.request.session[key] = form.validated[field]
def read_cached_runjob_values(self, handler, form):
handler_key = handler.get_key()
def make_key(field):
return 'rattail.importing.{}.{}'.format(handler_key, field)
for field in form.fields:
key = make_key(field)
if key in self.request.session:
form.set_default(field, self.request.session[key])
def make_runjob_form(self, handler_info, **kwargs):
"""
Creates a new form for the given model class/instance
"""
handler = handler_info['_handler']
factory = self.get_form_factory()
fields = list(self.runjob_form_fields)
schema = RunJobSchema()
kwargs = self.make_runjob_form_kwargs(handler_info, **kwargs)
form = factory(fields, schema, **kwargs)
self.configure_runjob_form(handler, form)
self.read_cached_runjob_values(handler, form)
return form
def make_runjob_form_kwargs(self, handler_info, **kwargs):
route_prefix = self.get_route_prefix()
handler = handler_info['_handler']
defaults = {
'request': self.request,
'model_instance': handler,
'cancel_url': self.request.route_url('{}.view'.format(route_prefix),
key=handler.get_key()),
# nb. these next 2 are set as sort of a hack, to prevent
# SA model inspection logic
'renderers': {},
'appstruct': handler_info,
}
defaults.update(kwargs)
return defaults
def configure_runjob_form(self, handler, f):
self.set_labels(f)
f.set_readonly('handler_spec')
f.set_renderer('handler_spec', lambda handler, field: handler.get_spec())
f.set_readonly('host_title')
f.set_readonly('local_title')
keys = handler.get_importer_keys()
f.set_widget('models', dfwidget.SelectWidget(values=[(k, k) for k in keys],
multiple=True,
size=len(keys)))
allow_create = True
allow_update = True
allow_delete = True
if len(keys) == 1:
importers = handler.get_importers().values()
importer = list(importers)[0]
allow_create = importer.allow_create
allow_update = importer.allow_update
allow_delete = importer.allow_delete
if allow_create:
f.set_default('create', True)
else:
f.remove('create')
if allow_update:
f.set_default('update', True)
else:
f.remove('update')
if allow_delete:
f.set_default('delete', False)
else:
f.remove('delete')
# f.set_default('runas', self.rattail_config.get('rattail', 'runas.default') or '')
f.set_default('versioning', True)
f.set_helptext('versioning', "If set, version history will be updated as appropriate")
f.set_default('dry_run', False)
f.set_helptext('dry_run', "If set, data will not actually be written")
f.set_default('warnings', False)
f.set_helptext('warnings', "If set, will send an email if any diffs")
def do_runjob(self, handler_info, form):
handler = handler_info['_handler']
handler_key = handler.get_key()
if self.request.POST.get('runjob') == 'true':
# will invoke handler to run job..
# ..but only if it is safe to do so
if not handler.safe_for_web_app:
self.request.session.flash("Handler is not (yet) safe to run "
"with this tool", 'error')
return self.redirect(self.request.current_route_url())
# TODO: this socket progress business was lifted from
# tailbone.views.batch.core:BatchMasterView.handler_action
# should probably refactor to share somehow
# make progress object
key = 'rattail.importing.{}'.format(handler_key)
progress = self.make_progress(key)
# make socket for progress thread to listen to action thread
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('127.0.0.1', 0))
sock.listen(1)
port = sock.getsockname()[1]
# launch thread to monitor progress
success_url = self.request.current_route_url()
thread = Thread(target=self.progress_thread,
args=(sock, success_url, progress))
thread.start()
true_cmd = self.make_runjob_cmd(handler, form, 'true', port=port)
# launch thread to invoke handler
thread = Thread(target=self.do_runjob_thread,
args=(handler, true_cmd, port, progress))
thread.start()
return self.render_progress(progress, {
'can_cancel': False,
'cancel_url': self.request.current_route_url(),
})
else: # explain only
notes_cmd = self.make_runjob_cmd(handler, form, 'notes')
self.cache_runjob_notes(handler, notes_cmd)
return self.redirect(self.request.current_route_url())
def do_runjob_thread(self, handler, cmd, port, progress):
# invoke handler command via subprocess
try:
result = subprocess.run(cmd, check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
output = result.stdout.decode('utf_8').strip()
except Exception as error:
log.warning("failed to invoke handler cmd: %s", cmd, exc_info=True)
if progress:
progress.session.load()
progress.session['error'] = True
msg = """\
{} failed! Here is the command I tried to run:
```
{}
```
And here is the output:
```
{}
```
""".format(handler.direction.capitalize(),
' '.join(cmd),
error.stdout.decode('utf_8').strip())
msg = markdown.markdown(msg, extensions=['fenced_code'])
msg = HTML.literal(msg)
msg = HTML.tag('div', class_='tailbone-markdown', c=[msg])
progress.session['error_msg'] = msg
progress.session.save()
else: # success
if progress:
progress.session.load()
msg = self.get_runjob_success_msg(handler, output)
progress.session['complete'] = True
progress.session['success_url'] = self.request.current_route_url()
progress.session['success_msg'] = msg
progress.session.save()
suffix = "\n\n.".encode('utf_8')
cxn = socket.create_connection(('127.0.0.1', port))
data = json.dumps({
'everything_complete': True,
})
data = data.encode('utf_8')
cxn.send(data)
cxn.send(suffix)
cxn.close()
def get_runjob_success_msg(self, handler, output):
notes = """\
{} went okay, here is the output:
```
{}
```
""".format(handler.direction.capitalize(), output)
notes = markdown.markdown(notes, extensions=['fenced_code'])
notes = HTML.literal(notes)
return HTML.tag('div', class_='tailbone-markdown', c=[notes])
def get_cmd_for_handler(self, handler, ignore_errors=False):
handler_key = handler.get_key()
cmd = self.rattail_config.getlist('rattail.importing',
'{}.cmd'.format(handler_key))
if not cmd or len(cmd) != 2:
cmd = self.rattail_config.getlist('rattail.importing',
'{}.default_cmd'.format(handler_key))
if not cmd or len(cmd) != 2:
msg = ("Missing or invalid config; please set '{}.default_cmd' in the "
"[rattail.importing] section of your config file".format(handler_key))
if ignore_errors:
return
raise ConfigurationError(msg)
return cmd
def get_runas_for_handler(self, handler):
handler_key = handler.get_key()
runas = self.rattail_config.get('rattail.importing',
'{}.runas'.format(handler_key))
if runas:
return runas
return self.rattail_config.get('rattail', 'runas.default')
def make_runjob_cmd(self, handler, form, typ, port=None):
command, subcommand = self.get_cmd_for_handler(handler)
runas = self.get_runas_for_handler(handler)
data = form.validated
if typ == 'true':
cmd = [
'{}/bin/{}'.format(sys.prefix, command),
'--config={}/app/quiet.conf'.format(sys.prefix),
'--progress',
'--progress-socket=127.0.0.1:{}'.format(port),
]
else:
cmd = [
'sudo', '-u', getpass.getuser(),
'bin/{}'.format(command),
'-c', 'app/quiet.conf',
'-P',
]
if runas:
if typ == 'true':
cmd.append('--runas={}'.format(runas))
else:
cmd.extend(['--runas', runas])
cmd.append(subcommand)
cmd.extend(data['models'])
if data['create']:
if typ == 'true':
cmd.append('--create')
else:
cmd.append('--no-create')
if data['update']:
if typ == 'true':
cmd.append('--update')
else:
cmd.append('--no-update')
if data['delete']:
cmd.append('--delete')
else:
if typ == 'true':
cmd.append('--no-delete')
if data['versioning']:
if typ == 'true':
cmd.append('--versioning')
else:
cmd.append('--no-versioning')
if data['dry_run']:
cmd.append('--dry-run')
if data['warnings']:
if typ == 'true':
cmd.append('--warnings')
else:
cmd.append('-W')
return cmd
def cache_runjob_notes(self, handler, notes_cmd):
notes = """\
You can run this {direction} job manually via command line:
```sh
cd {prefix}
{cmd}
```
""".format(direction=handler.direction,
prefix=sys.prefix,
cmd=' '.join(notes_cmd))
self.request.session['rattail.importing.runjob.notes'] = markdown.markdown(
notes, extensions=['fenced_code', 'codehilite'])
def configure_get_context(self):
app = self.get_rattail_app()
handlers_data = []
for handler in app.get_designated_import_handlers(
with_alternates=True,
ignore_errors=True, sort=True):
data = self.normalize(handler, keep_handler=False)
data['spec_options'] = [handler.get_spec()]
for alternate in handler.alternate_handlers:
data['spec_options'].append(alternate.get_spec())
data['spec_options'].sort()
handlers_data.append(data)
return {
'handlers_data': handlers_data,
}
def configure_gather_settings(self, data):
settings = []
for handler in json.loads(data['handlers']):
key = handler['key']
settings.extend([
{'name': 'rattail.importing.{}.handler'.format(key),
'value': handler['handler_spec']},
{'name': 'rattail.importing.{}.cmd'.format(key),
'value': '{} {}'.format(handler['command'],
handler['subcommand'])},
{'name': 'rattail.importing.{}.runas'.format(key),
'value': handler['default_runas']},
])
return settings
def configure_remove_settings(self):
app = self.get_rattail_app()
model = self.model
session = self.Session()
to_delete = session.query(model.Setting)\
.filter(sa.or_(
model.Setting.name.like('rattail.importing.%.handler'),
model.Setting.name.like('rattail.importing.%.cmd'),
model.Setting.name.like('rattail.importing.%.runas')))\
.all()
for setting in to_delete:
app.delete_setting(session, setting)
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._importing_defaults(config)
@classmethod
def _importing_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
# run job
config.add_tailbone_permission(permission_prefix,
'{}.runjob'.format(permission_prefix),
"Run an arbitrary Import / Export Job")
config.add_route('{}.runjob'.format(route_prefix),
'{}/runjob'.format(instance_url_prefix))
config.add_view(cls, attr='runjob',
route_name='{}.runjob'.format(route_prefix),
permission='{}.runjob'.format(permission_prefix))
class ImportHandlerSchema(colander.MappingSchema):
host_key = colander.SchemaNode(colander.String())
local_key = colander.SchemaNode(colander.String())
host_title = colander.SchemaNode(colander.String())
local_title = colander.SchemaNode(colander.String())
handler_spec = colander.SchemaNode(colander.String())
class RunJobSchema(colander.MappingSchema):
handler_spec = colander.SchemaNode(colander.String(),
missing=colander.null)
host_title = colander.SchemaNode(colander.String(),
missing=colander.null)
local_title = colander.SchemaNode(colander.String(),
missing=colander.null)
models = colander.SchemaNode(colander.List())
create = colander.SchemaNode(colander.Bool())
update = colander.SchemaNode(colander.Bool())
delete = colander.SchemaNode(colander.Bool())
# runas = colander.SchemaNode(colander.String())
versioning = colander.SchemaNode(colander.Bool())
dry_run = colander.SchemaNode(colander.Bool())
warnings = colander.SchemaNode(colander.Bool())
def defaults(config, **kwargs):
base = globals()
ImportingView = kwargs.get('ImportingView', base['ImportingView'])
ImportingView.defaults(config)
def includeme(config):
defaults(config)