Flask-admin batch action with argument via pop-up modal window
Asked Answered
B

1

9

Is there any way to initiate a pop-up window from a Flask function?

I have a flask-admin + flask-sqlalchemy app. A table in the DB contains a field foo with some values. I have a UserAdmin view and I'm trying to create a batch action with some external argument. I.e I want to:

  • select several elements from my DB and
  • substitute the old foo values for each of the element with the one new user-defined value and
  • the way I want to receive this new value is a modal window.

So the model is:

class User(db.Model):
    # some code
    foo = Column(Integer)
    def change_foo(self, new_foo):
        self.foo = int(new_foo)
        db.session.commit()
        return True

class UserAdmin(sqla.ModelView):
    # some code
    @action('change_foo', 'Change foo', 'Are you sure you want to change foo for selected users?')
    def action_change_foo(self, ids):
        query = tools.get_query_for_ids(self.get_query(), self.model, ids)
        count = 0
        # TODO: pop-up modal window here to get new_foo_value
        # new_foo_value = ???
        for user in query.all():
            if user.change_foo(new_foo_value):
                count += 1
        flash(ngettext('Foo was successfully changed.',
                       '%(count)s foos were successfully changed.',
                       count, count=count))
    except Exception as e:
        flash(ngettext('Failed to change foo for selected users. %(error)s', error=str(e)), 'error')

I admit that the whole approach is not optimal, so I'd be glad to be advised with the better one.

There are some related questions: «Batch Editing in Flask-Admin» (yet unanswered) and «Flask-Admin batch action with form» (with some workaround using WTF forms).

Bufflehead answered 1/12, 2017 at 12:14 Comment(1)
@pjcunningham Thank you very much for you really beautiful solution. I adapted it for my application and it works great! The only thing I've changed is a flash() message instead of print change_form.errors, and I removed code=307 afterwards. (Nevertheless it is still looks like a magic for me.)Bufflehead
B
23

Here's one way of achieving this. I've put the complete self-contained example on Github, flask-admin-modal.

Update 28 May 2018. The Github project has been enhanced by another user to handle form validation nicely.

In this example the SQLite database model is a Project with name (string) and cost (Integer) attributes and we will update the cost value for selected rows in the Flask-Admin list view. Note that the database is populated with random data when the Flask application is started.

Here's the model:

class Project(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(255), nullable=False, unique=True)
    cost = db.Column(db.Integer(), nullable=False)

    def __str__(self):
        return unicode(self).encode('utf-8')

    def __unicode__(self):
        return "Name: {name}; Cost : {cost}".format(name=self.name, cost=self.cost)

Define a form with an integer cost field that accepts the new cost. This form also has a hidden field to keep track of the selected row ids.

class ChangeForm(Form):
    ids = HiddenField()
    cost = IntegerField(validators=[InputRequired()])

Override the list template for the Project view model. We do this so we can inject a Bootstrap modal form, with an id changeModal, within the {% block body %}, making sure we call {{ super() }} first.

We also add a jQuery document ready function that will show the modal form if a template variable (change_modal) evaluates to true. The same variable is used in the modal-body to display the change_form. We use the Flask-Admin lib macros render_form to render the form in a Bootstrap style.

Note the value of the action parameter in render_form - it is a route that we define in our Project view where we can process the form's data. Also note the the "Close" button has been replaced with a link, but still styled as a button. The link is the original url (including page and filter details) where the action was initiated from.

{% extends 'admin/model/list.html' %}

{% block body %}
    {{ super() }}

    <div class="modal fade" tabindex="-1" role="dialog" id="changeModal">
      <div class="modal-dialog" role="document">
        <div class="modal-content">
          <div class="modal-header">
            <a href="{{ url }}" class="close" aria-label="Close"><span aria-hidden="true">&times;</span></a>
            <h4 class="modal-title">Change Project Costs</h4>
          </div>
          <div class="modal-body">
              {% if change_modal %}
                  {{ lib.render_form(change_form, action=url_for('project.update_view', url=url)) }}
              {% endif %}
          </div>
        </div><!-- /.modal-content -->
      </div><!-- /.modal-dialog -->
    </div><!-- /.modal -->
{% endblock body %}

{% block tail %}
    {{ super() }}
    <script>
        {% if change_modal %}
            $(document).ready(function(){
                $("#changeModal").modal('show');
            });
        {% endif %}
    </script>
{% endblock tail %}

The Project view class needs to modify the behaviour of the batch action method and define a couple of routes that accepts POST requests.

@action('change_cost', 'Change Cost', 'Are you sure you want to change Cost for selected projects?')
def action_change_cost(self, ids):
    url = get_redirect_target() or self.get_url('.index_view')
    return redirect(url, code=307)

Instead of processing the ids directly the batch action gets the url that posted the action, this url will include any page number and filter details. It then does a redirect back to the list view with a 307. This ensures the selected rows ids are carried along in the body as well as the fact that it was a POST request.

Define a POST route to process this redirect, get the ids and url from the request body, instance a ChangeForm, set the hidden ids form field to an encoded list of the ids. Add the url, change_form and change_model variables to the template args and then render the list view again - this time the modal popup form will be shown in the view.

@expose('/', methods=['POST'])
def index(self):
    if request.method == 'POST':
        url = get_redirect_target() or self.get_url('.index_view')
        ids = request.form.getlist('rowid')
        joined_ids = ','.join(ids)
        encoded_ids = base64.b64encode(joined_ids)
        change_form = ChangeForm()
        change_form.ids.data = encoded_ids
        self._template_args['url'] = url
        self._template_args['change_form'] = change_form
        self._template_args['change_modal'] = True
        return self.index_view() 

Define a POST route to process the modal form's data. This is standard form/database processing and when finished redirect back to the original url that initiated the action.

@expose('/update/', methods=['POST'])
def update_view(self):
    if request.method == 'POST':
        url = get_redirect_target() or self.get_url('.index_view')
        change_form = ChangeForm(request.form)
        if change_form.validate():
            decoded_ids = base64.b64decode(change_form.ids.data)
            ids = decoded_ids.split(',')
            cost = change_form.cost.data
            _update_mappings = [{'id': rowid, 'cost': cost} for rowid in ids]
            db.session.bulk_update_mappings(Project, _update_mappings)
            db.session.commit()
            return redirect(url)
        else:
            # Form didn't validate
            # todo need to display the error message in the pop-up
            print change_form.errors
            return redirect(url, code=307)
Bodwell answered 5/12, 2017 at 12:36 Comment(1)
Thanks for the solution. This deserves more upvotes!Dunaj

© 2022 - 2024 — McMap. All rights reserved.