Series: Building a Social Network with Flask & Stream – Part 9

9 min read
Spencer P.
Spencer P.
Published March 5, 2020 Updated March 4, 2021

This post is the ninth installment of a tutorial series focused on how to set up a full-stack application using Flask and Stream. This week, we’re going to be creating everything we need to make immersive social experiences for our app using follow relationships! Follow relationships includes the following of users, collections, as well as building a customized homepage timeline and notifications screen for each user. Be sure to check out the Github repo to follow along!

Getting Started

Our first step is to create our feeds. We are going to be using the aggregate feed type for our homepage, and a notifications feed (go figure) for our notifications page. Navigating to the Stream dashboard, click the add feed group button. Next, add the feeds:

Many-To-Many

Follow relationships, whether between users and collections or users and users, can be complicated structures to model. In the past, we have connected relationships between tables purely in the User, Collection, or Content tables. For this type, however, we will need to create an entirely new table to connect them. The nice part is that both of them will be remarkably similar, as what they are trying to accomplish is the same. We are going to start by creating those tables ( in app/models.py).

#...

class Follow(db.Model):
    __tablename__ = 'follows'
    follower_id = db.Column(db.Integer, db.ForeignKey('users.id'),
                            primary_key=True)
    followed_id = db.Column(db.Integer, db.ForeignKey('users.id'),
                            primary_key=True)
    timestamp = db.Column(db.DateTime, default=datetime.utcnow)

    
class CollectionFollow(db.Model):
    __tablename__ = 'collection_follows'
    follower_id = db.Column(db.Integer, db.ForeignKey('users.id'),
                            primary_key=True)

There is a lot of information there, so I will break it down. The Follow and CollectionFollow tables include the ID number for the follower, as well as the ID for the followed user, or collection (respectively). It also includes a timestamp so we can see when that association was formed. The additions to the User and Collection tables for following a collection should seem pretty familiar; we are just proxying that relationship to the new table. For the user-to-user follow, we are using a self-referential relationship. This means that the table is referencing itself for both the follower and the followee.

Methods to the Madness

Now that our models are created, we need ways to interact with it using methods. We will want to add a follow relationship, remove it, as well as check if one already exists, so we don't accidentally create multiple. We'll also make sure that these actions are updated to our Stream feeds as they happen. Once again, the actions will be similar between the two, so we will create them all together (in app/models.py).

#...

class User(db.Model, UserMixin):
    #...
    
    
    def is_following(self, user):
        if user.id is None:
            return False
        return self.followed.filter_by(
            followed_id=user.id).first() is not None

    def is_followed_by(self, user):
        if user.id is None:
            return False

The is_following, follow, and unfollow for both users and collections accomplish what we outlined above. Still, I added a new method that checks if the following relationship for users extends the other way. This will let us have a “follows you” tag in a user’s profile if the relationship exists.

Follow, Unfollow

Our next move is creating the endpoints for the follow and unfollow relationships (in app/main/views.py).

#...
from ..decorators import admin_required, permission_required
#...

@main.route('/follow/<username>')
@login_required
@permission_required(Permission.FOLLOW)
def follow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('Invalid user.')
        return redirect(url_for('.index'))
    if current_user.is_following(user):
        flash('You are already following %s.' % user.username)
        return redirect(url_for('.user', username=username))

The functions themselves once again work in a very similar fashion to each other. They first check to see if the object to be followed exists (a sensible start), before moving into ensuring the relationship hasn’t already been made. After passing those checks, it creates the following and uploads the activity to Stream, and commits the entry. For unfollowing, it is almost the same, except that it checks to make sure the relationship exists before trying to delete it.

It’s All About Me

Most users will expect to see their posts along with their friends when scrolling through a timeline or even notification screen. For this, we are going to add some self follow functions to the User class as well as adding this function to the deploy CLI method. We’ll start by creating the functions (in app/models.py).

#...

class User(db.Model, UserMixin):
  #...
  
    @staticmethod
    def add_self_follows():
        for user in User.query.all():
            if not user.is_following(user):
                user.follow(user)
                db.session.add(user)
                db.session.commit()
  
    @staticmethod
      def add_self_collection_follows():

Our next step is to add it to the deploy CLI command ( in application.py).

#...

@app.cli.command()
def deploy():
  #...
  
  # ensure all users are following their collections
  User.add_self_collection_follows()
  
  # ensure all users are following themselves
  User.add_self_follows()
  

Testing 1, 2

Now that we’ve created some new models and functionality, we should take some time to develop tests for them to ensure everything is working as it should. We’ll start with the collection follows (in tests/test_collection_model.py)

#...

class UserModelTestCase(unittest.TestCase):
    #...
    def test_collection_follow(self):
      client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET'])
      u1 = User(id=9999999999, username='john', email='john@example.com', password='test')
      u2 = User(id=9999999998, username='mary', email='mary@example.org', password='test1')
      db.session.add(u1)
      db.session.add(u2)
      db.session.commit()
      token1 = u1.generate_confirmation_token()
      token2 = u2.generate_confirmation_token()
      self.assertTrue(u1.confirm(token1))
      self.assertTrue(u2.confirm(token2))

Here we create two new users with the first creating a collection. We then check to make sure the second user isn’t already following it, before creating, checking, and destroying that relationship. Cleaning up, we erase the users from Stream. Next, we will do the same thing for user follows ( in tests/test_user_model.py)

#...

class UserModelTestCase(unittest.TestCase):
  #...
  def test_follows(self):
        client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET'])
        u1 = User(id=9999999999, username='john', email='john@example.com', password='test')
        u2 = User(id=9999999998, username='mary', email='mary@example.org', password='test1')
        db.session.add(u1)
        db.session.add(u2)
        db.session.commit()
        token1 = u1.generate_confirmation_token()
        token2 = u2.generate_confirmation_token()
        self.assertTrue(u1.confirm(token1))
        self.assertTrue(u2.confirm(token2))

Before we move, be sure to give a quick flask test in the CLI to make sure that everything is working!

Next Steps

At this point, if a new user were to join, we wouldn't be able to find them, nor them us. We need to make a way to have users find each other on our app. Stream does not provide the functionality to retrieve a list of all the users in our app, but in my mind, that is a lot better than potentially having a malicious actor use a token to get all of our user info. Stream rightly assumes that we have our users stored on our system, where we can use permissioned access to that resource using login_required. Since we don't have access to Stream’s lightning-quick data retrieval, we’ll be switching the pagination method from infinite scroll to a button-based system. I find that if you can't do something correctly, don’t do it at all. If a user perceives an infinite scroll to be clunky and slow to load, that impression is quickly spread across the rest of the site. If at a certain point, you notice that it’s taking a long time to find someone, you can also quickly scale up and down the number of returned results (in config.py) to reduce the number of pages. We will start this section by creating a view for all users (in app/main/views.py)

from flask import render_template, flash, redirect, url_for, abort, request, current_app
#...

@main.route('/users')
def users():
    page = request.args.get('page')
    pagination = User.query.filter(User.confirmed == True).filter(User.id != current_user.id).paginate(
        page,
        per_page=current_app.config['OFFBRAND_RESULTS_PER_PAGE'],
        error_out=False)
    users = pagination.items
    return render_template('users.html', users=users, pagination=pagination)


 #...

Next is creating a pagination tool to render the pages at the bottom of the screen. This will be kept in a new file that will be imported to the pages that use it (in app\templates\_macros.html).

{% macro pagination_widget(pagination, endpoint, fragment='') %}
<ul class="pagination">
    <li{% if not pagination.has_prev %} class="disabled"{% endif %}>
        <a href="{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{{ fragment }}{% else %}#{% endif %}">
            &laquo;
        </a>
    </li>
    {% for p in pagination.iter_pages() %}
        {% if p %}
            {% if p == pagination.page %}
            <li class="active">
                <a href="{{ url_for(endpoint, page = p, **kwargs) }}{{ fragment }}">{{ p }}</a>
            </li>
            {% else %}
            <li>

Now we will create a page to render the list of returned results on the page. As this template will only ever be imported and never be rendered as an independent page, we will use an underscore preceding its name (in app/templates/_users.html)

<ul class="users">
    {% for user in users %}
    <li class="user">
        <div class="user-thumbnail">
            <a href="{{ url_for('.user', username=user.username) }}">
                <img class="img-rounded profile-thumbnail" src="{{ user.gravatar(size=40) }}">
            </a>
        </div>
        <div class="user-info">
            <div class="user-username">
                <a href="{{ url_for('.user', username=user.username) }}">{{ user.username }}</a>
            </div>
        </div>
    </li>
    {% endfor %}
</ul>

After that, we will create the user's page that is currently being returned by the view and import the _users template inside (in app/templates/users.html)

{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block title %}Offbrand - Users{% endblock %}

{% block page_content %}
<div>
    <div class="page-header">
        <h2 id="page-title">Users</h2>
    </div>
</div>
{% include '_users.html' %}
{% if pagination %}
<div class="pagination">
    {{ macros.pagination_widget(pagination, '.users') }}
</div>
{% endif %}
{% endblock %}

Finally, we need to create a link to the page on the navbar to allow us to navigate to the user's page (in app/templates/base.html)

#...
    <ul class="nav navbar-nav">
        <li><a href="{{ url_for('main.index') }}">Home</a></li>
        {% if current_user.is_authenticated %}
        <li><a href="{{ url_for('main.user', username=current_user.username) }}">Profile</a></li>
        <li><a href="{{ url_for('main.users') }}">Users</a></li>
        <li><a href="{{ url_for('main.new_collection') }}">Add Collection</a></li>
        <li><a href="{{ url_for('main.new_content') }}">Add Content</a></li>
        {% endif %}
    </ul>
#...

F4F

Most social networks give you the ability to see who is following who, and who is followed by whom. We will implement this ourselves, as well as showing a count of those followers on the user/collection page.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

Since it is the more basic one, we will start with creating the collection followers template (in app/templates/collection_followers.html).

{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block title %}Offbrand - Followers{% endblock %}

{% block page_content %}
<div class="page-header">
    <h2 id="page-title">{{ title }}</h2>
</div>
<table class="table table-hover followers">
    <thead><tr><th>User</th><th>Since</th></tr></thead>
    {% for follow in follows %}
    {% if follow.user != collection.author %}
    <tr>
        <td>

User followers will be slightly more complicated, as unlike collections, users will both follow and be followed. We want to try and keep the number of pages to a minimum (DRY!), so we will need to use a little creativity with our templating (in app/templates/followers.html)

{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block title %}Offbrand - {{ title }} {{ user.username }}{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>{{ title }} {{ user.username }}</h1>
</div>
<table class="table table-hover followers">
    <thead><tr><th>User</th><th>Since</th></tr></thead>
    {% for follow in follows %}
    {% if follow.user != user %}
    <tr>
        <td>

Now that the templates are finished, we create the views to return them. Since the many to many relationships separate the classes from each other, we will need to use a list comprehension to get the details on each user and pass them through as a variable in the template (in app/main/views.py)


#...
@main.route('/followers/<username>')
def followers(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('Invalid user.')
        return redirect(url_for('.index'))
    page = request.args.get('page', 1, type=int)
    pagination = user.followers.paginate(
        page, per_page=current_app.config['OFFBRAND_RESULTS_PER_PAGE'],
        error_out=False)
    follows = [{'user': item.follower, 'timestamp': item.timestamp}
               for item in pagination.items]
    return render_template('followers.html', user=user, title="Followers of",

Finally, we will need a way to access these pages, so we can update our user and collection pages to give a count as well as a link to see who is following or being followed by what. The collection page will be first (in app/templates/collection.html)

#...
        </p>
        <div>
            <a href="{{ url_for('.collection_followers', id=collection.id) }}">Followers: <span class="badge">{{ collection.user_followers.count() - 1 }}</span></a>
        </div>
        {% if collection.author == current_user or current_user.is_administrator() %}
        #...

Now the same for the user page (in app/templates/user.html)

                
#...
{% endif %}
<a href="{{ url_for('.followers', username=user.username) }}">Followers: <span class="badge">{{ user.followers.count() - 1 }}</span></a>
<a href="{{ url_for('.followed_by', username=user.username) }}">Following: <span class="badge">{{ user.followed.count() - 1 }}</span></a>
{% if current_user.is_authenticated and user != current_user and user.is_following(current_user) %}
#...

Act of Creation

Before we move on, create a new user and confirm them, and if you’re feeling particularly adventurous, create a new collection with some content for them. Remember before you do so to run flask deploy to migrate and update the database, as well as to add the self follows to both your profile and your collection!

Injections

Before we can follow a user or a collection, we will have to make sure that the user has the follow level permissions. We will be “injecting” permission to our views to make them available in our templates (in app/main/__init__.py)

#...
from ..models import Permission


@main.app_context_processor
def inject_permissions():
    return dict(Permission=Permission)

On The Button

We will be updating the user page to add a dynamic follow/unfollow button dependent on the current follow status (in app/templates/user.html)

#...
<p>Member since {{ moment(user.member_since).format('L') }}. Last seen {{ moment(user.last_seen).fromNow() }}.</p>
<p>
    {% if current_user.can(Permission.FOLLOW) and user != current_user %}
        {% if not current_user.is_following(user) %}
        <a href="{{ url_for('.follow', username=user.username) }}" class="btn btn-primary">Follow</a>
        {% else %}
        <a href="{{ url_for('.unfollow', username=user.username) }}" class="btn btn-default">Unfollow</a>
        {% endif %}
    {% endif %}
    {% if current_user.is_authenticated and user != current_user and user.is_following(current_user) %}
    | <span class="label label-default">Follows you</span>
    {% endif %}
</p>
#...

Next, we will do the same for our collection page (in app/templates/collections.html)

#...
<p id="collection-description">{{ collection.description }}</p>
<p>
    {% if current_user.can(Permission.FOLLOW) and user != current_user %}
        {% if not current_user.is_following_collection(collection) %}
        <a href="{{ url_for('.follow_collection', id=collection.id) }}" class="btn btn-primary">Follow</a>
        {% else %}
        <a href="{{ url_for('.unfollow_collection', id=collection.id) }}" class="btn btn-default">Unfollow</a>
        {% endif %}
    {% endif %}
</p>
#...

Finally, a little bit of housekeeping. Since there can be unauthenticated users who will be accessing our site, we will want to create an empty Stream user token for anonymous users so that a non-authenticated user won't cause the site to crash ( in app/models.py)

#...

class AnonymousUser(AnonymousUserMixin):
  #...
  def stream_user_token(self):
    return None

Following

If you haven't created a new user and some collections/content, do so now. It will help provide some validation after we are finished that everything is working properly. If/once you have, navigate to that user’s page with your original account (using that spiffy new all users endpoint) and follow both the account and it’s collection. You should see all of the new features that we’ve built so far.

Index and Notifications

Aggregate and Notification feeds have some important distinctions between flat feeds in the way that data is retrieved. As such, we will have to make some small changes to the script we used in our user and collection pages to construct our infinite scroll feed. Starting with the homepage timeline (in app/templates/index.html)

{% extends "base.html" %}

{% block title %}Offbrand{% endblock %}

{% block page_content %}
    <div class="page-header">
        <h1>Welcome to Offbrand!</h1>
    </div>
    {% if current_user.can(Permission.FOLLOW) %}
    <div class="collection-content">
        <div id="content-scroller">
            {% include "_content.html" %}
        </div>
    </div>
    <div id="content-sentinel"></div>

Next, we need to create our notification page (in app/templates/notifications.html)

{% extends "base.html" %}

{% block title %}Notifications{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Notifications</h1>
</div>
<div class="user-collections">
    <div id="collection-scroller">
        {% include "_collection.html" %}
    </div>
</div>
<div id="collection-sentinel"></div>
<script>

While similar to flat feeds, there are some significant changes that I want to run through. First, the results now contain a nested list comprising “activities”. For the aggregated feed, it allows you to create custom logic to compress multiple similar activities (x added y new pieces of content to z collection). Notifications have their benefits, like seen and read receipts that can be updated in your JavaScript request. We will be taking advantage of this next week when we integrate a counter into the navbar to help users keep track of new activities.

After setting up the templates, we will update the view for the index as well as a notifications file (in app/main/views.py).

#...

# Index
@main.route('/')
def index():
    token = current_user.stream_user_token()
    return render_template('index.html', token=token, user=current_user)

# Notifications
@main.route('/notifications')
def notifications():
    token = current_user.stream_user_token()
    return render_template('notifications.html', token=token, user=current_user)
  
#...

Last but not least, we will include the notifications link within the user options tab in the navbar (in app/templates/base.html)

#...
<ul class="dropdown-menu">
    <li><a href="{{ url_for('main.notifications') }}">Notifications</a></li>
    <li><a href="{{ url_for('auth.change_password') }}">Change Password</a></li>
    <li><a href="{{ url_for('auth.change_email_request') }}">Change Email</a></li>
    <li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>
</ul>
#...

Sanity Check

Running flask deploy and flask run, we can see our brand new homepage. This will show all of the newest content added to any collections the user is following. In the top right corner under an account, there is a notifications tab that will show you all of the newest collections created by users that you follow!

Final Thoughts

It’s taken a little bit of thought and time to get here, but the core elements of a fully-featured social web app are taking shape. We have customized feeds, a navigation system, and (hopefully not too) complex follow relationships between users. Users can create, edit, and delete content on the site, as well as have a customizable profile page. The one drawback we see is that the page isn’t particularly aesthetically pleasing. In our next article, we will change all that, as we start implementing link previews using Open Graph, CSS Styling, as well as some UX tweaks like displaying notification counts.

As always, thanks for reading, and happy coding!

Note: The next post in this series can be found here

decorative lines
Integrating Video With Your App?
We've built an audio and video solution just for you. Launch in days with our new APIs & SDKs!
Check out the BETA!