Add basic support for receiving from multiple invoice files
This commit is contained in:
		
							parent
							
								
									2b7ebedb22
								
							
						
					
					
						commit
						dfa4178204
					
				
					 10 changed files with 295 additions and 40 deletions
				
			
		| 
						 | 
					@ -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()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										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']"
 | 
					                  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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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:
 | 
					        for node in form.schema:
 | 
				
			||||||
            if isinstance(node.typ, deform.FileData):
 | 
					            if skip and node.name in skip:
 | 
				
			||||||
                if skip and node.name in skip:
 | 
					                continue
 | 
				
			||||||
                    continue
 | 
					
 | 
				
			||||||
                # TODO: does form ever *not* have 'validated' attr here?
 | 
					            value = form.validated.get(node.name)
 | 
				
			||||||
                if hasattr(form, 'validated'):
 | 
					            if not value:
 | 
				
			||||||
                    filedict = form.validated.get(node.name)
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if isinstance(value, dfwidget.filedict):
 | 
				
			||||||
 | 
					                uploads[node.name] = normalize(value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            elif not isinstance(value, dict):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    values = iter(value)
 | 
				
			||||||
 | 
					                except TypeError:
 | 
				
			||||||
 | 
					                    pass
 | 
				
			||||||
                else:
 | 
					                else:
 | 
				
			||||||
                    filedict = self.form_deserialized.get(node.name)
 | 
					                    for value in values:
 | 
				
			||||||
                if filedict:
 | 
					                        if isinstance(value, dfwidget.filedict):
 | 
				
			||||||
                    tempdir = tempfile.mkdtemp()
 | 
					                            uploads.setdefault(node.name, []).append(
 | 
				
			||||||
                    filepath = os.path.join(tempdir, filedict['filename'])
 | 
					                                normalize(value))
 | 
				
			||||||
                    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,
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
        return uploads
 | 
					        return uploads
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def process_uploads(self, obj, form, uploads):
 | 
					    def process_uploads(self, obj, form, uploads):
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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},
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue