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:
| # ...
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.
| (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:
| 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:
| 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":
| <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:
| 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:
| urlpatterns = [
# ...
path("feed", Home.as_view(), name="home_feed"),
]
|