From dfa4178204ce410798e10ba44bc8f0f35048e476 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Jan 2023 16:46:21 -0600 Subject: [PATCH] Add basic support for receiving from multiple invoice files --- tailbone/forms/core.py | 53 +++++++++---- tailbone/forms/widgets.py | 75 ++++++++++++++++++- .../templates/deform/multi_file_upload.pt | 7 ++ tailbone/templates/multi_file_upload.mako | 60 +++++++++++++++ tailbone/templates/receiving/configure.mako | 11 ++- tailbone/templates/receiving/view_row.mako | 2 +- tailbone/templates/themes/falafel/base.mako | 4 + tailbone/views/batch/core.py | 25 ++++++- tailbone/views/master.py | 51 ++++++++----- tailbone/views/purchasing/receiving.py | 47 +++++++++++- 10 files changed, 295 insertions(+), 40 deletions(-) create mode 100644 tailbone/templates/deform/multi_file_upload.pt create mode 100644 tailbone/templates/multi_file_upload.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index a791f4cb..99e6ba2d 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -51,7 +51,9 @@ from webhelpers2.html import tags, HTML from tailbone.db import Session from tailbone.util import raw_datetime, get_form_data, render_markdown from . import types -from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget +from .widgets import (ReadonlyWidget, PlainDateWidget, + JQueryDateWidget, JQueryTimeWidget, + MultiFileUploadWidget) from tailbone.exceptions import TailboneJSONFieldError @@ -579,6 +581,10 @@ class Form(object): node = colander.SchemaNode(nodeinfo, **kwargs) self.nodes[key] = node + # must explicitly replace node, if we already have a schema + if self.schema: + self.schema[key] = node + def set_type(self, key, type_, **kwargs): if type_ == 'datetime': self.set_renderer(key, self.render_datetime) @@ -624,9 +630,18 @@ class Form(object): if 'required' in kwargs and not kwargs['required']: kw['missing'] = colander.null self.set_node(key, colander.SchemaNode(deform.FileData(), **kw)) - # must explicitly replace node, if we already have a schema - if self.schema: - self.schema[key] = self.nodes[key] + elif type_ == 'multi_file': + tmpstore = SessionFileUploadTempStore(self.request) + file_node = colander.SchemaNode(deform.FileData(), + name='upload') + + kw = {'name': key, + 'title': self.get_label(key), + 'widget': MultiFileUploadWidget(tmpstore)} + # if 'required' in kwargs and not kwargs['required']: + # kw['missing'] = colander.null + files_node = colander.SequenceSchema(file_node, **kw) + self.set_node(key, files_node) else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) @@ -853,25 +868,31 @@ class Form(object): 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 - if field.cstruct and field.cstruct['filename']: - return json.dumps({'name': field.cstruct['filename']}) - return 'null' - if isinstance(field.schema.typ, colander.Set): if field.cstruct is colander.null: return '[]' - if field.cstruct is colander.null: - return 'null' - try: - return json.dumps(field.cstruct) + return self.jsonify_value(field.cstruct) except Exception as error: raise TailboneJSONFieldError(field.name, error) + def jsonify_value(self, value): + """ + Take a Python value and convert to JSON + """ + if value is colander.null: + return 'null' + + if isinstance(value, dfwidget.filedict): + # TODO: we used to always/only return 'null' here but hopefully + # this also works, to show existing filename when present + if value and value['filename']: + return json.dumps({'name': value['filename']}) + return 'null' + + return json.dumps(value) + def get_error_messages(self, field): if field.error: return field.error.messages() diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index e72ab6b9..02fcdb76 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -289,6 +289,79 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): return field.renderer(template, **tmpl_values) +class MultiFileUploadWidget(dfwidget.FileUploadWidget): + """ + Widget to handle multiple (arbitrary number) of file uploads. + """ + template = 'multi_file_upload' + requirements = () + + def deserialize(self, field, pstruct): + if pstruct is colander.null: + return colander.null + + # TODO: why is this a thing? pstruct == [b''] + if len(pstruct) == 1 and not pstruct[0]: + return colander.null + + files_data = [] + for upload in pstruct: + + data = self.deserialize_upload(upload) + if data: + files_data.append(data) + + if not files_data: + return colander.null + + return files_data + + def deserialize_upload(self, upload): + # nb. this logic was copied from parent class and adapted + # to allow for multiple files. needs some more love. + + uid = None # TODO? + + if hasattr(upload, "file"): + # the upload control had a file selected + data = dfwidget.filedict() + data["fp"] = upload.file + filename = upload.filename + # sanitize IE whole-path filenames + filename = filename[filename.rfind("\\") + 1 :].strip() + data["filename"] = filename + data["mimetype"] = upload.type + data["size"] = upload.length + if uid is None: + # no previous file exists + while 1: + uid = self.random_id() + if self.tmpstore.get(uid) is None: + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + break + else: + # a previous file exists + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + else: + # the upload control had no file selected + if uid is None: + # no previous file exists + return colander.null + else: + # a previous file should exist + data = self.tmpstore.get(uid) + # but if it doesn't, don't blow up + if data is None: + return colander.null + return data + + def make_customer_widget(request, **kwargs): """ Make a customer widget; will be either autocomplete or dropdown diff --git a/tailbone/templates/deform/multi_file_upload.pt b/tailbone/templates/deform/multi_file_upload.pt new file mode 100644 index 00000000..f94e59c8 --- /dev/null +++ b/tailbone/templates/deform/multi_file_upload.pt @@ -0,0 +1,7 @@ + + ${field.start_sequence()} + + + ${field.end_sequence()} + diff --git a/tailbone/templates/multi_file_upload.mako b/tailbone/templates/multi_file_upload.mako new file mode 100644 index 00000000..ea9b5121 --- /dev/null +++ b/tailbone/templates/multi_file_upload.mako @@ -0,0 +1,60 @@ +## -*- coding: utf-8; -*- + +<%def name="render_template()"> + + + +<%def name="declare_vars()"> + + + +<%def name="make_component()"> + + diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 9f4a6c3b..faa13a24 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -24,7 +24,16 @@ v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" native-value="true" @input="settingsNeedSaved = true"> - From Invoice + From Single Invoice + + + + + + From Multiple (Combined) Invoices diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index dca71c35..8c397c4f 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -81,12 +81,12 @@
+ ${form.render_field_readonly('item_entry')} % if row.product: ${form.render_field_readonly(product_key_field)} ${form.render_field_readonly('product')} % else: ${form.render_field_readonly(product_key_field)} - ${form.render_field_readonly('item_entry')} % if product_key_field != 'upc': ${form.render_field_readonly('upc')} % endif diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 654f61df..3cfa00a2 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -4,6 +4,7 @@ <%namespace name="base_meta" file="/base_meta.mako" /> <%namespace file="/formposter.mako" import="declare_formposter_mixin" /> <%namespace name="page_help" file="/page_help.mako" /> +<%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> @@ -577,6 +578,7 @@ ${tailbone_autocomplete_template()} + ${multi_file_upload.render_template()} <%def name="render_this_page_component()"> @@ -764,6 +766,7 @@ <%def name="declare_whole_page_vars()"> ${page_help.declare_vars()} + ${multi_file_upload.declare_vars()} ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}