Add basic support for receiving from multiple invoice files

This commit is contained in:
Lance Edgar 2023-01-10 16:46:21 -06:00
parent 2b7ebedb22
commit dfa4178204
10 changed files with 295 additions and 40 deletions

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar # Copyright © 2010-2023 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -51,7 +51,9 @@ from webhelpers2.html import tags, HTML
from tailbone.db import Session from tailbone.db import Session
from tailbone.util import raw_datetime, get_form_data, render_markdown from tailbone.util import raw_datetime, get_form_data, render_markdown
from . import types from . import types
from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget from .widgets import (ReadonlyWidget, PlainDateWidget,
JQueryDateWidget, JQueryTimeWidget,
MultiFileUploadWidget)
from tailbone.exceptions import TailboneJSONFieldError from tailbone.exceptions import TailboneJSONFieldError
@ -579,6 +581,10 @@ class Form(object):
node = colander.SchemaNode(nodeinfo, **kwargs) node = colander.SchemaNode(nodeinfo, **kwargs)
self.nodes[key] = node 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): def set_type(self, key, type_, **kwargs):
if type_ == 'datetime': if type_ == 'datetime':
self.set_renderer(key, self.render_datetime) self.set_renderer(key, self.render_datetime)
@ -624,9 +630,18 @@ class Form(object):
if 'required' in kwargs and not kwargs['required']: if 'required' in kwargs and not kwargs['required']:
kw['missing'] = colander.null kw['missing'] = colander.null
self.set_node(key, colander.SchemaNode(deform.FileData(), **kw)) self.set_node(key, colander.SchemaNode(deform.FileData(), **kw))
# must explicitly replace node, if we already have a schema elif type_ == 'multi_file':
if self.schema: tmpstore = SessionFileUploadTempStore(self.request)
self.schema[key] = self.nodes[key] 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: else:
raise ValueError("unknown type for '{}' field: {}".format(key, type_)) raise ValueError("unknown type for '{}' field: {}".format(key, type_))
@ -853,25 +868,31 @@ class Form(object):
value = convert(field.cstruct) value = convert(field.cstruct)
return json.dumps(value) 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 isinstance(field.schema.typ, colander.Set):
if field.cstruct is colander.null: if field.cstruct is colander.null:
return '[]' return '[]'
if field.cstruct is colander.null:
return 'null'
try: try:
return json.dumps(field.cstruct) return self.jsonify_value(field.cstruct)
except Exception as error: except Exception as error:
raise TailboneJSONFieldError(field.name, 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): def get_error_messages(self, field):
if field.error: if field.error:
return field.error.messages() return field.error.messages()

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar # Copyright © 2010-2023 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -289,6 +289,79 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
return field.renderer(template, **tmpl_values) 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): def make_customer_widget(request, **kwargs):
""" """
Make a customer widget; will be either autocomplete or dropdown Make a customer widget; will be either autocomplete or dropdown

View file

@ -0,0 +1,7 @@
<tal:block tal:define="field_name field_name|field.name;
vmodel vmodel|'field_model_' + field_name;">
${field.start_sequence()}
<multi-file-upload v-model="${vmodel}">
</multi-file-upload>
${field.end_sequence()}
</tal:block>

View file

@ -0,0 +1,60 @@
## -*- coding: utf-8; -*-
<%def name="render_template()">
<script type="text/x-template" id="multi-file-upload-template">
<section>
<b-field class="file">
<b-upload name="upload" multiple drag-drop expanded
v-model="files">
<section class="section">
<div class="content has-text-centered">
<p>
<b-icon icon="upload" size="is-large"></b-icon>
</p>
<p>Drop your files here or click to upload</p>
</div>
</section>
</b-upload>
</b-field>
<div class="tags" style="max-width: 40rem;">
<span v-for="(file, index) in files" :key="index" class="tag is-primary">
{{file.name}}
<button class="delete is-small" type="button"
@click="deleteFile(index)">
</button>
</span>
</div>
</section>
</script>
</%def>
<%def name="declare_vars()">
<script type="text/javascript">
let MultiFileUpload = {
template: '#multi-file-upload-template',
methods: {
deleteFile(index) {
this.files.splice(index, 1);
},
},
}
let MultiFileUploadData = {
files: [],
}
</script>
</%def>
<%def name="make_component()">
<script type="text/javascript">
MultiFileUpload.data = function() { return MultiFileUploadData }
Vue.component('multi-file-upload', MultiFileUpload)
</script>
</%def>

View file

@ -24,7 +24,16 @@
v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']"
native-value="true" native-value="true"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true">
From Invoice From Single Invoice
</b-checkbox>
</b-field>
<b-field>
<b-checkbox name="rattail.batch.purchase.allow_receiving_from_multi_invoice"
v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_multi_invoice']"
native-value="true"
@input="settingsNeedSaved = true">
From Multiple (Combined) Invoices
</b-checkbox> </b-checkbox>
</b-field> </b-field>

View file

@ -81,12 +81,12 @@
<div class="panel-block"> <div class="panel-block">
<div style="display: flex;"> <div style="display: flex;">
<div> <div>
${form.render_field_readonly('item_entry')}
% if row.product: % if row.product:
${form.render_field_readonly(product_key_field)} ${form.render_field_readonly(product_key_field)}
${form.render_field_readonly('product')} ${form.render_field_readonly('product')}
% else: % else:
${form.render_field_readonly(product_key_field)} ${form.render_field_readonly(product_key_field)}
${form.render_field_readonly('item_entry')}
% if product_key_field != 'upc': % if product_key_field != 'upc':
${form.render_field_readonly('upc')} ${form.render_field_readonly('upc')}
% endif % endif

View file

@ -4,6 +4,7 @@
<%namespace name="base_meta" file="/base_meta.mako" /> <%namespace name="base_meta" file="/base_meta.mako" />
<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> <%namespace file="/formposter.mako" import="declare_formposter_mixin" />
<%namespace name="page_help" file="/page_help.mako" /> <%namespace name="page_help" file="/page_help.mako" />
<%namespace name="multi_file_upload" file="/multi_file_upload.mako" />
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -577,6 +578,7 @@
</script> </script>
${tailbone_autocomplete_template()} ${tailbone_autocomplete_template()}
${multi_file_upload.render_template()}
</%def> </%def>
<%def name="render_this_page_component()"> <%def name="render_this_page_component()">
@ -764,6 +766,7 @@
<%def name="declare_whole_page_vars()"> <%def name="declare_whole_page_vars()">
${page_help.declare_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__))} ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
<script type="text/javascript"> <script type="text/javascript">
@ -902,6 +905,7 @@
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
${page_help.make_component()} ${page_help.make_component()}
${multi_file_upload.make_component()}
<script type="text/javascript"> <script type="text/javascript">

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar # Copyright © 2010-2023 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -362,6 +362,7 @@ class BatchMasterView(MasterView):
f.remove('params') f.remove('params')
else: else:
f.set_readonly('params') f.set_readonly('params')
f.set_renderer('params', self.render_params)
# created # created
f.set_readonly('created') f.set_readonly('created')
@ -419,6 +420,16 @@ class BatchMasterView(MasterView):
f.remove_fields('executed', f.remove_fields('executed',
'executed_by') 'executed_by')
def render_params(self, batch, field):
params = self.get_visible_params(batch)
if not params:
return
return params
def get_visible_params(self, batch):
return dict(batch.params or {})
def render_complete(self, batch, field): def render_complete(self, batch, field):
permission_prefix = self.get_permission_prefix() permission_prefix = self.get_permission_prefix()
use_buefy = self.get_use_buefy() use_buefy = self.get_use_buefy()
@ -515,11 +526,21 @@ class BatchMasterView(MasterView):
return batch return batch
def process_uploads(self, batch, form, uploads): def process_uploads(self, batch, form, uploads):
for key, upload in six.iteritems(uploads):
def process(upload, key):
self.handler.set_input_file(batch, upload['temp_path'], attr=key) self.handler.set_input_file(batch, upload['temp_path'], attr=key)
os.remove(upload['temp_path']) os.remove(upload['temp_path'])
os.rmdir(upload['tempdir']) os.rmdir(upload['tempdir'])
for key, upload in six.iteritems(uploads):
if isinstance(upload, dict):
process(upload, key)
else:
uploads = upload
for upload in uploads:
if isinstance(upload, dict):
process(upload, key)
def get_batch_kwargs(self, batch, **kwargs): def get_batch_kwargs(self, batch, **kwargs):
""" """
Return a kwargs dict for use with ``self.handler.make_batch()``, using Return a kwargs dict for use with ``self.handler.make_batch()``, using

View file

@ -53,6 +53,7 @@ from rattail.gpc import GPC
import colander import colander
import deform import deform
from deform import widget as dfwidget
from pyramid import httpexceptions from pyramid import httpexceptions
from pyramid.renderers import get_renderer, render_to_response, render from pyramid.renderers import get_renderer, render_to_response, render
from pyramid.response import FileResponse from pyramid.response import FileResponse
@ -691,26 +692,40 @@ class MasterView(View):
def normalize_uploads(self, form, skip=None): def normalize_uploads(self, form, skip=None):
uploads = {} uploads = {}
for node in form.schema:
if isinstance(node.typ, deform.FileData): def normalize(filedict):
if skip and node.name in skip:
continue
# TODO: does form ever *not* have 'validated' attr here?
if hasattr(form, 'validated'):
filedict = form.validated.get(node.name)
else:
filedict = self.form_deserialized.get(node.name)
if filedict:
tempdir = tempfile.mkdtemp() tempdir = tempfile.mkdtemp()
filepath = os.path.join(tempdir, filedict['filename']) filepath = os.path.join(tempdir, filedict['filename'])
tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid'])
tmpdata = tmpinfo['fp'].read() tmpdata = tmpinfo['fp'].read()
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
f.write(tmpdata) f.write(tmpdata)
uploads[node.name] = { return {'tempdir': tempdir,
'tempdir': tempdir, 'temp_path': filepath}
'temp_path': filepath,
} for node in form.schema:
if skip and node.name in skip:
continue
value = form.validated.get(node.name)
if not value:
continue
if isinstance(value, dfwidget.filedict):
uploads[node.name] = normalize(value)
elif not isinstance(value, dict):
try:
values = iter(value)
except TypeError:
pass
else:
for value in values:
if isinstance(value, dfwidget.filedict):
uploads.setdefault(node.name, []).append(
normalize(value))
return uploads return uploads
def process_uploads(self, obj, form, uploads): def process_uploads(self, obj, form, uploads):

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar # Copyright © 2010-2023 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -26,6 +26,7 @@ Views for 'receiving' (purchasing) batches
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import os
import re import re
import decimal import decimal
import logging import logging
@ -551,6 +552,15 @@ class ReceivingBatchView(PurchasingBatchView):
if not self.editing: if not self.editing:
f.remove_field('order_quantities_known') f.remove_field('order_quantities_known')
# multiple invoice files (if applicable)
if (not self.creating
and batch.get_param('receiving_workflow') == 'from_multi_invoice'):
if 'invoice_files' not in f:
f.insert_before('invoice_file', 'invoice_files')
f.set_renderer('invoice_files', self.render_invoice_files)
f.set_readonly('invoice_files', True)
# invoice totals # invoice totals
f.set_label('invoice_total', "Invoice Total (Orig.)") f.set_label('invoice_total', "Invoice Total (Orig.)")
f.set_label('invoice_total_calculated', "Invoice Total (Calc.)") f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
@ -584,6 +594,17 @@ class ReceivingBatchView(PurchasingBatchView):
'invoice_date', 'invoice_date',
'invoice_number') 'invoice_number')
elif workflow == 'from_multi_invoice':
if 'invoice_files' not in f:
f.insert_before('invoice_file', 'invoice_files')
f.set_type('invoice_files', 'multi_file')
f.set_required('invoice_parser_key')
f.remove('truck_dump_batch_uuid',
'po_number',
'invoice_file',
'invoice_date',
'invoice_number')
elif workflow == 'from_po': elif workflow == 'from_po':
f.remove('truck_dump_batch_uuid', f.remove('truck_dump_batch_uuid',
'date_ordered', 'date_ordered',
@ -620,12 +641,31 @@ class ReceivingBatchView(PurchasingBatchView):
'invoice_date', 'invoice_date',
'invoice_number') 'invoice_number')
def render_invoice_files(self, batch, field):
datadir = self.batch_handler.datadir(batch)
items = []
for filename in batch.get_param('invoice_files', []):
path = os.path.join(datadir, filename)
url = self.get_action_url('download', batch,
_query={'filename': filename})
link = self.render_file_field(path, url)
items.append(HTML.tag('li', c=[link]))
return HTML.tag('ul', c=items)
def render_receiving_workflow(self, batch, field): def render_receiving_workflow(self, batch, field):
key = self.request.matchdict['workflow_key'] key = self.request.matchdict['workflow_key']
info = self.handler.receiving_workflow_info(key) info = self.handler.receiving_workflow_info(key)
if info: if info:
return info['display'] return info['display']
def get_visible_params(self, batch):
params = super(ReceivingBatchView, self).get_visible_params(batch)
# remove this since we show it separately
params.pop('invoice_files', None)
return params
def template_kwargs_create(self, **kwargs): def template_kwargs_create(self, **kwargs):
kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs) kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs)
if self.handler.allow_truck_dump_receiving(): if self.handler.allow_truck_dump_receiving():
@ -655,6 +695,8 @@ class ReceivingBatchView(PurchasingBatchView):
kwargs.pop('truck_dump_batch_uuid', None) kwargs.pop('truck_dump_batch_uuid', None)
elif batch_type == 'from_invoice': elif batch_type == 'from_invoice':
pass pass
elif batch_type == 'from_multi_invoice':
pass
elif batch_type == 'from_po': elif batch_type == 'from_po':
# TODO: how to best handle this field? this doesn't seem flexible # TODO: how to best handle this field? this doesn't seem flexible
kwargs['purchase_key'] = batch.purchase_uuid kwargs['purchase_key'] = batch.purchase_uuid
@ -1952,6 +1994,9 @@ class ReceivingBatchView(PurchasingBatchView):
{'section': 'rattail.batch', {'section': 'rattail.batch',
'option': 'purchase.allow_receiving_from_invoice', 'option': 'purchase.allow_receiving_from_invoice',
'type': bool}, 'type': bool},
{'section': 'rattail.batch',
'option': 'purchase.allow_receiving_from_multi_invoice',
'type': bool},
{'section': 'rattail.batch', {'section': 'rattail.batch',
'option': 'purchase.allow_receiving_from_purchase_order', 'option': 'purchase.allow_receiving_from_purchase_order',
'type': bool}, 'type': bool},