Profile Page and Avatars (2024 Edition)

This is the sixth part of the Flask mega tutorial series in which I am going to tell you how to create a user profile page.

Table of contents

This chapter will focus on adding user profile pages to the application. A user profile page is a page that presents information about a user, often with information entered by the users themselves. I'll show you how to dynamically create profile pages for all users, and then add a small profile editor that users can use to enter their information.

GitHub links for this chapter: Browse, Zip, Diff.

User Profile Page

To create a user profile page, let's add a route to the application /user/.

app/routes.py: User profile view function

@app.route('/user/<username>')
@login_required
def user(username):
    user = db.first_or_404(sa.select(User).where(User.username == username))
    posts = [
        {'author': user, 'body': 'Test post #1'},
        {'author': user, 'body': 'Test post #2'}
    ]
    return render_template('user.html', user=user, posts=posts)

Decorator @app.route, which I used to declare this view function, looks a little different than the previous ones. In this case, I have a dynamic component in it, which is designated as a URL component <username>surrounded < And >. When a route has a dynamic component, Flask will accept any text in that part of the URL and call the view function with the actual text as an argument. For example, if the client browser requests the URL /user/susanthe view function will be called with an argument for which username set value 'susan'. This view feature will only be available to authorized users, so I added a decorator @login_required from Flask-Login.

The implementation of this view function is quite simple. First I'm trying to load a user from a database using a query by username. You have already seen earlier that a database query can be done using db.session.scalars() if you want to get all the results, or db.session.scalar() if you want to get only the first result, or None if there are no results. In this view function I am using the option scalar()provided by Flask-SQLAlchemy called db.first_or_404()which works like scalar() if there are results, but in case there are no results, it automatically sends error 404 back to the client. By running the query this way, I save myself the hassle of checking whether the user's query returned, because when the username does not exist in the database, the user's function will not return and a 404 exception will be thrown instead.

If the database query does not generate a 404 error, then it means that a user with the specified username was found. Then I initialize a fake list of entries for that user and create a new one user.html template to which I pass a user object and a list of entries.

Sample user.html shown below:

app/templates/user.html: User Profile Template

{% extends "base.html" %}

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

The profile page is now complete, but there is no link to it anywhere on the website. To make it a little easier for users to check their profile, I'm going to add a link to it in the navigation bar at the top:

app/templates/base.html: User Profile Template

<div>
  Microblog:
  <a href="https://habr.com/ru/articles/809411/{{ url_for("index') }}">Home</a>
  {% if current_user.is_anonymous %}
  <a href="https://habr.com/ru/articles/809411/{{ url_for("login') }}">Login</a>
  {% else %}
  <a href="https://habr.com/ru/articles/809411/{{ url_for("user', username=current_user.username) }}">Profile</a>
  <a href="https://habr.com/ru/articles/809411/{{ url_for("logout') }}">Logout</a>
  {% endif %}
</div>

The only interesting change here is the challenge url_for(), which is used to generate a link to the profile page. Because the user profile view function takes a dynamic argument, the function url_for() gets the value for that part of the URL as a keyword argument. Since this is a link pointing to the logged in user's profile, I can use current_user Flask-Login to generate the correct URL.

Try the app now. By clicking on the link Profile at the top, you will be taken to your own user page. At this stage, there are no links that will take you to other users' profile pages, but if you want to access these pages, you can enter the URL manually in your browser's address bar. For example, if you have a user named “john” registered in your application, you can view the corresponding user profile by typing http://localhost:5000/user/john in the address bar.

Avatars

I'm sure you'll agree that the profile pages I just created are pretty boring. To make them a little more interesting, I'm going to add user avatars, but instead of dealing with a possibly large collection of uploaded images on the server, I'm going to use a service Gravatar to provide images to all users.

Gravatar is very easy to use. To request an image for a given user, you must use a URL in the format https://www.gravatar.com/avatar /Where <hash> – MD5 hash of the user's email address. Below you can see how to get the Gravatar URL for a user with an email john@example.com:

>>> from hashlib import md5
>>> 'https://www.gravatar.com/avatar/' + md5(b'john@example.com').hexdigest()
'https://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6'

If you want to see a real example, my own Gravatar URL:

https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35

Here's what Gravatar returns for this URL when you enter it into your browser's address bar:

By default the returned image size is 80×80 pixels, but you can request a different size by adding an argument s into the URL query string. For example, to get your own avatar as a 128×128 pixel image, use the URL https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35?s=128.

Another interesting argument that can be passed to Gravatar as a query string argument is d, which determines what image Gravatar provides to users who don't have an avatar registered with the service. My favorite is called “identicon”, which returns a beautiful geometric design that is different for each email. For example:

Please note that some privacy web browser extensions, such as Ghostery, block Gravatar images because they believe that Automattic (the owners of the Gravatar service) can determine which sites you visit based on the requests they receive to your avatar. If you are not seeing avatars in your browser, consider that the problem may be related to an extension you have installed in your browser.

Since avatars are associated with users, it makes sense to add the logic that generates avatar URLs to the user model.

app/models.py: User avatar URLs

from hashlib import md5
# ...

class User(UserMixin, db.Model):
    # ...
    def avatar(self, size):
        digest = md5(self.email.lower().encode('utf-8')).hexdigest()
        return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}'

New method avatar() class User returns the URL of the user's avatar image, scaled to the required pixel size. For users who do not have an avatar registered, an “identicon” image will be generated. To generate the MD5 hash, I first convert the email to lower case as the Gravatar service requires it. Then, since Python's MD5 support works on bytes and not strings, I encode the string as bytes before passing it to the hash function.

If you are interested in exploring other options that the Gravatar service offers, visit them documentation site.

The next step is to insert avatar images into the user profile template:

app/templates/user.html: User avatar in the template

{% extends "base.html" %}

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

The best part about creating a class User responsible for returning avatar URLs is that if one day I decide that Gravatar avatars are not what I want, I can simply rewrite the method avatar()allowing you to return different URLs and all templates will automatically start showing new avatars.

I now have a nice big avatar at the top of my user profile page, but there's really no reason to stop there. At the bottom I have several messages from the user, each of which may also have a small avatar. For the user profile page, of course all posts will have the same avatar, but then I can implement the same functionality on the home page and then each post will be decorated with the author's avatar and it will look really nice.

To show avatars for individual posts, I just need to make one more small change to the template:

app/templates/user.html: User avatars in messages

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="https://habr.com/ru/articles/809411/{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
    <table>
        <tr valign="top">
            <td><img src="https://habr.com/ru/articles/809411/{{ post.author.avatar(36) }}"></td>
            <td>{{ post.author.username }} says:<br>{{ post.body }}</td>
        </tr>
    </table>
    {% endfor %}
{% endblock %}

Using Nested Jinja Templates

I designed the user profile page to display posts written by the user along with their avatars. Now I want the index page to also display similarly styled entries. I could just copy/paste the post rendering portion of the template, but that's really not ideal because later if I decide to make changes to that layout, I'll have to remember to update both templates.

Instead I'm going to create a subtemplate that displays just one message and then I'm going to link to it from both templates user.html And index.html . To get started, I can create a sub-template using HTML markup for just one post. I'm going to name this template app/templates/_post.html. Prefix _ is just a naming convention to help me recognize which template files are nested templates.

app/templates/_post.html: Nested template for publication

<table>
  <tr valign="top">
    <td><img src="https://habr.com/ru/articles/809411/{{ post.author.avatar(36) }}"></td>
    <td>{{ post.author.username }} says:<br>{{ post.body }}</td>
  </tr>
</table>

To call this sub-template from a template user.html I use the operator include Jinja:

app/templates/user.html: User avatars in messages

{% extends "base.html" %}

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

The main page of the application is not really finalized yet, so I'm not going to add this functionality there yet.

More interesting profiles

One of the problems that new user profile pages have is that they don't show much. Users like to talk a little about themselves on these pages, so I'm going to let them write something about themselves to show here. I'm also going to keep track of when each user last visited the site and display this on their profile page.

The first thing I need to do to support all this additional information is to expand the table users in the database with two new fields:

app/models.py: New fields in the user model

class User(UserMixin, db.Model):
    # ...
    about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140))
    last_seen: so.Mapped[Optional[datetime]] = so.mapped_column(
        default=lambda: datetime.now(timezone.utc))

Every time you change the database, you need to generate a database migration. IN Chapter 4 I showed you how to set up an application to track database changes using migration scripts. Now I have two new fields that I want to add to the database, so the first step is to create a migration script:

(venv) $ flask db migrate -m "new fields in user model"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'user.about_me'
INFO  [alembic.autogenerate.compare] Detected added column 'user.last_seen'
  Generating migrations/versions/37f06a334dbf_new_fields_in_user_model.py ... done

Command output migrate looks good because it shows that two new fields in the class have been discovered User . Now I can apply this change to the database:

(venv) $ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 780739b227a7 -> 37f06a334dbf, new fields in user model

I hope you understand how useful it is to work with a migration framework. All the users that were in the database are still there, the migration framework surgically applies the changes to the migration script without destroying any data.

In the next step, I'm going to add these two new fields to the user profile template:

app/templates/user.html: Show user information in user profile template

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="https://habr.com/ru/articles/809411/{{ user.avatar(128) }}"></td>
            <td>
                <h1>User: {{ user.username }}</h1>
                {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
                {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
            </td>
        </tr>
    </table>
    ...
{% endblock %}

Note that I'm adding these two fields to the Jinja legend because I only want them to be visible if they are set. These two new fields are currently empty for all users, so you won't see them yet.

Recording the user's last presence time

Let's start with the field last_seen, which is the simpler of the two. What I want to do is indicate the current time in this field for a given user whenever that user submits a request to the server.

Adding a login to set this field on all possible view functions that might be requested from the browser is obviously impractical, but doing some general logic before sending a request to the view function is such a common task in web applications that Flask offers it as a built-in feature. Take a look at the solution:

app/routes.py: Record last visited time

from datetime import datetime, timezone

@app.before_request
def before_request():
    if current_user.is_authenticated:
        current_user.last_seen = datetime.now(timezone.utc)
        db.session.commit()

Decorator @before_request from Flask registers a function that will be executed immediately before the view function. This is extremely useful because now I can insert the code that I want to run before any view function in the application and I can put it in one place. The implementation simply checks whether current_user to the logged in user, and in this case in the field last_seen the current time is set. I mentioned this earlier, the server application must work in time units and standard practice is to use the UTC time zone. Using the system's local time is not a good idea because then what goes into the database depends on your location.

The last step is to commit the database session so that the above change is written to the database. If you're wondering why not db.session.add() before committing please keep this in mind when referencing current_user. Flask-Login calls the user loader callback function, which will run a database query that will place the target user in a database session. So, you can add the user again to this function, but it is not necessary because it is already there.

If you view your profile page after making this change, you will see the line “Last seen on” with a time very close to the current one. And if you leave the profile page and then come back, you will see the time constantly updated.

The fact that I save these timestamps in the UTC time zone causes the time displayed on the profile page to also be in UTC. In addition to this, the time format is not what you would expect since it is actually a Python object's internal representation datetime . I'm not going to worry about these two issues for now, since I'm going to cover the topic of handling dates and times in a web application in a later chapter.

Profile editor

I also need to provide users with a form where they can enter some information about themselves. The form will allow users to change their username as well as write something about themselves to be saved in a new field about_me. Let's start writing a form class for it:

app/forms.py: Profile editor form

from wtforms import TextAreaField
from wtforms.validators import Length

# ...

class EditProfileForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
    submit = SubmitField('Submit')

In this form I am using a new field type and a new validator. For the “About me” field I use TextAreaField, which is a multiline field in which the user can enter text. To validate this field I use a validator Lengthwhich ensures that the entered text contains between 0 and 140 characters, which is how much space I have allocated for the corresponding field in the database.

The template that displays this form is shown below:

app/templates/edit_profile.html: Profile editor form

{% extends "base.html" %}

{% block content %}
    <h1>Edit Profile</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.username.label }}<br>
            {{ form.username(size=32) }}<br>
            {% for error in form.username.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.about_me.label }}<br>
            {{ form.about_me(cols=50, rows=4) }}<br>
            {% for error in form.about_me.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

And finally, here's the view function that ties everything together:

app/routes.py: Editing the profile view function

from app.forms import EditProfileForm

@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
    form = EditProfileForm()
    if form.validate_on_submit():
        current_user.username = form.username.data
        current_user.about_me = form.about_me.data
        db.session.commit()
        flash('Your changes have been saved.')
        return redirect(url_for('edit_profile'))
    elif request.method == 'GET':
        form.username.data = current_user.username
        form.about_me.data = current_user.about_me
    return render_template('edit_profile.html', title="Edit Profile",
                           form=form)

This view function handles the form a little differently. If validate_on_submit() returns Truethen I copy the data from the form to the user object and then write the object to the database. But when validate_on_submit() returns False this could be due to two different reasons. Firstly, it could be because the browser has just sent a request GET, which I need to answer by providing an initial version of the form template. This could also be when the browser sends a request POST with the form data, but something about the data is wrong. For this form, I need to consider these two cases separately. When the form is requested for the first time with a request GET, I want to pre-populate the fields with data that is stored in the database, so I need to do the reverse of what I did in the submit case and move the data stored in the user fields into the form, as this ensures that those form fields are stored current data for the user. But in case of a validation error, I don't want to write anything to the form fields because they were already populated by WTForms. To differentiate between these two cases I check request.methodwhich will GET for the initial request and POST for a submission that has not been verified.

To make it easier for users to access the profile editor page, I can add a link to their profile page:

app/templates/user.html: Link to edit profile

{% if user == current_user %}
  <p><a href="https://habr.com/ru/articles/809411/{{ url_for("edit_profile') }}">Edit your profile</a></p>
{% endif %}

Notice the clever condition I use to make sure the edit link appears when you're viewing your own profile, but not when you're viewing someone else's profile.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *