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 @@
+