Skip to content

Follows#

Introduction#

In this chapter, we'll let users follow other users, so that they can keep abreast of their favourite users' articles.

Creating a few new users and articles#

To make the following sections more interesting, let's create a new users and posts. Run Django shell with (django) django_tutorial$ python manage.py shell and then paste the following into your shell (no need to clean it):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
In [1]: from django.contrib.auth import get_user_model
In [2]: from conduit.users.models import Profile
In [3]: from conduit.articles.models import Article
In [4]: from faker import Faker
In [5]: fake = Faker()
In [6]: fake.seed_instance(42)
In [7]: for i in range(2):
   ...:     user = get_user_model().objects.create_user(username=fake.user_name(), email=fake.email(), password=fake.password())
   ...:     profile = user.profile
   ...:     profile.image = fake.image_url(600, 600)
   ...:     profile.bio = fake.text()
   ...:     user.save()
   ...:     for j in range(2):
   ...:         Article.objects.create(
   ...:             author=Profile.objects.last(),
   ...:             title=fake.sentence(),
   ...:             description=fake.paragraph(),
   ...:             body=fake.text()
   ...:         )
   ...:
In [8]: get_user_model().objects.get(username='admin').profile.follow(Profile.objects.last())

This will create two users with full profiles and a couple articles each.

Model#

We'll now let our users follow other users, i.e. subscribe to other users' articles. This should be a relationship between Profile objects, where one Profile object can follow, and be followed by, many other Profile objects: we'll use a ManyToManyField relationship.

We need a new field in our Profile model in users/models.py:

1
2
3
4
5
6
7
# ...
class Profile(models.Model):
    # ...
    follows = models.ManyToManyField(
        "self", related_name="followed_by", symmetrical=False, blank=True
    )
    # ...

The args we pass to the ManyToManyField signify that the relationship works between Profile objects, that we can get the Profile objects followed by a given Profile with the follows attribute, that we can know who's following a given Profile with the followed_by attribute, and that follows are a one-way relationship (it's not because User A follows User B that User B necessarily follows User A).

We also need to define a few methods that will be helpful later on. In users/models.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Profile(models.Model):
    # ...
    def follow(self, profile):
        """Follow `profile`"""
        self.follows.add(profile)

    def unfollow(self, profile):
        """Unfollow `profile`"""
        self.follows.remove(profile)

    def is_following(self, profile):
        """Return True if `profile` is in self.follows, False otherwise"""
        return self.follows.filter(pk=profile.pk).exists()

Let's makemigrations and migrate, since we have modified the model.

1
2
3
4
(django) django_tutorial$ python manage.py makemigrations
# ...
(django) django_tutorial$ python manage.py migrate
# ...

ProfileDetailView#

We need to let users follow or unfollow other users in our templates. This involves some work around checking whether the user is already in our follows or not. Because the Django Template Language (intentionally) makes it difficult to write non-trivial queries within templates, we'll have to do some groundwork in our views, with the help of the model methods we just created.

In users/views.py, we add is_following to the context of ProfileDetailView to enable our template to know whether the authenticated user follows a given profile:

1
2
3
4
5
6
7
8
class ProfileDetailView(DetailView):
    # ...
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        if self.request.user.is_authenticated:
            context["my_articles"] = self.object.articles.order_by('-created_at')
            context["is_following"] = self.object.is_following(self.object)     # new
        return context

Still in users/views.py, we add a RedirectView whose only purpose is to follow or unfollow a profile, depending on whether or not the profile is followed already.

In users/urls.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# ...
from .views import (
    # ...
    ProfileFollowView,
)


urlpatterns = [
    # ...
    path(
        "profile/@<str:username>/follow",
        ProfileFollowView.as_view(),
        name="profile_follow",
    ),
]

Let's implement the follow functionality in templates/profile_detail.html now:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<div class="col-xs-12 col-md-10 offset-md-1">
  <img src="{{ profile.image }}" class="user-img" alt="{{ profile.user.username }}" />
  <h4>{{ profile.user.username }}</h4>
  <p>{{ profile.bio|default:"This user doesn't have a bio for now" }}</p>
  {% if user.username == profile.user.username %}
    <a
      href="{% url 'settings' %}"
      class="btn btn-sm btn-outline-secondary action-btn"
    >
      <span class="ion-gear-a">
        Edit Profile Settings
      </span>
    </a>
  {% else %}                                <!-- new -->
    {% include 'profile_follow.html' %}     <!-- new -->
  {% endif %}
</div>

Create templates/profile_follow.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<form
  method="post"
  action="{% url 'profile_follow' username=profile.user.username %}"
>
  {% csrf_token %}
  <button class="btn btn-sm action-btn
                 {% if is_following %}
                 btn-secondary
                 {% else %}
                 btn-outline-secondary
                 {% endif %}"
  >
    <span class="ion-plus-round">
      {% if is_following %}Unfollow{% else %}Follow{% endif %} {{ profile.user.username }}
    </span>
  </button>
</form>

What we're doing in this template is the following:

  • if the user's viewing their own profile, show a link to the settings URL.
  • if the user's viewing another profile (or is not logged in), redirect them to the profile_follow URL, which toggles a Profile object's follow and unfollow methods
  • adapt the text and UI based on whether the user's following the viewed profile via a bunch of {% if ...%} template tags.

ArticleDetailView#

We also expose the follow/unfollow feature on article pages.

In articles/views.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class ArticleDetailView(DetailView):
    # ...
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["form"] = CommentCreateView().get_form_class()
        if self.request.user.is_authenticated:
            context["is_following"] =   self.request.user.profile.is_following(
                self.object.author
            )
        return context

In templates/article_detail.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{% if user == article.author.user %}
  <span>
    <a
      href="{% url 'editor_update' slug_uuid=article.slug_uuid %}"
      class="btn btn-outline-secondary btn-sm"
    >
      <span class="ion-edit">
        Edit Article
      </span>
    </a>
    {% include 'article_delete.html' %}
  </span>
{% else %}                                                          <!-- new -->
  <span>                                                            <!-- new -->
    {% include 'profile_follow.html' with profile=article.author %} <!-- new -->
  </span>                                                           <!-- new -->
{% endif %}

In templates/profile_follow.html, we add style="display:inline":

1
2
3
4
5
6
<form
    method="post"
    action="{% url 'profile_follow' username=profile.user.username %}"
    style="display:inline"
>
<!-- ... -->

An interesting aside: for the longest time, I tried to follow or unfollow a profile based on whether the template form had method="post" or method="delete" (because RedirectView has both post and delete methods), only to discover that HTML forms only support GET and POST and that workarounds are not very pretty. Live and learn.

Redirect URL#

If you play around with the Follow feature, you will notice that it redirects us to the profile page of the user we want to (un)follow. This is due to the fact that the Follow button is exposed both in profile_detail.html and in home.html (through article_preview.html), so for the sake of simplicity we chose a single redirect URL in our ProfileFollowView.

However, it would be better if we could stay on whatever page we are when we follow a user. This involves implementing the next kwarg.

In templates/profile_follow.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<form
    method="post"
    action="{% url 'profile_follow' username=profile.user.username %}"
    style="display:inline"
>
    <input type="hidden" name="next" value="{{ request.path }}">    <!-- new -->
    {% csrf_token %}
    <button class="btn btn-sm action-btn
                   {% if is_following %}
                   btn-secondary
                   {% else %}
                   btn-outline-secondary
                   {% endif %}"
    >
        <span class="ion-plus-round">
            {% if is_following %}Unfollow{% else %}Follow{% endif %} {{ profile.user.username }}
        </span>
    </button>
</form>

The next parameter above just holds the URL that the profile_follow URL pattern was called from.

In users/views.py:

1
2
3
4
5
6
7
8
9
class ProfileFollowView(LoginRequiredMixin, RedirectView):
    def get_redirect_url(self, *args, **kwargs):
        url = self.request.POST.get("next", None)
        if url:
            return url
        else:
            return super().get_redirect_url(*args, **kwargs)

    # ...

We override the get_redirect_url method of RedirectView so that we go to the URL specified by next, or fall back to profile_detail if for some reason the next parameter is missing (for example, if the user visits profile_follow directly by typing .../profile/@<username>/follow in their browser's URL bar).

Feeds#

We need to go back all the way to the beginning for this one.

In articles/views.py, we need to modify our very first view, home, so that it can give us a feed of articles written by users we follow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Home(ListView):
    # ...
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["global_feed"] = Article.objects.order_by("-created_at")
        if self.request.user.is_authenticated:                      # new from here
            context["your_articles"] = Article.objects.filter(
                author__in=self.request.user.profile.follows.all()
            ).order_by("-created_at")
        else:
            context["your_articles"] = None                         # new to here
        return context

In templates/home.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<div class="container page">
  <div class="row">
    <div class="col-md-9">
      <div class="feed-toggle">                 <!-- new from here-->
        <ul class="nav nav-pills outline-active">
          <li class="nav-item">
            {% url 'home' as home %}
            <a
              href="{{ home }}"
              rel="prefetch"
              class="nav-link
                     {% if request.path == home %}active{% endif %}"
            >
              Global Feed
            </a>
          </li>
          {% if user.is_authenticated %}
            <li class="nav-item">
              {% url 'home_feed' as home_feed %}
              <a
                href="{{ home_feed }}"
                rel="prefetch"
                class="nav-link
                       {% if request.path == home_feed %}active{% endif %}"
              >
                Your Feed
              </a>
            </li>
          {% else %}
            <li class="nav-item">
              <a href="{% url 'login' %}" rel="prefetch" class="nav-link">
                Sign in to see your Feed
              </a>
            </li>
          {% endif %}
        </ul>
      </div>
      {% if request.path == home %}
        {% include 'article_list.html' with articles=your_articles %}
      {% elif request.path == home_feed %}
        {% include 'article_list.html' with articles=global_feed %}
      {% endif %}                               <!-- new to here -->
    </div>
  </div>
</div>

In articles/urls.py:

1
2
3
4
urlpatterns = [
    # ...
    path("feed", Home.as_view(), name="home_feed"),
]