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
相关产品推荐
相关产品推荐

