3

What I'm trying to do
I'm building a simple single-route Flask app that takes a value from a single-field form, creates multiple CSV files & automatically provides the files after the form is submitted.

Existing, Related Question
I stumbled upon the Download multiple CSVs using Flask? question that contains an answer that explains how to do exactly what I'm looking to do: return multiple downloads.

My Problem
I've implemented the MultipartEncoder from the requests_toolbelt as the answer shows, but when submitting the form it just downloads a single file (named after the route) with no extension instead of downloading all the files.

Things I tried to diagnose
If I open up the file in notepad++ I can see that all CSV files are included in the file separated by their Content-Type & Content-Disposition headers. So, the data is all present, but for some reason the files are not individually downloaded. That leads me to believe that my form is misconfigured or maybe I need to post to a different route.

What am I doing wrong? How can I accomplish downloading multiple files from a single route?

Minimal Working Example Code

app.py

from flask import Flask, Response, render_template, request
from requests_toolbelt import MultipartEncoder
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired
from flask_bootstrap import Bootstrap
from flask_wtf import FlaskForm

app = Flask(__name__)
app.config['SECRET_KEY'] = 'n0T_a-R3a1_sEcR3t-KeY'
Bootstrap(app)

def build_files_for(term):
    # Create CSV files based on term
    # Return filenames
    return ['filename1.csv', 'filename2.csv', 'filename3.csv']

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
def index():
    form = TermBuilderForm()
    if form.validate_on_submit():
        term_results = form.term.data
        downloads = build_files_for(term_results)
        me_dict = {}
        for i, download in enumerate(downloads, 1):
            me_dict['field' + str(i)] = (download, open(download, 'rb'), 'text/csv')
        m = MultipartEncoder(me_dict)
        return Response(m.to_string(), mimetype=m.content_type)
    return render_template('index.html', form=form)

class TermBuilderForm(FlaskForm):
    term = StringField('Term', validators=[DataRequired()], id='term')
    submit = SubmitField('Create')

if __name__ == '__main__':
    app.run(debug = True)

index.html

{% extends 'bootstrap/base.html' %}
{% import 'bootstrap/wtf.html' as wtf %}

{% block title %}
    Term Builder
{% endblock %}

{% block scripts %}
    {{ super() }}
{% endblock %}
{% block content %}
    <div class="container" style="width:100%;padding-left:35px;">
        {% block app_content %}
        <h1>Term Builder</h1>
        {% if form %}
        <!--enctype="multipart/form-data"-->
        <form id="termbuilder" action="{{ url_for('index') }}" method="post" style="width:30%">
            <div style="display:none">{{ wtf.form_field(form.csrf_token) }}</div>
            <div class="row">
                {{ wtf.form_field(form.term) }}
            </div>
            <hr>
            <p>{{ wtf.form_field(form.submit) }}</p>
        </form>
        {% endif %}
        {% endblock %}
    </div>
{% endblock %}
CaffeinatedMike
  • 1,537
  • 2
  • 25
  • 57

1 Answers1

3

HTTP protocol was designed to send one file per one request.

it just downloads a single file (named after the route) with no extension instead of downloading all the files.

That's the default behaviour in browser, you can read about it here

The recommended way is to zip all the files and send it in one response.

One way to get the behaviour you are expecting:

In app.py return the list of files to be downloaded with any template (here index.html is used) and add a new route /files_download/<filename> to download files by filename

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
def index():
    form = TermBuilderForm()
    if form.validate_on_submit():
        term_results = form.term.data
        downloads = build_files_for(term_results)
        return render_template('index.html', form=form, files=downloads)
    return render_template('index.html', form=form)

@app.route('/files_download/<filename>')
def files_download(filename):
    return send_file(filename, mimetype='text/csv')

In template where return render_template('index.html', form=form, files=downloads) (here index.html) add:

{% if files %}
<script>
    var urls = []
    {% for file in files %}
    urls.push("{{ url_for('files_download', filename=file) }}")
    {% endfor %}
    urls.forEach(url => {
        var iframe = document.createElement('iframe'); iframe.style.visibility = 'collapse';
        iframe.style.visibility = 'collapse';
        iframe.src = url;
        document.body.append(iframe);
        setTimeout(() => iframe.remove(), 2000);
    });
</script>
{% endif %}
waynetech
  • 731
  • 6
  • 11
  • 1
    This worked great, thank you! I know it's not the normal behavior. But, for this small task that produces a handful of small files I was just looking to save the end-users an extra step. I notice that at least in Chrome it does prompt the user if they'd like to download multiple files with this method, so they have the option of rejecting to do so. But, since this is an application for strictly internal use there should be no issue. Thank you so much for the insight! – CaffeinatedMike Aug 01 '19 at 14:01