From 871dd35a3a3f22ac3e3d8bb5c5fc28fd0e998ce7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Dec 2021 17:45:21 -0600 Subject: [PATCH 0001/1155] 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 0002/1155] 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 0003/1155] 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 0004/1155] 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 0005/1155] 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 0006/1155] 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 0007/1155] 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 0008/1155] 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 0009/1155] 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 0010/1155] 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 0011/1155] 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 0012/1155] 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 0013/1155] 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 0016/1155] 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 0017/1155] 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 0018/1155] 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 0019/1155] 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 0029/1155] 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 0030/1155] 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 0031/1155] 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 0032/1155] 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 0033/1155] 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'"> -