diff --git a/docs/changes.rst b/docs/changes.rst index 58e79a2f..aebec0ba 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1,6 +1,14 @@ Changes ======= +Version 1.1.3 +------------- + +Unreleased + +- Add field ``MultipleFileField``. ``FileRequired``, ``FileAllowed``, ``FileSize`` + now can be used to validate multiple files :pr:`556` :issue:`338` + Version 1.1.2 ------------- diff --git a/docs/form.rst b/docs/form.rst index 69fb79f1..b7edfe14 100644 --- a/docs/form.rst +++ b/docs/form.rst @@ -56,6 +56,34 @@ field. It will check that the file is a non-empty instance of return render_template('upload.html', form=form) + +Similarly, you can use the :class:`MultipleFileField` provided by Flask-WTF +to handle multiple files. It will check that the files is a list of non-empty instance of +:class:`~werkzeug.datastructures.FileStorage`, otherwise ``data`` will be +``None``. :: + + from flask_wtf import FlaskForm + from flask_wtf.file import MultipleFileField, FileRequired + from werkzeug.utils import secure_filename + + class PhotoForm(FlaskForm): + photos = MultipleFileField(validators=[FileRequired()]) + + @app.route('/upload', methods=['GET', 'POST']) + def upload(): + form = PhotoForm() + + if form.validate_on_submit(): + for f in form.photo.data: # form.photo.data return a list of FileStorage object + filename = secure_filename(f.filename) + f.save(os.path.join( + app.instance_path, 'photos', filename + )) + return redirect(url_for('index')) + + return render_template('upload.html', form=form) + + Remember to set the ``enctype`` of the HTML form to ``multipart/form-data``, otherwise ``request.files`` will be empty. @@ -81,8 +109,9 @@ Validation ~~~~~~~~~~ Flask-WTF supports validating file uploads with -:class:`FileRequired` and :class:`FileAllowed`. They can be used with both -Flask-WTF's and WTForms's ``FileField`` classes. +:class:`FileRequired`, :class:`FileAllowed`, and :class:`FileSize`. They +can be used with both Flask-WTF's and WTForms's ``FileField`` and +``MultipleFileField`` classes. :class:`FileAllowed` works well with Flask-Uploads. :: diff --git a/src/flask_wtf/file.py b/src/flask_wtf/file.py index b2166966..5646600f 100644 --- a/src/flask_wtf/file.py +++ b/src/flask_wtf/file.py @@ -2,6 +2,7 @@ from werkzeug.datastructures import FileStorage from wtforms import FileField as _FileField +from wtforms import MultipleFileField as _MultipleFileField from wtforms.validators import DataRequired from wtforms.validators import StopValidation from wtforms.validators import ValidationError @@ -20,8 +21,24 @@ def process_formdata(self, valuelist): self.raw_data = () +class MultipleFileField(_MultipleFileField): + """Werkzeug-aware subclass of :class:`wtforms.fields.MultipleFileField`. + + .. versionadded:: 1.2.0 + """ + + def process_formdata(self, valuelist): + valuelist = (x for x in valuelist if isinstance(x, FileStorage) and x) + data = list(valuelist) or None + + if data is not None: + self.data = data + else: + self.raw_data = () + + class FileRequired(DataRequired): - """Validates that the data is a Werkzeug + """Validates that the uploaded files(s) is a Werkzeug :class:`~werkzeug.datastructures.FileStorage` object. :param message: error message @@ -30,7 +47,11 @@ class FileRequired(DataRequired): """ def __call__(self, form, field): - if not (isinstance(field.data, FileStorage) and field.data): + if not isinstance(field.data, list): + field.data = [field.data] + if not ( + all(isinstance(x, FileStorage) and x for x in field.data) and field.data + ): raise StopValidation( self.message or field.gettext("This field is required.") ) @@ -40,7 +61,7 @@ def __call__(self, form, field): class FileAllowed: - """Validates that the uploaded file is allowed by a given list of + """Validates that the uploaded file(s) is allowed by a given list of extensions or a Flask-Uploads :class:`~flaskext.uploads.UploadSet`. :param upload_set: A list of extensions or an @@ -55,34 +76,39 @@ def __init__(self, upload_set, message=None): self.message = message def __call__(self, form, field): - if not (isinstance(field.data, FileStorage) and field.data): + if not isinstance(field.data, list): + field.data = [field.data] + if not ( + all(isinstance(x, FileStorage) and x for x in field.data) and field.data + ): return - filename = field.data.filename.lower() + filenames = [f.filename.lower() for f in field.data] - if isinstance(self.upload_set, abc.Iterable): - if any(filename.endswith("." + x) for x in self.upload_set): - return + for filename in filenames: + if isinstance(self.upload_set, abc.Iterable): + if any(filename.endswith("." + x) for x in self.upload_set): + continue - raise StopValidation( - self.message - or field.gettext( - "File does not have an approved extension: {extensions}" - ).format(extensions=", ".join(self.upload_set)) - ) + raise StopValidation( + self.message + or field.gettext( + "File does not have an approved extension: {extensions}" + ).format(extensions=", ".join(self.upload_set)) + ) - if not self.upload_set.file_allowed(field.data, filename): - raise StopValidation( - self.message - or field.gettext("File does not have an approved extension.") - ) + if not self.upload_set.file_allowed(field.data, filename): + raise StopValidation( + self.message + or field.gettext("File does not have an approved extension.") + ) file_allowed = FileAllowed class FileSize: - """Validates that the uploaded file is within a minimum and maximum + """Validates that the uploaded file(s) is within a minimum and maximum file size (set in bytes). :param min_size: minimum allowed file size (in bytes). Defaults to 0 bytes. @@ -98,22 +124,28 @@ def __init__(self, max_size, min_size=0, message=None): self.message = message def __call__(self, form, field): - if not (isinstance(field.data, FileStorage) and field.data): + if not isinstance(field.data, list): + field.data = [field.data] + if not ( + all(isinstance(x, FileStorage) and x for x in field.data) and field.data + ): return - file_size = len(field.data.read()) - field.data.seek(0) # reset cursor position to beginning of file - - if (file_size < self.min_size) or (file_size > self.max_size): - # the file is too small or too big => validation failure - raise ValidationError( - self.message - or field.gettext( - "File must be between {min_size} and {max_size} bytes.".format( - min_size=self.min_size, max_size=self.max_size + for f in field.data: + file_size = len(f.read()) + print(f, file_size, self.max_size, self.min_size) + f.seek(0) # reset cursor position to beginning of file + + if (file_size < self.min_size) or (file_size > self.max_size): + # the file is too small or too big => validation failure + raise ValidationError( + self.message + or field.gettext( + "File must be between {min_size} and {max_size} bytes.".format( + min_size=self.min_size, max_size=self.max_size + ) ) ) - ) file_size = FileSize diff --git a/tests/test_file.py b/tests/test_file.py index e242a7ff..66d261be 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -1,13 +1,17 @@ import pytest from werkzeug.datastructures import FileStorage +from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import MultiDict from wtforms import FileField as BaseFileField +from wtforms import MultipleFileField as BaseMultipleFileField +from wtforms.validators import Length from flask_wtf import FlaskForm from flask_wtf.file import FileAllowed from flask_wtf.file import FileField from flask_wtf.file import FileRequired from flask_wtf.file import FileSize +from flask_wtf.file import MultipleFileField @pytest.fixture @@ -17,6 +21,7 @@ class Meta: csrf = False file = FileField() + files = MultipleFileField() return UploadForm @@ -126,3 +131,142 @@ class Meta: assert not F().validate() assert not F(f=FileStorage()).validate() assert F(f=FileStorage(filename="real")).validate() + assert F(f=FileStorage(filename="real")).validate() + + +def test_process_formdata_for_files(form): + assert ( + form( + ImmutableMultiDict([("files", FileStorage()), ("files", FileStorage())]) + ).files.data + is None + ) + assert ( + form( + ImmutableMultiDict( + [ + ("files", FileStorage(filename="a.jpg")), + ("files", FileStorage(filename="b.jpg")), + ] + ) + ).files.data + is not None + ) + + +def test_files_required(form): + form.files.kwargs["validators"] = [FileRequired()] + + f = form() + assert not f.validate() + assert f.files.errors[0] == "This field is required." + + f = form(files="not a file") + assert not f.validate() + assert f.files.errors[0] == "This field is required." + + f = form(files=[FileStorage()]) + assert not f.validate() + + f = form(files=[FileStorage(filename="real")]) + assert f.validate() + + +def test_files_allowed(form): + form.files.kwargs["validators"] = [FileAllowed(("txt",))] + + f = form() + assert f.validate() + + f = form( + files=[FileStorage(filename="test.txt"), FileStorage(filename="test2.txt")] + ) + assert f.validate() + + f = form(files=[FileStorage(filename="test.txt"), FileStorage(filename="test.png")]) + assert not f.validate() + assert f.files.errors[0] == "File does not have an approved extension: txt" + + +def test_files_allowed_uploadset(app, form): + pytest.importorskip("flask_uploads") + from flask_uploads import UploadSet, configure_uploads + + app.config["UPLOADS_DEFAULT_DEST"] = "uploads" + txt = UploadSet("txt", extensions=("txt",)) + configure_uploads(app, (txt,)) + form.files.kwargs["validators"] = [FileAllowed(txt)] + + f = form() + assert f.validate() + + f = form( + files=[FileStorage(filename="test.txt"), FileStorage(filename="test2.txt")] + ) + assert f.validate() + + f = form(files=[FileStorage(filename="test.txt"), FileStorage(filename="test.png")]) + assert not f.validate() + assert f.files.errors[0] == "File does not have an approved extension." + + +def test_validate_base_multiple_field(req_ctx): + class F(FlaskForm): + class Meta: + csrf = False + + f = BaseMultipleFileField(validators=[FileRequired()]) + + assert not F().validate() + assert not F(f=[FileStorage()]).validate() + assert F(f=[FileStorage(filename="real")]).validate() + + +def test_file_size_small_files_pass_validation(form, tmp_path): + form.files.kwargs["validators"] = [FileSize(max_size=100)] + path = tmp_path / "test_file_smaller_than_max.txt" + path.write_bytes(b"\0") + + with path.open("rb") as file: + f = form(files=[FileStorage(file)]) + assert f.validate() + + +@pytest.mark.parametrize( + "min_size, max_size, invalid_file_size", [(1, 100, 0), (0, 100, 101)] +) +def test_file_size_invalid_file_sizes_fails_validation( + form, min_size, max_size, invalid_file_size, tmp_path +): + form.files.kwargs["validators"] = [FileSize(min_size=min_size, max_size=max_size)] + path = tmp_path / "test_file_invalid_size.txt" + path.write_bytes(b"\0" * invalid_file_size) + + with path.open("rb") as file: + f = form(files=[FileStorage(file)]) + assert not f.validate() + assert f.files.errors[ + 0 + ] == "File must be between {min_size} and {max_size} bytes.".format( + min_size=min_size, max_size=max_size + ) + + +def test_files_length(form, min_num=2, max_num=3): + form.files.kwargs["validators"] = [Length(min_num, max_num)] + + f = form(files=[FileStorage("1")]) + assert not f.validate() + assert f.files.errors[ + 0 + ] == "Field must be between {min_num} and {max_num} characters long.".format( + min_num=min_num, max_num=max_num + ) + + f = form( + files=[ + FileStorage(filename="1"), + FileStorage(filename="2"), + ] + ) + assert f.validate()