Ad

How To Optimise Templates For Flask's Render_template()

- 1 answer

I'm creating a web app for our student pub to manage transactions, inventory, customers, etc. using Flask and a MySQL database. However, the index page, which lists up to 12 customers in Bootstrap cards (with buttons to pay, top up their account, etc.), takes 4 seconds to render with render_template().

This app is deployed on a DigitalOcean droplet with 2 vCPUs and handles around 50-100 simultaneous clients at a given time using the typical Nginx/Gunicorn duo. I've tried to run it on my local computer and had the same results: render_template() takes ages to render the page.

This is the part of the template that takes 4 seconds to render:

<div class="container">
  <div class="row">
    {% for user in users.items %}
    <div class="col-lg-3 col-md-4 col-sm-6 col-6">
      <div class="card mb-4 shadow {% if not user.deposit %}text-secondary border-secondary{% elif user.balance <= 0 %}text-danger border-danger{% elif user.balance <= 5 %}text-warning border-warning{% else %}text-primary border-primary{% endif %}">
        <a target="_blank" rel="nofollow noreferrer" href="{{ url_for('main.user', username=user.username) }}">
          <img class="card-img-top img-fluid" src="{{ user.avatar() }}" alt="{{ user.username }}">
        </a>
        <div class="card-body">
          <h5 class="card-title text-nowrap user-card-title">
            {% if user.nickname %}
            "{{ user.nickname }}"<br>{{ user.last_name|upper }}
            {% else %}
            {{ user.first_name }}<br>{{ user.last_name|upper }}
            {% endif %}
          </h5>
        </div>
        <div class="card-footer">
          {% if user.deposit %}
          <div class="btn-toolbar justify-content-between" role="toolbar" aria-label="Pay and quick access item">
            <div class="btn-group" role="group" aria-label="Pay">
              {% include '_pay.html.j2' %}
            </div>
            <div class="btn-group" role="group" aria-label="Quick access item">
              {% include '_quick_access_item.html.j2' %}
            </div>
          </div>
          {% else %}
          <div class="btn-toolbar justify-content-between" role="toolbar" aria-label="Deposit">
            <div class="btn-group" role="group" aria-label="Deposit">
              {% include '_deposit.html.j2' %}
            </div>
          </div>
          {% endif %}
        </div>
      </div>
    </div>
    {% endfor %}
  </div>
</div>

"Quick access item", "Top up" and "Deposit" are simple dropdown buttons and "Pay" is a scrollable dropdown that can list up to 50 products. This index page is paginated and users is a Flask pagination object with up to 12 users per page and 100 users total.

This is the route function for the index page:

@bp.route('/', methods=['GET'])
@bp.route('/index', methods=['GET'])
@login_required
def index():
    """ View index page. For bartenders, it's the customers page and for clients,
    it redirects to the profile. """
    if not current_user.is_bartender:
        return redirect(url_for('main.user', username=current_user.username))

    # Get arguments
    page = request.args.get('page', 1, type=int)
    sort = request.args.get('sort', 'asc', type=str)
    grad_class = request.args.get('grad_class', str(current_app.config['CURRENT_GRAD_CLASS']), type=int)

    # Get graduating classes
    grad_classes_query = db.session.query(User.grad_class.distinct().label('grad_class'))
    grad_classes = [row.grad_class for row in grad_classes_query.all()]

    # Get inventory
    inventory = Item.query.order_by(Item.name.asc()).all()

    # Get favorite items
    favorite_inventory = Item.query.filter_by(is_favorite=True).order_by(Item.name.asc()).all()

    # Get quick access item
    quick_access_item = Item.query.filter_by(id=current_app.config['QUICK_ACCESS_ITEM_ID']).first()

    # Sort users alphabetically
    if sort == 'asc':
        users = User.query.filter_by(grad_class=grad_class).order_by(User.last_name.asc()).paginate(page,
        current_app.config['USERS_PER_PAGE'], True)
    else:
        users = User.query.filter_by(grad_class=grad_class).order_by(User.last_name.desc()).paginate(page,
            current_app.config['USERS_PER_PAGE'], True)

    return render_template('index.html.j2', title='Checkout',
                        users=users, sort=sort, inventory=inventory,
                        favorite_inventory=favorite_inventory,
                        quick_access_item=quick_access_item,
                        grad_class=grad_class, grad_classes=grad_classes)

I timed the steps in index(): render_template() takes ~4s and the rest of the view function takes ~15ms.

What am I doing wrong here? Is my template too complex? Being a web hobbyist, I don't know how much abuse jinja2 templates can take. I thought about pre-rendering templates into static files, but then how can I ensure security (only bartender accounts can access this page) if Nginx serves static files?

EDIT: Here is the "Pay" dropdown template that gets included in the index template. He's the culprit for the very long rendering times.

<div class="btn-group pay-btn" role="group">
  <button class="user-card-btn btn {% if user.balance <= 0 %}btn-danger{% elif user.balance <= 5 %}btn-warning{% else %}btn-primary{% endif %} dropdown-toggle{% if user.balance <= 0 or not user.deposit %} disabled{% endif %}" type="button" id="dropdownPay" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
    <span class="icon" data-feather="shopping-cart"></span><span class="text">Pay</span>
  </button>
  <div class="dropdown-menu scrollable-menu" aria-labelledby="dropdownPay">
    {% if favorite_inventory|length > 1 %}
    <h6 class="dropdown-header">Favorites</h6>
    {% for item in favorite_inventory %}
    <a class="dropdown-item{% if (not user.deposit) or (user.can_buy(item) != True) or (item.is_quantifiable and item.quantity <= 0) %} disabled{% endif %}" target="_blank" rel="nofollow noreferrer" href="{{ url_for('main.pay', username=user.username, item_name=item.name) }}">
      {% if (not user.deposit) or (user.can_buy(item) != True) or (item.is_quantifiable and item.quantity <= 0) %}
      <strike>
      {% endif %}
      {{ item.name }} ({{ item.price }}€)
      {% if (not user.deposit) or (user.can_buy(item) != True) or (item.is_quantifiable and item.quantity <= 0) %}
      </strike>
      {% endif %}
    </a>
    {% endfor %}
    <div class="dropdown-divider"></div>
    {% endif %}
    <h6 class="dropdown-header">Products</h6>
    {% for item in inventory %}
    {% if item not in favorite_inventory %}
    <a class="dropdown-item{% if (not user.deposit) or (user.can_buy(item) != True) or (item.is_quantifiable and item.quantity <= 0) %} disabled{% endif %}" target="_blank" rel="nofollow noreferrer" href="{{ url_for('main.pay', username=user.username, item_name=item.name) }}">
      {% if (not user.deposit) or (user.can_buy(item) != True) or (item.is_quantifiable and item.quantity <= 0) %}
      <strike>
      {% endif %}
      {{ item.name }} ({{ item.price }}€)
      {% if (not user.deposit) or (user.can_buy(item) != True) or (item.is_quantifiable and item.quantity <= 0) %}
      </strike>
      {% endif %}
    </a>
    {% endif %}
    {% endfor %}
  </div>
</div>
Ad

Answer

As mentioned in my question, I found that the culprit was the "Pay" dropdown which is rendered 12 times per page and contains many items. To solve the issue, I populate each dropdown with AJAX on click instead of rendering them with jinja2:

<script>
$(".pay-btn").click(function() {
  var pay_btn = $(this)
  var dropdown = $(pay_btn).find('.dropdown-menu')
  $.post('/get_user_products', {
    username: $(pay_btn).attr('id').replace('-pay-btn', '')
  }).done(function(response) {
    $(dropdown).append(response['html'])
  }).fail(function() {
    $(dropdown).append('Error: Could not contact server.')
  });
});
</script>

I get user products from this route:

@bp.route('/get_user_products', methods=['POST'])
@login_required
def get_user_products():
    """ Returns the list of products that a user can buy. """
    if not current_user.is_bartender:
        flash("You don't have the rights to access this page.", 'danger')
        return redirect(url_for('main.index'))

    # Get user
    user = User.query.filter_by(username=request.form['username']).first_or_404()

    # Get inventory
    inventory = Item.query.order_by(Item.name.asc()).all()

    # Get favorite items
    favorite_inventory = Item.query.filter_by(is_favorite=True).order_by(Item.name.asc()).all()

    pay_template = render_template('_user_products.html.j2', user=user,
                                    inventory=inventory,
                                    favorite_inventory=favorite_inventory)

    return jsonify({'html': pay_template})

Thus, I only render these dropdowns when they're actually used and the render time of the index page is back to normal.

Feel free to comment if you find that this solution isn't ideal!

Ad
source: stackoverflow.com
Ad