flaskflask-sqlalchemyflask-wtformswtforms

How to use FileField within WTForms FieldList? Using Flask-WTF for FileField


I have created dynamic fields using WTForms FieldList, macros and Javascript.

Using this code to validate many fields (StringField, SelectField etc) I can add many fields dynamically, and they validate and update my Sqlite3 database. However I want to be able to add files dynamically now.

The standard Flask-WTF code for file uploads looks like this: How can I utilisie this for validation on a FieldList form?

 if form.validate_on_submit():
    f_fileA = form.inputname.data
    filename_fileA = secure_filename(f_fileA.filename)
    f_fileA.save(os.path.join(
    UPLOAD_FOLDER, filename_fileA
    ))

My code Forms

forms.py

class ChildForm(Form):
    childAinput_1 = StringField(
        'ChildA input 1'
    )
    childAinput_2 = IntegerField(
        'ChildA input 2'
    )
    category = SelectField(
        'Category',
        choices=[('cat1', 'Category 1'), ('cat2', 'Category 2')]
    )
    fileA = FileField(
        'FileA'
    )


class MainForm(FlaskForm):
    parentinput_1 = StringField(
        'Parent input 1'
    )
    parentinput_2 = StringField(
        'Parent input 2'
    )
    childrenA = FieldList(
        FormField(ChildForm),
        min_entries=1,
        max_entries=20
    )


My HTML template with form fields

form.html template

<a id="add" href="#">Add ChildA</a>
{# Show all subforms #}
        <form id="childA-form" action="" method="POST" role="form">
            {{ form.hidden_tag() }}

            {# show parents fields #}
            <div>
            {{ form.parentinput_1.label }}
            {{ form.parentinput_1 }}
        </div>
        <div>
            {{ form.parentinput_2.label }}
            {{ form.parentinput_2 }}
        </div>

            <div id="subforms-container">
                {% for subform in form.childrenA %}
                    {{ macros.render_childA_form(subform, loop.index0) }}
                {% endfor %}
            </div>

            <button type="submit">Send</button>
        </form>

        {% if form.errors %}
            {{ form.errors }}
        {% endif %}

        {# Form template #}
        {{ macros.render_childA_form(_template, '_') }}

Current form validation (without adding in file upload yet)

routes.py

@bp.route('/', methods=['GET', 'POST'])
def index():
    form = MainForm()
    template_form = ChildForm(prefix='childrenA-_-')

    if form.validate_on_submit():
        # Create parent
        new_parent = Parent(
            parentinput_1 = form.parentinput_1.data,
            parentinput_2 = form.parentinput_2.data
        )

        db.session.add(new_parent)

        for childAe in form.childrenA.data:

            new_childA = ChildA(**childAe)

            # Add to parent
            new_parent.childrenA.append(new_childA)

        db.session.commit()

    return render_template(
        'index.html',
        form=form,
        _template=template_form
    )

And the macros if it's useful:

macros.html

{%- macro render_childA_form(subform, index) %}
        <div id="childA-{{ index }}-form" class="{% if index != '_' %}subform{% else %}is-hidden{% endif %}" data-index="{{ index }}">
            <div>
                {{ subform.childAinput_1.label }}
                {{ subform.childAinput_1 }}
            </div>
            <div>
                {{ subform.childAinput_2.label }}
                {{ subform.childAinput_2}}
            </div>
            <div>
                {{ subform.category.label }}
                {{ subform.category }}
            </div>
            <div>
                {{ subform.fileA.label }}
                {{ subform.fileA }}
            </div>
    
            <a class="remove" href="#">Remove</a>
            <hr/>
        </div>
    {%- endmacro %}

My models

models.py
class Parent(db.Model):
    """Stores parents."""
    __tablename__ = 'parents'
    parentinput_1 = db.Column(db.String(100))
    parentinput_2 = db.Column(db.String(100))

    id = db.Column(db.Integer, primary_key=True)


class ChildA(db.Model):
    """Stores childrenA of a parent."""
    __tablename__ = 'childrenA'

    id = db.Column(db.Integer, primary_key=True)
    parent_id = db.Column(db.Integer, db.ForeignKey('parents.id'))

    childAinput_1 = db.Column(db.String(100))
    childAinput_2 = db.Column(db.Integer)
    category = db.Column(db.String(4))
    fileA = db.Column(db.String(255))

    # Relationship
    parent = db.relationship(
        'Parent',
        backref=db.backref('childrenA', lazy='dynamic', collection_class=list)
    )



(The models, routes and forms are actually in one file, but I split them in the question for ease of reading)

Thank you very much


Solution

  • The procedure remains the same as in the standard example code, even if you use a list of FormFields.
    You can iterate over all nested forms and query the value of the respective FileField and save the resulting file.

    Here is my simple example based on your code sections.

    from flask import (
        Flask, 
        current_app, 
        redirect, 
        render_template, 
        url_for
    )
    from flask_sqlalchemy import SQLAlchemy
    from sqlalchemy.orm import (
        DeclarativeBase, 
        Mapped 
    )
    from typing import List
    from flask_wtf import FlaskForm
    from flask_wtf.file import FileField
    from wtforms import FieldList, FormField, SelectField, StringField
    from werkzeug.utils import secure_filename
    import os
    
    class Base(DeclarativeBase):
        pass
    
    db = SQLAlchemy(model_class=Base)
    
    app = Flask(__name__)
    app.config.from_mapping(
        SECRET_KEY='your secret here', 
        SQLALCHEMY_DATABASE_URI='sqlite:///example.db', 
        UPLOAD_FOLDER=os.path.join(app.static_folder, 'uploads')
    )
    db.init_app(app)
    
    os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
    
    class Parent(db.Model):
        id:Mapped[int] = db.mapped_column(db.Integer, primary_key=True)
        input1:Mapped[str] = db.mapped_column(db.String)
        input2:Mapped[str] = db.mapped_column(db.String)
        children:Mapped[List['Child']] = db.relationship(back_populates='parent')
    
    class Child(db.Model):
        id:Mapped[int] = db.mapped_column(db.Integer, primary_key=True)
        input1:Mapped[str] = db.mapped_column(db.String)
        category:Mapped[str] = db.mapped_column(db.String)
        parent_id:Mapped[int]= db.mapped_column(db.Integer, db.ForeignKey('parent.id'), nullable=False)
        parent:Mapped['Parent'] = db.relationship(back_populates='children')
    
    with app.app_context():
        db.drop_all()
        db.create_all()
    
    class ChildForm(FlaskForm):
        class Meta: 
            csrf = False
    
        input1 = StringField('Input 1')
        category = SelectField(
            'Category',
            choices=[('cat1', 'Category 1'), ('cat2', 'Category 2')]
        )
        file = FileField('File')
    
    class MainForm(FlaskForm):
        input1 = StringField('Input 1')
        input2 = StringField('Input 2')
        
        children = FieldList(
            FormField(ChildForm),
            min_entries=1,
            max_entries=20
        )
    
    @app.route('/', methods=['GET', 'POST'])
    def index():
        form = MainForm()
        if form.validate_on_submit():
            parent = Parent(
                input1 = form.input1.data,
                input2 = form.input2.data,
            )
    
            for subform in form.children:
                child = Child()
                subform.form.populate_obj(child)
    
                file = subform.file.data
                file.save(os.path.join(
                    current_app.config['UPLOAD_FOLDER'], 
                    secure_filename(file.filename)
                ))
    
                parent.children.append(child)
    
            db.session.add(parent)
            db.session.commit()
    
            return redirect(url_for('index'))
    
        return render_template('index.html', **locals())
    
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Index</title>
        <style>
            fieldset[name="child"] {
                border: none; 
                border-top: 1px dotted #000; 
                padding: 0.32rem 0; 
                margin: 0.32rem 0;
            }
        </style>
    </head>
    <body>
    
        <form method="post" enctype="multipart/form-data">
            {{ form.hidden_tag() }}
    
            <div>
                {{ form.input1.label() }}
                {{ form.input1() }}
            </div>
            <div>
                {{ form.input2.label() }}
                {{ form.input2() }}
            </div>
    
            <div id="children">
                {% for subform in form.children -%}
                <fieldset name="child" style="display: flex;">
                    <div style="flex-grow: 1;">
                    {% for field in subform -%}
                        <div>
                            {{ field.label() }}
                            {{ field() }}
                        </div>
                    {% endfor -%}
                    </div>
                    <div>
                        <button type="button" class="btn-remove">-</button>
                    </div>
                </fieldset>
                {% endfor -%}
            </div>
    
            <div style="display: flex;">
                <div style="flex-grow: 1;">
                    <button type="submit">Submit</button>
                </div>
                <div>
                    <button type="button" id="btn-add">+</button>
                </div>
            </div>
        </form>
    
        <script>
            (function() {
                const childrensDiv = document.getElementById('children');
    
                const removeChildDiv = (event) => {
                    if (childrensDiv.childElementCount > 1) {
                        event.target.closest('fieldset[name="child"]').remove();
                    }
                };
    
                const addBtn = document.getElementById('btn-add');
                addBtn.addEventListener('click', () => {
                    if (childrensDiv.childElementCount >= 1 && childrensDiv.childElementCount < 20) {
                        const newChildDiv = childrensDiv.lastElementChild.cloneNode(true); 
                        const fields = newChildDiv.querySelectorAll('input[name^="children-"], select[name^="children-"]');
                        fields.forEach(field => {
                            const nameAttr = field.name, 
                                    newNameAttr = nameAttr.replace(
                                        /^children-(\d+)-(\w+)$/, 
                                        (match, p1, p2) => `children-${parseInt(p1)+1}-${p2}`
                                    );
                            field.id = field.name = newNameAttr; 
                            field.value = '';
    
                            const label = newChildDiv.querySelector(`label[for="${nameAttr}"]`);
                            label.setAttribute('for', newNameAttr);
                        });
    
                        const rmBtn = newChildDiv.querySelector('.btn-remove');
                        rmBtn.addEventListener('click', removeChildDiv);
    
                        childrensDiv.appendChild(newChildDiv);
                    }
                });
    
                document.querySelectorAll('.btn-remove').forEach(btn => {
                    btn.addEventListener('click', removeChildDiv);
                });
    
            })();
        </script>
    </body>
    </html>