You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flask-WTForms动态添加字段后表单提交失败求助

问题描述

首次开发,一直对Python感兴趣,用Flask-WTForms构建了三层嵌套表单,模板渲染后通过jQuery动态添加字段时成功设置了id和name属性,也写好了路由,但提交按钮无法将合同数据存入数据库。清楚可以纯用jQuery实现全动态表单,但希望学会Flask-WTForms的实现方式,附上Model、Forms、Route及Template代码请求排查问题。

Model:

class Contract(BaseModel):
    supplier_id = db.Column(db.Integer, db.ForeignKey('supplier.id'))
    customer_id = db.Column(db.Integer, db.ForeignKey('customer.id'))
    incoterm_id = db.Column(db.Integer, db.ForeignKey('incoterm.id'))
    pod_id = db.Column(db.Integer, db.ForeignKey('port_of_discharge.id'))
    total_price = db.Column(db.DECIMAL(10, 2), nullable=False)
    contract_transports = db.relationship('ContractTransport', back_populates='contracts')

Forms:

class ProductForm(FlaskForm):
    product = SelectField('Product', render_kw={'class': 'product'}, validators=[DataRequired()])
    quantity = IntegerField('Quantity', render_kw={'class': 'quantity'}, validators=[DataRequired()])
    price_per_unit = DecimalField('Price Per Unit', render_kw={'class': 'price_per_unit'}, validators=[DataRequired()])

class TransportForm(FlaskForm):
    transport = SelectField('Transport', render_kw={'class': 'transport'}, validators=[DataRequired()])
    products = FieldList(FormField(ProductForm), render_kw={'class': 'products_form'}, min_entries=1)

class UnifiedForm(FlaskForm):
    contract_supplier = SelectField('Supplier', render_kw={'class': 'supplier'}, validators=[DataRequired()])
    contract_customer = SelectField('Customer', render_kw={'class': 'customer'}, validators=[DataRequired()])
    contract_incoterm = SelectField('Incoterm', render_kw={'class': 'incoterm'}, validators=[DataRequired()])
    contract_pod = SelectField('POD', render_kw={'class': 'pod'}, validators=[DataRequired()])
    transports = FieldList(FormField(TransportForm), render_kw={'class': 'trasports_form'}, min_entries=1)
    submit = SubmitField('Add Contract')

Route:

@contract_flow_bp.route('/add_contract_unified', methods=['GET', 'POST'])
@login_required
def add_contract_unified():
    form = UnifiedForm()
    form.contract_supplier.choices = [(s.id, s.name) for s in Supplier.query.all()]
    form.contract_customer.choices = [(c.id, c.name) for c in Customer.query.all()]
    form.contract_incoterm.choices = [(i.id, i.incoterm_name) for i in Incoterm.query.all()]
    form.contract_pod.choices = [(p.id, p.pod_name) for p in PortOfDischarge.query.all()]
    transport_choices = [(t.id, t.type_name) for t in TransportType.query.all()]
    for transport_form in form.transports:
        transport_form.transport.choices = transport_choices
    product_choices = [(p.id, p.product_name) for p in Product.query.all()]
    for transport_form in form.transports:
        for product_form in transport_form.products:
            product_form.product.choices = product_choices
    if form.validate_on_submit() and 'submit' in request.form:
        total_price = 0  # Initialize total_price to 0
    
        # Step 1: Create and commit Contract
        new_contract = Contract(
            supplier_id=form.contract_supplier.data,
            customer_id=form.contract_customer.data,
            incoterm_id=form.contract_incoterm.data,
            pod_id=form.contract_pod.data,
            total_price=0  # Initialize to 0, we will update it later
        )
        db.session.add(new_contract)
        db.session.flush()
        flash('Step 1: Contract created.', 'info')

        # Step 2: Iterate through each TransportForm
        for transport_form in form.transports:
            new_transport = Transport(transport_type_id=transport_form.transport.data)
            db.session.add(new_transport)
            db.session.flush()
            flash('Step 2: Transport created.', 'info')

            # Step 3: Create and commit ContractTransport
            new_contract_transport = ContractTransport(
                contract_id=new_contract.id,
                transport_id=new_transport.id
            )
            db.session.add(new_contract_transport)
            db.session.flush()
            flash('Step 3: ContractTransport created.', 'info')

            # Step 4: Iterate through each ProductForm
            for product_form in transport_form.products:
                subtotal_price = product_form.quantity.data * product_form.price_per_unit.data
                total_price += subtotal_price  # Update the total_price
            
                new_contract_transport_product = ContractTransportProduct(
                    contract_transport_id=new_contract_transport.id,
                    product_id=product_form.product.data,
                    quantity=product_form.quantity.data,
                    price_per_unit=product_form.price_per_unit.data,
                    subtotal_price=subtotal_price
                )
                db.session.add(new_contract_transport_product)
                flash('Step 4: ContractTransportProduct created.', 'info')

        # Update the total_price in the Contract
        new_contract.total_price = total_price
        db.session.commit()        
        flash('Contract added successfully!', 'success')
        return redirect(url_for('contract_flow.add_contract_unified'))
    return render_template('add_contract_unified.html', form=form)

Template:

{% extends "base.html" %}
{% block content %}

<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>

<script>

$(document).ready(function() {

    let indexproduct = 1;  // Start index at 1
    let indextransport = 1;  // Start index at 1
    
// Add more ProductForms to each TransportForm
$(document).on('click', '.addProduct', function() {
    var transportDiv = $(this).closest('.transport-div');
    var lastProductDiv = transportDiv.find(".product-div:last");
    var newProduct = lastProductDiv.clone();

    lastProductDiv.after(newProduct);
    newProduct.find("input").val("");  // Clear input fields

    // Extract the transport index and last product index from an existing id
    var sampleId = lastProductDiv.find(".product").attr("id");
    var [transportIndex, productIndex] = sampleId.match(/transports-(\d+)-products-(\d+)-/).slice(1, 3);

    // Increment the product index for new entries
    productIndex++;

    // An array of other field classes to loop through
    const fields = ['product', 'quantity', 'price_per_unit'];

    // Loop through each field to update 'id' and 'name'
    fields.forEach(function(field) {
        const newIdAndName = `transports-${transportIndex}-products-${productIndex}-${field}`;
        newProduct.find(`.${field}`).attr("id", newIdAndName);
        newProduct.find(`.${field}`).attr("name", newIdAndName);
    });
});

    
    // Add more TransportForms to UnifiedForm
    $('#addTransport').click(function() {
        var newTransport = $(".transport-div:last").clone();
        $(".transport-div:last").after(newTransport);
        $(".transport-div:last").find("input").val("");  // Clear input fields
        $(".transport-div:last").find("select").prop('selectedIndex', 0);  // Reset select elements

        // Update 'id' and 'name' for 'transport' separately
        $(".transport-div:last").find(".transport").attr("id", "transports-" + indextransport + "-transport");
        $(".transport-div:last").find(".transport").attr("name", "transports-" + indextransport + "-transport");

        // An array of other field classes to loop through
        const otherFields = ['product', 'quantity', 'price_per_unit'];

            // Loop through each field to update 'id' and 'name'
            otherFields.forEach(function(field) {
                const newIdAndName = `transports-${indextransport}-products-0-${field}`;
            $(".transport-div:last").find(`.${field}`).attr("id", newIdAndName);
            $(".transport-div:last").find(`.${field}`).attr("name", newIdAndName);
            });

            indextransport++;
    });

});

</script>

<h2>Add Contract</h2>
<form action="{{ url_for('contract_flow.add_contract_unified') }}" method="post" role= "form">
    <fieldset>
        <legend>Contract Header</legend>
        {{ form.hidden_tag() }}
        {{ form.contract_supplier.label }} {{ form.contract_supplier }}
        {{ form.contract_customer.label }} {{ form.contract_customer }}
        {{ form.contract_incoterm.label }} {{ form.contract_incoterm }}
        {{ form.contract_pod.label }} {{ form.contract_pod }}
    </fieldset><br>
    {% for transport in form.transports %}
        <fieldset>
            <legend>Transport & Product Details</legend>
                <div class="transport-div">
                    {% for product in transport.products %}
                    {{ transport.hidden_tag() }}
                    <table class="transport-table">
                        <tbody>
                            <tr>
                                <td>
                                    {{ transport.transport }}
                                </td>
                                <td>
                                    <div class="product-div">
                                    <table>
                                        <tbody>
                                            <tr>
                                                <td>
                                                    {{ product.product }}
                                                </td>
                                                <td>
                                                    {{ product.quantity }}
                                                </td>
                                                <td>
                                                    {{ product.price_per_unit }}
                                                </td>
                                                <td>
                                                    <button type="button" class="addProduct">Add Another Product</button>
                                                </td>
                                            </tr>
                                        </tbody>
                                    </table>
                                </div>
                                </td>
                            </tr>
                        </tbody>
                    </table>
                    {% endfor %}
                </div>
        </fieldset>
    {% endfor %}
<button type="button" id="addTransport">Add Another Transport</button>
    {{ form.submit }}
</form>

{% with messages = get_flashed_messages(with_categories=true) %}
  {% if messages %}
    <ul class=flashes>
    {% for category, message in messages %}
      <li class="{{ category }}">{{ message }}</li>
    {% endfor %}
    </ul>
  {% endif %}
{% endwith %}

{% endblock %}

问题排查和修复要点

1. 动态字段索引不连续导致解析失败

Flask-WTForms的FieldList依赖连续的整数索引才能正确解析提交的表单数据,你的jQuery克隆逻辑虽设置了id/name,但路由端没有主动扩展FieldList的长度来匹配动态新增的字段,导致新增的transport和product数据被直接忽略。

2. 重复渲染隐藏字段触发CSRF验证失败

模板中将{{ transport.hidden_tag() }}放在了product循环内,每个product都会渲染一次transport的隐藏字段(包括csrf token),重复的token会导致表单验证不通过。

3. 模板结构错误导致数据重复提交

transport下拉框被放在product循环内,每个product对应一个transport下拉框,不仅视觉混乱,还会导致同一transport数据被多次提交,干扰表单解析。


修正后的代码

模板部分(关键修改)

{% extends "base.html" %}
{% block content %}

<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>

<script>
$(document).ready(function() {
    let indextransport = 1;

    // 添加产品
    $(document).on('click', '.addProduct', function() {
        const transportDiv = $(this).closest('.transport-div');
        const productContainer = transportDiv.find('.products-container');
        const lastProductDiv = productContainer.find(".product-div:last");
        const newProduct = lastProductDiv.clone(true);

        // 清空输入值
        newProduct.find("input").val("");
        newProduct.find("select").prop('selectedIndex', 0);

        // 提取并更新索引
        const sampleId = lastProductDiv.find(".product").attr("id");
        const [transportIndex, productIndex] = sampleId.match(/transports-(\d+)-products-(\d+)-/).slice(1, 3);
        const newProductIndex = parseInt(productIndex) + 1;

        // 更新字段id和name
        const fields = ['product', 'quantity', 'price_per_unit'];
        fields.forEach(field => {
            const newId = `transports-${transportIndex}-products-${newProductIndex}-${field}`;
            newProduct.find(`.${field}`).attr({id: newId, name: newId});
        });

        productContainer.append(newProduct);
    });

    // 添加运输方式
    $('#addTransport').click(function() {
        const lastTransportDiv = $(".transport-div:last");
        const newTransport = lastTransportDiv.clone(true);

        // 清空输入值
        newTransport.find("input").val("");
        newTransport.find("select").prop('selectedIndex', 0);

        // 更新transport字段的id和name
        newTransport.find(".transport").attr({
            id: `transports-${indextransport}-transport`,
            name: `transports-${indextransport}-transport`
        });

        // 更新products字段的id和name
        const productFields = ['product', 'quantity', 'price_per_unit'];
        newTransport.find(".product-div").each((idx, el) => {
            productFields.forEach(field => {
                const newId = `transports-${indextransport}-products-${idx}-${field}`;
                $(el).find(`.${field}`).attr({id: newId, name: newId});
            });
        });

        lastTransportDiv.after(newTransport);
        indextransport++;
    });
});
</script>

<h2>添加合同</h2>
<form action="{{ url_for('contract_flow.add_contract_unified') }}" method="post" role="form">
    <fieldset>
        <legend>合同头部信息</legend>
        {{ form.hidden_tag() }}
        {{ form.contract_supplier.label }} {{ form.contract_supplier }}
        {{ form.contract_customer.label }} {{ form.contract_customer }}
        {{ form.contract_incoterm.label }} {{ form.contract_incoterm }}
        {{ form.contract_pod.label }} {{ form.contract_pod }}
    </fieldset><br>

    {% for transport in form.transports %}
        <fieldset>
            <legend>运输与产品详情</legend>
            <div class="transport-div">
                {{ transport.hidden_tag() }} <!-- 每个Transport只渲染一次隐藏字段 -->
                <div>
                    {{ transport.transport.label }} {{ transport.transport }}
                </div>
                <div class="products-container"> <!-- 统一管理产品字段容器 -->
                    {% for product in transport.products %}
                        <div class="product-div">
                            <table>
                                <tbody>
                                    <tr>
                                        <td>{{ product.product.label }} {{ product.product }}</td>
                                        <td>{{ product.quantity.label }} {{ product.quantity }}</td>
                                        <td>{{ product.price_per_unit.label }} {{ product.price_per_unit }}</td>
                                        <td><button type="button" class="addProduct">添加产品</button></td>
                                    </tr>
                                </tbody>
                            </table>
                        </div>
                    {% endfor %}
                </div>
            </div>
        </fieldset>
    {% endfor %}

    <button type="button" id="addTransport">添加运输方式</button>
    {{ form.submit }}
</form>

{% with messages = get_flashed_messages(with_categories=true) %}
  {% if messages %}
    <ul class=flashes>
    {% for category, message in messages %}
      <li class="{{ category }}">{{ message }}</li>
    {% endfor %}
    </ul>
  {% endif %}
{% endwith %}

{% endblock %}

路由部分(关键修改)

@contract_flow_bp.route('/add_contract_unified', methods=['GET', 'POST'])
@login_required
def add_contract_unified():
    form = UnifiedForm()

    # 设置下拉选项
    form.contract_supplier.choices = [(s.id, s.name) for s in Supplier.query.all()]
    form.contract_customer.choices = [(c.id, c.name) for c in Customer.query.all()]
    form.contract_incoterm.choices = [(i.id, i.incoterm_name) for i in Incoterm.query.all()]
    form.contract_pod.choices = [(p.id, p.pod_name) for p in PortOfDischarge.query.all()]
    transport_choices = [(t.id, t.type_name) for t in TransportType.query.all()]
    product_choices = [(p.id, p.product_name) for p in Product.query.all()]

    # 处理POST请求,扩展FieldList长度匹配动态字段
    if request.method == 'POST':
        # 扩展transports的长度
        transport_count = len(request.form.getlist('transports-0-transport'))
        while len(form.transports) < transport_count:
            form.transports.append_entry()
        
        # 为每个transport设置选项并扩展products长度
        for transport_form in form.transports:
            transport_form
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 11:51:05