Disclaimer: I am fairly new to coding so although I'm sure there are better ways to do what I did, I really wanted to find a solution that I was comfortable with and I felt like I understood.
Though I hope this is helpful to someone else, this isn't so much a tutorial as it is a story and a gateway for deeper discussion about techniques and tools.
The Problem & My Attempts to Solve it
For a while now, I've been trying to incorporate a form feature into my debut Flask app that was a) formatted like a table and b) pre-populated the headers and first row dynamically, leaving the internal cells as inputs corresponding to each header and row value, to be added to my database.
This functionality felt essential, even for my MVP, but I couldn't find a way to do it that made sense using the tools I had (primarily WTForms). I did attempt to learn Dash to create an editable table and had it incorporated into my app (cred: Hackers and Slackers Plotly Dash in Flask Tutorial), but I didn't really enjoy how separate the Dash app felt from my main app (and I couldn't figure out the editable table feature right away, so boo).
I may have read the WTForms documentation 1,000 times, but so much of it still doesn't click for me. I also read a Tolstoy novel's worth of stack overflow posts and posted here, with out response, earlier this week (it's cool guys).
Original Post: ISO Help: Nested Form Submission in Flask
The Project & Journey
This is the solution that I ended up with and I'd love to hear some feedback. More importantly, if I make any mistakes please let me know so I can edit (with cred).
Originally I was designing this for my old job, a small rural public school district. The page in question is meant to allow an educator to enter the scores students in their classroom received on a variety of assessments. Each assessment has several components that are scored separately. So the app had to identify a user's current classroom & students and create a table to enter scores for the chosen assessment components in one of three periods.
forms.py
#Nested Form
class ComponentForm(Form):
component_id = HiddenField('Component ID')
score = FloatField('Score', default=0.0, widget=NumberInput())
#Main Form
class AssessmentScoreForm(FlaskForm):
student_id = HiddenField('Student ID')
assessment_name=HiddenField(u'Assessment Name')
period = SelectField("Choose an option", validate_choice=False, choices={'1':'fall','2':'winter','3':'spring'})
components = FieldList(FormField(ComponentForm)) #nested form
submit = SubmitField()
As of posting on DevCommunity for help, I had been able to get the layout I wanted and could see my using 'View Page Source' in my browser, that the input cells in my table were in fact mini-forms corresponding to the student and assessment component. Where I believe the issue lied was in how I was dynamically populating my nested form.
api/routes.py
@api.route('/data_entry', methods=['GET', 'POST'])
def data_entry(): # <this will need to accept an assessment name as a parameter>
#logic for getting user classroom & student data
#get all components for a given assessment
components = db.session.scalars(
sa.select(AssessmentComponent).where(
AssessmentComponent.assessment_name == assessment).order_by(
AssessmentComponent.component_name)).all()
#main form
form = AssessmentScoreForm()
#nested form
component_form = ComponentForm()
for component in components:
component_form.component_id = component.id
form.components.append_entry(component_form) #append nested form to main form
According to the WTForms documentation (as I understand it), because I was using .append_entry()
to add the components, I could not then allow the user to update the score information in that entry.
append_entry([data])
Create a new entry with optional default data.
Entries added in this way will not receive formdata however, and can only receive object data.
WTForms Documentation > FieldList > append_entry
Looking at the page source again, I could see that the 'score' data in my nested form, was coming through as an object:
<td><input type="number" id="components-0" name="components-0" value="{'component_id': 'PARLW', 'component': 'Letter Word Calling', 'score': **<wtforms.fields.numeric.FloatField object at 0x0000016AFE5C23D0>}**"></td>
The Solution
First, I'll note that I was OK'd by my instructor to use ChatGPT for this particular feature as I was already working outside the scope of what I was taught and, apparently, forms can be really annoying (who knew). That being said, my experience is that ChatGPT is often more frustrating and confusing than it is helpful. Prior attempts to use it for this problem threatened angry tears, so I was pretty careful not to be super general or use its code without inspection.
Second, this might be an obvious answer for anyone who knows a lick more about this stuff than I do. Well, whoopee (jk, please share your knowledge with me).
The question I asked:
would it be possible for me to create score instances for each student and component, then populate object with score data? How would I attach the score data to each instance?
I was thinking I could create db entries like you would create a new user, then use WTForm's populate_obj
to edit those entries, as in the example in their docs:
populate_obj(obj)
Populates the attributes of the passed obj with data from the form’s fields.
Note
This is a destructive operation; Any attribute with the same name as a field will be overridden. Use with caution.
One common usage of this is an edit profile view:
def edit_profile(request):
user = User.objects.get(pk=request.session['userid'])
form = EditProfileForm(request.POST, obj=user)
if request.POST and form.validate():
form.populate_obj(user)
user.save()
return redirect('/home')
return render_to_response('edit_profile.html', form=form)
In the above example, because the form isn’t directly tied to the user object, you don’t have to worry about any dirty data getting onto there until you’re ready to move it over.
WTForms Documentation > Forms > populate_obj
As expected, Mr.GPT's first answer did not properly address the issue, but the follow up made me gasp a little 'oh', like the first time I learned about pivot tables in Excel.
data_entry.html
<form method="POST">
{{ form.hidden_tag() }}
{{ form.csrf_token }}
{% for period in form.period.choices %}
<label class="radio">
<input type="radio" name="period" value="{{ period[0] }}"/>
{{ period[1] }}
</label>
{% endfor %}
<div class="table-container">
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<thead>
<tr>
<th>Student</th>
{% for component in components %}
<th>{{ component.component_name }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for data in roster_data %}
<tr>
<td>{{ data.full_name }}</td>
{% for component in components %}
<td>
<!--NEW CODE-->
<input type="number" name="score_{{ data.id }}_{{ component.id }}" value="0.0">
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="buttons is-right is-focused">
<input class="button" type="submit" value="Submit" />
<input class="button is-primary is-inverted" type="reset" value="Reset" />
</div>
</form>
api/routes.py
from flask import request
if form.validate_on_submit():
period = request.form.get('period')
for student_data in roster_data:
student_id = student_data['id']
for component in components:
component_id = component.id
# NEW CODE
score_key = f"score_{student_id}_{component_id}"
student_score = request.form.get(score_key)
new_score = AssessmentScore()
new_score.period = period
new_score.component_id = component_id
new_score.student_id = student_id
new_score.classroom_id = current_class.id
new_score.student_score = student_score
db.session.add(new_score)
db.session.commit()
In case you missed it, ChatGPT changed one line in my html:
<input type="number" name="score_{{ data.id }}_{{ component.id }}" value="0.0">
and added two lines to my function:
score_key = f"score_{student_id}_{component_id}"
student_score = request.form.get(score_key)
So, rather than using append_entry
to dynamically create form fields, I would create a form field using HTML and give it a name that corresponded with a student and a component, extract that data from the form and create an affiliated db entry. All other details (classroom, school year, period, etc.) are either handled elsewhere in the form or behind the scenes when accessing current_user
data.
**Final Thoughts & Questions *
- I know this is not some remarkable algorithmic thinking, but my HTML knowledge is limited to playing in Flask and some freecodecamp modules. I wanted to write about this solution to get feedback as well as to demonstrate how to make this work without copy & pasting code that I didn't understand or trying to learn a new language on the fly.
- Some preliminary searching has shown me that WTForms is preferred over HTML forms for several reasons, the most prominent being security. I believe, however, that Flask
request
, which is used to retrieve the data, has similar security features. I'd love to get some clarity around this as, if this were in production, student privacy is major legal and ethical concern. - Even though for this particular project I wanted to stay within the bounds of my knowledge, I'm curious what other tools or approaches more experienced folks would recommend.
- I'm curious if there is a way to do this with WTForms that I missed. I tried using the TableWidget, but I'm a little lost as to its application and if it works on nested forms. On top of that, similar to what I asked in my original post, would contributing to WTForms documentation be a worthwhile endeavor for a newbie? How does one do such a thing? Would there be a need/desire for this kind of functionality (pivot table form thing) in WTForms?
Please let me know your thoughts, if you have any questions and/or if I'm just straight up wrong about something I wrote here. Thank you!
Top comments (2)
Heyo! My name is Michael and I'm a Community Manager here at DEV. It's nice to meet ya! 🙂
Just a quick heads up that I think you might've accidentally posted this one twice. This sometimes happens if you duplicate a tab... it'll copy over the work and you might end up with two versions.
In any case, I noticed ya said ya are new here in your post (welcome by the way!!) I just wanna mention that it's not entirely obvious how to delete your posts here on our platform. You actually need to unpublish a post before you can delete it.
So, if you wanna delete one copy of the post, just follow these steps:
I hope this info is helpful!
By the way, if you wanna delete this version of your post to remove my comment, that is totally cool with me! I realize my comment is not actually answering your question, so it's all good to remove it. 😀
And as for answering your question, I'll be happy to give this one a boost to see if we can get ya a bit of help here! If you please just lemme know which version of the post you're going to keep, I'll help promote the other accordingly! 🙌
Thank you!! I deleted the other one. Anything to help gin up some conversation would be great. :)