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.
mkdir -p templates static \
&& touch templates/layout.html templates/_navbar.html templates/_flashes.html
These directories are the standard locations the 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:
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:
{# templates/layout.html #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>
{% block title %}Hello Flask Unchained!{% endblock %}
</title>
{% block stylesheets %}
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap-v4.1.2.min.css') }}">
{% endblock stylesheets %}
{% block extra_head %}
{% endblock extra_head %}
</head>
<body>
{% block header %}
<header>
{% block navbar %}
{% include '_navbar.html' %}
{% endblock %}
</header>
{% endblock%}
{% block body %}
<div class="container">
{% include '_flashes.html' %}
{% block content %}
{% endblock content %}
</div>
{% endblock body %}
{% block javascripts %}
<script src="{{ url_for('static', filename='jquery-v3.3.1.slim.min.js') }}"></script>
<script src="{{ url_for('static', filename='popper-v1.14.3.min.js') }}"></script>
<script src="{{ url_for('static', filename='bootstrap-v4.1.2.min.js') }}"></script>
{% endblock javascripts %}
</body>
</html>
And also the included templates/_flashes.html
and templates/_navbar.html
templates:
{# templates/_flashes.html #}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
<div class="row flashes">
<div class="col">
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissable fade show" role="alert">
{{ message }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endwith %}
{# templates/_navbar.html #}
{% macro nav_link(label) %}
{% set href = kwargs.get('href', url_for(kwargs['endpoint'])) %}
<li class="nav-item {% if kwargs is active %}active{% endif %}">
<a class="nav-link" href="{{ href }}">
{{ label }}
{% if kwargs is active %}
<span class="sr-only">(current)</span>
{% endif %}
</a>
</li>
{% endmacro %}
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" href="{{ url_for('site_controller.index') }}">
Hello Flask Unchained
</a>
<button type="button"
class="navbar-toggler"
data-toggle="collapse"
data-target="#navbarCollapse"
aria-controls="navbarCollapse"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav mr-auto">
{{ nav_link('Home', endpoint='site_controller.index') }}
</ul>
</div>
</nav>
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:
{# app/templates/site/index.html #}
{% extends 'layout.html' %}
{% block title %}Hello World!{% endblock %}
{% block content %}
<div class="row">
<div class="col">
<h1>Hello World!</h1>
</div>
</div>
{% endblock %}
Tests should still pass…
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:
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:
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:
{# templates/layout.html #}
{% block stylesheets %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-v4.1.2.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='main.css') }}">
{% endblock stylesheets %}
{% block javascripts %}
<script src="{{ url_for('static', filename='vendor/jquery-v3.3.1.slim.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/popper-v1.14.3.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/bootstrap-v4.1.2.min.js') }}"></script>
{% endblock javascripts %}
And of course, the custom rule for our h1
tags:
/* static/main.css */
h1 {
padding-top: 0.5em;
margin-top: 0.5em;
}
Let’s commit our changes:
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 flask_unchained.decorators.param_converter()
here so that we can (optionally) customizable the name we’re saying hello to via the query string:
# 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.
{# templates/_navbar.html #}
<ul class="navbar-nav mr-auto">
{{ nav_link('Home', endpoint='site_controller.index') }}
{{ nav_link('Hello', endpoint='site_controller.hello') }} <!-- add this line -->
</ul>
{# app/templates/site/hello.html #}
{% extends 'layout.html' %}
{% block title %}Hello {{ name }}!{% endblock %}
{% block content %}
<div class="row">
<div class="col">
<h1>Hello {{ name }}!</h1>
</div>
</div>
{% endblock %}
{# app/templates/site/index.html #}
{% extends 'layout.html' %}
{% block body %}
<div class="jumbotron">
<div class="container">
<div class="row">
<div class="col">
<h1 class="display-3">Hello Flask Unchained!</h1>
</div>
</div>
</div>
</div>
{% endblock %}
We need to update our tests:
# 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:
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!
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:
{# app/templates/site/hello.html #}
{% extends 'layout.html' %}
{% block title %}Hello {{ name }}!{% endblock %}
{% block content %}
<div class="row">
<div class="col">
<h1>Hello {{ name }}!</h1>
<h2>Enter your name:</h2>
<form name="hello_form" action="{{ url_for('site_controller.hello') }}" method="POST">
{% if error %}
<ul class="errors">
<li class="error">{{ error }}</li>
</ul>
{% endif %}
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
{% endblock %}
And the corresponding view code:
# 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:
/* static/main.css */
h1, h2 {
padding-top: 0.5em;
margin-top: 0.5em;
}
And let’s fix our tests:
# 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,
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:
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:
touch app/forms.py
# 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:
# 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:
{# app/templates/site/hello.html #}
{% extends 'layout.html' %}
{% from '_macros.html' import render_form %}
{% block title %}Hello {{ name }}!{% endblock %}
{% block content %}
<div class="row">
<div class="col">
<h1>Hello {{ name }}!</h1>
<h2>Enter your name:</h2>
{{ render_form(hello_form, endpoint='site_controller.hello') }}
</div>
</div>
{% 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 FlaskForm
subclasses. Here’s the code for it:
touch templates/_macros.html
{% macro render_form(form) %}
{% set action = kwargs.get('action', url_for(kwargs['endpoint'])) %}
<form name="{{ form._name }}" {% if action %}action="{{ action }}"{% endif %} method="POST">
{{ render_errors(form.errors.get('_error', [])) }}
{% for field in form %}
{{ render_field(field) }}
{% endfor %}
</form>
{% endmacro %}
{% macro render_field(field) %}
{% set input_type = field.widget.input_type %}
{% if input_type == 'hidden' %}
{{ field(**kwargs)|safe }}
{% elif input_type == 'submit' %}
<div class="form-group">
{{ field(class='btn btn-primary', **kwargs)|safe }}
</div>
{% else %}
<div class="form-group">
{% if input_type == 'checkbox' %}
<label for="{{ field.id }}">
{{ field(**kwargs)|safe }} {{ field.label.text }}
</label>
{% else %}
{{ field.label }}
{{ field(class='form-control', **kwargs)|safe }}
{% endif %}
{# always render description and/or errors if they are present #}
{% if field.description %}
<small class="form-text text-muted form-field-description">
{{ field.description }}
</small>
{% endif %}
{{ render_errors(field.errors) }}
</div> {# /.form-group #}
{% endif %}
{% endmacro %}
{% macro render_errors(errors) %}
{% if errors %}
<ul class="errors">
{% for error in errors %}
<li class="error">{{ error }}</li>
{% endfor %}
</ul>
{% 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:
# 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
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:
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:
touch app/config.py
# 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:
# 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:
git add .
git status
git commit -m 'enable CSRF protection'
Cool. Let’s move on to Setting up the Database in preparation for installing the Security Bundle.