Add basic support for receiving from multiple invoice files
This commit is contained in:
parent
2b7ebedb22
commit
dfa4178204
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
7
tailbone/templates/deform/multi_file_upload.pt
Normal file
7
tailbone/templates/deform/multi_file_upload.pt
Normal 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>
|
60
tailbone/templates/multi_file_upload.mako
Normal file
60
tailbone/templates/multi_file_upload.mako
Normal 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>
|
|
@ -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
|
||||
</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-field>
|
||||
|
||||
|
|
|
@ -81,12 +81,12 @@
|
|||
<div class="panel-block">
|
||||
<div style="display: flex;">
|
||||
<div>
|
||||
${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
|
||||
|
|
|
@ -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" />
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
@ -577,6 +578,7 @@
|
|||
</script>
|
||||
|
||||
${tailbone_autocomplete_template()}
|
||||
${multi_file_upload.render_template()}
|
||||
</%def>
|
||||
|
||||
<%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__))}
|
||||
<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__))}
|
||||
|
||||
${page_help.make_component()}
|
||||
${multi_file_upload.make_component()}
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2022 Lance Edgar
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -362,6 +362,7 @@ class BatchMasterView(MasterView):
|
|||
f.remove('params')
|
||||
else:
|
||||
f.set_readonly('params')
|
||||
f.set_renderer('params', self.render_params)
|
||||
|
||||
# created
|
||||
f.set_readonly('created')
|
||||
|
@ -419,6 +420,16 @@ class BatchMasterView(MasterView):
|
|||
f.remove_fields('executed',
|
||||
'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):
|
||||
permission_prefix = self.get_permission_prefix()
|
||||
use_buefy = self.get_use_buefy()
|
||||
|
@ -515,11 +526,21 @@ class BatchMasterView(MasterView):
|
|||
return batch
|
||||
|
||||
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)
|
||||
os.remove(upload['temp_path'])
|
||||
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):
|
||||
"""
|
||||
Return a kwargs dict for use with ``self.handler.make_batch()``, using
|
||||
|
|
|
@ -53,6 +53,7 @@ from rattail.gpc import GPC
|
|||
|
||||
import colander
|
||||
import deform
|
||||
from deform import widget as dfwidget
|
||||
from pyramid import httpexceptions
|
||||
from pyramid.renderers import get_renderer, render_to_response, render
|
||||
from pyramid.response import FileResponse
|
||||
|
@ -691,26 +692,40 @@ class MasterView(View):
|
|||
|
||||
def normalize_uploads(self, form, skip=None):
|
||||
uploads = {}
|
||||
|
||||
def normalize(filedict):
|
||||
tempdir = tempfile.mkdtemp()
|
||||
filepath = os.path.join(tempdir, filedict['filename'])
|
||||
tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid'])
|
||||
tmpdata = tmpinfo['fp'].read()
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(tmpdata)
|
||||
return {'tempdir': tempdir,
|
||||
'temp_path': filepath}
|
||||
|
||||
for node in form.schema:
|
||||
if isinstance(node.typ, deform.FileData):
|
||||
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)
|
||||
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:
|
||||
filedict = self.form_deserialized.get(node.name)
|
||||
if filedict:
|
||||
tempdir = tempfile.mkdtemp()
|
||||
filepath = os.path.join(tempdir, filedict['filename'])
|
||||
tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid'])
|
||||
tmpdata = tmpinfo['fp'].read()
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(tmpdata)
|
||||
uploads[node.name] = {
|
||||
'tempdir': tempdir,
|
||||
'temp_path': filepath,
|
||||
}
|
||||
for value in values:
|
||||
if isinstance(value, dfwidget.filedict):
|
||||
uploads.setdefault(node.name, []).append(
|
||||
normalize(value))
|
||||
|
||||
return uploads
|
||||
|
||||
def process_uploads(self, obj, form, uploads):
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2022 Lance Edgar
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -26,6 +26,7 @@ Views for 'receiving' (purchasing) batches
|
|||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import os
|
||||
import re
|
||||
import decimal
|
||||
import logging
|
||||
|
@ -551,6 +552,15 @@ class ReceivingBatchView(PurchasingBatchView):
|
|||
if not self.editing:
|
||||
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
|
||||
f.set_label('invoice_total', "Invoice Total (Orig.)")
|
||||
f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
|
||||
|
@ -584,6 +594,17 @@ class ReceivingBatchView(PurchasingBatchView):
|
|||
'invoice_date',
|
||||
'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':
|
||||
f.remove('truck_dump_batch_uuid',
|
||||
'date_ordered',
|
||||
|
@ -620,12 +641,31 @@ class ReceivingBatchView(PurchasingBatchView):
|
|||
'invoice_date',
|
||||
'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):
|
||||
key = self.request.matchdict['workflow_key']
|
||||
info = self.handler.receiving_workflow_info(key)
|
||||
if info:
|
||||
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):
|
||||
kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs)
|
||||
if self.handler.allow_truck_dump_receiving():
|
||||
|
@ -655,6 +695,8 @@ class ReceivingBatchView(PurchasingBatchView):
|
|||
kwargs.pop('truck_dump_batch_uuid', None)
|
||||
elif batch_type == 'from_invoice':
|
||||
pass
|
||||
elif batch_type == 'from_multi_invoice':
|
||||
pass
|
||||
elif batch_type == 'from_po':
|
||||
# TODO: how to best handle this field? this doesn't seem flexible
|
||||
kwargs['purchase_key'] = batch.purchase_uuid
|
||||
|
@ -1952,6 +1994,9 @@ class ReceivingBatchView(PurchasingBatchView):
|
|||
{'section': 'rattail.batch',
|
||||
'option': 'purchase.allow_receiving_from_invoice',
|
||||
'type': bool},
|
||||
{'section': 'rattail.batch',
|
||||
'option': 'purchase.allow_receiving_from_multi_invoice',
|
||||
'type': bool},
|
||||
{'section': 'rattail.batch',
|
||||
'option': 'purchase.allow_receiving_from_purchase_order',
|
||||
'type': bool},
|
||||
|
|
Loading…
Reference in a new issue