The Flask Mega-Tutorial, Part VI: Profile Page And Avatars (2012)

Posted by
on under

(Great news! There is a new version of this tutorial!)

This is the sixth article in the series in which I document my experience writing web applications in Python using the Flask microframework.

The goal of the tutorial series is to develop a decently featured microblogging application that demonstrating total lack of originality I have decided to call microblog.

NOTE: This article was revised in September 2014 to be in sync with current versions of Python and Flask.

Here is an index of all the articles in the series that have been published to date:

Recap

In the previous chapter of this tutorial we created our user login system, so we can now have users log in and out of the website using their OpenIDs.

Today, we are going to work on the user profiles. First, we'll create the user profile page, which shows the user's information and more recent blog posts, and as part of that we will learn how to show user avatars. Then we are going to create a web form for users to edit their profiles.

User Profile Page

Creating a user profile page does not really require any new major concepts to be introduced. We just need to create a new view function and its accompanying HTML template.

Here is the view function (file app/views.py):

@app.route('/user/<nickname>')
@login_required
def user(nickname):
    user = User.query.filter_by(nickname=nickname).first()
    if user == None:
        flash('User %s not found.' % nickname)
        return redirect(url_for('index'))
    posts = [
        {'author': user, 'body': 'Test post #1'},
        {'author': user, 'body': 'Test post #2'}
    ]
    return render_template('user.html',
                           user=user,
                           posts=posts)

The @app.route decorator that we used to declare this view function looks a little bit different than the previous ones. In this case we have an argument in it, which is indicated as <nickname>. This translates into an argument of the same name added to the view function. When the client requests, say, URL /user/miguel the view function will be invoked with nickname set to 'miguel'.

The implementation of the view function should have no surprises. First we try to load the user from the database, using the nickname that we received as argument. If that doesn't work then we just redirect to the main page with an error message, as we have seen in the previous chapter.

Once we have our user, we just send it in the render_template call, along with some fake posts. Note that in the user profile page we will be displaying only posts by this user, so our fake posts have the author field correctly set.

Our initial view template will be extremely simple (file app/templates/user.html):

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <h1>User: {{ user.nickname }}!</h1>
  <hr>
  {% for post in posts %}
  <p>
    {{ post.author.nickname }} says: <b>{{ post.body }}</b>
  </p>
  {% endfor %}
{% endblock %}

The profile page is now complete, but a link to it does not exist anywhere in the web site. To make it a bit more easy for a user to check his or her own profile, we are going to add a link to it in the navigation bar at the top (file `app/templates/base.html'):

    <div>Microblog:
        <a href="{{ url_for('index') }}">Home</a>
        {% if g.user.is_authenticated %}
        | <a href="{{ url_for('user', nickname=g.user.nickname) }}">Your Profile</a>
        | <a href="{{ url_for('logout') }}">Logout</a>
        {% endif %}
    </div>

Note how in the url_for function we have added the required nickname argument.

Give the application a try now. Clicking on the Your Profile link at the top should take you to your user page. Since we don't have any links that will direct you to an arbitrary user profile page you will need to type the URL by hand if you want to see someone else. For example, you would type http://localhost:5000/user/miguel to see the profile of user miguel.

Avatars

I'm sure you agree that our profile pages are pretty boring. To make them a bit more interesting, let's add user avatars.

Instead of having to deal with a possibly large collection of uploaded images in our server, we will rely on the Gravatar service to provide our user avatars.

Since returning an avatar is a user related task, we will be putting it in the User class (file app/models.py):

from hashlib import md5
# ...
class User(db.Model):
    # ...
    def avatar(self, size):
        return 'http://www.gravatar.com/avatar/%s?d=mm&s=%d' % (md5(self.email.encode('utf-8')).hexdigest(), size)

The avatar method of User returns the URL of the user's avatar image, scaled to the requested size in pixels.

Turns out with the Gravatar service this is really easy to do. You just need to create an md5 hash of the user email and then incorporate it into the specially crafted URL that you see above. After the md5 of the email you can provide a number of options to customize the avatar. The d=mm determines what placeholder image is returned when a user does not have an Gravatar account. The mm option returns the "mystery man" image, a gray silhouette of a person. The s=N option requests the avatar scaled to the given size in pixels.

The Gravatar's website has documentation for the avatar URL.

Now that our User class knows how to return avatar images, we can incorporate them into our profile page layout (file app/templates/user.html):

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <table>
      <tr valign="top">
          <td><img src="{{ user.avatar(128) }}"></td>
          <td><h1>User: {{ user.nickname }}</h1></td>
      </tr>
  </table>
  <hr>
  {% for post in posts %}
  <p>
    {{ post.author.nickname }} says: <b>{{ post.body }}</b>
  </p>
  {% endfor %}
{% endblock %}

The nice thing about making the User class responsible for returning avatars is that if some day we decide Gravatar avatars are not what we want, we just rewrite the avatar method to return different URLs (even ones that points to our own web server, if we decide we want to host our own avatars), and all our templates will start showing the new avatars automatically.

We have added the user avatar to the top of the profile page, but at the bottom of the page we have posts, and those could have a little avatar as well. For the user profile page we will of course be showing the same avatar for all the posts, but then when we move this functionality to the main page we will have each post decorated with the author's avatar, and that will look really nice.

To show avatars for the posts we just need to make a small change in our template (file app/templates/user.html):

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <table>
      <tr valign="top">
          <td><img src="{{ user.avatar(128) }}"></td>
          <td><h1>User: {{ user.nickname }}</h1></td>
      </tr>
  </table>
  <hr>
  {% for post in posts %}
  <table>
      <tr valign="top">
          <td><img src="{{ post.author.avatar(50) }}"></td><td><i>{{ post.author.nickname }} says:</i><br>{{ post.body }}</td>
      </tr>
  </table>
  {% endfor %}
{% endblock %}

Here is how our profile page looks at this point:

microblog profile page

Reusing at the sub-template level

We designed the user profile page so that it displays the posts written by the user. Our index page also displays posts, this time of any user. So now we have two templates that will need to display posts made by users. We could just copy/paste the portion of the template that deals with the rendering of a post, but that is really not ideal, because when we decide to change the layout of a post we'll have to remember to go update all the templates that have posts in them.

Instead, we are going to make a sub-template that just renders a post, then we'll include it in all the templates that need it.

To start, we create a post sub-template, which is really nothing more than a regular template. We do so by simply extracting the HTML for a post from our user template (file /app/templates/post.html):

<table>
    <tr valign="top">
        <td><img src="{{ post.author.avatar(50) }}"></td><td><i>{{ post.author.nickname }} says:</i><br>{{ post.body }}</td>
    </tr>
</table>

And then we invoke this sub-template from our user template using Jinja2's include command (file app/templates/user.html):

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <table>
      <tr valign="top">
          <td><img src="{{ user.avatar(128) }}"></td>
          <td><h1>User: {{ user.nickname }}</h1></td>
      </tr>
  </table>
  <hr>
  {% for post in posts %}
      {% include 'post.html' %}
  {% endfor %}
{% endblock %}

Once we have a fully functioning index page we will invoke this same sub-template from over there, but we aren't quite ready to do that, we'll leave that for a future chapter of this tutorial.

More interesting profiles

While we now have a nice profile page, we don't really have much information to show on it. Users like to tell a bit about them on these pages, so we'll let them write something about themselves that we can show here. We will also keep track of what was the last time each user accessed the site, so that we can also show that on their profile page.

To add these things we have to start by modifying our database. More specifically, we need to add two new fields to our User class (file app/models.py):

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    nickname = db.Column(db.String(64), index=True, unique=True)
    email = db.Column(db.String(120), index=True, unique=True)
    posts = db.relationship('Post', backref='author', lazy='dynamic')
    about_me = db.Column(db.String(140))
    last_seen = db.Column(db.DateTime)

Every time we modify the database we have to generate a new migration. Remember that in the database chapter we went through the pain of setting up a database migration system. We can see the fruits of that effort now. To add these two new fields to our database we just do this:

$ ./db_migrate.py

To which our script will respond:

New migration saved as db_repository/versions/003_migration.py
Current database version: 3

And our two new fields are now in our database. Remember that if you are on Windows the way to invoke this script is different.

If we did not have migration support you would have needed to edit your database manually, or worse, delete it and recreate it from scratch.

Next, let's modify our profile page template to show these fields (file app/templates/user.html):

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <table>
    <tr valign="top">
      <td><img src="{{user.avatar(128)}}"></td>
      <td>
        <h1>User: {{user.nickname}}</h1>
          {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
          {% if user.last_seen %}<p><i>Last seen on: {{ user.last_seen }}</i></p>{% endif %}
      </td>
    </tr>
  </table>
  <hr>
  {% for post in posts %}
    {% include 'post.html' %}
  {% endfor %}
{% endblock %}

Note we make use of Jinja2's conditionals to show these fields, because we only want to show them if they are set (at this point these two new fields are empty for all users, so nothing will show).

The last_seen field is pretty easy to support. Remember that in a previous chapter we created a before_request handler, to register the logged in user with the flask.g global, as g.user. That is the perfect time to update our database with the last access time for a user (file app/views.py):

from datetime import datetime
# ...
@app.before_request
def before_request():
    g.user = current_user
    if g.user.is_authenticated:
        g.user.last_seen = datetime.utcnow()
        db.session.add(g.user)
        db.session.commit()

If you log in to your profile page again the last seen time will now display, and each time you refresh the page the time will update as well, because each time the browser makes a request the before_request handler will update the time in the database.

Note that we are writing the time in the standard UTC timezone. We discussed this in a previous chapter, we will write all timestamps in UTC so that they are consistent. That has the undesired side effect that the time displayed in the user profile page is also in UTC. We will fix this in a future chapter that will be dedicated to date and time handling.

To display the user's about me information we have to give them a place to enter it, and the proper place for this is in the edit profile page.

Editing the profile information

Adding a profile form is surprisingly easy. We start by creating the web form (file app/forms.py):

from flask_wtf import Form
from wtforms import StringField, BooleanField, TextAreaField
from wtforms.validators import DataRequired, Length

class EditForm(Form):
    nickname = StringField('nickname', validators=[DataRequired()])
    about_me = TextAreaField('about_me', validators=[Length(min=0, max=140)])

Then the view template (file app/templates/edit.html):

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <h1>Edit Your Profile</h1>
  <form action="" method="post" name="edit">
      {{form.hidden_tag()}}
      <table>
          <tr>
              <td>Your nickname:</td>
              <td>{{ form.nickname(size=24) }}</td>
          </tr>
          <tr>
              <td>About yourself:</td>
              <td>{{ form.about_me(cols=32, rows=4) }}</td>
          </tr>
          <tr>
              <td></td>
              <td><input type="submit" value="Save Changes"></td>
          </tr>
      </table>
  </form>
{% endblock %}

And finally we write the view function (file app/views.py):

from .forms import LoginForm, EditForm

@app.route('/edit', methods=['GET', 'POST'])
@login_required
def edit():
    form = EditForm()
    if form.validate_on_submit():
        g.user.nickname = form.nickname.data
        g.user.about_me = form.about_me.data
        db.session.add(g.user)
        db.session.commit()
        flash('Your changes have been saved.')
        return redirect(url_for('edit'))
    else:
        form.nickname.data = g.user.nickname
        form.about_me.data = g.user.about_me
    return render_template('edit.html', form=form)

To make this page easy to reach, we also add a link to it from the user profile page (file app/templates/user.html):

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <table>
      <tr valign="top">
          <td><img src="{{ user.avatar(128) }}"></td>
          <td>
              <h1>User: {{user.nickname}}</h1>
              {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
              {% if user.last_seen %}<p><i>Last seen on: {{ user.last_seen }}</i></p>{% endif %}
              {% if user.id == g.user.id %}<p><a href="{{ url_for('edit') }}">Edit</a></p>{% endif %}
          </td>
      </tr>
  </table>
  <hr>
  {% for post in posts %}
      {% include 'post.html' %}
  {% endfor %}
{% endblock %}

Pay attention to the clever conditional we are using to make sure that the Edit link appears when you are viewing your own profile, but not when you are viewing someone else's.

Below is a new screenshot of the user profile page, with all the additions, and with some "about me" words written:

microblog profile page

Final words... and your homework!

So it seems we are pretty much done with user profiles, right? We sort of are, but we have a nasty bug that we need to fix.

Can you find it?

Here is a clue. We have introduced this bug in the previous chapter of the tutorial, when we were looking at the login system. And today we have written a new piece of code that has the same bug.

Think about it, and if you know what the problem is feel free to show off in the comments below. I will explain the bug, and how to fix it properly in the next chapter.

As always, here is the download link for the complete application with today's additions:

Download microblog-0.6.zip.

Remember that I'm not including a database in the archive. If you have a database from the previous chapter, just put it in the correct location and then run db_upgrade.py to upgrade it. If you don't have a previous database then call db_create.py to make a brand new one.

Thank you for following my tutorial. I hope to see you again in the next installment.

Miguel

Become a Patron!

Hello, and thank you for visiting my blog! If you enjoyed this article, please consider supporting my work on this blog on Patreon!

107 comments
  • #1 JonoB said

    Your post.html template is wrong, since you are creating a new <table> element for each post. Your for look should be inside the table, and inside the post.html template (not inside the user.html template)

  • #2 JonoB said

    I thought that jinja2 templates are safe by default, and that auto-escaping happens by default. By adding the |safe method, you STOP auto-escaping for that field. See http://flask.pocoo.org/docs/templating/ : use the |safe filter to explicitly mark a string as safe HTML ({{ myvariable|safe }})

  • #3 Miguel Grinberg said

    @JonoB: My intention was to create a different table per post, as that allows you to have other formatting elements or spacing between posts. The page could also very well work with a single table like you suggest. Thanks.

  • #4 Miguel Grinberg said

    @JonoB: absolutely right about the the "safe" qualifier. I have removed it and will find the opportunity to introduce it again in a later chapter. Thanks again for keeping me honest!

  • #5 GianPaJ said

    I already have done a Flask app. But it's great to revisit the steps I did about year ago and few things I should have done better :)

    Thanks so much for this tutorial!

  • #6 American Gaucho said

    I haven't looked at the later phases of the tutorial yet, but I'm guessing the bug has to do with the fact that we may have collisions with our OpenIDs. In fact, I would have hit this bug in my code if Yahoo presented its OpenIDs a bit differently than Google does. Whenever a collision happens between two OpenIDs, someone will simply be unable to register for our app.

    I'm glad you said there was a bug, because I noticed it during the login chapter and was wondering if it would ever be fixed lol. Thanks for an awesome tutorial so far - you could easily turn this thing into a great book!

  • #7 azed said

    This part of the code
    if g.user.is_authenticated():
    g.user.last_seen = datetime.utcnow()
    db.session.add(g.user)
    db.session.commit()

    raises AttributeError: 'RequestContext' object has no attribute 'user'

  • #8 Miguel Grinberg said

    @azed: the line right above the "if g.user.is_authenticated" sets g.user to current_user. Do you have that in your code?

  • #9 azed said

    Yeah i have it. This is the stack trace

    File "C:\Users\NOD\microblog\flask\lib\site-packages\flask\app.py", line 1701, in call
    return self.wsgi_app(environ, start_response)
    File "C:\Users\NOD\microblog\flask\lib\site-packages\flask\app.py", line 1689, in wsgi_app
    response = self.make_response(self.handle_exception(e))
    File "C:\Users\NOD\microblog\flask\lib\site-packages\flask\app.py", line 1687, in wsgi_app
    response = self.full_dispatch_request()
    File "C:\Users\NOD\microblog\flask\lib\site-packages\flask\app.py", line 1360, in full_dispatch_request
    rv = self.handle_user_exception(e)
    File "C:\Users\NOD\microblog\flask\lib\site-packages\flask\app.py", line 1356, in full_dispatch_request
    rv = self.preprocess_request()
    File "C:\Users\NOD\microblog\flask\lib\site-packages\flask\app.py", line 1539, in preprocess_request
    rv = func()
    File "C:\Users\NOD\microblog\app\views.py", line 75, in before_request
    if g.user.is_authenticated():
    File "C:\Users\NOD\microblog\flask\lib\site-packages\werkzeug\local.py", line 336, in getattr
    return getattr(self._get_current_object(), name)
    File "C:\Users\NOD\microblog\flask\lib\site-packages\werkzeug\local.py", line 295, in _get_current_object
    return self.__local()
    File "C:\Users\NOD\microblog\flask\lib\site-packages\flask_login.py", line 403, in <lambda>
    current_user = LocalProxy(lambda: _request_ctx_stack.top.user)
    AttributeError: 'RequestContext' object has no attribute 'user'

  • #10 Miguel Grinberg said

    @azed: I can't really confirm that g.user was set before by looking at the stack trace. I was able to reproduce your stack trace by not initializating the LoginManager class. Look in app/init.py for a line that reads lm.init_app(app). Any chance you missed this?

    In general when you have a problem like this you may also want to download my version of the application and try it alongside yours to see what the differences are.

  • #11 azed said

    I initialized the LoginManager class. I'll down load yours and try it.
    Thanks for the tutorial and your time.

  • #12 azed said

    I have solved the issue. I moved the lm.init_app(app) 3 steps higher in my code. I figured if it was the problem when you tried to reproduce my error, then it means my app was crashing before it got to that line.

    Thanks once again.

  • #13 saurshaz said

    Hi Miguel -

    Thanks a lot for this series .. Learning a lot. My python experience is beginner level. But I am pretty experienced in JS and java based web development

    One issue i see is that in case of form errors, the edit form loses the changed values(which contain the error)

    for ex- a text section for about_me containing more than 140 characters. DO you have a way to solve that problem.

    Thanks again

  • #14 Miguel Grinberg said

    @saurshaz: I'm not sure I understand what you mean. WTForms makes sure values for form fields are remembered, are your fields going back to blank when there is an error?

  • #15 Stefan said

    I had to change:
    from flask.ext.wtf import Form, TextField, BooleanField, TextAreaField
    from flask.ext.wtf import Required, Length
    to:
    from flask.ext.wtf import Form
    from wtforms import TextField, BooleanField, TextAreaField
    from wtforms.validators import Required, Length

    I think this may be related to a known issue with Flask.ext?! (I am on Windows in case that matters)

  • #16 wang long said

    Last seen on: 2013-09-01 14:07:06.775599

    in app/views.py, the datetime format is wrong.
    def before_request():
    g.user = current_user
    if g.user.is_authenticated():
    g.user.last_seen = datetime.utcnow()
    db.session.add(g.user)
    db.session.commit()

  • #17 Liam said

    Trying to nut this out - my Edit page loads with heading 'Edit Your Profile' but there's nothing underneath. So far the code looks the same. Any ideas?

  • #18 Miguel Grinberg said

    @Liam: Did you check the edit.html template? Try replacing your version with mine from github.

  • #19 Yifan Wu said

    Hi Miguel, I was implementing a slightly more complex profile with a few dropdown options for birth year (etc). I was wondering if there is a way for Flask to remember the saved selection from what's stored on the user profile (as opposed to defaulting to the top on the choices list for the selection). Thanks so much and looking forward to your book.

  • #20 Miguel Grinberg said

    @Yifan: Yes, you can set any form field to the value of your choice by setting "form.field-name.data" to that value before displaying the form.

  • #21 Siyuan said

    I think the bug is missing the csrf token. Since I type in words in "about me" text area, it can not be saved. The form.validate_on_sutmit() return False. Then I print out form.errors and get the "CSRF token missing" message.

  • #22 Siyuan said

    Oh, that's my fault. I just missing it in my edit.html.

  • #23 Rich said

    In part 3, we imported TextField, BooleanField and Required from wtforms and wtforms.validators. In this part, we changed to importing them from flask.ext.wtf. Is this importing the exact same classes because of an inheritance hierarchy, or are they different classes with more/less/different functionality? Not a big deal, but would appreciate an explanation of the change.

  • #24 Miguel Grinberg said

    @Rich: my bad. In an older release of Flask-WTF you had to import everything from flask.ext.wtf. Current releases stopped supporting that and now only Form comes from flask.ext.wtf, while everything else is imported straight from wtforms. I updated Part 3 with this change but missed Part 4. This is now corrected.

  • #25 Samuel said

    Hi Miguel, thanks for your tutorial. Till this moment i had not problems with app, but now im facing one.
    What could be wrong with saving about_me information. Each time i hit 'save changes' after editing, it does nothing. It does not save anything. It just creates a new object in memory from, i can see it from 'hidden_tag' info, first hit:
    <bound method EditForm.hidden_tag of \<app.forms.EditForm object at 0x7f9c9813f410>>
    Second hit:
    <bound method EditForm.hidden_tag of \<app.forms.EditForm object at 0x7f9c98146290>>
    In my opinion it might be that my environment set up wrong, it has to deal something with database, but it still does save my last session time though.

Leave a Comment