# -*- 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 . # ################################################################################ """ 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)