Handling Multiple Query Strings for Jinja2 Templates with Flask

Written by timbushell | Published 2020/12/16
Tech Story Tags: web-development | python | flask | jinja | hackernoon-top-story | flask-deployment | programming | coding

TLDRvia the TL;DR App

I was writing a web-based MP3 Player with amplitudejs.com on the front-end while using Flask on the back-end.
To navigate a large MP3 collection, I placed buttons everywhere - picking out all the fields like genres, year, artist, and album which my database of MP3s supported. Buttons for genre and year (high-level filters) are placed permanently in a sidebar on the left. Buttons for artist and album are shown against a list of matching MP3 albums on the right.
we want query strings to work in concert
Just to give you an idea of what I was trying to achieve, the front end of the app looks like this--using buttons to filter the MP3 collection.
Clicking indie will replace the list of songwriters from the list on the right with indie bands; replace the tracklist and highlight the indie button.
Clicking 90s will replace the list of 80s songwriters with songwriters from the 90s... and so on.
But it doesn't really matter about any of that.
The point is we want query strings to work in concert in a Flask app, in useful ways, to add filters for page results - in this case, to filter the playlist available to the amplitudejs MP3 player in the middle of the app's page.

A Scenario

For instance, let's assume the current query string of the URL is
domain.com/music/?year=1980&genre=rock
This would imply I have already clicked the year and genre filters.
As an example, this is how I would like the URLs for all the filter buttons to resolve.
Year group filters. The 1979 and 1981 buttons change the year query string, the 1980 button toggles it off.
  • href=/music/?year=1980&genre=rock
    => 1979 =>
    href=/music/?year=1979&genre=rock 
  • href=/music/?year=1980&genre=rock
    => 1980 =>
    href=/music/?genre=rock
  • href=/music/?year=1980&genre=rock
    => 1981 =>
    href=/music/?year=1981&genre=rock
Genre group filters. The jazz and pop filters change the genre query string, the rock button toggles it off.
  • href=/music/?year=1980&genre=rock
    => jazz =>
    href=/music/?year=1980&genre=jazz
  • href=/music/?year=1980&genre=rock
    => pop =>
    href=/music/?year=1980&genre=pop
  • href=/music/?year=1980&genre=rock
    => rock =>
    href=/music/?year=1980
Artist filters. The sting and genesis buttons just add to the existing query string.
  • href=/music/?year=1980&genre=rock
    => sting =>
    href=/music/?year=1980&genre=jazz&artist=sting
  • href=/music/?year=1980&genre=rock
    => genesis =>
    href=/music/?year=1980&genre=pop&artist=genesis
With me?
I hope this description of my goal will help fellow developers facing a similar use case.

Jinja2 custom filters to the rescue!

In my Flask app, I created a new module file and added the features I needed in the way of two custom filters. As the documentation for custom filters explains, these are just standard python functions:
/repo/myproject/myapp/jinja_filters.py
from urllib.parse import urlencode
def qs_active(existing_qs, filter, by):
    """Active when identical key/value in existing query string."""
    qs_set = {(filter, by)}
    # Not active if either are empty.
    if not existing_qs or not qs_set:
        return False
    # See if the intersection of sets is the same.
    existing_qs_set = set(existing_qs.items())
    return existing_qs_set.intersection(qs_set) == qs_set

def qs_toggler(existing_qs, filter, by):
    """Resolve filter against an existing query string."""
    qs = {filter: by}
    # Don't change the currently rendering existing query string!
    rtn_qs = existing_qs.copy()
    # Test for identical key and value in existing query string.
    if qs_active(existing_qs, filter, by):
        # Remove so that buttons toggle their own value on and off.
        rtn_qs.pop(filter)
    else:
        # Update or add the query string.
        rtn_qs.update(qs)
    return urlencode(rtn_qs)
We will use the
qs_active
filter to add the
active
class to any button whose filter values are already (duh!) active in a URL.
We will use the
qs_toggler
filter to resolve a button's filter values against the current query string. This also calls the
qs_active
function, reusing it.
I wrote tests for both these functions, which will help you understand how they work.
/repo/myproject/myapp/tests/test_jinja_filters.py
import unittest
from psalms.jinja_filters import qs_active, qs_toggler


class TestMp3Model(unittest.TestCase):
    def test_jinja_filters_qs_toggler(self):
        TEST_QS = {"a": "1", "b": "2"}
        # Adds a new qs
        self.assertEqual("a=1&b=2&c=3", qs_toggler(TEST_QS.copy(), "c", "3"))
        # Changes an existing qs
        self.assertEqual("a=1&b=1", qs_toggler(TEST_QS.copy(), "b", "1"))
        # Removes a qs of the same value.
        self.assertEqual("b=2", qs_toggler(TEST_QS.copy(), "a", "1"))
        # Handle empty existing qs
        self.assertEqual("a=1", qs_toggler({}, "a", "1"))

    def test_jinja_filters_qs_active(self):
        TEST_QS = {"a": "1", "b": "2"}
        self.assertTrue(qs_active(TEST_QS, "a", "1"))
        self.assertTrue(qs_active(TEST_QS, "b", "2"))
        self.assertFalse(qs_active(TEST_QS, "a", "2"))
        self.assertFalse(qs_active(TEST_QS, "b", "1"))
        self.assertFalse(qs_active(TEST_QS, "c", "3"))
        # Handles empty query strings
        self.assertFalse(qs_active({}, "a", "1"))
        self.assertFalse(qs_active({}, "", ""))
        self.assertFalse(qs_active({"a": "1"}, "", ""))

Coupling the custom filters to the Jinja2 templates

I opened my main Flask app page, imported these filters, then told Flask I wanted to use them as custom template
filters
:
__init__.py
import flask # You should already have this!

# You should already have something like this for your views!
from myapp.views import Main, Music

# You'll add this to your current solution.
from myapp.jinja_filters import qs_active, qs_toggler

# You should already have this!
app = flask.Flask(__name__)

# You'll add this to your current solution
app.jinja_env.filters["qs_active"] = qs_active
app.jinja_env.filters["qs_toggler"] = qs_toggler

# You should already have some other stuff as well, like this:
app.add_url_rule(
    "/music/", view_func=Music.as_view("music"), methods=["GET"]
)

Handling the query strings in a Flask view

My Flask view (handling the URL) looks like this:
/repo/myproject/myapp/views.py
class Music(flask.views.MethodView):
    @login_required
    def get(self):
        # Existing query string as a dictionary
        existing_qs_as_dict = request.args.to_dict()

        # use the existing query string to filter the db.
        # You'll do this your way!
        sidebar, results = navigator(existing_qs_as_dict)    

        # Send existing query string back into the template
        return flask.render_template(
            "music.html",
            sidebar=sidebar,
            results=results,
            existing_qs=existing_qs_as_dict,
        )
I added a file instantiating a Flask macro to build the buttons. The macro calls my custom Jinja filters and decides whether the querystring key (which the buttons control) is already active and creates a link combining any other querysting keys with its own filter value.
/repo/myproject/myapp/templates/macros.html
{% macro filter_music_button(filter, by) %}
  <a href="/music/?{{ existing_qs|qs_toggler(filter, by) }}">
    <button{% if existing_qs|qs_active(filter, by) %} class="active" {% endif %}>
      {{ by }}
    </button>
  </a>
{% endmacro %}
In the main template, for writing the buttons, you'd import the macro like this at the top of the page:
/repo/myproject/myapp/templates/music.html
{% from "macros.html" import filter_music_button with context %}
Now you can add buttons simply by calling the macro (where appropriate), like this:
{{filter_music_button("year", 1980"}}
For instance, in my implementation, I send the
sidebar
variable to my template in the context (see
class Music
above) -- a dictionary of high-level filters with lists of values to filter against, pulled out of the MP3 collection, which I iterate like this:
/repo/myproject/myapp/templates/music.html
{% block menu %}
  {% for filter, bys in sidebar.items() %}
    <h3>
      {{ filter }}
    </h3>
    <p>
      {% for by in bys %}
        {{ filter_music_button(filter, by) }}
      {% endfor %}
    </p>
  {% endfor %}
{% endblock %}
Given the scenario above (where the current URL is
?year=1980&genre=rock
, it would render like this:
<h3>
  year
</h3>
<p>
  <a href="/music/?year=1979&genre=rock">
    <button>1979</button>
  </a>
  <a href="/music/?genre=rock">
    <button class="active">1980</button>
  </a>
  <a href="/music/?year=1981&genre=rock">
    <button>1981</button>
  </a>
</p><h3>
  genre
</h3>
<p>
  <a href="/music/?year=1980&genre=jazz">
    <button>jazz</button>
  </a>
  <a href="/music/?year=1980&genre=pop">
    <button>pop</button>
  </a>
  <a href="/music/?year=1980">
    <button class="active">rock</button>
  </a>
</p>
How you implement the solution exactly will largely come down to your application.
Meanwhile, I hope this post has shown you how to:
  1. Create Jinja2 custom filters for handling query strings.
  2. Activate them for your Flask app.
  3. Call your custom filters in the templates.
  4. Handle the throughput of query strings from URL to view to template render to button to URL to view to template render to button to URL...

Core, thanks!

For those interested in playing music from a directory of MP3 files using query strings in a Flask app, this is the project which is working nicely: psalms

Written by timbushell | 20 years web development; leader in the elioWay; Quora Top Writer (2014-2018);
Published by HackerNoon on 2020/12/16