VizslaVizsla
VizslaVizsla

Reputation: 146

Flask form: how can I use multiple forms to add Parent and Child entities to DB in one commit?

NB: see answers for resolution... long story short, one cannot "use multiple forms to add Parent and Child entities to DB in one commit" using the same database session in SQLAlchemy if there are HTTP requests between forms. The appropriate approach for my use case was to save the output of my multiple forms in the Flask session and subsequently iterate through the session in a single view to make the database commits.

Original question:

TL;DR: can I use a Flask-WTF form to tentatively create a Parent item via SQLAlchemy, db.session.flush() to get Parent's ID and pass it to a second Flask-WTF form to populate Child's foreign key, and then commit Parent and Child in one db.session.commit()?

I'm building a Flask web app that enables users to create and manage competitive events. My database models include Events and Eventsets. Events may be children of Eventsets, but it is not required for an Event to have a corresponding Eventset parent. However, for situations where users want to create Eventsets and corresponding Events all at once, I want to enable this via a two step form (which I'm trying to implement using two separate flask-wtf forms and Flask views).

The first form and view enable the user to create an instance of Eventset(). This Eventset() is added to a sqlalchemy database session and flushed, but not committed. If the form validates, the app redirects to the next view that enables the creation of an Event. I want to pass the ID of the previously-created Eventset to my Event() model to complete the Parent-Child relationship.

I'm attempting to do this by passing the ID generated by SQLAlchemy for the Eventset in the first step via the Flask session. **I am able to successfully add the Eventset_id to my Flask session and verify that a SQLAlchemy session is active, but any events created in the second step do not recognize the flushed (but not committed) Eventset, and end up committed with eventset_id = NONE.

I want to avoid committing the Eventset from the first step, as I don't want users to inadvertently create orphaned Eventsets if they don't complete the full setup process (i.e. creating an Eventset and n Events).

forms.py

class EventsetsForm(FlaskForm):
    name = StringField("Eventset Name", validators=[DataRequired()])
    submit = SubmitField('Submit')

class EventForm(FlaskForm):
    eventset_id = SelectField('Eventset', validators=[Optional()], coerce=int)
    name = StringField("Event Name", validators=[DataRequired()])
    submit = SubmitField('Submit')

    def __init__(self, *args, **kwargs):
        super(EventForm, self).__init__(*args, **kwargs)
        self.eventset_id.choices = [(0, "---")]+[(eventset.id, eventset.name)
                             for eventset in Eventset.query.order_by(Eventset.name).all()]

views.py

nb: the flashed and printed messages are to help me see what's happening

@main.route('/eventsets/setup/step_one', methods=['GET', 'POST'])
@login_required
@admin_required
def setup_step_one():
    form = EventsetsForm()
    if current_user.can(Permission.WRITE) and form.validate_on_submit():
        eventset = Eventset(name=form.name.data, 
                            author=current_user._get_current_object())
        db.session.add(eventset)
        db.session.flush()
        session['eventset_id'] = eventset.id
        flash('STEP ONE: an eventset named %s has been propped.' % eventset.name)
        flash('STEP ONE: The id from session is: %s' % session['eventset_id'])
        print('STEP ONE: %s' % session['eventset_id'])
        if eventset in db.session:
            print('STEP ONE: sqlalchemy object for eventset is: %s' % eventset)
        return redirect(url_for('.setup_step_two'))
    return render_template('eventset_setup.html', form=form)  

@main.route('/eventsets/setup/step_two', methods=['GET', 'POST'])
@login_required
@admin_required
def setup_step_two():
    print('Is the db session active? %s' % db.session.is_active)
    print('STEP TWO: the eventset id from Flask session should be: %s' % session['eventset_id'])
    eventset_id = int(session['eventset_id'])
    print('STEP TWO: is the eventset_id in the session an int? %s ' % isinstance(eventset_id, int))
    form = EventForm()
    form.eventset_id.data = eventset_id
    if current_user.can(Permission.WRITE) and form.validate_on_submit():
        event = Event(name=form.name.data,
                      author=current_user._get_current_object(),
                      description=form.description.data,
                      location=form.location.data,
                      scheduled=form.scheduled.data,
                      eventset_id=form.eventset_id.data,
                      event_datetime=form.event_datetime.data,
                      open_datetime=form.open_datetime.data)
        db.session.add(event)
        db.session.commit()
        flash('An event named %s has been created, with eventset_id of %s.' % (event.name, event.eventset_id))
        return redirect(url_for('.setup_step_two'))
    return render_template('eventset_setup.html', eventset_id=eventset_id, form=form)

eventset_setup.html

{% block page_content %}
<div class="row">
    <div class="col-md-4">
        {% if session['eventset_id'] != None %}<p>Eventset id should be: {{ session['eventset_id'] }}</p>{% endif %}
        {% if flarg != None %}{{ flarg }}{% endif %}
    </div>
    <div class="col-md-4">
        {{ wtf.quick_form(form) }}
    </div>
</div>
{% endblock %}

Terminal output

127.0.0.1 - - [11/Apr/2019 23:11:34] "GET /eventsets/setup/step_one HTTP/1.1" 200 -
STEP ONE: 54
STEP ONE: sqlalchemy object for eventset is: <app.models.Eventset object at 0x103c4dd30>
127.0.0.1 - - [11/Apr/2019 23:11:38] "POST /eventsets/setup/step_one HTTP/1.1" 302 -
Is the db session active? True
STEP TWO: the eventset id from Flask session should be: 54
STEP TWO: is the eventset_id in the session an int? True
127.0.0.1 - - [11/Apr/2019 23:11:38] "GET /eventsets/setup/step_two HTTP/1.1" 200 -

...yet Events created in this flow result in event.eventset_id == NONE

Ideally, I would like to enable users to create an Eventset and a related Event with a single SQLAlchemy commit (if I get one Eventset:Event creation to work I can figure out adding multiple Events). Currently, my code results in an Eventset.id value being written to the session, and Events created and committed to the db without the expected Eventset parent. I strongly prefer to avoid using hidden form fields to accomplish this, and unfortunately my Javascript knowledge is negligible.

Upvotes: 3

Views: 1771

Answers (2)

VizslaVizsla
VizslaVizsla

Reputation: 146

All credit to Attack68 for the suggestion and guidance re: Flask's session to solve my problem. Here I am posting my working implementation for anyone else struggling with multi-step forms in Flask that involve one-to-many database relationships and foreign key dependencies.

Some context: I am creating an "eventset," its children ("events"), and resultsets (children of each event).

First, per Attack68's suggestion I create an eventset using a standard FlaskForm, saving it to the session: session['new_eventset_name'] = form.name.data

My next view includes a form to create events that I save to the session in a nested dictionary. I create a unique numerical key per entry and subsequently increment it for each additional event.

if current_user.can(Permission.WRITE) and form.validate_on_submit():
                if session['new_event_batch'].keys():
                    event_key = str(int(max(session['new_event_batch'].keys())) + 1) 
                else:
                    event_key = 1
                session['new_event_batch'][event_key] = { 'name': form.name.data, 
                                                'description':form.description.data,
                                                'location':form.location.data, 
                                                'scheduled':form.scheduled.data,
                                                'event_datetime':form.event_datetime.data,
                                                'open_datetime':form.open_datetime.data }
                session.modified = True
                return redirect(url_for('.setup_step_two'))

My next view contains another simple form that enables the creation of resultsets that will be attached to every event created in the eventset. Its code is not materially different from that for event.

Finally, I follow Attack68's advice and create the eventset in the database, using flush the database session to get its id. Then I iterate through the nested dict of events, inserting the newly-created eventset.id as the foreign key:

eventset = Eventset(name=eventset_name, 
                    author=current_user._get_current_object())
        db.session.add(eventset)
        db.session.flush()
        eventset_id = eventset.id

        event_id_list = []

        for event_key in session['new_event_batch']:

            event = Event(name=session['new_event_batch'][event_key].get('name', ''),
                              author=current_user._get_current_object(),
                              description=session['new_event_batch'][event_key].get('description', ''),
                              location=session['new_event_batch'][event_key].get('location', ''),
                              scheduled=session['new_event_batch'][event_key].get('scheduled', ''),
                              eventset_id=eventset_id,  ## <-- NOTE THIS
                              event_datetime=session['new_event_batch'][event_key].get('event_datetime', ''),
                              open_datetime=session['new_event_batch'][event_key].get('open_datetime', ''))
            db.session.add(event)
            db.session.flush()
            event_id = event.id
            event_id_list.append(event_id)

I also create a list of the newly-created event.id values. I subsequently iterate over that list to create resultsets per event, delete the session values I no longer need, and commit everything to the db:

        for i in event_id_list:

            for resultset_key in session['new_resultset_batch']:
                resultset = Resultset(name=session['new_resultset_batch'][resultset_key],
                                        author=current_user._get_current_object(),
                                        event_id=i,
                                        last_updated=datetime.utcnow())
                db.session.add(resultset)
                db.session.flush()

        del session['new_eventset_name']
        del session['new_event_batch']
        del session['new_resultset_batch']

        db.session.commit()

Upvotes: 1

Attack68
Attack68

Reputation: 4785

Further to my comments your approach is not recommended becuase you are attempting to persist a database session over two routes. What if the browser did not follow the second route or was redirected somewhere else on your website? You would then have an open session with partially modified data that might prevent a commit to the database by breaking integrity.

As I commented the SQLAlchemy docs explain a little more about this here: https://docs.sqlalchemy.org/en/13/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it

And if you are using flask-sqlachemy then the bottom of this page, https://flask-sqlalchemy.palletsprojects.com/en/2.x/quickstart/ explains that it closes and rolls back your session automatically at the end of the first route (request). The particular line of code in the flask-sqlachemy source that does this is:

# flask-sqlalchemy source __init__.py lines 805  - 812
@app.teardown_appcontext
def shutdown_session(response_or_exc):
    if app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN']:
        if response_or_exc is None:
            self.session.commit()

    self.session.remove()
    return response_or_exc

A better way of achieving what you want if to probably use the session object as a store, instead of adding data to the database add it to session:

session['new_eventset_name'] = form.name.data

Then when you are in the second route perform a check for this:

eventset_name = session.get('new_eventset_name', None)
if eventset_name:
    eventset = Eventset(name=eventset_name, 
                        author=current_user._get_current_object())
    db.session.add(eventset)
    db.session.flush()
    eventset_id = eventset.id
    del session['new_eventset_name']
else:
    eventset_id = None

event = Event(name=form.name.data,
                  author=current_user._get_current_object(),
                  description=form.description.data,
                  location=form.location.data,
                  scheduled=form.scheduled.data,
                  eventset_id=eventset_id,  ## <-- NOTE THIS
                  event_datetime=form.event_datetime.data,
                  open_datetime=form.open_datetime.data)
db.session.add(event)
db.session.commit()

Upvotes: 1

Related Questions