From 282185c5af2f9b6411e2b430a5d3569c12f3bd3a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 5 Dec 2021 17:23:11 -0600 Subject: [PATCH 0001/1164] Add basic import/export handler views, tool to run jobs --- tailbone/forms/core.py | 1 + tailbone/templates/importing/index.mako | 12 + tailbone/templates/importing/runjob.mako | 85 ++++ tailbone/templates/importing/view.mako | 22 + tailbone/views/batch/core.py | 76 ---- tailbone/views/importing.py | 543 +++++++++++++++++++++++ tailbone/views/master.py | 82 +++- 7 files changed, 744 insertions(+), 77 deletions(-) create mode 100644 tailbone/templates/importing/index.mako create mode 100644 tailbone/templates/importing/runjob.mako create mode 100644 tailbone/templates/importing/view.mako create mode 100644 tailbone/views/importing.py diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 2267b8dc..060e1133 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -771,6 +771,7 @@ class Form(object): # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: if self.use_buefy: + context['form_kwargs']['ref'] = self.component_studly context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) else: context['form_kwargs']['class_'] = 'autodisable' diff --git a/tailbone/templates/importing/index.mako b/tailbone/templates/importing/index.mako new file mode 100644 index 00000000..c2d9c6ec --- /dev/null +++ b/tailbone/templates/importing/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="render_grid_component()"> +

+ ${request.rattail_config.get_app().get_title()} can run import / export jobs for the following: +

+ ${parent.render_grid_component()} + + + +${parent.body()} diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako new file mode 100644 index 00000000..2b9642f6 --- /dev/null +++ b/tailbone/templates/importing/runjob.mako @@ -0,0 +1,85 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%def name="title()"> + Run ${handler.direction.capitalize()}:  ${handler.get_generic_title()} + + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('view'): +
  • ${h.link_to("View this {}".format(model_title), action_url('view', handler_info))}
  • + % endif + + +<%def name="render_this_page()"> + % if 'rattail.importing.runjob.notes' in request.session: + + ${request.session['rattail.importing.runjob.notes']|n} + + <% del request.session['rattail.importing.runjob.notes'] %> + % endif + + ${parent.render_this_page()} + + +<%def name="render_form_buttons()"> +
    + ${h.hidden('runjob', **{':value': 'runJob'})} +
    + + + + {{ submittingRun ? "Working, please wait..." : "Run this ${handler.direction.capitalize()}" }} + + + {{ submittingExplain ? "Working, please wait..." : "Just show me the notes" }} + +
    + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + +${parent.body()} diff --git a/tailbone/templates/importing/view.mako b/tailbone/templates/importing/view.mako new file mode 100644 index 00000000..3a28737c --- /dev/null +++ b/tailbone/templates/importing/view.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if master.has_perm('runjob'): + + % endif + + + +${parent.body()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 821628aa..90614079 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -64,10 +64,6 @@ from tailbone.util import csrf_token log = logging.getLogger(__name__) -class EverythingComplete(Exception): - pass - - class BatchMasterView(MasterView): """ Base class for all "batch master" views. @@ -872,78 +868,6 @@ class BatchMasterView(MasterView): 'cancel_msg': "{} of batch was canceled.".format(batch_action.capitalize()), }) - def progress_thread(self, sock, success_url, progress): - """ - This method is meant to be used as a thread target. Its job is to read - progress data from ``connection`` and update the session progress - accordingly. When a final "process complete" indication is read, the - socket will be closed and the thread will end. - """ - while True: - try: - self.process_progress(sock, progress) - except EverythingComplete: - break - - # close server socket - sock.close() - - # finalize session progress - progress.session.load() - progress.session['complete'] = True - if callable(success_url): - success_url = success_url() - progress.session['success_url'] = success_url - progress.session.save() - - def process_progress(self, sock, progress): - """ - This method will accept a client connection on the given socket, and - then update the given progress object according to data written by the - client. - """ - connection, client_address = sock.accept() - active_progress = None - - # TODO: make this configurable? - suffix = "\n\n.".encode('utf_8') - data = b'' - - # listen for progress info, update session progress as needed - while True: - - # accumulate data bytestring until we see the suffix - byte = connection.recv(1) - data += byte - if data.endswith(suffix): - - # strip suffix, interpret data as JSON - data = data[:-len(suffix)] - if six.PY3: - data = data.decode('utf_8') - data = json.loads(data) - - if data.get('everything_complete'): - if active_progress: - active_progress.finish() - raise EverythingComplete - - elif data.get('process_complete'): - active_progress.finish() - active_progress = None - break - - elif 'value' in data: - if not active_progress: - active_progress = progress(data['message'], data['maximum']) - active_progress.update(data['value']) - - # reset data buffer - data = b'' - - # close client connection - connection.close() - def launch_subprocess(self, port=None, username=None, command='rattail', command_args=None, subcommand=None, subcommand_args=None): diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py new file mode 100644 index 00000000..23a039cd --- /dev/null +++ b/tailbone/views/importing.py @@ -0,0 +1,543 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 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 +""" + +from __future__ import unicode_literals, absolute_import + +import getpass +import socket +import sys +import logging +import subprocess +import time + +import json +import six + +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 + + labels = { + 'host_title': "Data Source", + 'local_title': "Data Target", + } + + grid_columns = [ + 'host_title', + 'local_title', + 'handler_spec', + ] + + form_fields = [ + 'key', + 'local_key', + 'host_key', + 'handler_spec', + 'host_title', + 'local_title', + '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.all_import_handlers(): + handler = Handler(self.rattail_config) + data.append(self.normalize(handler)) + + data.sort(key=lambda handler: (handler['host_title'], + handler['local_title'])) + return data + + def normalize(self, handler): + Handler = handler.__class__ + return { + '_handler': handler, + '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(), + } + + 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() + for Handler in app.all_import_handlers(): + if Handler.get_key() == key: + return self.normalize(Handler(self.rattail_config)) + 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(six.text_type(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(key): + return 'rattail.importing.{}.{}'.format(handler_key, key) + + 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(key): + return 'rattail.importing.{}.{}'.format(handler_key, key) + + 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, + 'use_buefy': self.get_use_buefy(), + '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))) + # f.set_default('models', keys) + + f.set_default('create', True) + f.set_default('update', True) + f.set_default('delete', False) + # f.set_default('runas', self.rattail_config.get('rattail', 'runas.default') or '') + f.set_default('versioning', True) + f.set_default('dry_run', False) + f.set_default('warnings', False) + + 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 + + # 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, capture_output=True) + output = result.stderr.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 STDERR output: + +``` +{} +``` +""".format(handler.direction.capitalize(), + ' '.join(cmd), + error.stderr.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, + }) + if six.PY3: + 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 make_runjob_cmd(self, handler, form, typ, port=None): + handler_key = handler.get_key() + + option = '{}.cmd'.format(handler_key) + cmd = self.rattail_config.getlist('rattail.importing', option) + if not cmd or len(cmd) != 2: + msg = ("Missing or invalid config; please set '{}' in the " + "[rattail.importing] section of your config file".format(option)) + raise ConfigurationError(msg) + + command, subcommand = cmd + + option = '{}.runas'.format(handler_key) + runas = self.rattail_config.require('rattail.importing', option) + + 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), + '--runas={}'.format(runas), + subcommand, + ] + else: + cmd = [ + 'sudo', '-u', getpass.getuser(), + 'bin/{}'.format(command), + '-c', 'app/quiet.conf', + '-P', + '--runas', runas, + 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']: + cmd.append('--warnings') + + 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']) + + @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()) + + host_title = colander.SchemaNode(colander.String()) + + local_title = colander.SchemaNode(colander.String()) + + 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 includeme(config): + ImportingView.defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f288ec34..2a3189c4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -32,6 +32,7 @@ import datetime import tempfile import logging +import json import six import sqlalchemy as sa from sqlalchemy import orm @@ -65,6 +66,10 @@ from tailbone.config import global_help_url log = logging.getLogger(__name__) +class EverythingComplete(Exception): + pass + + class MasterView(View): """ Base "master" view class. All model master views should derive from this. @@ -1743,6 +1748,78 @@ class MasterView(View): def get_execute_success_url(self, obj, **kwargs): return self.get_action_url('view', obj, **kwargs) + def progress_thread(self, sock, success_url, progress): + """ + This method is meant to be used as a thread target. Its job is to read + progress data from ``connection`` and update the session progress + accordingly. When a final "process complete" indication is read, the + socket will be closed and the thread will end. + """ + while True: + try: + self.process_progress(sock, progress) + except EverythingComplete: + break + + # close server socket + sock.close() + + # finalize session progress + progress.session.load() + progress.session['complete'] = True + if callable(success_url): + success_url = success_url() + progress.session['success_url'] = success_url + progress.session.save() + + def process_progress(self, sock, progress): + """ + This method will accept a client connection on the given socket, and + then update the given progress object according to data written by the + client. + """ + connection, client_address = sock.accept() + active_progress = None + + # TODO: make this configurable? + suffix = "\n\n.".encode('utf_8') + data = b'' + + # listen for progress info, update session progress as needed + while True: + + # accumulate data bytestring until we see the suffix + byte = connection.recv(1) + data += byte + if data.endswith(suffix): + + # strip suffix, interpret data as JSON + data = data[:-len(suffix)] + if six.PY3: + data = data.decode('utf_8') + data = json.loads(data) + + if data.get('everything_complete'): + if active_progress: + active_progress.finish() + raise EverythingComplete + + elif data.get('process_complete'): + active_progress.finish() + active_progress = None + break + + elif 'value' in data: + if not active_progress: + active_progress = progress(data['message'], data['maximum']) + active_progress.update(data['value']) + + # reset data buffer + data = b'' + + # close client connection + connection.close() + def get_merge_fields(self): if hasattr(self, 'merge_fields'): return self.merge_fields @@ -2287,7 +2364,10 @@ class MasterView(View): try: mapper = orm.object_mapper(row) except orm.exc.UnmappedInstanceError: - return {self.model_key: row[self.model_key]} + try: + return {self.model_key: row[self.model_key]} + except TypeError: + return {self.model_key: getattr(row, self.model_key)} else: pkeys = get_primary_keys(row) keys = list(pkeys) From cc4b2278e732335ad49d0919ea399687ef48986d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 6 Dec 2021 20:04:34 -0600 Subject: [PATCH 0002/1164] OMG a ridiculous commit to overhaul import handler config etc. - add `MasterView.configurable` concept, `/configure.mako` template - add new master view for DataSync Threads (needs content) - tweak view config for DataSync Changes accordingly - update the Configure DataSync page per `configurable` concept - add new Configure Import/Export page, per `configurable` - add basic views for Raw Permissions --- tailbone/templates/configure.mako | 175 ++++++++++++++++ .../templates/datasync/changes/index.mako | 4 +- tailbone/templates/datasync/configure.mako | 136 ++---------- tailbone/templates/datasync/index.mako | 19 ++ tailbone/templates/importing/configure.mako | 197 ++++++++++++++++++ tailbone/templates/master/index.mako | 3 + tailbone/views/datasync.py | 160 +++++++------- tailbone/views/importing.py | 156 +++++++++++--- tailbone/views/master.py | 65 +++++- tailbone/views/permissions.py | 58 ++++++ 10 files changed, 735 insertions(+), 238 deletions(-) create mode 100644 tailbone/templates/configure.mako create mode 100644 tailbone/templates/datasync/index.mako create mode 100644 tailbone/templates/importing/configure.mako create mode 100644 tailbone/views/permissions.py diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako new file mode 100644 index 00000000..b0bfb14e --- /dev/null +++ b/tailbone/templates/configure.mako @@ -0,0 +1,175 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Configure ${config_title} + +<%def name="save_undo_buttons()"> +
    + + {{ savingSettings ? "Working, please wait..." : "Save All Settings" }} + + + +
    + + +<%def name="purge_button()"> + + Remove All Settings + + + +<%def name="buttons_row()"> +
    +
    + +
    +

    + This tool lets you modify the ${config_title} configuration. +

    +
    + +
    + ${self.save_undo_buttons()} +
    +
    + +
    +
    + ${self.purge_button()} +
    +
    +
    + + +<%def name="page_content()"> + ${parent.page_content()} + +
    + + ${self.buttons_row()} + + + + + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index c28076fe..7a79010f 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -3,8 +3,8 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if master.has_perm('configure'): - ${h.link_to("Configure DataSync", url('datasync.configure'))} + % if request.has_perm('datasync.list'): +
  • ${h.link_to("View DataSync Threads", url('datasync'))}
  • % endif diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 161328f7..0bed21e3 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -1,13 +1,10 @@ ## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> - -<%def name="title()">Configure DataSync - -<%def name="page_content()"> -
    +<%inherit file="/configure.mako" /> +<%def name="buttons_row()">
    +

    This tool lets you modify the DataSync configuration.  @@ -19,24 +16,13 @@

    -
    - - {{ saveSettingsButtonText }} - - - +
    + ${self.save_undo_buttons()}
    +
    ${h.form(url('datasync.restart'), **{'@submit': 'submitRestartDatasyncForm'})} ${h.csrf_token(request)} @@ -50,56 +36,16 @@ ${h.end_form()}
    +
    - - Remove All Settings - + ${self.purge_button()}
    + - - - +<%def name="page_content()"> + ${parent.page_content()} @@ -496,13 +442,6 @@ ThisPageData.restartCommand = ${json.dumps(restart_command)|n} - ThisPageData.purgeSettingsShowDialog = false - ThisPageData.purgingSettings = false - - ThisPageData.settingsNeedSaved = false - ThisPageData.undoChanges = false - ThisPageData.savingSettings = false - ThisPage.computed.filteredProfilesData = function() { if (this.showDisabledProfiles) { return this.profilesData @@ -539,13 +478,6 @@ return false } - ThisPage.computed.saveSettingsButtonText = function() { - if (this.savingSettings) { - return "Working, please wait..." - } - return "Save All Settings" - } - ThisPage.methods.toggleDisabledProfiles = function() { this.showDisabledProfiles = !this.showDisabledProfiles } @@ -743,53 +675,11 @@ } } - ThisPage.methods.purgeSettingsInit = function() { - this.purgeSettingsShowDialog = true - } - - ThisPage.methods.saveSettings = function() { - this.savingSettings = true - let url = ${json.dumps(request.current_route_url())|n} - - let params = { + ThisPage.methods.settingsCollectParams = function() { + return { profiles: this.profilesData, restart_command: this.restartCommand, } - - let headers = { - 'X-CSRF-TOKEN': this.csrftoken, - } - - 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 - }) - }) - } - - // cf. https://stackoverflow.com/a/56551646 - ThisPage.methods.beforeWindowUnload = function(e) { - if (this.settingsNeedSaved && !this.undoChanges) { - e.preventDefault() - e.returnValue = '' - } - } - - ThisPage.created = function() { - window.addEventListener('beforeunload', this.beforeWindowUnload) } % if request.has_perm('datasync.restart'): diff --git a/tailbone/templates/datasync/index.mako b/tailbone/templates/datasync/index.mako new file mode 100644 index 00000000..fd7c39c6 --- /dev/null +++ b/tailbone/templates/datasync/index.mako @@ -0,0 +1,19 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('datasync_changes.list'): +
  • ${h.link_to("View DataSync Changes", url('datasyncchanges'))}
  • + % endif + + +<%def name="render_grid_component()"> + + TODO: this page coming soon... + + ${parent.render_grid_component()} + + + +${parent.body()} diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako new file mode 100644 index 00000000..462a5215 --- /dev/null +++ b/tailbone/templates/importing/configure.mako @@ -0,0 +1,197 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + +

    Designated Handlers

    + + + + + + + +
    +
    + + + {{ editingHandlerHostTitle }} -> {{ editingHandlerLocalTitle }} + + + + + + + + + + + +
    +
    +
    + bin/ +
    +
    + + +
    +
    +
    +
    + + + + + + + + + + + +
    + + + + + Cancel + + + + Update Handler + + + + +
    +
    +
    + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 8e855422..f58a59d1 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -162,6 +162,9 @@
  • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
  • % endif % endif + % if master.configurable and master.has_perm('configure'): +
  • ${h.link_to("Configure {}".format(config_title), url('{}.configure'.format(route_prefix)))}
  • + % endif <%def name="grid_tools()"> diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 0fe1e709..cff9553f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -32,54 +32,44 @@ import logging from rattail.db import model from rattail.datasync.config import load_profiles -from rattail.datasync.util import get_lastrun, purge_datasync_settings +from rattail.datasync.util import purge_datasync_settings from tailbone.views import MasterView -from tailbone.util import csrf_token log = logging.getLogger(__name__) -class DataSyncChangeView(MasterView): +class DataSyncThreadView(MasterView): """ - Master view for the DataSyncChange model. + Master view for DataSync itself. + + This should (eventually) show all running threads in the main + index view, with status for each, sort of akin to "dashboard". + For now it only serves the config view. """ - model_class = model.DataSyncChange - url_prefix = '/datasync/changes' - permission_prefix = 'datasync' + normalized_model_name = 'datasyncthread' + model_title = "DataSync Thread" + model_key = 'key' + route_prefix = 'datasync' + url_prefix = '/datasync' + viewable = False creatable = False editable = False - bulk_deletable = True + deletable = False + filterable = False + pageable = False - labels = { - 'batch_id': "Batch ID", - } + configurable = True + config_title = "DataSync" grid_columns = [ - 'source', - 'batch_id', - 'batch_sequence', - 'payload_type', - 'payload_key', - 'deletion', - 'obtained', - 'consumer', + 'key', ] - def configure_grid(self, g): - super(DataSyncChangeView, self).configure_grid(g) - - # batch_sequence - g.set_label('batch_sequence', "Batch Seq.") - g.filters['batch_sequence'].label = "Batch Sequence" - - g.set_sort_defaults('obtained') - g.set_type('obtained', 'datetime') - - def template_kwargs_index(self, **kwargs): - kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) - return kwargs + def get_data(self, session=None): + data = [] + return data def restart(self): cmd = self.rattail_config.getlist('tailbone', 'datasync.restart', @@ -93,23 +83,7 @@ class DataSyncChangeView(MasterView): self.request.session.flash("DataSync daemon could not be restarted; result was: {}".format(result), 'error') return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges'))) - def configure(self): - """ - View for configuring the DataSync daemon. - """ - if self.request.method == 'POST': - # if self.request.is_xhr and not self.request.POST: - if self.request.POST.get('purge_settings'): - self.delete_settings() - self.request.session.flash("Settings have been removed.") - return self.redirect(self.request.current_route_url()) - else: - data = self.request.json_body - self.save_settings(data) - self.request.session.flash("Settings have been saved. " - "You should probably restart DataSync now.") - return self.json_response({'success': True}) - + def configure_get_context(self): profiles = load_profiles(self.rattail_config, include_disabled=True, ignore_problems=True) @@ -148,27 +122,21 @@ class DataSyncChangeView(MasterView): profiles_data.append(data) return { - 'master': self, - # TODO: really only buefy themes are supported here - 'use_buefy': self.get_use_buefy(), - 'index_title': "DataSync Changes", - 'index_url': self.get_index_url(), 'profiles': profiles, 'profiles_data': profiles_data, 'restart_command': self.rattail_config.get('tailbone', 'datasync.restart'), 'system_user': getpass.getuser(), } - def save_settings(self, data): - model = self.model - - # collect new settings + def configure_gather_settings(self, data): settings = [] watch = [] + for profile in data['profiles']: pkey = profile['key'] if profile['enabled']: watch.append(pkey) + settings.extend([ {'name': 'rattail.datasync.{}.watcher'.format(pkey), 'value': profile['watcher_spec']}, @@ -183,10 +151,12 @@ class DataSyncChangeView(MasterView): {'name': 'rattail.datasync.{}.consumers.runas'.format(pkey), 'value': profile['watcher_default_runas']}, ]) + consumers = [] if profile['watcher_consumes_self']: consumers = ['self'] else: + for consumer in profile['consumers_data']: ckey = consumer['key'] if consumer['enabled']: @@ -205,10 +175,12 @@ class DataSyncChangeView(MasterView): {'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey), 'value': consumer['consumer_runas']}, ]) + settings.extend([ {'name': 'rattail.datasync.{}.consumers'.format(pkey), 'value': ', '.join(consumers)}, ]) + settings.extend([ {'name': 'rattail.datasync.watch', 'value': ', '.join(watch)}, @@ -216,15 +188,9 @@ class DataSyncChangeView(MasterView): 'value': data['restart_command']}, ]) - # delete all current settings - self.delete_settings() + return settings - # create all new settings - for setting in settings: - self.Session.add(model.Setting(name=setting['name'], - value=setting['value'])) - - def delete_settings(self): + def configure_remove_settings(self): purge_datasync_settings(self.rattail_config, self.Session()) @classmethod @@ -235,33 +201,65 @@ class DataSyncChangeView(MasterView): @classmethod def _datasync_defaults(cls, config): permission_prefix = cls.get_permission_prefix() + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() - # fix permission group title - config.add_tailbone_permission_group(permission_prefix, label="DataSync") - - # restart datasync + # restart config.add_tailbone_permission(permission_prefix, '{}.restart'.format(permission_prefix), label="Restart the DataSync daemon") - config.add_route('datasync.restart', '/datasync/restart', + config.add_route('{}.restart'.format(route_prefix), + '{}/restart'.format(url_prefix), request_method='POST') config.add_view(cls, attr='restart', - route_name='datasync.restart', + route_name='{}.restart'.format(route_prefix), permission='{}.restart'.format(permission_prefix)) - # configure datasync - config.add_tailbone_permission(permission_prefix, - '{}.configure'.format(permission_prefix), - label="Configure the DataSync daemon") - config.add_route('datasync.configure', '/datasync/configure') - config.add_view(cls, attr='configure', - route_name='datasync.configure', - permission='{}.configure'.format(permission_prefix), - renderer='/datasync/configure.mako') + +class DataSyncChangeView(MasterView): + """ + Master view for the DataSyncChange model. + """ + model_class = model.DataSyncChange + url_prefix = '/datasync/changes' + permission_prefix = 'datasync_changes' + creatable = False + editable = False + bulk_deletable = True + + labels = { + 'batch_id': "Batch ID", + } + + grid_columns = [ + 'source', + 'batch_id', + 'batch_sequence', + 'payload_type', + 'payload_key', + 'deletion', + 'obtained', + 'consumer', + ] + + def configure_grid(self, g): + super(DataSyncChangeView, self).configure_grid(g) + + # batch_sequence + g.set_label('batch_sequence', "Batch Seq.") + g.filters['batch_sequence'].label = "Batch Sequence" + + g.set_sort_defaults('obtained') + g.set_type('obtained', 'datetime') + + def template_kwargs_index(self, **kwargs): + kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) + return kwargs # TODO: deprecate / remove this DataSyncChangesView = DataSyncChangeView def includeme(config): + DataSyncThreadView.defaults(config) DataSyncChangeView.defaults(config) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index 23a039cd..80f54c37 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -35,6 +35,7 @@ import time import json import six +import sqlalchemy as sa from rattail.exceptions import ConfigurationError from rattail.threads import Thread @@ -66,14 +67,19 @@ class ImportingView(MasterView): 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', ] @@ -84,6 +90,7 @@ class ImportingView(MasterView): 'handler_spec', 'host_title', 'local_title', + 'direction_display', 'models', ] @@ -105,18 +112,14 @@ class ImportingView(MasterView): app = self.get_rattail_app() data = [] - for Handler in app.all_import_handlers(): - handler = Handler(self.rattail_config) + for handler in app.get_designated_import_handlers( + ignore_errors=True, sort=True): data.append(self.normalize(handler)) - data.sort(key=lambda handler: (handler['host_title'], - handler['local_title'])) return data - def normalize(self, handler): - Handler = handler.__class__ - return { - '_handler': handler, + def normalize(self, handler, keep_handler=True): + data = { 'key': handler.get_key(), 'generic_title': handler.get_generic_title(), 'host_key': handler.host_key, @@ -124,7 +127,31 @@ class ImportingView(MasterView): '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(), + } + + 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) @@ -139,9 +166,9 @@ class ImportingView(MasterView): """ key = self.request.matchdict['key'] app = self.get_rattail_app() - for Handler in app.all_import_handlers(): - if Handler.get_key() == key: - return self.normalize(Handler(self.rattail_config)) + handler = app.get_designated_import_handler(key, ignore_errors=True) + if handler: + return self.normalize(handler) raise self.notfound() def get_instance_title(self, handler_info): @@ -206,8 +233,8 @@ class ImportingView(MasterView): def cache_runjob_form_values(self, handler, form): handler_key = handler.get_key() - def make_key(key): - return 'rattail.importing.{}.{}'.format(handler_key, key) + def make_key(field): + return 'rattail.importing.{}.{}'.format(handler_key, field) for field in form.fields: key = make_key(field) @@ -216,8 +243,8 @@ class ImportingView(MasterView): def read_cached_runjob_values(self, handler, form): handler_key = handler.get_key() - def make_key(key): - return 'rattail.importing.{}.{}'.format(handler_key, key) + def make_key(field): + return 'rattail.importing.{}.{}'.format(handler_key, field) for field in form.fields: key = make_key(field) @@ -331,8 +358,10 @@ class ImportingView(MasterView): # invoke handler command via subprocess try: - result = subprocess.run(cmd, check=True, capture_output=True) - output = result.stderr.decode('utf_8').strip() + 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) @@ -346,14 +375,14 @@ class ImportingView(MasterView): {} ``` -And here is the STDERR output: +And here is the output: ``` {} ``` """.format(handler.direction.capitalize(), ' '.join(cmd), - error.stderr.decode('utf_8').strip()) + 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]) @@ -394,21 +423,35 @@ And here is the STDERR output: notes = HTML.literal(notes) return HTML.tag('div', class_='tailbone-markdown', c=[notes]) - def make_runjob_cmd(self, handler, form, typ, port=None): + def get_cmd_for_handler(self, handler, ignore_errors=False): handler_key = handler.get_key() - option = '{}.cmd'.format(handler_key) - cmd = self.rattail_config.getlist('rattail.importing', option) + cmd = self.rattail_config.getlist('rattail.importing', + '{}.cmd'.format(handler_key)) if not cmd or len(cmd) != 2: - msg = ("Missing or invalid config; please set '{}' in the " - "[rattail.importing] section of your config file".format(option)) - raise ConfigurationError(msg) + cmd = self.rattail_config.getlist('rattail.importing', + '{}.default_cmd'.format(handler_key)) - command, subcommand = cmd + 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) - option = '{}.runas'.format(handler_key) - runas = self.rattail_config.require('rattail.importing', option) + 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': @@ -460,7 +503,10 @@ And here is the STDERR output: cmd.append('--dry-run') if data['warnings']: - cmd.append('--warnings') + if typ == 'true': + cmd.append('--warnings') + else: + cmd.append('-W') return cmd @@ -479,6 +525,54 @@ cd {prefix} 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 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): + model = self.model + self.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')))\ + .delete(synchronize_session=False) + @classmethod def defaults(cls, config): cls._defaults(config) @@ -493,7 +587,7 @@ cd {prefix} # run job config.add_tailbone_permission(permission_prefix, '{}.runjob'.format(permission_prefix), - "Run an arbitrary import / export job") + "Run an arbitrary Import / Export Job") config.add_route('{}.runjob'.format(route_prefix), '{}/runjob'.format(instance_url_prefix)) config.add_view(cls, attr='runjob', diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2a3189c4..cdd958a0 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -114,6 +114,7 @@ class MasterView(View): execute_progress_initial_msg = None supports_prev_next = False supports_import_batch_from_file = False + configurable = False # set to True to add "View *global* Objects" permission, and # expose / leverage the ``local_only`` object flag @@ -2032,6 +2033,16 @@ class MasterView(View): """ return getattr(cls, 'index_title', cls.get_model_title_plural()) + @classmethod + def get_config_title(cls): + """ + Returns the view's "config title". + """ + if hasattr(cls, 'config_title'): + return cls.config_title + + return cls.get_model_title_plural() + def get_action_url(self, action, instance, **kwargs): """ Generate a URL for the given action on the given instance @@ -2075,6 +2086,7 @@ class MasterView(View): 'permission_prefix': self.get_permission_prefix(), 'index_title': self.get_index_title(), 'index_url': self.get_index_url(), + 'config_title': self.get_config_title(), 'action_url': self.get_action_url, 'grid_index': self.grid_index, 'help_url': self.get_help_url(), @@ -3982,7 +3994,46 @@ class MasterView(View): return diffs.Diff(old_data, new_data, **kwargs) ############################## - # Config Stuff + # Configuration Views + ############################## + + def configure(self): + """ + Generic view for configuring some aspect of the software. + """ + if self.request.method == 'POST': + if self.request.POST.get('remove_settings'): + self.configure_remove_settings() + self.request.session.flash("Settings have been removed.") + return self.redirect(self.request.current_route_url()) + else: + data = self.request.json_body + 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}) + + context = self.configure_get_context() + return self.render_to_response('configure', context) + + def configure_get_context(self): + return {} + + def configure_gather_settings(self, data): + return [] + + def configure_remove_settings(self): + pass + + def configure_save_settings(self, settings): + model = self.model + for setting in settings: + self.Session.add(model.Setting(name=setting['name'], + value=setting['value'])) + + ############################## + # Pyramid View Config ############################## @classmethod @@ -4025,6 +4076,7 @@ class MasterView(View): model_key = cls.get_model_key() model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() + config_title = cls.get_config_title() if cls.has_rows: row_model_title = cls.get_row_model_title() @@ -4087,6 +4139,17 @@ class MasterView(View): config.add_view(cls, attr='download_results_rows', route_name='{}.download_results_rows'.format(route_prefix), permission='{}.download_results_rows'.format(permission_prefix)) + # configure + if cls.configurable: + config.add_tailbone_permission(permission_prefix, + '{}.configure'.format(permission_prefix), + label="Configure {}".format(config_title)) + config.add_route('{}.configure'.format(route_prefix), + '{}/configure'.format(url_prefix)) + config.add_view(cls, attr='configure', + route_name='{}.configure'.format(route_prefix), + permission='{}.configure'.format(permission_prefix)) + # quickie (search) if cls.supports_quickie_search: config.add_tailbone_permission(permission_prefix, '{}.quickie'.format(permission_prefix), diff --git a/tailbone/views/permissions.py b/tailbone/views/permissions.py new file mode 100644 index 00000000..67f6e9b1 --- /dev/null +++ b/tailbone/views/permissions.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 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 . +# +################################################################################ +""" +Raw Permission Views +""" + +from __future__ import unicode_literals, absolute_import + +from sqlalchemy import orm + +from rattail.db import model + +from tailbone.views import MasterView + + +class PermissionView(MasterView): + """ + Master view for the permissions model. + """ + model_class = model.Permission + model_title = "Raw Permission" + editable = False + bulk_deletable = True + + grid_columns = [ + 'role', + 'permission', + ] + + def query(self, session): + model = self.model + query = super(PermissionView, self).query(session) + query = query.options(orm.joinedload(model.Permission.role)) + return query + + +def includeme(config): + PermissionView.defaults(config) From 092f1cda0ce9d17191959d0f027340df23c1f6d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 6 Dec 2021 21:29:33 -0600 Subject: [PATCH 0003/1164] Honor "safe for web app" flags for import/export handlers --- tailbone/templates/importing/runjob.mako | 5 +++++ tailbone/views/importing.py | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako index 2b9642f6..2bc2a4e9 100644 --- a/tailbone/templates/importing/runjob.mako +++ b/tailbone/templates/importing/runjob.mako @@ -44,7 +44,12 @@ {{ submittingRun ? "Working, please wait..." : "Run this ${handler.direction.capitalize()}" }} diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index 80f54c37..4d142cf3 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -129,6 +129,7 @@ class ImportingView(MasterView): '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: @@ -314,7 +315,13 @@ class ImportingView(MasterView): if self.request.POST.get('runjob') == 'true': - # will invoke handler to run job + # 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 From 5a4abbb1636a50eef16e959c0aab48f33b0412cf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 11:28:23 -0600 Subject: [PATCH 0004/1164] When viewing report output, show params as proper buefy table plus couple of other random tweaks --- .../templates/reports/generated/view.mako | 12 +++++++ tailbone/views/datasync.py | 7 +++- tailbone/views/reports.py | 34 +++++++++++++++++-- tailbone/views/stores.py | 1 + 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index c7d34efa..ce8ef38d 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -8,4 +8,16 @@ % endif +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + ${parent.body()} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index cff9553f..03be846e 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -224,7 +224,6 @@ class DataSyncChangeView(MasterView): url_prefix = '/datasync/changes' permission_prefix = 'datasync_changes' creatable = False - editable = False bulk_deletable = True labels = { @@ -256,6 +255,12 @@ class DataSyncChangeView(MasterView): kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) return kwargs + def configure_form(self, f): + super(DataSyncChangeView, self).configure_form(f) + + f.set_readonly('obtained') + + # TODO: deprecate / remove this DataSyncChangesView = DataSyncChangeView diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 5d1ca5eb..204dc9df 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -45,7 +45,7 @@ from mako.template import Template from pyramid.response import Response from webhelpers2.html import HTML -from tailbone import forms, grids +from tailbone import forms from tailbone.db import Session from tailbone.views import View from tailbone.views.exports import ExportMasterView @@ -257,18 +257,39 @@ class ReportOutputView(ExportMasterView): params.sort(key=lambda param: param['key']) route_prefix = self.get_route_prefix() - g = grids.Grid( + factory = self.get_grid_factory() + g = factory( key='{}.params'.format(route_prefix), data=params, columns=['key', 'value'], + labels={'key': "Name"}, ) - return HTML.literal(g.render_grid()) + if self.get_use_buefy(): + return HTML.literal( + g.render_buefy_table_element(data_prop='paramsData')) + else: + return HTML.literal(g.render_grid()) def render_download(self, report, field): path = report.filepath(self.rattail_config) url = self.get_action_url('download', report) return self.render_file_field(path, url=url) + def template_kwargs_view(self, **kwargs): + use_buefy = self.get_use_buefy() + if use_buefy: + + report = kwargs['instance'] + params_data = [] + for name, value in report.params.items(): + params_data.append({ + 'name': name, + 'value': value, + }) + kwargs['params_data'] = params_data + + return kwargs + def download(self): report = self.get_instance() path = report.filepath(self.rattail_config) @@ -387,6 +408,13 @@ class GenerateReport(View): if param.type is datetime.date: form.set_type(param.name, 'date_jquery') + # auto-select default choice for fields which have only one + for param in report_params: + if param.type == 'choice' and param.required: + values = form.schema[param.name].widget.values + if len(values) == 1: + form.set_default(param.name, values[0][0]) + # if form validates, start generating new report output; show progress page if form.validate(newstyle=True): key = 'report_output.generate' diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index b3107a83..ef09e69b 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.py @@ -42,6 +42,7 @@ class StoreView(MasterView): """ model_class = model.Store has_versions = True + touchable = True grid_columns = [ 'id', From a7c6380a3a1370b15277b2e99e20c8d8907b8cbe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 11:36:46 -0600 Subject: [PATCH 0005/1164] Update changelog --- CHANGES.rst | 18 ++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 68c0ef39..1060d7d0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.180 (2021-12-07) +-------------------- + +* Add basic import/export handler views, tool to run jobs. + +* Overhaul import handler config etc.: + * add ``MasterView.configurable`` concept, ``/configure.mako`` template + * add new master view for DataSync Threads (needs content) + * tweak view config for DataSync Changes accordingly + * update the Configure DataSync page per ``configurable`` concept + * add new Configure Import/Export page, per ``configurable`` + * add basic views for Raw Permissions + +* Honor "safe for web app" flags for import/export handlers. + +* When viewing report output, show params as proper buefy table. + + 0.8.179 (2021-12-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ff41db5a..b99f1145 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.179' +__version__ = '0.8.180' From 1353f6ed3c6235bfcdef347d546f2e518d6428b4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 12:09:43 -0600 Subject: [PATCH 0006/1164] Bugfix --- tailbone/views/reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 204dc9df..30a9c6b6 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -283,7 +283,7 @@ class ReportOutputView(ExportMasterView): params_data = [] for name, value in report.params.items(): params_data.append({ - 'name': name, + 'key': name, 'value': value, }) kwargs['params_data'] = params_data From 095afcde24f44cc19040bee91ce20a1ab02381d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 13:19:18 -0600 Subject: [PATCH 0007/1164] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1060d7d0..cdbbe8fe 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.181 (2021-12-07) +-------------------- + +* Bugfix. + + 0.8.180 (2021-12-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b99f1145..35cbd36e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.180' +__version__ = '0.8.181' From 6fc666e221d71e5f12192c9df37fd13c40137049 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 16:18:56 -0600 Subject: [PATCH 0008/1164] Fix form ref bug, for batch execution --- tailbone/forms/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 060e1133..6b463f55 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -771,7 +771,7 @@ class Form(object): # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: if self.use_buefy: - context['form_kwargs']['ref'] = self.component_studly + context['form_kwargs'].setdefault('ref', self.component_studly) context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) else: context['form_kwargs']['class_'] = 'autodisable' From f687078bbf7f568e2bf411a6b2292ce6e7496b3e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 16:19:32 -0600 Subject: [PATCH 0009/1164] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cdbbe8fe..defb6035 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.182 (2021-12-07) +-------------------- + +* Fix form ref bug, for batch execution. + + 0.8.181 (2021-12-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 35cbd36e..c5a1b4cb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.181' +__version__ = '0.8.182' From 871dd35a3a3f22ac3e3d8bb5c5fc28fd0e998ce7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 17:45:21 -0600 Subject: [PATCH 0010/1164] Add basic views to expose Problem Reports, and run them not very sophisticated yet but heck better than we had yesterday --- tailbone/templates/reports/problems/view.mako | 76 ++++++++++ tailbone/views/batch/core.py | 12 +- tailbone/views/master.py | 64 +++++--- tailbone/views/reports.py | 139 +++++++++++++++++- 4 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 tailbone/templates/reports/problems/view.mako diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako new file mode 100644 index 00000000..cbd2a942 --- /dev/null +++ b/tailbone/templates/reports/problems/view.mako @@ -0,0 +1,76 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if master.has_perm('execute'): + + + + + + % endif + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 90614079..c0a3a1a3 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -850,7 +850,7 @@ class BatchMasterView(MasterView): # launch thread to invoke handler action thread = Thread(target=self.action_subprocess_thread, - args=(batch.uuid, port, username, batch_action, progress), + args=((batch.uuid,), port, username, batch_action, progress), kwargs=kwargs) thread.start() @@ -859,7 +859,7 @@ class BatchMasterView(MasterView): # launch thread to populate batch; that will update session progress directly target = getattr(self, '{}_thread'.format(batch_action)) - thread = Thread(target=target, args=(batch.uuid, user_uuid, progress), kwargs=kwargs) + thread = Thread(target=target, args=((batch.uuid,), user_uuid, progress), kwargs=kwargs) thread.start() return self.render_progress(progress, { @@ -894,7 +894,7 @@ class BatchMasterView(MasterView): log.debug("launching command in subprocess: %s", cmd) subprocess.check_call(cmd) - def action_subprocess_thread(self, batch_uuid, port, username, handler_action, progress, **kwargs): + def action_subprocess_thread(self, key, port, username, handler_action, progress, **kwargs): """ This method is sort of an alternative thread target for batch actions, to be used in the event versioning is enabled for the main process but @@ -902,6 +902,8 @@ class BatchMasterView(MasterView): launch a separate process with versioning disabled in order to act on the batch. """ + batch_uuid = key[0] + # figure out the (sub)command args we'll be passing subargs = [ '--batch-type', @@ -1216,7 +1218,7 @@ class BatchMasterView(MasterView): def execute_error_message(self, error): return "Batch execution failed: {}".format(simple_error(error)) - def execute_thread(self, batch_uuid, user_uuid, progress, **kwargs): + def execute_thread(self, key, user_uuid, progress, **kwargs): """ Thread target for executing a batch with progress indicator. """ @@ -1224,7 +1226,7 @@ class BatchMasterView(MasterView): # session here; can't use tailbone because it has web request # transaction binding etc. session = RattailSession() - batch = session.query(self.model_class).get(batch_uuid) + batch = self.get_instance_for_key(key, session) user = session.query(model.User).get(user_uuid) try: result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index cdd958a0..73562e8d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1688,36 +1688,43 @@ class MasterView(View): """ obj = self.get_instance() model_title = self.get_model_title() - if self.request.method == 'POST': + progress = self.make_execute_progress(obj) - progress = self.make_execute_progress(obj) - kwargs = {'progress': progress} - thread = Thread(target=self.execute_thread, args=(obj.uuid, self.request.user.uuid), kwargs=kwargs) - thread.start() + kwargs = {'progress': progress} + key = [self.request.matchdict[k] + for k in self.get_model_key(as_tuple=True)] + thread = Thread(target=self.execute_thread, args=(key, self.request.user.uuid), kwargs=kwargs) + thread.start() - return self.render_progress(progress, { - 'instance': obj, - 'initial_msg': self.execute_progress_initial_msg, - 'cancel_url': self.get_action_url('view', obj), - 'cancel_msg': "{} execution was canceled".format(model_title), - }, template=self.execute_progress_template) - - self.request.session.flash("Sorry, you must POST to execute a {}.".format(model_title), 'error') - return self.redirect(self.get_action_url('view', obj)) + return self.render_progress(progress, { + 'instance': obj, + 'initial_msg': self.execute_progress_initial_msg, + 'cancel_url': self.get_action_url('view', obj), + 'cancel_msg': "{} execution was canceled".format(model_title), + }, template=self.execute_progress_template) def make_execute_progress(self, obj): key = '{}.execute'.format(self.get_grid_key()) return self.make_progress(key) - def execute_thread(self, uuid, user_uuid, progress=None, **kwargs): + def get_instance_for_key(self, key, session): + model_key = self.get_model_key(as_tuple=True) + if len(model_key) == 1 and model_key[0] == 'uuid': + uuid = key[0] + return session.query(self.model_class).get(uuid) + raise NotImplementedError + + def execute_thread(self, key, user_uuid, progress=None, **kwargs): """ Thread target for executing an object. """ session = RattailSession() - obj = session.query(self.model_class).get(uuid) + obj = self.get_instance_for_key(key, session) user = session.query(model.User).get(user_uuid) try: - self.execute_instance(obj, user, progress=progress, **kwargs) + success_msg = self.execute_instance(obj, user, + progress=progress, + **kwargs) # If anything goes wrong, rollback and log the error etc. except Exception as error: @@ -1733,13 +1740,21 @@ class MasterView(View): # If no error, check result flag (false means user canceled). else: session.commit() - session.refresh(obj) + try: + needs_refresh = obj in session + except: + pass + else: + if needs_refresh: + session.refresh(obj) success_url = self.get_execute_success_url(obj) session.close() if progress: progress.session.load() progress.session['complete'] = True progress.session['success_url'] = success_url + if success_msg: + progress.session['success_msg'] = success_msg progress.session.save() def execute_error_message(self, error): @@ -1991,8 +2006,10 @@ class MasterView(View): the master view class. This is the plural, lower-cased name of the model class by default, e.g. 'products'. """ + if hasattr(cls, 'route_prefix'): + return cls.route_prefix model_name = cls.get_normalized_model_name() - return getattr(cls, 'route_prefix', '{0}s'.format(model_name)) + return '{}s'.format(model_name) @classmethod def get_url_prefix(cls): @@ -2377,7 +2394,10 @@ class MasterView(View): mapper = orm.object_mapper(row) except orm.exc.UnmappedInstanceError: try: - return {self.model_key: row[self.model_key]} + if isinstance(self.model_key, six.string_types): + return {self.model_key: row[self.model_key]} + return dict([(key, row[key]) + for key in self.model_key]) except TypeError: return {self.model_key: getattr(row, self.model_key)} else: @@ -4311,7 +4331,9 @@ class MasterView(View): if cls.executable: config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix), "Execute {}".format(model_title)) - config.add_route('{}.execute'.format(route_prefix), '{}/execute'.format(instance_url_prefix)) + config.add_route('{}.execute'.format(route_prefix), + '{}/execute'.format(instance_url_prefix), + request_method='POST') config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), permission='{}.execute'.format(permission_prefix)) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 30a9c6b6..4c8fe6e9 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -43,12 +43,12 @@ import colander from deform import widget as dfwidget from mako.template import Template from pyramid.response import Response -from webhelpers2.html import HTML +from webhelpers2.html import HTML, tags from tailbone import forms from tailbone.db import Session from tailbone.views import View -from tailbone.views.exports import ExportMasterView +from tailbone.views.exports import ExportMasterView, MasterView plu_upc_pattern = re.compile(r'^000000000(\d{5})$') @@ -511,6 +511,140 @@ class NewReport(colander.Schema): validator=valid_report_type) +class ProblemReportView(MasterView): + """ + Master view for problem reports + """ + model_title = "Problem Report" + model_key = ('system_key', 'problem_key') + route_prefix = 'problem_reports' + url_prefix = '/reports/problems' + + creatable = False + editable = False + deletable = False + filterable = False + pageable = False + executable = True + + labels = { + 'system_key': "System", + } + + grid_columns = [ + 'system_key', + # 'problem_key', + 'problem_title', + 'email_recipients', + ] + + form_fields = [ + 'system_key', + 'problem_title', + 'email_key', + 'email_recipients', + ] + + def __init__(self, request): + super(ProblemReportView, self).__init__(request) + + app = self.get_rattail_app() + self.handler = app.get_problem_report_handler() + + def normalize(self, report, keep_report=True): + data = { + 'system_key': report.system_key, + 'problem_key': report.problem_key, + 'problem_title': report.problem_title, + 'email_key': self.handler.get_email_key(report), + } + + app = self.get_rattail_app() + handler = app.get_mail_handler() + email = handler.get_email(data['email_key']) + data['email_recipients'] = email.get_recips('all') + + if keep_report: + data['_report'] = report + return data + + def get_data(self, session=None): + data = [] + + reports = self.handler.get_all_problem_reports() + organized = self.handler.organize_problem_reports(reports) + + for system_key, reports in six.iteritems(organized): + for report in six.itervalues(reports): + data.append(self.normalize(report)) + + return data + + def configure_grid(self, g): + super(ProblemReportView, self).configure_grid(g) + + g.set_renderer('email_recipients', self.render_email_recipients) + + g.set_link('problem_key') + g.set_link('problem_title') + + def get_instance(self): + system_key = self.request.matchdict['system_key'] + problem_key = self.request.matchdict['problem_key'] + return self.get_instance_for_key((system_key, problem_key), + None) + + def get_instance_for_key(self, key, session): + report = self.handler.get_problem_report(*key) + if report: + return self.normalize(report) + raise self.notfound() + + def get_instance_title(self, report_info): + return report_info['problem_title'] + + def make_form_schema(self): + return ProblemReportSchema() + + def configure_form(self, f): + super(ProblemReportView, self).configure_form(f) + + f.set_renderer('email_key', self.render_email_key) + f.set_renderer('email_recipients', self.render_email_recipients) + + def render_email_key(self, report_info, field): + email_key = report_info[field] + if not email_key: + return + + if self.request.has_perm('emailprofiles.view'): + text = email_key + url = self.request.route_url('emailprofiles.view', key=email_key) + return tags.link_to(text, url) + + return email_key + + def render_email_recipients(self, report_info, field): + recips = report_info['email_recipients'] + return ', '.join(recips) + + def execute_instance(self, report_info, user, progress=None, **kwargs): + report = report_info['_report'] + problems = self.handler.run_problem_report(report) + return "Report found {} problems".format(len(problems)) + + +class ProblemReportSchema(colander.MappingSchema): + + system_key = colander.SchemaNode(colander.String()) + + problem_key = colander.SchemaNode(colander.String()) + + problem_title = colander.SchemaNode(colander.String()) + + email_key = colander.SchemaNode(colander.String()) + + def add_routes(config): config.add_route('reports.ordering', '/reports/ordering') config.add_route('reports.inventory', '/reports/inventory') @@ -531,3 +665,4 @@ def includeme(config): # note that GenerateReport must come first, per route matching GenerateReport.defaults(config) ReportOutputView.defaults(config) + ProblemReportView.defaults(config) From ff588b6a5cee0e55edff0a5cacfe978e258ac2f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 19:57:26 -0600 Subject: [PATCH 0011/1164] Only include `--runas` arg if we have a value --- tailbone/views/importing.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index 4d142cf3..ecb2ea02 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -467,8 +467,6 @@ And here is the output: '--config={}/app/quiet.conf'.format(sys.prefix), '--progress', '--progress-socket=127.0.0.1:{}'.format(port), - '--runas={}'.format(runas), - subcommand, ] else: cmd = [ @@ -476,10 +474,16 @@ And here is the output: 'bin/{}'.format(command), '-c', 'app/quiet.conf', '-P', - '--runas', runas, - subcommand, ] + if runas: + if typ == 'true': + cmd.apend('--runas={}'.format(runas)) + else: + cmd.extend(['--runas', runas]) + + cmd.append(subcommand) + cmd.extend(data['models']) if data['create']: From 60222c4977d6beb89ceb8c650fc4a436f5d29b2a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 19:58:11 -0600 Subject: [PATCH 0012/1164] Assume default receiving workflow if there is only one --- tailbone/views/purchasing/receiving.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index ccc97d9a..1be9df49 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -291,6 +291,8 @@ class ReceivingBatchView(PurchasingBatchView): else: form.set_widget('workflow', forms.widgets.JQuerySelectWidget(values=values)) + if len(workflows) == 1: + form.set_default('workflow', workflows[0]['workflow_key']) form.submit_label = "Continue" form.cancel_url = self.get_index_url() @@ -753,8 +755,8 @@ class ReceivingBatchView(PurchasingBatchView): # flag is set, since that batch type is only concerned with receiving batch = self.get_instance() if batch.is_truck_dump_parent() and not batch.truck_dump_children_first: - g.hide_column('cases_ordered') - g.hide_column('units_ordered') + g.remove('cases_ordered', + 'units_ordered') # add "Transform to Unit" action, if appropriate if batch.is_truck_dump_parent(): @@ -771,7 +773,7 @@ class ReceivingBatchView(PurchasingBatchView): # truck_dump_status if not batch.is_truck_dump_parent(): - g.hide_column('truck_dump_status') + g.remove('truck_dump_status') else: g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS) From 6f60387f30bf60c17ce5e1fea34df52ac20f5d2b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 8 Dec 2021 12:21:23 -0600 Subject: [PATCH 0013/1164] Fix bug when report has no params dict --- tailbone/views/reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 4c8fe6e9..6359c471 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -281,7 +281,7 @@ class ReportOutputView(ExportMasterView): report = kwargs['instance'] params_data = [] - for name, value in report.params.items(): + for name, value in (report.params or {}).items(): params_data.append({ 'key': name, 'value': value, From ae76ceea04e69c08298b6f719e7025ab6147251d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 8 Dec 2021 15:54:23 -0600 Subject: [PATCH 0014/1164] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index defb6035..cfdaa909 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.183 (2021-12-08) +-------------------- + +* Add basic views to expose Problem Reports, and run them. + +* Only include ``--runas`` arg if we have a value, for import jobs. + +* Assume default receiving workflow if there is only one. + +* Fix bug when report has no params dict. + + 0.8.182 (2021-12-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c5a1b4cb..fc96b463 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.182' +__version__ = '0.8.183' From 10e34b83ed4efacfc3d8a93f663dcccaa4ad2433 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 8 Dec 2021 19:44:50 -0600 Subject: [PATCH 0015/1164] Refactor "receive row" and "declare credit" tools per buefy theme --- tailbone/api/batch/receiving.py | 6 +- tailbone/forms/core.py | 24 ++++- tailbone/templates/deform/cases_units.pt | 71 ++++++++++----- tailbone/templates/forms/deform_buefy.mako | 54 +++++------ tailbone/templates/forms/util.mako | 24 +++++ .../templates/receiving/declare_credit.mako | 60 ++++++++++--- tailbone/templates/receiving/receive_row.mako | 57 +++++++++--- tailbone/templates/receiving/view_row.mako | 11 +++ tailbone/views/purchasing/batch.py | 5 ++ tailbone/views/purchasing/receiving.py | 90 +++++++++++++++++-- 10 files changed, 313 insertions(+), 89 deletions(-) create mode 100644 tailbone/templates/forms/util.mako diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 37bb00b5..905a0872 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -118,11 +118,7 @@ class ReceivingBatchViews(APIBatchView): return {'purchases': purchases} def normalize_eligible_purchase(self, purchase): - return { - 'key': purchase.uuid, - 'department_uuid': purchase.department_uuid, - 'display': self.render_eligible_purchase(purchase), - } + return self.handler.normalize_eligible_purchase(purchase) def render_eligible_purchase(self, purchase): return self.handler.render_eligible_purchase(purchase) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 6b463f55..949222bc 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -343,8 +343,9 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form'): - + action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form', + vuejs_field_converters={}, + ): self.fields = None if fields is not None: self.set_fields(fields) @@ -380,6 +381,7 @@ class Form(object): self.cancel_url = cancel_url self.use_buefy = use_buefy self.component = component + self.vuejs_field_converters = vuejs_field_converters or {} @property def component_studly(self): @@ -702,6 +704,9 @@ class Form(object): """ return self.helptext[key] + def set_vuejs_field_converter(self, field, converter): + self.vuejs_field_converters[field] = converter + def render(self, template=None, **kwargs): if not template: if self.readonly and not self.use_buefy: @@ -788,6 +793,11 @@ class Form(object): model value for the given field. This JS will be written as part of the overall response, to be interpreted on the client side. """ + if field.name in self.vuejs_field_converters: + convert = self.vuejs_field_converters[field.name] + value = convert(field.cstruct) + return json.dumps(value) + if isinstance(field.schema.typ, deform.FileData): # TODO: we used to always/only return 'null' here but hopefully # this also works, to show existing filename when present @@ -807,6 +817,16 @@ class Form(object): except Exception as error: raise TailboneJSONFieldError(field.name, error) + def get_error_messages(self, field): + if field.error: + return field.error.messages() + + error = self.make_deform_form().error + if error: + if isinstance(error, colander.Invalid): + if error.node.name == field.name: + return error.messages() + def messages_json(self, messages): dump = json.dumps(messages) dump = dump.replace("'", ''') diff --git a/tailbone/templates/deform/cases_units.pt b/tailbone/templates/deform/cases_units.pt index 05e06d50..db4a49e0 100644 --- a/tailbone/templates/deform/cases_units.pt +++ b/tailbone/templates/deform/cases_units.pt @@ -1,29 +1,58 @@
    - ${field.start_mapping()} -
    - - Cases + +
    + ${field.start_mapping()} +
    + + Cases +
    +
    + + Units +
    + ${field.end_mapping()}
    -
    - - Units + +
    + + ${field.start_mapping()} + + + + + + + + + + + + ${field.end_mapping()}
    - ${field.end_mapping()} +
    diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 0f1ae184..17ccf7d1 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -1,4 +1,5 @@ ## -*- coding: utf-8; -*- +<%namespace file="/forms/util.mako" import="render_buefy_field" /> + % endif -
    +<%def name="render_buefy_form()"> -
    +

    + Please select the "state" of the product, and enter the + appropriate quantity. +

    + +

    + Note that this tool will + deduct from the + "received" quantity, and + add to the + corresponding credit quantity. +

    + +

    + Please see ${h.link_to("Receive Row", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))} + if you need to "receive" instead of "convert" the product. +

    + + ${parent.render_buefy_form()} + + + +<%def name="buefy_form_body()"> + + ${render_buefy_field(dform['credit_type'])} + + ${render_buefy_field(dform['quantity'])} + + ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_credit_type == 'expired'"})} + + + +<%def name="render_form()"> + % if use_buefy: + + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} + + % else:

    Please select the "state" of the product, and enter the appropriate @@ -55,11 +96,10 @@ if you need to "receive" instead of "convert" the product.

    - ${form.render()|n} -
    + ${parent.render_form()} -
      - ${self.context_menu_items()} -
    + % endif + -
    + +${parent.body()} diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako index 188fbe7b..b17b118a 100644 --- a/tailbone/templates/receiving/receive_row.mako +++ b/tailbone/templates/receiving/receive_row.mako @@ -1,16 +1,19 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/form.mako" /> +<%namespace file="/forms/util.mako" import="render_buefy_field" /> <%def name="title()">Receive for Row #${row.sequence} <%def name="context_menu_items()"> - % if master.rows_viewable and request.has_perm('{}.view'.format(permission_prefix)): + ${parent.context_menu_items()} + % if master.rows_viewable and master.has_perm('view'):
  • ${h.link_to("View this {}".format(row_model_title), row_action_url('view', row))}
  • % endif <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: + % endif -
    +<%def name="render_buefy_form()"> -
    +

    + Please select the "state" of the product, and enter the appropriate + quantity. +

    + +

    + Note that this tool will add + the corresponding quantities for the row. +

    + +

    + Please see ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))} + if you need to "convert" some already-received amount, into a credit. +

    + + ${parent.render_buefy_form()} + + + +<%def name="buefy_form_body()"> + + ${render_buefy_field(dform['mode'])} + + ${render_buefy_field(dform['quantity'])} + + ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_mode == 'expired'"})} + + + +<%def name="render_form()"> + % if use_buefy: + + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} + + % else:

    Please select the "state" of the product, and enter the appropriate @@ -55,11 +93,10 @@ if you need to "convert" some already-received amount, into a credit.

    - ${form.render()|n} -
    + ${parent.render_form()} -
      - ${self.context_menu_items()} -
    + % endif + -
    + +${parent.body()} diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 9ba6a0bb..744f58f3 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -8,8 +8,19 @@

    Receiving Tools

    + % if use_buefy: + + + + + % else: ${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} + % endif
    diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 96fe2128..c75c9fa3 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -637,6 +637,11 @@ class PurchasingBatchView(BatchMasterView): g.set_label('catalog_unit_cost', "Catalog Cost") g.filters['catalog_unit_cost'].label = "Catalog Unit Cost" + # po_unit_cost + g.set_renderer('po_unit_cost', self.render_row_grid_cost) + g.set_label('po_unit_cost', "PO Cost") + g.filters['po_unit_cost'].label = "PO Unit Cost" + # invoice_unit_cost g.set_renderer('invoice_unit_cost', self.render_row_grid_cost) g.set_label('invoice_unit_cost', "Invoice Cost") diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 1be9df49..a3b17c16 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -144,6 +144,7 @@ class ReceivingBatchView(PurchasingBatchView): 'cases_received', 'units_received', 'catalog_unit_cost', + 'po_unit_cost', 'invoice_unit_cost', 'invoice_total_calculated', 'credits', @@ -152,6 +153,7 @@ class ReceivingBatchView(PurchasingBatchView): ] row_form_fields = [ + 'sequence', 'item_entry', 'upc', 'item_id', @@ -487,6 +489,14 @@ class ReceivingBatchView(PurchasingBatchView): if self.creating: f.remove('invoice_total_calculated') + # hide all invoice fields if batch does not have invoice file + if not self.creating and not self.handler.has_invoice_file(batch): + f.remove('invoice_file', + 'invoice_date', + 'invoice_number', + 'invoice_total', + 'invoice_total_calculated') + # receiving_complete if self.creating: f.remove('receiving_complete') @@ -506,8 +516,12 @@ class ReceivingBatchView(PurchasingBatchView): elif workflow == 'from_po': f.remove('truck_dump_batch_uuid', + 'date_ordered', + 'po_number', 'invoice_file', - 'invoice_parser_key') + 'invoice_parser_key', + 'invoice_date', + 'invoice_number') elif workflow == 'from_po_with_invoice': f.remove('truck_dump_batch_uuid') @@ -736,11 +750,25 @@ class ReceivingBatchView(PurchasingBatchView): def configure_row_grid(self, g): super(ReceivingBatchView, self).configure_row_grid(g) + batch = self.get_instance() # vendor_code g.filters['vendor_code'].default_active = True g.filters['vendor_code'].default_verb = 'contains' + # catalog_unit_cost + if (self.handler.has_purchase_order(batch) + or self.handler.has_invoice_file(batch)): + g.remove('catalog_unit_cost') + + # po_unit_cost + if self.handler.has_invoice_file(batch): + g.remove('po_unit_cost') + + # invoice_unit_cost + if not self.handler.has_invoice_file(batch): + g.remove('invoice_unit_cost') + # credits # note that sorting by credits involves a subquery with group by clause. # seems likely there may be a better way? but this seems to work fine @@ -753,7 +781,6 @@ class ReceivingBatchView(PurchasingBatchView): # hide 'ordered' columns for truck dump parent, if its "children first" # flag is set, since that batch type is only concerned with receiving - batch = self.get_instance() if batch.is_truck_dump_parent() and not batch.truck_dump_children_first: g.remove('cases_ordered', 'units_ordered') @@ -788,6 +815,11 @@ class ReceivingBatchView(PurchasingBatchView): return css_class + def get_row_instance_title(self, row): + if row.upc: + return row.upc.pretty() + return super(ReceivingBatchView, self).get_row_instance_title(row) + def transform_unit_url(self, row, i): # grid action is shown only when we return a URL here if self.row_editable(row): @@ -795,6 +827,18 @@ class ReceivingBatchView(PurchasingBatchView): if row.product and row.product.is_pack_item(): return self.get_row_action_url('transform_unit', row) + def vuejs_convert_quantity(self, cstruct): + result = dict(cstruct) + if result['cases'] is colander.null: + result['cases'] = None + elif isinstance(result['cases'], decimal.Decimal): + result['cases'] = float(result['cases']) + if result['units'] is colander.null: + result['units'] = None + elif isinstance(result['units'], decimal.Decimal): + result['units'] = float(result['units']) + return result + def receive_row(self, **kwargs): """ Primary desktop view for row-level receiving. @@ -830,14 +874,24 @@ class ReceivingBatchView(PurchasingBatchView): schema = ReceiveRowForm().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) form.cancel_url = self.get_row_action_url('view', row) + + # mode mode_values = [(mode, mode) for mode in possible_modes] if use_buefy: - form.set_widget('mode', dfwidget.SelectWidget(values=mode_values)) + mode_widget = dfwidget.SelectWidget(values=mode_values) else: - form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=mode_values)) + mode_widget = forms.widgets.JQuerySelectWidget(values=mode_values) + form.set_widget('mode', mode_widget) + + # quantity form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True, one_amount_only=True)) + form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity) + + # expiration_date form.set_type('expiration_date', 'date_jquery') + + # TODO: what is this one about again? form.remove_field('quick_receive') if form.validate(newstyle=True): @@ -946,6 +1000,7 @@ class ReceivingBatchView(PurchasingBatchView): View for declaring a credit, i.e. converting some "received" or similar quantity, to a credit of some sort. """ + use_buefy = self.get_use_buefy() row = self.get_row_instance() batch = row.batch possible_credit_types = [ @@ -965,11 +1020,23 @@ class ReceivingBatchView(PurchasingBatchView): } schema = DeclareCreditForm() - form = forms.Form(schema=schema, request=self.request) - form.set_widget('credit_type', forms.widgets.JQuerySelectWidget( - values=[(m, m) for m in possible_credit_types])) + form = forms.Form(schema=schema, request=self.request, + use_buefy=use_buefy) + + # credit_type + values = [(m, m) for m in possible_credit_types] + if use_buefy: + widget = dfwidget.SelectWidget(values=values) + else: + widget = forms.widgets.JQuerySelectWidget(values=values) + form.set_widget('credit_type', widget) + + # quantity form.set_widget('quantity', forms.widgets.CasesUnitsWidget( amount_required=True, one_amount_only=True)) + form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity) + + # expiration_date form.set_type('expiration_date', 'date_jquery') if form.validate(newstyle=True): @@ -1554,6 +1621,15 @@ class ReceiveRowForm(colander.MappingSchema): quick_receive = colander.SchemaNode(colander.Boolean()) + def deserialize(self, *args): + result = super(ReceiveRowForm, self).deserialize(*args) + + if result['mode'] == 'expired' and not result['expiration_date']: + msg = "Expiration date is required for items with 'expired' mode." + self.raise_invalid(msg, node=self.get('expiration_date')) + + return result + class DeclareCreditForm(colander.MappingSchema): From be92075abbd108c5f1c1fd11be20c38ef77b51a6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 8 Dec 2021 20:26:31 -0600 Subject: [PATCH 0016/1164] Allow "auto-receive all items" batch feature in production but require a dedicated permission --- tailbone/templates/receiving/view.mako | 43 ++++++++++++-------------- tailbone/views/purchasing/receiving.py | 31 ++++++++++++++++--- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 32c327fe..df42de47 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -285,31 +285,26 @@ <%def name="object_helpers()"> ${parent.object_helpers()} - ## TODO: for now this is a truck-dump-only feature? maybe should change that - % if not request.rattail_config.production() and master.handler.allow_truck_dump_receiving(): - % if not batch.executed and not batch.complete and request.has_perm('admin'): - % if (batch.is_truck_dump_parent() and batch.truck_dump_children_first) or not batch.is_truck_dump_related(): -
    -

    Development Tools

    -
    - % if use_buefy: - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), ref='auto_receive_all_form')} - ${h.csrf_token(request)} - - - ${h.end_form()} - % else: - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} - ${h.csrf_token(request)} - ${h.submit('submit', "Auto-Receive All Items")} - ${h.end_form()} - % endif -
    -
    + % if master.has_perm('auto_receive') and master.can_auto_receive(batch): +
    +

    Tools

    +
    + % if use_buefy: + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), ref='auto_receive_all_form')} + ${h.csrf_token(request)} + + + ${h.end_form()} + % else: + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} + ${h.csrf_token(request)} + ${h.submit('submit', "Auto-Receive All Items")} + ${h.end_form()} % endif - % endif +
    +
    % endif diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index a3b17c16..f0fc3e12 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1469,6 +1469,24 @@ class ReceivingBatchView(PurchasingBatchView): if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): return pod.get_image_url(self.rattail_config, row.upc) + def can_auto_receive(self, batch): + if batch.executed: + return False + if batch.complete: + return False + + if batch.is_truck_dump_related(): + if not batch.is_truck_dump_parent(): + return False + if not batch.truck_dump_children_first(): + return False + + # only auto-receive once per batch + if batch.get_param('auto_received'): + return False + + return True + def auto_receive(self): """ View which can "auto-receive" all items in the batch. Meant only as a @@ -1535,6 +1553,7 @@ class ReceivingBatchView(PurchasingBatchView): url_prefix = cls.get_url_prefix() instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() + model_title = cls.get_model_title() permission_prefix = cls.get_permission_prefix() # new receiving batch using workflow X @@ -1569,11 +1588,13 @@ class ReceivingBatchView(PurchasingBatchView): permission='{}.edit_row'.format(permission_prefix), renderer='json') # auto-receive all items - if not rattail_config.production(): - config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix), - request_method='POST') - config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), - permission='admin') + config.add_tailbone_permission(permission_prefix, + '{}.auto_receive'.format(permission_prefix), + "Auto-receive all items for a {}".format(model_title)) + config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), + permission='{}.auto_receive'.format(permission_prefix)) @colander.deferred From e906c01e6477876651f7626081e57eda697dd3a5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 8 Dec 2021 21:59:41 -0600 Subject: [PATCH 0017/1164] Make "view row" prettier for receiving batch, for buefy themes this seems like a good direction; should make "receive product" and "declare item" use b-modal on same page probably --- tailbone/templates/receiving/view.mako | 61 +++++++++-- tailbone/templates/receiving/view_row.mako | 119 +++++++++++++++++++-- tailbone/views/purchasing/batch.py | 24 +++++ tailbone/views/purchasing/receiving.py | 22 ++++ 4 files changed, 212 insertions(+), 14 deletions(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index df42de47..0fe3636a 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -286,17 +286,15 @@ <%def name="object_helpers()"> ${parent.object_helpers()} % if master.has_perm('auto_receive') and master.can_auto_receive(batch): +

    Tools

    % if use_buefy: - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), ref='auto_receive_all_form')} - ${h.csrf_token(request)} - - - ${h.end_form()} + + Auto-Receive All Items + % else: ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} ${h.csrf_token(request)} @@ -305,9 +303,58 @@ % endif
    + + % if use_buefy: + + + + % endif % endif +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + ${parent.body()} diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 744f58f3..53f426df 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -1,14 +1,77 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + <%def name="object_helpers()"> ${parent.object_helpers()} - % if not batch.executed and not batch.is_truck_dump_child(): + % if not use_buefy and master.row_editable(row) and not batch.is_truck_dump_child():

    Receiving Tools

    - % if use_buefy: + ${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} + ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} +
    +
    +
    + % endif + + +<%def name="page_content()"> + % if use_buefy: + + + ${form.render_field_readonly('sequence')} + ${form.render_field_readonly('status_code')} + + +
    + + + + + +
    + +
    + + + + + +
    + +
    + + + % else: + ## legacy / not buefy + ${parent.page_content()} % endif + ${parent.body()} diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index c75c9fa3..96e7fda9 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -129,14 +129,19 @@ class PurchasingBatchView(BatchMasterView): 'description', 'size', 'case_quantity', + 'ordered', 'cases_ordered', 'units_ordered', + 'received', 'cases_received', 'units_received', + 'damaged', 'cases_damaged', 'units_damaged', + 'expired', 'cases_expired', 'units_expired', + 'mispick', 'cases_mispick', 'units_mispick', 'po_line_number', @@ -699,6 +704,13 @@ class PurchasingBatchView(BatchMasterView): f.set_readonly('case_quantity') # quantity fields + f.set_renderer('ordered', self.render_row_quantity) + f.set_renderer('shipped', self.render_row_quantity) + f.set_renderer('received', self.render_row_quantity) + f.set_renderer('damaged', self.render_row_quantity) + f.set_renderer('expired', self.render_row_quantity) + f.set_renderer('mispick', self.render_row_quantity) + f.set_type('case_quantity', 'quantity') f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') @@ -770,6 +782,18 @@ class PurchasingBatchView(BatchMasterView): else: f.remove_field('product') + def render_row_quantity(self, row, field): + app = self.get_rattail_app() + cases = getattr(row, 'cases_{}'.format(field)) + units = getattr(row, 'units_{}'.format(field)) + if cases and units: + return "{} cases + {} units".format(app.render_quantity(cases), + app.render_quantity(units)) + if cases and not units: + return "{} cases".format(app.render_quantity(cases)) + if units and not cases: + return "{} units".format(app.render_quantity(units)) + def render_row_credits(self, row, field): if not row.credits: return "" diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index f0fc3e12..48b5fc00 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -163,18 +163,25 @@ class ReceivingBatchView(PurchasingBatchView): 'description', 'size', 'case_quantity', + 'ordered', 'cases_ordered', 'units_ordered', + 'shipped', 'cases_shipped', 'units_shipped', + 'received', 'cases_received', 'units_received', + 'damaged', 'cases_damaged', 'units_damaged', + 'expired', 'cases_expired', 'units_expired', + 'mispick', 'cases_mispick', 'units_mispick', + 'catalog_unit_cost', 'po_line_number', 'po_unit_cost', 'po_total', @@ -607,6 +614,19 @@ class ReceivingBatchView(PurchasingBatchView): raise NotImplementedError return kwargs + def template_kwargs_view_row(self, **kwargs): + kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs) + app = self.get_rattail_app() + handler = app.get_products_handler() + row = kwargs['instance'] + + if row.product: + kwargs['image_url'] = handler.get_image_url(row.product) + elif row.upc: + kwargs['image_url'] = handler.get_image_url(upc=row.upc) + + return kwargs + def department_for_purchase(self, purchase): pass @@ -816,6 +836,8 @@ class ReceivingBatchView(PurchasingBatchView): return css_class def get_row_instance_title(self, row): + if row.product: + return six.text_type(row.product) if row.upc: return row.upc.pretty() return super(ReceivingBatchView, self).get_row_instance_title(row) From 9d02180c92a804f5032c0c1b07889955312a32a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 8 Dec 2021 22:31:54 -0600 Subject: [PATCH 0018/1164] Add buttons to edit, confirm cost for receiving batch row view not yet fully implemented --- tailbone/templates/receiving/view_row.mako | 38 +++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 53f426df..d1c35c5b 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -46,8 +46,8 @@ ${form.render_field_readonly('item_entry')} % endif ${form.render_field_readonly('upc')} - ${form.render_field_readonly('vendor_code')} ${form.render_field_readonly('product')} + ${form.render_field_readonly('vendor_code')} ${form.render_field_readonly('case_quantity')} ${form.render_field_readonly('catalog_unit_cost')}
    @@ -74,10 +74,12 @@
    @@ -107,7 +109,26 @@
    ${form.render_field_readonly('invoice_line_number')} ${form.render_field_readonly('invoice_unit_cost')} + % if master.has_perm('edit_row'): +
    + + +
    + % endif ${form.render_field_readonly('invoice_cost_confirmed')} +
    + + Confirm Unit Cost + +
    ${form.render_field_readonly('invoice_total')} ${form.render_field_readonly('invoice_total_calculated')}
    @@ -131,5 +152,20 @@ % endif +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + ${parent.body()} From f549858c5d060bfc61e3e58a9c2f97799a46ca00 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Dec 2021 12:13:59 -0600 Subject: [PATCH 0019/1164] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cfdaa909..185d0763 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.184 (2021-12-09) +-------------------- + +* Refactor "receive row" and "declare credit" tools per buefy theme. + +* Allow "auto-receive all items" batch feature in production. + +* Make "view row" prettier for receiving batch, for buefy themes. + +* Add buttons to edit, confirm cost for receiving batch row view. + + 0.8.183 (2021-12-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fc96b463..3066e92b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.183' +__version__ = '0.8.184' From a2032a7be22f311ac936e24e2910f993c322d7b7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 10 Dec 2021 16:33:53 -0600 Subject: [PATCH 0020/1164] Allow for null price when showing price history --- tailbone/views/products.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 40414cf8..97f0b631 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1093,6 +1093,7 @@ class ProductView(MasterView): """ AJAX view for fetching various types of price history for a product. """ + app = self.get_rattail_app() product = self.get_instance() typ = self.request.params.get('type', 'regular') @@ -1106,8 +1107,9 @@ class ProductView(MasterView): for history in data: history = dict(history) price = history['price'] - history['price'] = float(price) - history['price_display'] = "${:0.2f}".format(price) + if price is not None: + history['price'] = float(price) + history['price_display'] = app.render_currency(price) changed = localtime(self.rattail_config, history['changed'], from_utc=True) history['changed'] = six.text_type(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) From 2f676774e9982c3dee8671cf9d6210332e71feb5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 11 Dec 2021 15:40:46 -0600 Subject: [PATCH 0021/1164] Bugfix --- tailbone/views/importing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index ecb2ea02..b63e4d43 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -478,7 +478,7 @@ And here is the output: if runas: if typ == 'true': - cmd.apend('--runas={}'.format(runas)) + cmd.append('--runas={}'.format(runas)) else: cmd.extend(['--runas', runas]) From 340a177a29bc3d359b975a7362a749aaf687f13e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 13 Dec 2021 17:53:14 -0600 Subject: [PATCH 0022/1164] Overhaul desktop views for receiving, for efficiency still could use even more i'm sure, but this takes advantage of buefy to add dialogs etc. from the "view receiving batch row" page. this batch no longer allows direct edit of rows but that's hopefully for the better. --- tailbone/forms/core.py | 5 +- tailbone/templates/appsettings.mako | 6 +- tailbone/templates/formposter.mako | 12 +- tailbone/templates/grids/buefy.mako | 2 +- tailbone/templates/master/edit_row.mako | 4 +- tailbone/templates/master/view_row.mako | 2 +- tailbone/templates/page.mako | 1 + tailbone/templates/receiving/view.mako | 24 +- tailbone/templates/receiving/view_row.mako | 652 +++++++++++++++++--- tailbone/templates/themes/falafel/base.mako | 3 +- tailbone/views/batch/core.py | 25 +- tailbone/views/master.py | 12 +- tailbone/views/purchasing/batch.py | 83 ++- tailbone/views/purchasing/receiving.py | 340 ++++++++-- 14 files changed, 1014 insertions(+), 157 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 949222bc..f194e53e 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -848,7 +848,10 @@ class Form(object): return '' # TODO: fair bit of duplication here, should merge with deform.mako - label = HTML.tag('label', self.get_label(field_name), for_=field_name) + label = kwargs.get('label') + if not label: + label = self.get_label(field_name) + label = HTML.tag('label', label, for_=field_name) field = self.render_field_value(field_name) or '' field_div = HTML.tag('div', class_='field', c=[field]) contents = [label, field_div] diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index 79b2d952..dbe747bf 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -145,14 +145,14 @@
    + + {{ formButtonText }} - -
    diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index 47c6ffd3..6fc6eadc 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -6,7 +6,7 @@ let FormPosterMixin = { methods: { - submitForm(action, params, success) { + submitForm(action, params, success, failure) { let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} @@ -21,18 +21,24 @@ } else { this.$buefy.toast.open({ - message: "Failed to send feedback: " + response.data.error, + message: "Submit failed: " + response.data.error, type: 'is-danger', duration: 4000, // 4 seconds }) + if (failure) { + failure(response) + } } }, response => { this.$buefy.toast.open({ - message: "Failed to submit form! (unknown server error)", + message: "Submit failed! (unknown server error)", type: 'is-danger', duration: 4000, // 4 seconds }) + if (failure) { + failure(response) + } }) }, }, diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 08cb2969..70ce04f3 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -203,7 +203,7 @@ % for action in grid.main_actions + grid.more_actions: <%def name="context_menu_items()"> -
  • ${h.link_to("Back to {}".format(model_title), index_url)}
  • +
  • ${h.link_to("Back to {}".format(parent_model_title), parent_url)}
  • % if master.rows_viewable and request.has_perm('{}.view'.format(row_permission_prefix)):
  • ${h.link_to("View this {}".format(row_model_title), row_action_url('view', instance))}
  • % endif diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index 66756c3e..29a77497 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -12,7 +12,7 @@ % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
  • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
  • % endif - % if master.rows_deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)): + % if instance_deletable and master.has_perm('delete_row'):
  • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}
  • % endif % if rows_creatable and request.has_perm('{}.create'.format(permission_prefix)): diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 2d8227d4..321e60d7 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -32,6 +32,7 @@ let ThisPage = { template: '#this-page-template', + mixins: [FormPosterMixin], computed: {}, methods: {}, } diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 0fe3636a..01b93724 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -284,7 +284,19 @@ <%def name="object_helpers()"> - ${parent.object_helpers()} + ${self.render_status_breakdown()} + + % if use_buefy and master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch): +
    +

    PO vs. Invoice

    +
    + ${po_vs_invoice_breakdown_grid.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', empty_labels=True)|n} +
    +
    + % endif + + ${self.render_execute_helper()} + % if master.has_perm('auto_receive') and master.can_auto_receive(batch):
    @@ -292,7 +304,9 @@
    % if use_buefy: + @click="autoReceiveShowDialog = true" + icon-pack="fas" + icon-left="check"> Auto-Receive All Items % else: @@ -334,7 +348,7 @@ :disabled="autoReceiveSubmitting" @click="autoReceiveSubmitting = true" icon-pack="fas" - icon-left="arrow-circle-right"> + icon-left="check"> {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} ${h.end_form()} @@ -352,6 +366,10 @@ ThisPageData.autoReceiveShowDialog = false ThisPageData.autoReceiveSubmitting = false + % if po_vs_invoice_breakdown_grid is not Undefined: + ThisPageData.poVsInvoiceBreakdownData = ${json.dumps(po_vs_invoice_breakdown_grid.get_buefy_data()['data'])|n} + % endif + diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index d1c35c5b..bee71475 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -5,9 +5,37 @@ ${parent.extra_styles()} @@ -30,9 +58,20 @@ <%def name="page_content()"> % if use_buefy: - - ${form.render_field_readonly('sequence')} - ${form.render_field_readonly('status_code')} + + + + {{ rowData.sequence }} + + + + {{ rowData.status }} + + + + {{ rowData.invoice_total_calculated }} + +
    @@ -42,18 +81,23 @@
    - % if not row.product: + % if row.product: + ${form.render_field_readonly('upc')} + ${form.render_field_readonly('product')} + % else: ${form.render_field_readonly('item_entry')} + ${form.render_field_readonly('upc')} + ${form.render_field_readonly('brand_name')} + ${form.render_field_readonly('description')} + ${form.render_field_readonly('size')} % endif - ${form.render_field_readonly('upc')} - ${form.render_field_readonly('product')} ${form.render_field_readonly('vendor_code')} ${form.render_field_readonly('case_quantity')} ${form.render_field_readonly('catalog_unit_cost')}
    % if image_url:
    - ${h.image(image_url, "Product Image")} + ${h.image(image_url, "Product Image", width=150, height=150)}
    % endif
    @@ -64,88 +108,351 @@

    Quantities

    - ${form.render_field_readonly('ordered')} - ${form.render_field_readonly('shipped')} - ${form.render_field_readonly('received')} - ${form.render_field_readonly('damaged')} - ${form.render_field_readonly('expired')} - ${form.render_field_readonly('mispick')} +
    + + + {{ rowData.ordered }} + + +
    + + + {{ rowData.shipped }} + + +
    + + + {{ rowData.received }} + + + + {{ rowData.damaged }} + + + + {{ rowData.expired }} + + + + {{ rowData.mispick }} + + + + {{ rowData.missing }} + -
    - - - -
    -
    -
    - - -
    - -
    - - - -
    + + + + + + + + + + + + +
    + + % if master.batch_handler.has_purchase_order(batch): + + % endif + + % if master.batch_handler.has_invoice_file(batch): + + % endif + +
    + % else: ## legacy / not buefy ${parent.page_content()} @@ -164,6 +471,211 @@ alert("TODO: not yet implemented") } + ThisPageData.rowData = ${json.dumps(row_context)|n} + ThisPageData.possibleReceivingModes = ${json.dumps(possible_receiving_modes)|n} + ThisPageData.possibleCreditTypes = ${json.dumps(possible_credit_types)|n} + + ThisPageData.accountForProductShowDialog = false + ThisPageData.accountForProductMode = null + ThisPageData.accountForProductQuantity = null + ThisPageData.accountForProductUOM = 'units' + ThisPageData.accountForProductExpiration = null + ThisPageData.accountForProductSubmitting = false + + ThisPage.computed.accountForProductTotalUnits = function() { + return this.renderQuantity(this.accountForProductQuantity, + this.accountForProductUOM) + } + + ThisPage.computed.accountForProductSubmitDisabled = function() { + if (!this.accountForProductMode) { + return true + } + if (this.accountForProductMode == 'expired' && !this.accountForProductExpiration) { + return true + } + if (!this.accountForProductQuantity) { + return true + } + if (this.accountForProductSubmitting) { + return true + } + return false + } + + ThisPage.methods.accountForProductInit = function() { + this.accountForProductMode = 'received' + this.accountForProductExpiration = null + this.accountForProductQuantity = null + this.accountForProductUOM = 'units' + this.accountForProductShowDialog = true + } + + ThisPage.methods.accountForProductUOMClicked = function(uom) { + + // TODO: this does not seem to work as expected..even though + // the code appears to be correct + this.$nextTick(() => { + this.$refs.accountForProductQuantityInput.focus() + }) + } + + ThisPage.methods.accountForProductSubmit = function() { + + let qty = parseFloat(this.accountForProductQuantity) + if (qty == NaN || !qty) { + this.$buefy.toast.open({ + message: "You must enter a quantity.", + type: 'is-warning', + duration: 4000, // 4 seconds + }) + return + } + + if (this.accountForProductMode != 'received' && qty < 0) { + this.$buefy.toast.open({ + message: "Negative amounts are only allowed for the \"received\" state.", + type: 'is-warning', + duration: 4000, // 4 seconds + }) + return + } + + this.accountForProductSubmitting = true + let url = '${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}' + let params = { + mode: this.accountForProductMode, + quantity: {cases: null, units: null}, + expiration_date: this.accountForProductExpiration, + } + + if (this.accountForProductUOM == 'cases') { + params.quantity.cases = this.accountForProductQuantity + } else { + params.quantity.units = this.accountForProductQuantity + } + + this.submitForm(url, params, response => { + this.rowData = response.data.row + this.accountForProductSubmitting = false + this.accountForProductShowDialog = false + }, response => { + this.accountForProductSubmitting = false + }) + } + + ThisPageData.declareCreditShowDialog = false + ThisPageData.declareCreditType = null + ThisPageData.declareCreditExpiration = null + ThisPageData.declareCreditQuantity = null + ThisPageData.declareCreditUOM = 'units' + ThisPageData.declareCreditSubmitting = false + + ThisPage.methods.renderQuantity = function(qty, uom) { + qty = parseFloat(qty) + if (qty == NaN) { + return "n/a" + } + if (uom == 'cases') { + qty *= this.rowData.case_quantity + } + if (qty == NaN) { + return "n/a" + } + if (qty == 1) { + return "1 unit" + } + if (qty == -1) { + return "-1 unit" + } + if (Math.round(qty) == qty) { + return qty.toString() + " units" + } + return qty.toFixed(4) + " units" + } + + ThisPage.computed.declareCreditTotalUnits = function() { + return this.renderQuantity(this.declareCreditQuantity, + this.declareCreditUOM) + } + + ThisPage.computed.declareCreditSubmitDisabled = function() { + if (!this.declareCreditType) { + return true + } + if (this.declareCreditType == 'expired' && !this.declareCreditExpiration) { + return true + } + if (!this.declareCreditQuantity) { + return true + } + if (this.declareCreditSubmitting) { + return true + } + return false + } + + ThisPage.methods.declareCreditInit = function() { + this.declareCreditType = null + this.declareCreditExpiration = null + if (this.rowData.cases_received) { + this.declareCreditQuantity = this.rowData.cases_received + this.declareCreditUOM = 'cases' + } else { + this.declareCreditQuantity = this.rowData.units_received + this.declareCreditUOM = 'units' + } + this.declareCreditShowDialog = true + } + + ThisPage.methods.declareCreditSubmit = function() { + this.declareCreditSubmitting = true + let url = '${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}' + let params = { + credit_type: this.declareCreditType, + cases: null, + units: null, + expiration_date: this.declareCreditExpiration, + } + + if (this.declareCreditUOM == 'cases') { + params.cases = this.declareCreditQuantity + } else { + params.units = this.declareCreditQuantity + } + + this.submitForm(url, params, response => { + this.rowData = response.data.row + this.declareCreditSubmitting = false + this.declareCreditShowDialog = false + }, response => { + this.declareCreditSubmitting = false + }) + } + + ThisPageData.removeCreditShowDialog = false + ThisPageData.removeCreditRow = {} + ThisPageData.removeCreditSubmitting = false + + ThisPage.methods.removeCreditInit = function(row) { + this.removeCreditRow = row + this.removeCreditShowDialog = true + } + + ThisPage.methods.removeCreditSubmit = function() { + this.removeCreditSubmitting = true + let url = '${url('{}.undeclare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}' + let params = { + uuid: this.removeCreditRow.uuid, + } + + this.submitForm(url, params, response => { + this.rowData = response.data.row + this.removeCreditSubmitting = false + this.removeCreditShowDialog = false + }) + } + diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index bf8f5ee7..2c2dd2ce 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -31,6 +31,8 @@ + ${declare_formposter_mixin()} + ${self.body()}
    @@ -517,7 +519,6 @@ <%def name="declare_whole_page_vars()"> - ${declare_formposter_mixin()} ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + + + +${parent.body()} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 97f0b631..e6d2b7d4 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -85,6 +85,7 @@ class ProductView(MasterView): has_versions = True results_downloadable_xlsx = True supports_autocomplete = True + configurable = True labels = { 'item_id': "Item ID", @@ -1906,6 +1907,22 @@ class ProductView(MasterView): if batch.batch_key == 'delproduct': return self.request.route_url('batch.delproduct.view', uuid=batch.uuid) + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # key field + {'section': 'rattail', + 'option': 'product.key'}, + {'section': 'rattail', + 'option': 'product.key_title'}, + + # display + {'section': 'tailbone', + 'option': 'products.show_pod_image', + 'type': bool}, + ] + @classmethod def defaults(cls, config): cls._product_defaults(config) From 12446590642c2cc73150ec3b34ab0a7318b771d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 13 Dec 2021 21:33:10 -0600 Subject: [PATCH 0025/1164] Add more basic config views, obviating some App Settings --- tailbone/templates/master/configure.mako | 1 + .../reports/generated/configure.mako | 21 +++++++++++++++++++ .../templates/settings/email/configure.mako | 21 +++++++++++++++++++ tailbone/templates/vendors/configure.mako | 21 +++++++++++++++++++ tailbone/views/email.py | 12 +++++++++++ tailbone/views/master.py | 8 ++++++- tailbone/views/reports.py | 13 ++++++++++++ tailbone/views/vendors/core.py | 11 ++++++++++ 8 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tailbone/templates/reports/generated/configure.mako create mode 100644 tailbone/templates/settings/email/configure.mako create mode 100644 tailbone/templates/vendors/configure.mako diff --git a/tailbone/templates/master/configure.mako b/tailbone/templates/master/configure.mako index 4c007730..bfe0574c 100644 --- a/tailbone/templates/master/configure.mako +++ b/tailbone/templates/master/configure.mako @@ -24,6 +24,7 @@
    • /datasync/configure.mako
    • /importing/configure.mako
    • +
    • /products/configure.mako
    • /receiving/configure.mako
    diff --git a/tailbone/templates/reports/generated/configure.mako b/tailbone/templates/reports/generated/configure.mako new file mode 100644 index 00000000..27e60afa --- /dev/null +++ b/tailbone/templates/reports/generated/configure.mako @@ -0,0 +1,21 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + +

    Generating

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

    Sending

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

    Display

    +
    + + + + Show vendor chooser as autocomplete field + + + +
    + + + +${parent.body()} diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 58a0320b..7b46f490 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -54,6 +54,8 @@ class EmailSettingView(MasterView): pageable = False creatable = False deletable = False + configurable = True + config_title = "Email" grid_columns = [ 'key', @@ -224,6 +226,16 @@ class EmailSettingView(MasterView): kwargs['email'] = self.handler.get_email(key) return kwargs + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # sending + {'section': 'rattail.mail', + 'option': 'record_attempts', + 'type': bool}, + ] + # TODO: deprecate / remove this ProfilesView = EmailSettingView diff --git a/tailbone/views/master.py b/tailbone/views/master.py index dce2d3ef..76f8967b 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -287,6 +287,12 @@ class MasterView(View): return self.request.has_perm('{}.{}'.format( self.get_permission_prefix(), name)) + @classmethod + def get_config_url(cls): + if hasattr(cls, 'config_url'): + return cls.config_url + return '{}/configure'.format(cls.get_url_prefix()) + ############################## # Available Views ############################## @@ -4265,7 +4271,7 @@ class MasterView(View): '{}.configure'.format(permission_prefix), label="Configure {}".format(config_title)) config.add_route('{}.configure'.format(route_prefix), - '{}/configure'.format(url_prefix)) + cls.get_config_url()) config.add_view(cls, attr='configure', route_name='{}.configure'.format(route_prefix), permission='{}.configure'.format(permission_prefix)) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 6359c471..21ef3a20 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -213,6 +213,9 @@ class ReportOutputView(ExportMasterView): route_prefix = 'report_output' url_prefix = '/reports/generated' downloadable = True + configurable = True + config_title = "Reporting" + config_url = '/reports/configure' grid_columns = [ 'id', @@ -295,6 +298,16 @@ class ReportOutputView(ExportMasterView): path = report.filepath(self.rattail_config) return self.file_response(path) + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # generating + {'section': 'tailbone', + 'option': 'reporting.choosing_uses_form', + 'type': bool}, + ] + class GenerateReport(View): """ diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index ceac1c71..bf73e1b1 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -43,6 +43,7 @@ class VendorView(MasterView): has_versions = True touchable = True supports_autocomplete = True + configurable = True labels = { 'id': "ID", @@ -168,6 +169,16 @@ class VendorView(MasterView): (model.VendorContact, 'vendor_uuid'), ] + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # display + {'section': 'rattail', + 'option': 'vendor.use_autocomplete', + 'type': bool}, + ] + def includeme(config): VendorView.defaults(config) From 197d3de74a006c9ac2dd67121f4918290b59c5a6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 13 Dec 2021 22:32:10 -0600 Subject: [PATCH 0026/1164] Add "jump to" chooser in App Settings, for various "configure" pages --- tailbone/templates/appsettings.mako | 54 ++++++++++++++++++++++------- tailbone/views/settings.py | 26 ++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index dbe747bf..e3fa2ccf 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -66,17 +66,40 @@
    -
    - - - - - +
    + +
    +
    + + + + + + +
    +
    + +
    +
    + + + + + +
    +
    +
    Date: Tue, 14 Dec 2021 19:08:32 -0600 Subject: [PATCH 0027/1164] Fix params field when deleting a report --- .../templates/reports/generated/delete.mako | 16 ++++++++++ tailbone/views/master.py | 6 ++++ tailbone/views/reports.py | 32 +++++++++++++------ 3 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 tailbone/templates/reports/generated/delete.mako diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako new file mode 100644 index 00000000..0c994ad0 --- /dev/null +++ b/tailbone/templates/reports/generated/delete.mako @@ -0,0 +1,16 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/delete.mako" /> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 76f8967b..eb4ef1a5 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2250,6 +2250,12 @@ class MasterView(View): """ return kwargs + def template_kwargs_delete(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ + return kwargs + def get_db_engines(self): """ Must return a dict (or even better, OrderedDict) which contains all diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 21ef3a20..e2aa3db6 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -278,18 +278,30 @@ class ReportOutputView(ExportMasterView): url = self.get_action_url('download', report) return self.render_file_field(path, url=url) - def template_kwargs_view(self, **kwargs): - use_buefy = self.get_use_buefy() - if use_buefy: + def get_params_context(self, report): + params_data = [] + for name, value in (report.params or {}).items(): + params_data.append({ + 'key': name, + 'value': value, + }) + return params_data + def template_kwargs_view(self, **kwargs): + kwargs = super(ReportOutputView, self).template_kwargs_view(**kwargs) + + if self.get_use_buefy(): report = kwargs['instance'] - params_data = [] - for name, value in (report.params or {}).items(): - params_data.append({ - 'key': name, - 'value': value, - }) - kwargs['params_data'] = params_data + kwargs['params_data'] = self.get_params_context(report) + + return kwargs + + def template_kwargs_delete(self, **kwargs): + kwargs = super(ReportOutputView, self).template_kwargs_delete(**kwargs) + + if self.get_use_buefy(): + report = kwargs['instance'] + kwargs['params_data'] = self.get_params_context(report) return kwargs From ca57bd35721857c6ea487fdd538dfb51db9efde0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Dec 2021 00:00:46 -0600 Subject: [PATCH 0028/1164] Auto-register all config pages, for dropdown in App Settings --- tailbone/app.py | 14 ++++++++++++++ tailbone/templates/configure.mako | 4 ++-- tailbone/views/master.py | 2 ++ tailbone/views/settings.py | 20 +++----------------- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index bbb6d295..80cce0f6 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -156,9 +156,23 @@ def make_pyramid_config(settings, configure_csrf=True): config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + # and some similar magic for config views + config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page') + return config +def add_config_page(config, route_name, label): + """ + Register a config page for the app. + """ + def action(): + pages = config.get_settings().get('tailbone_config_pages', []) + pages.append({'label': label, 'route': route_name}) + config.add_settings({'tailbone_config_pages': pages}) + config.action(None, action) + + def establish_theme(settings): rattail_config = settings['rattail_config'] diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index d80a07c0..93d059dd 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -70,8 +70,8 @@
    +<%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), From 31dff0d35315306123eb19700044e68427fe52e1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 17 Dec 2021 21:36:51 -0600 Subject: [PATCH 0038/1164] Add some standard CRUD buttons for buefy themes finally! also disable the permalink "feature" since it seems not useful --- tailbone/static/themes/falafel/css/layout.css | 7 +- tailbone/templates/master/delete.mako | 2 +- tailbone/templates/master/edit.mako | 2 +- tailbone/templates/master/form.mako | 2 +- tailbone/templates/master/index.mako | 2 +- tailbone/templates/master/view.mako | 10 ++- tailbone/templates/master/view_row.mako | 2 +- tailbone/templates/themes/falafel/base.mako | 89 ++++++++++++++++--- 8 files changed, 89 insertions(+), 27 deletions(-) diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css index 20fcf36e..3a292cac 100644 --- a/tailbone/static/themes/falafel/css/layout.css +++ b/tailbone/static/themes/falafel/css/layout.css @@ -51,14 +51,9 @@ header .navbar-item.nested { padding-left: 2.5rem; } -header .level #current-context, -header .level-left #current-context { +header span.header-text { font-size: 2em; font-weight: bold; -} - -header .level #current-context span, -header .level-left #current-context span { margin-right: 10px; } diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 444c4e1d..62f28241 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -11,7 +11,7 @@ % if master.editable and request.has_perm('{}.edit'.format(permission_prefix)):
  • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
  • % endif - % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): + % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): % if master.creates_multiple:
  • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
  • % else: diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index febd0bcd..1aae24b4 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -27,7 +27,7 @@
  • ${h.link_to("View this {}".format(model_title), action_url('view', instance))}
  • % endif ${self.context_menu_item_delete()} - % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): + % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): % if master.creates_multiple:
  • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
  • % else: diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index 6f67f77e..a37e3f91 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -2,7 +2,7 @@ <%inherit file="/form.mako" /> <%def name="context_menu_item_delete()"> - % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)): + % if not use_buefy and master.deletable and instance_deletable and master.has_perm('delete'): % if master.delete_confirm == 'simple':
  • ## note, the `ref` here is for buefy only diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index de58af83..ca0615ce 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -155,7 +155,7 @@ % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)):
  • ${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}
  • % endif - % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): + % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): % if master.creates_multiple:
  • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
  • % else: diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 94454bd9..37d60c39 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -48,22 +48,24 @@ <%def name="context_menu_items()"> -
  • ${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}
  • + ## TODO: either make this configurable, or just lose it. + ## nobody seems to ever find it useful in practice. + ##
  • ${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}
  • % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)):
  • ${h.link_to("Version History", action_url('versions', instance))}
  • % endif - % if master.editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): + % if not use_buefy and master.editable and instance_editable and master.has_perm('edit'):
  • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
  • % endif ${self.context_menu_item_delete()} - % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): + % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): % if master.creates_multiple:
  • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
  • % else:
  • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
  • % endif % endif - % if master.cloneable and request.has_perm('{}.clone'.format(permission_prefix)): + % if not use_buefy and master.cloneable and master.has_perm('clone'):
  • ${h.link_to("Clone this as new {}".format(model_title), url('{}.clone'.format(route_prefix), uuid=instance.uuid))}
  • % endif % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)): diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index 29a77497..255caf69 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -12,7 +12,7 @@ % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
  • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
  • % endif - % if instance_deletable and master.has_perm('delete_row'): + % if not use_buefy and instance_deletable and master.has_perm('delete_row'):
  • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}
  • % endif % if rows_creatable and request.has_perm('{}.create'.format(permission_prefix)): diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 78fcd4d7..7b168a5d 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -274,24 +274,48 @@
    % if master: % if master.listing: - ${index_title} + + ${index_title} + + % if use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): + + + % endif % elif index_url: - ${h.link_to(index_title, index_url)} + + ${h.link_to(index_title, index_url)} + % if parent_url is not Undefined: -  » - ${h.link_to(parent_title, parent_url)} + +  » + + + ${h.link_to(parent_title, parent_url)} + % elif instance_url is not Undefined: -  » - ${h.link_to(instance_title, instance_url)} + +  » + + + ${h.link_to(instance_title, instance_url)} + % endif % if master.viewing and grid_index: ${grid_index_nav()} % endif % else: - ${index_title} + + ${index_title} + % endif % elif index_title: - ${index_title} + + ${index_title} + % endif
    @@ -384,8 +408,49 @@

    - % if show_prev_next is not Undefined and show_prev_next: -
    +
    + % if use_buefy and master and master.viewing: + ## TODO: is there a better way to check if viewing parent? + % if parent_instance is Undefined: + % if master.editable and instance_editable and master.has_perm('edit'): +
    + + +
    + % endif + % if master.cloneable and master.has_perm('clone'): +
    + + +
    + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): +
    + + +
    + % endif + % else: + ## viewing row + % if instance_deletable and master.has_perm('delete_row'): +
    + + +
    + % endif + % endif + % endif + % if show_prev_next is not Undefined and show_prev_next: % if prev_url:
    ${h.link_to(u"« Older", prev_url, class_='button autodisable')} @@ -404,8 +469,8 @@ ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')}
    % endif -
    - % endif + % endif +
    % endif From e97b8a9f7ed92463ff7d42cd8bacc8aea1f66f5b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Dec 2021 14:27:47 -0600 Subject: [PATCH 0039/1164] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a0877466..d6da0447 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.187 (2021-12-20) +-------------------- + +* Add common configuration logic for "input file templates". + +* Add some standard CRUD buttons for buefy themes. + + 0.8.186 (2021-12-17) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bb8ce909..c1c66bcb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.186' +__version__ = '0.8.187' From a6f608e8ccf9abaf332ce78f152f4b26c037d491 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Dec 2021 14:56:25 -0600 Subject: [PATCH 0040/1164] Flag discontinued items for main Products grid no styling is applied but custom app can do so --- tailbone/views/products.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index e6d2b7d4..7945b5db 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -664,6 +664,8 @@ class ProductView(MasterView): classes = [] if product.not_for_sale: classes.append('not-for-sale') + if product.discontinued: + classes.append('discontinued') if product.deleted: classes.append('deleted') if classes: From 408bffb7754e324b21efdfda4805b60a68b5046f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Dec 2021 14:58:01 -0600 Subject: [PATCH 0041/1164] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index d6da0447..787de2e6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.188 (2021-12-20) +-------------------- + +* Flag discontinued items for main Products grid. + + 0.8.187 (2021-12-20) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c1c66bcb..3aae353e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.187' +__version__ = '0.8.188' From c0db03bc28618816c27e0fb66ebed54e55650db2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 22 Dec 2021 12:06:00 -0600 Subject: [PATCH 0042/1164] Add basic "pending product" support for new custorder batch --- tailbone/templates/custorders/configure.mako | 72 ++++ tailbone/templates/custorders/create.mako | 370 ++++++++++++++++--- tailbone/templates/master/delete.mako | 5 +- tailbone/templates/master/edit.mako | 2 +- tailbone/templates/products/view.mako | 2 +- tailbone/templates/themes/falafel/base.mako | 171 +++++---- tailbone/views/batch/core.py | 10 - tailbone/views/customers.py | 13 +- tailbone/views/custorders/batch.py | 41 +- tailbone/views/custorders/items.py | 4 + tailbone/views/custorders/orders.py | 264 +++++++++---- tailbone/views/master.py | 49 +++ tailbone/views/products.py | 75 +++- 13 files changed, 844 insertions(+), 234 deletions(-) create mode 100644 tailbone/templates/custorders/configure.mako diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako new file mode 100644 index 00000000..e3e47054 --- /dev/null +++ b/tailbone/templates/custorders/configure.mako @@ -0,0 +1,72 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + +

    Customer Handling

    +
    + + + + Require a Customer account + + + + + + Allow user to choose contact info + + + + + + Allow user to enter new contact info + + + +

    + If you allow users to enter new contact info, the default action + when the order is submitted, is to send email with details of + the new contact info.  Settings for these are at: +

    + +
      +
    • + ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} +
    • +
    • + ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} +
    • +
    +
    + +

    Product Handling

    +
    + + + + Allow creating orders for "unknown" products + + + + + + Allow prices to be flagged as "questionable" + + + +
    + + + +${parent.body()} diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 0071b61f..ff41f765 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -12,6 +12,18 @@ % endif +<%def name="render_instance_header_buttons()"> + ${parent.render_instance_header_buttons()} + % if use_buefy and master.configurable and master.has_perm('configure'): +
    + + +
    + % endif + + <%def name="page_content()">
    % if use_buefy: @@ -155,11 +167,11 @@
    -

    +

    {{ orderPhoneNumber }}

    + class="is-size-7 is-italic has-text-success"> will be added to customer record

    @@ -170,7 +182,7 @@
    % if allow_contact_info_choice:
    @@ -203,7 +215,7 @@ - % if not restrict_contact_info: + % if allow_contact_info_create: @@ -249,11 +261,11 @@
    -

    +

    {{ orderEmailAddress }}

    + class="is-size-7 is-italic has-text-success"> will be added to customer record

    @@ -264,7 +276,7 @@
    % if allow_contact_info_choice:
    @@ -296,7 +308,7 @@ - % if not restrict_contact_info: + % if allow_contact_info_create: @@ -556,7 +568,13 @@ {{ productCaseQuantity }} - + + {{ productUnitRegularPriceDisplay }} + + +
    - Product is not yet in the system.
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    - ##

    {{ productKey }}

    - {{ productDisplay }} + + {{ productIsKnown ? productDisplay : pendingProduct.brand_name + ' ' + pendingProduct.description + ' ' + pendingProduct.size }} + - {{ productSize }} + + {{ productIsKnown ? productSize : pendingProduct.size }} + - - - {{ productUnitPriceDisplay }} + + + {{ productUnitRegularPriceDisplay }} + + + + + + {{ productIsKnown ? productUnitPriceDisplay : '$' + pendingProduct.regular_price_amount }} - + {{ productSalePriceDisplay }} - + {{ productSaleEndsDisplay }} - {{ productCaseQuantity }} + + {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }} + - {{ productCasePriceDisplay }} + % if product_price_may_be_questionable: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}" + % else: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}" + % endif + > + {{ getCasePriceDisplay() }} @@ -671,7 +791,9 @@ - + + @@ -684,6 +806,14 @@ + + + + {{ getItemTotalPriceDisplay() }} + + + +
    @@ -692,9 +822,10 @@ Cancel + icon-left="save"> {{ itemDialogSaveButtonText }}
    @@ -807,60 +938,65 @@ + :data="items" + :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'"> -