Views, Templates, and Static Assets
-----------------------------------
So far we've been returning a raw string from our view function. This works fine for demo purposes, however, in the real world you'll most often use template files. Let's create a directory each for our templates and static assets.
.. code:: bash
mkdir -p templates static \
&& touch templates/layout.html templates/_navbar.html templates/_flashes.html
These directories are the standard locations the :class:`~flask.Flask` constructor expects, so they will automatically be used. (Note however that if just created either of these directories, then you will need to restart the development server for them to be picked up by it.)
Our site looks pretty weak as it stands. Let's add Bootstrap to spruce things up a bit:
.. code:: bash
wget https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/js/bootstrap.min.js -O static/bootstrap-v4.1.2.min.js \
&& wget https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/css/bootstrap.min.css -O static/bootstrap-v4.1.2.min.css \
&& wget https://code.jquery.com/jquery-3.3.1.slim.min.js -O static/jquery-v3.3.1.slim.min.js \
&& wget https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js -O static/popper-v1.14.3.min.js
Layout Template
^^^^^^^^^^^^^^^
In order to share as much code as possible between templates, it's best practice to abstract away the shared boilerplate into ``templates/layout.html``. Just like vanilla Flask, Flask Unchained uses the Jinja2 templating engine. If you're unfamiliar with what anything below is doing, I recommend checking out the excellent `official Jinja2 documentation `_.
Now let's write our ``templates/layout.html`` file:
.. code-block:: html+jinja
{# templates/layout.html #}
{% endblock body %}
{% block javascripts %}
{% endblock javascripts %}
And also the included ``templates/_flashes.html`` and ``templates/_navbar.html`` templates:
.. code-block:: html+jinja
{# templates/_flashes.html #}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
{% endmacro %}
The ``nav_link`` macro perhaps deserves some explanation. This is a small utility function that renders a navigation item in the bootstrap navbar. We do this to make our code more DRY, because every navigation link needs to contain logic to determine whether or not it is the currently active view. The ``{% if endpoint is active %}`` bit is special - Flask Unchained adds the ``active`` template test by default to make this easier.
And now let's update our ``app/templates/site/index.html`` template to use our new layout template:
.. code:: html+jinja
{# app/templates/site/index.html #}
{% extends 'layout.html' %}
{% block title %}Hello World!{% endblock %}
{% block content %}
Hello World!
{% endblock %}
Tests should still pass...
.. code:: bash
pytest
=================================== test session starts ====================================
platform linux -- Python 3.6.6, pytest-3.6.4, py-1.5.4, pluggy-0.7.1
rootdir: /home/user/dev/hello-flask-unchained, inifile:
plugins: flask-0.10.0, Flask-Unchained-0.8.0
collected 1 item
tests/app/test_views.py . [100%]
================================= 1 passed in 0.10 seconds =================================
This seems like a good place to make a commit:
.. code:: bash
git add .
git status
git commit -m 'refactor templates to extend a base layout template'
Customizing Styles
^^^^^^^^^^^^^^^^^^
If you take a look at how our new template looks, it's pretty good, but the ``h1`` tag is now very close to the navbar. Let's fix that by adding some style customizations:
.. code:: bash
mkdir static/vendor \
&& mv static/*.min.* static/vendor \
&& touch static/main.css
Let's update the ``stylesheets`` and ``javascripts`` blocks in our layout template to reference the changed locations of the vendor assets, and our new ``main.css`` stylesheet:
.. code:: html+jinja
{# templates/layout.html #}
{% block stylesheets %}
{% endblock stylesheets %}
{% block javascripts %}
{% endblock javascripts %}
And of course, the custom rule for our ``h1`` tags:
.. code:: css
/* static/main.css */
h1 {
padding-top: 0.5em;
margin-top: 0.5em;
}
Let's commit our changes:
.. code:: bash
git add .
git status
git commit -m 'add a custom stylesheet'
Adding a Landing Page
^^^^^^^^^^^^^^^^^^^^^
OK, let's refactor our views so we have a landing page and a separate page for the hello view. We're also going to introduce :meth:`flask_unchained.decorators.param_converter` here so that we can (optionally) customizable the name we're saying hello to via the query string:
.. code:: python
# app/views.py
from flask_unchained import Controller, route, param_converter
class SiteController(Controller):
@route('/')
def index(self):
return self.render('index')
@route('/hello')
@param_converter(name=str)
def hello(self, name=None):
name = name or 'World'
return self.render('hello', name=name)
The ``param_converter`` converts arguments passed in via the query string to arguments that get passed to the decorated view function. It can make sure you get the right type via a callable (like here), or as we'll cover later, it can even convert unique identifiers from the URL directly into database models. But that's getting ahead of ourselves.
Now that we've added another view/route, our templates need some work again. Let's update the navbar, move our existing ``index.html`` template to ``hello.html`` (adding support for the ``name`` template context variable), and lastly add a new ``index.html`` template for the landing page.
.. code:: html+jinja
{# templates/_navbar.html #}
{% endblock %}
We need to update our tests:
.. code:: python
# tests/app/test_views.py
class TestSiteController:
def test_index(self, client, templates):
r = client.get('site_controller.index')
assert r.status_code == 200
assert templates[0].template.name == 'site/index.html'
assert r.html.count('Hello Flask Unchained!') == 2
def test_hello(self, client, templates):
r = client.get('site_controller.hello')
assert r.status_code == 200
assert templates[0].template.name == 'site/hello.html'
assert r.html.count('Hello World!') == 2
def test_hello_with_name_parameter(self, client, templates):
r = client.get('site_controller.hello', name='User')
assert r.status_code == 200
assert templates[0].template.name == 'site/hello.html'
assert r.html.count('Hello User!') == 2
A couple things to note here. Most obviously, we added another view, and therefore need to add methods to test it. Also of note is the ``templates`` pytest fixture, which we're using to verify the correct template got rendered for each of the views.
Let's make sure they pass:
.. code:: bash
pytest
=================================== test session starts ===================================
platform linux -- Python 3.6.6, pytest-3.6.4, py-1.5.4, pluggy-0.7.1
rootdir: /home/user/dev/hello-flask-unchained, inifile:
plugins: flask-0.10.0, Flask-Unchained-0.8.0
collected 3 items
tests/app/test_views.py ... [100%]
================================ 3 passed in 0.17 seconds =================================
Cool. You guessed it, time to make a commit!
.. code:: bash
git add .
git status
git commit -m 'add landing page, parameterize hello view to accept a name'
Adding a Form to the Hello View
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
We've parameterized our hello view take a ``name`` argument, however, it's not exactly discoverable by users (unless perhaps they're a developer with good variable naming intuition). One way to improve this is by using a form. First, we'll add a form the old-school way, followed by a refactor to use Flask-WTF form classes.
Let's update our hello template:
.. code:: html+jinja
{# app/templates/site/hello.html #}
{% extends 'layout.html' %}
{% block title %}Hello {{ name }}!{% endblock %}
{% block content %}
Hello {{ name }}!
Enter your name:
{% endblock %}
And the corresponding view code:
.. code:: python
# app/views.py
# import request from flask_unchained
from flask_unchained import Controller, request, route, param_converter
class SiteController(Controller):
# and update the code for our hello view
@route('/hello', methods=['GET', 'POST'])
@param_converter(name=str)
def hello(self, name=None):
if request.method == 'POST':
name = request.form['name']
if not name:
return self.render('hello', error='Name is required.', name='World')
return self.redirect('hello', name=name)
return self.render('hello', name=name or 'World')
A wee styling update to also put some spacing above ``h2`` headers:
.. code:: css
/* static/main.css */
h1, h2 {
padding-top: 0.5em;
margin-top: 0.5em;
}
And let's fix our tests:
.. code:: python
# tests/app/test_views.py
# add this import
from flask_unchained import url_for
class TestSiteController:
# and add this method
def test_hello_with_form_post(self, client, templates):
r = client.post('site_controller.hello', data=dict(name='User'))
assert r.status_code == 302
assert r.path == url_for('site_controller.hello')
r = client.follow_redirects(r)
assert r.status_code == 200
assert r.html.count('Hello User!') == 2
# note: when request is a POST, the templates fixture only works after redirecting
assert templates[0].template.name == 'site/hello.html'
Make sure they pass,
.. code:: bash
pytest
================================== test session starts ===================================
platform linux -- Python 3.6.6, pytest-3.7.1, py-1.5.4, pluggy-0.7.1
rootdir: /home/user/dev/hello-flask-unchained, inifile:
plugins: flask-0.10.0, Flask-Unchained-0.8.0
collected 4 items
tests/app/test_views.py .... [100%]
================================ 4 passed in 0.16 seconds ================================
And commit our changes once satisfied:
.. code:: bash
git add .
git status
git commit -m 'add a form to the hello view'
Converting to a Flask-WTF Form
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The above method works, as far as it goes, but both our view code and our template code are very verbose, and the form verification/error handling is awfully manual. Luckily the Flask ecosystem has a solution to this problem, in the awesomely named ``Flask-WTF`` package (it's installed by default as a dependency of Flask Unchained). With it, our new form looks like this:
.. code:: bash
touch app/forms.py
.. code:: python
# app/forms.py
from flask_unchained.forms import FlaskForm, fields, validators
class HelloForm(FlaskForm):
name = fields.StringField('Name', validators=[
validators.DataRequired('Name is required.')])
submit = fields.SubmitField('Submit')
The updated view code:
.. code:: python
# app/views.py
from flask_unchained import Controller, request, route, param_converter
from .forms import HelloForm
class SiteController(Controller):
@route('/')
def index(self):
return self.render('index')
@route('/hello', methods=['GET', 'POST'])
@param_converter(name=str)
def hello(self, name=None):
form = HelloForm(request.form)
if form.validate_on_submit():
return self.redirect('hello', name=form.name.data)
return self.render('hello', hello_form=form, name=name or 'World')
And the updated template:
.. code:: html+jinja
{# app/templates/site/hello.html #}
{% extends 'layout.html' %}
{% from '_macros.html' import render_form %}
{% block title %}Hello {{ name }}!{% endblock %}
{% block content %}
{% endblock %}
What is this mythical ``render_form`` macro? Well, we need to write it ourselves. But luckily once it's written, it should work on the majority of :class:`FlaskForm` subclasses. Here's the code for it:
.. code:: bash
touch templates/_macros.html
.. code:: html+jinja
{% macro render_form(form) %}
{% set action = kwargs.get('action', url_for(kwargs['endpoint'])) %}
{% endmacro %}
{% macro render_field(field) %}
{% set input_type = field.widget.input_type %}
{% if input_type == 'hidden' %}
{{ field(**kwargs)|safe }}
{% elif input_type == 'submit' %}
{% endif %}
{% endmacro %}
More complicated forms, for example those with multiple submit buttons or multiple pages, that require more manual control over the presentation can use ``render_field`` directly for each field in the form.
As usual, let's update our tests and make sure they pass:
.. code:: python
# tests/app/test_views.py
class TestSiteController:
# add this method
def test_hello_errors_with_empty_form_post(self, client, templates):
r = client.post('site_controller.hello')
assert r.status_code == 200
assert templates[0].template.name == 'site/hello.html'
assert r.html.count('Name is required.') == 1
.. code:: bash
pytest
================================== test session starts ===================================
platform linux -- Python 3.6.6, pytest-3.7.1, py-1.5.4, pluggy-0.7.1
rootdir: /home/user/dev/hello-flask-unchained, inifile:
plugins: flask-0.10.0, Flask-Unchained-0.8.0
collected 5 items
tests/app/test_views.py ..... [100%]
================================ 5 passed in 0.19 seconds ================================
Once your tests are passing, it's time to make commit:
.. code:: bash
git add .
git status
git commit -m 'refactor hello form to use flask-wtf'
Enabling CSRF Protection
^^^^^^^^^^^^^^^^^^^^^^^^
By default, CSRF protection is disabled. However, any time you're using forms or have enabled authentication (covered later), you should also enable CSRF protection. There are two requirements:
The first is to update our configuration:
.. code:: bash
touch app/config.py
.. code:: python
# app/config.py
from flask_unchained import BundleConfig
class Config(BundleConfig):
SECRET_KEY = 'some-secret-key'
WTF_CSRF_ENABLED = True
class TestConfig(Config):
WTF_CSRF_ENABLED = False
And secondly, we need to actually send the CSRF token in the cookie with every response:
.. code:: python
# app/__init__.py
from flask_unchained import AppBundle, generate_csrf
class App(AppBundle):
def after_init_app(self, app) -> None:
@app.after_request
def set_csrf_token_cookie(response):
if response:
response.set_cookie('csrf_token', generate_csrf())
return response
Tests should still pass, so it's time to make commit:
.. code:: bash
git add .
git status
git commit -m 'enable CSRF protection'
Cool. Let's move on to :doc:`db` in preparation for installing the Security Bundle.