I have an application I am building using Python and Flask. I have a form I am creating using WTForms which will allow the user to edit customer contact details, including a dynamic number of phone numbers. When the form is submitted, I want to save the data from the form back into the Customer
object using the form's populate_obj
function.
The code for the forms is as follows:
class PhoneNumberFormPart(FlaskForm):
class Meta:
csrf = False # Disable CSRF protection, it will confilct with protection on the parent form
number = StringField("Phone Number", widget=Input('tel'))
label = SelectField('Label', choices=(("Cell", "Cell"), ("Home", "Home"), ("Work", "Work")))
preferred = BooleanField('Preferred', default=False)
class CustomerEditForm(FlaskForm):
name = StringField('Name', validators=[DataRequired()])
name2 = StringField('Contact Person Name')
isBusiness = BooleanField('This is a business client', default=False)
phones = FieldList(FormField(PhoneNumberFormPart), min_entries=1)
address = StringField("Address")
city = StringField("City")
state = StringField("State")
zip = StringField("Zip Code")
email = StringField("Email Address", widget=Input('email'), validators=[Email()])
submit = SubmitField('Save Customer Details')
I have the following javascript to add additional phone number fields on the client side:
/// Called in forms using a FieldList to duplicate the last FieldList-item
function addFieldListItem(fieldList){
let lastField = fieldList.lastElementChild.previousElementSibling;
// Last child is the "add phone" button added in the template, so the last field is the 2nd-to-last item
let newField = lastField.cloneNode(true);
let newInputs = newField.getElementsByTagName('input');
Array.prototype.forEach.call(newInputs, function(input){
// Increment the number that flask assigns to each field name
input.name = input.name.replace(/(\d+)/, function(n){return ++n});
input.id = input.id.replace(/(\d+)/, function(n){return ++n});
// Clear the input values
input.value = null;
input.checked = false;
});
let newSelects = newField.getElementsByTagName('select');
Array.prototype.forEach.call(newSelects, function(select){
// Increment the number that flask assigns to each field name
select.name = select.name.replace(/(\d+)/, function(n){return ++n});
select.id = select.id.replace(/(\d+)/, function(n){return ++n});
});
let newLabels = newField.getElementsByTagName('label');
Array.prototype.forEach.call(newLabels, function(label){
// Increment the number that flask assigns to each field name
label.htmlFor = label.htmlFor.replace(/(\d+)/, function(n){return ++n});
});
fieldList.insertBefore(newField, fieldList.lastElementChild);
}
Everything seems to work as I expect as long as I don't add an additional phone number client-side. However, if I do add another number client-side, when I call populate_obj
I get the following exception:
Traceback (most recent call last):
File "c:\python\lib\site-packages\flask\app.py", line 2463, in __call__
return self.wsgi_app(environ, start_response)
File "c:\python\lib\site-packages\flask\app.py", line 2449, in wsgi_app
response = self.handle_exception(e)
File "c:\python\lib\site-packages\flask\app.py", line 1866, in handle_exception
reraise(exc_type, exc_value, tb)
File "c:\python\lib\site-packages\flask\_compat.py", line 39, in reraise
raise value
File "c:\python\lib\site-packages\flask\app.py", line 2446, in wsgi_app
response = self.full_dispatch_request()
File "c:\python\lib\site-packages\flask\app.py", line 1951, in full_dispatch_request
rv = self.handle_user_exception(e)
File "c:\python\lib\site-packages\flask\app.py", line 1820, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "c:\python\lib\site-packages\flask\_compat.py", line 39, in reraise
raise value
File "c:\python\lib\site-packages\flask\app.py", line 1949, in full_dispatch_request
rv = self.dispatch_request()
File "c:\python\lib\site-packages\flask\app.py", line 1935, in dispatch_request
return self.view_functions[rule.endpoint](**req.view_args)
File "c:\python\lib\site-packages\flask_login\utils.py", line 261, in decorated_view
return func(*args, **kwargs)
File "C:\Users\Techris Design\Project Spiderman\spiderman\spiderman\views\customers.py", line 40, in customer_landing
form.populate_obj(customer)
File "c:\python\lib\site-packages\wtforms\form.py", line 96, in populate_obj
field.populate_obj(obj, name)
File "c:\python\lib\site-packages\wtforms\fields\core.py", line 962, in populate_obj
field.populate_obj(fake_obj, 'data')
File "c:\python\lib\site-packages\wtforms\fields\core.py", line 829, in populate_obj
raise TypeError('populate_obj: cannot find a value to populate from the provided obj or input data/defaults')
TypeError: populate_obj: cannot find a value to populate from the provided obj or input data/defaults
I'm pretty sure it's because the phones
property (which is a list of PhoneNumber
objects) in the corresponding Customer
object doesn't have enough items in it to contain all the form data into the list.
I've looked over the WTForms documentation to see if there is a way to assign a factory function or class to the FormField
so that it could create additional PhoneNumber
objects to add to customer.phones
when I call populate_obj
if there are more items in the form data than in the target object. However, so far as I can tell from the documentation there is no such option.
Anyone know the best way to do this?
Okay, I spend some time studying the WTForms source code on Github and figured it out. Pass a class or factory function to the default
parameter of the FormField
instance, as follows:
phones = FieldList(FormField(PhoneNumberFormPart, default=PhoneNumber), min_entries=1)
It's actually pretty easy, although calling the parameter default
made it a little hard for me to find. Never would have even thought to look there...