From 1d8cb9cade9a804445dce7683946edf24b77809c Mon Sep 17 00:00:00 2001 From: Andrew Twijukye Date: Thu, 13 Dec 2018 19:13:08 +0300 Subject: [PATCH] Feat(user following):Users should be able to follow each other Follow or unfollow other authors Only authenticated users can follow/unfollow Get list of followed authors Get list of followers [Delivers #162163272] --- authors/apps/authentication/models.py | 4 +- .../test/test_authentication.py | 3 + authors/apps/profiles/exceptions.py | 43 ++++- .../apps/profiles/migrations/0002_follow.py | 23 +++ .../migrations/0003_auto_20181212_1726.py | 30 ++++ authors/apps/profiles/models.py | 21 ++- authors/apps/profiles/serializers.py | 30 +++- .../apps/profiles/test/test_create_profile.py | 1 + .../apps/profiles/test/test_follow_users.py | 154 ++++++++++++++++ authors/apps/profiles/urls.py | 9 +- authors/apps/profiles/views.py | 164 +++++++++++++++++- requirements.txt | 13 +- 12 files changed, 471 insertions(+), 24 deletions(-) create mode 100644 authors/apps/profiles/migrations/0002_follow.py create mode 100644 authors/apps/profiles/migrations/0003_auto_20181212_1726.py create mode 100644 authors/apps/profiles/test/test_follow_users.py diff --git a/authors/apps/authentication/models.py b/authors/apps/authentication/models.py index 5855650..fd6fd96 100644 --- a/authors/apps/authentication/models.py +++ b/authors/apps/authentication/models.py @@ -24,8 +24,8 @@ def create_user(self, username, email, password=None, social_id=None): if email is None: raise TypeError('Users must have an email address.') - - user = self.model(username=username, email=self.normalize_email(email), social_id=social_id) + user = self.model(username=username, email=self.normalize_email( + email), social_id=social_id) user.set_password(password) user.save() diff --git a/authors/apps/authentication/test/test_authentication.py b/authors/apps/authentication/test/test_authentication.py index 38e3c92..70c1eeb 100644 --- a/authors/apps/authentication/test/test_authentication.py +++ b/authors/apps/authentication/test/test_authentication.py @@ -198,6 +198,9 @@ def test_password_is_required(self): class TestSocialAuthUsers(APITestCase): + def setUp(self): + self.client = APIClient() + def save_user_to_db(self, username='', email='', password=''): user = { 'user': { diff --git a/authors/apps/profiles/exceptions.py b/authors/apps/profiles/exceptions.py index e41f198..160f173 100644 --- a/authors/apps/profiles/exceptions.py +++ b/authors/apps/profiles/exceptions.py @@ -3,4 +3,45 @@ class ProfileDoesNotExist(APIException): status_code = 400 - default_detail = 'Profile does not exist.' \ No newline at end of file + default_detail = 'Profile does not exist.' + + +class UserDoesNotExist(APIException): + status_code = 404 + default_detail = 'User does not exist' + + +class FollowDoesNotExist(APIException): + status_code = 202 + default_detail = 'You are not following this user.' + + @staticmethod + def not_following_user(username): + status_code = 202 + default_detail = 'You are not following {}.'.format(username) + return status_code, default_detail + + +class FollowingAlreadyException(APIException): + status_code = 409 + default_detail = 'You are already following this user.' + + +class FollowSelfException(APIException): + status_code = 400 + default_detail = 'You can not follow yourself.' + + +class NoFollowersException(APIException): + status_code = 200 + default_detail = 'You do not have any followers.' + + +class NoFollowingException(APIException): + status_code = 200 + default_detail = 'You are not following any users yet.' + + +class NotFollowingAnyUserException(APIException): + status_code = 200 + default_detail = 'You are not following any user.' diff --git a/authors/apps/profiles/migrations/0002_follow.py b/authors/apps/profiles/migrations/0002_follow.py new file mode 100644 index 0000000..0d18050 --- /dev/null +++ b/authors/apps/profiles/migrations/0002_follow.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.3 on 2018-12-12 16:34 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('profiles', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Follow', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('followed', models.IntegerField()), + ('follower', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/authors/apps/profiles/migrations/0003_auto_20181212_1726.py b/authors/apps/profiles/migrations/0003_auto_20181212_1726.py new file mode 100644 index 0000000..366b770 --- /dev/null +++ b/authors/apps/profiles/migrations/0003_auto_20181212_1726.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1.3 on 2018-12-12 17:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('profiles', '0002_follow'), + ] + + operations = [ + migrations.AlterField( + model_name='follow', + name='followed', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='followed_user_id', to=settings.AUTH_USER_MODEL), + ), + migrations.RemoveField( + model_name='follow', + name='follower', + ), + migrations.AddField( + model_name='follow', + name='follower', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='follower_user_id', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/authors/apps/profiles/models.py b/authors/apps/profiles/models.py index 000cb54..539d530 100644 --- a/authors/apps/profiles/models.py +++ b/authors/apps/profiles/models.py @@ -1,5 +1,6 @@ from django.db import models from django.conf import settings +from ..authentication.models import User class Profile(models.Model): @@ -7,11 +8,25 @@ class Profile(models.Model): This model defines a one to one relationship between a user and a profile """ - user = models.OneToOneField( - 'authentication.User', on_delete=models.CASCADE - ) + user = models.OneToOneField(User, on_delete=models.CASCADE) bio = models.TextField(blank=True) image = models.URLField(blank=True) def __str__(self): return self.user.username + + +class Follow(models.Model): + """ + This model defines a many to many relationship between a + user and a 'followed', another user they are following + It also defines a follower i.e. the user doing the following + """ + follower = models.ForeignKey( + User, on_delete=models.CASCADE, related_name='follower_user_id', null=True) + followed = models.ForeignKey( + User, on_delete=models.CASCADE, related_name='followed_user_id', null=True) + + def __str__(self): + return "User with id: {} follows user with id: {}".format(self.follower.pk, + self.followed.pk) diff --git a/authors/apps/profiles/serializers.py b/authors/apps/profiles/serializers.py index 591042a..7e5aabf 100644 --- a/authors/apps/profiles/serializers.py +++ b/authors/apps/profiles/serializers.py @@ -1,6 +1,8 @@ from django.contrib.auth import authenticate from rest_framework import serializers -from .models import Profile +from .models import Profile, Follow +from ..authentication.models import User +from .exceptions import * class ProfileSerializer(serializers.ModelSerializer): @@ -19,3 +21,29 @@ def get_image(self, obj): return obj.image return '' + + +class FollowSerializer(serializers.ModelSerializer): + profile = None + + class Meta: + model = Follow + fields = ('follower', 'followed') + + +class FollowingSerializer(serializers.ModelSerializer): + """" + This class represents the 'get all the users I follow' serializer + """ + class Meta: + model = Follow + fields = ['follower'] + + +class FollowerSerializer(serializers.ModelSerializer): + """ + This class represents the 'get all my followers' serializer + """ + class Meta: + model = Follow + fields = ['followed'] diff --git a/authors/apps/profiles/test/test_create_profile.py b/authors/apps/profiles/test/test_create_profile.py index dfe644e..6ce767d 100644 --- a/authors/apps/profiles/test/test_create_profile.py +++ b/authors/apps/profiles/test/test_create_profile.py @@ -18,6 +18,7 @@ def setUp(self): 'password': 'Sokosok1!' } } + def verify_account(self, token, uidb64): request = APIRequestFactory().get( reverse( diff --git a/authors/apps/profiles/test/test_follow_users.py b/authors/apps/profiles/test/test_follow_users.py new file mode 100644 index 0000000..d434108 --- /dev/null +++ b/authors/apps/profiles/test/test_follow_users.py @@ -0,0 +1,154 @@ +""" +This test suite covers all the tests for the 'follow a user' feature +""" + +import json +from rest_framework.test import APITestCase, APIClient +from rest_framework.views import status +from ...authentication.models import User +from ..models import Follow + + +class TestFollowUser(APITestCase): + + def setUp(self): + self.client = APIClient() + self.andrew = self.save_user( + 'andrew', 'andrew@a.com', 'P@ssword23lslsn') + self.maria = self.save_user('maria', 'maria@a.com', 'P@ssword23lslsn') + self.juliet = self.save_user( + 'julie', 'juliet@a.com', 'P@ssword23lslsn') + self.roni = self.save_user('roni', 'roni@a.com', 'P@ssword23lslsn') + self.sama = self.save_user('sama', 'samantha@a.com', 'P@ssword23lslsn') + + def save_user(self, username, email, pwd): + validated_data = {'username': username, + 'email': email, 'password': pwd} + return User.objects.create_user(**validated_data) + + def return_verified_user_object(self, username='athena', + email='athena@a.com', password='P@ssword23lslsn'): + user = self.save_user(username, email, password) + return user + + def test_user_trying_to_follow_is_not_authenticated(self): + res = self.client.post('/api/profiles/sama/follow') + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(json.loads(res.content), {'profile': { + 'detail': 'Authentication credentials were not provided.'}}) + + def test_user_followed_correctly(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + res = self.client.post( + '/api/profiles/maria/follow') + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + self.assertEqual(json.loads(res.content), {'profile': { + 'username': 'maria', 'bio': '', 'image': '', 'following': True}}) + + def test_user_cannot_follow_self(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + res = self.client.post( + '/api/profiles/{}/follow'.format(verified_user.username)) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(json.loads(res.content), {'profile': { + 'detail': 'You can not follow yourself.'}}) + + def test_user_follow_target_does_not_exist(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + res = self.client.post( + '/api/profiles/josephine/follow') + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + + def test_user_already_followed(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + self.client.post('/api/profiles/maria/follow') + res = self.client.post( + '/api/profiles/maria/follow') + self.assertEqual(res.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(json.loads(res.content), {'profile': { + 'detail': 'You are already following this user.'}}) + + def test_user_not_following_anyone(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + res = self.client.get('/api/profiles/following') + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(json.loads(res.content), {'profile': { + 'detail': 'You are not following any users yet.'}}) + + def test_get_all_following(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + follow = Follow() + follow.follower_id = verified_user.pk + follow.followed_id = self.andrew.pk + follow.save() + + res = self.client.get('/api/profiles/following') + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(json.loads(res.content), {'profile': {'following': [ + {'username': 'andrew', 'bio': '', 'image': '', 'following': True}]}}) + + def test_get_all_followers(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + follow = Follow() + follow.followed_id = verified_user.pk + follow.follower_id = self.roni.pk + follow.save() + + res = self.client.get('/api/profiles/followers') + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(json.loads(res.content), {'profile': {'followers': [ + {'username': 'roni', 'bio': '', 'image': '', 'following': True}]}}) + + def test_user_has_no_followers(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + res = self.client.get('/api/profiles/followers') + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(json.loads(res.content), {'profile': { + 'detail': 'You do not have any followers.'}}) + + def test_user_unfollows(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + self.client.post('/api/profiles/maria/follow') + res = self.client.delete( + '/api/profiles/maria/follow') + self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(json.loads(res.content), {'profile': { + 'username': 'maria', 'bio': '', 'image': '', 'following': False}}) + + def test_user_unfollows_non_followed_user(self): + verified_user = self.return_verified_user_object() + jwt = verified_user.token() + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + jwt) + res = self.client.delete( + '/api/profiles/maria/follow') + self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(json.loads(res.content), {'profile': { + 'detail': 'You are not following this user.'}}) diff --git a/authors/apps/profiles/urls.py b/authors/apps/profiles/urls.py index 9221d27..e820892 100644 --- a/authors/apps/profiles/urls.py +++ b/authors/apps/profiles/urls.py @@ -1,6 +1,11 @@ from django.urls import include, path from django.conf.urls import url -from .views import ProfileRetrieveView +from .views import ProfileRetrieveView, FollowAPIView, FollowingAPIView, FollowersAPIView + urlpatterns = [ - url('^profiles/(?P[-\w]+)$', ProfileRetrieveView.as_view(), name='profiles'), + path('profiles/following', FollowingAPIView.as_view()), + path('profiles/followers', FollowersAPIView.as_view()), + url('^profiles/(?P[-\w]+)$', + ProfileRetrieveView.as_view(), name='profiles'), + path('profiles//follow', FollowAPIView.as_view()), ] diff --git a/authors/apps/profiles/views.py b/authors/apps/profiles/views.py index 576c194..d4db788 100644 --- a/authors/apps/profiles/views.py +++ b/authors/apps/profiles/views.py @@ -1,16 +1,17 @@ +from ..authentication.backends import JWTAuthentication +from .exceptions import * +from .renderers import ProfileJSONRenderer from django.shortcuts import render -from .models import Profile +from .models import Profile, Follow from authors.apps.authentication.models import User import json from rest_framework import status -from rest_framework.generics import RetrieveAPIView +from rest_framework.generics import RetrieveAPIView, GenericAPIView from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response -from .serializers import ProfileSerializer -from .models import Profile -from .renderers import ProfileJSONRenderer -from .exceptions import ProfileDoesNotExist + +from .serializers import ProfileSerializer, FollowSerializer, FollowingSerializer, FollowerSerializer class ProfileRetrieveView(RetrieveAPIView): @@ -20,7 +21,7 @@ class ProfileRetrieveView(RetrieveAPIView): def retrieve(self, request, *args, **kwargs): username = self.kwargs['slug'] - + try: profile = Profile.objects.select_related('user').get( user__username=username @@ -31,4 +32,151 @@ def retrieve(self, request, *args, **kwargs): serializer = self.serializer_class(profile) return Response(serializer.data, status=status.HTTP_200_OK) - + + +class FollowAPIView(GenericAPIView): + """ + api view handles following/unfollowing a user + """ + permission_classes = (IsAuthenticated,) + renderer_classes = (ProfileJSONRenderer,) + serializer_class = FollowSerializer + + def is_following(self, follower, followed): + """ + This method returns a boolean after checking if 'follower' is following the 'followed' + """ + try: + Follow.objects.get( + followed=followed.pk, follower=follower.pk) + return True + except Follow.DoesNotExist: + return False + + def post(self, request, *args, **kwargs): + follower = JWTAuthentication().authenticate(request)[0] + username = self.kwargs['username'] + + try: + followed = User.objects.get(username=username) + except User.DoesNotExist: + raise UserDoesNotExist + + if self.is_following(follower, followed): + raise FollowingAlreadyException + if follower.pk == followed.pk: + raise FollowSelfException + + serializer_data = {"followed": followed.pk, + "follower": follower.pk} + serializer = self.serializer_class(data=serializer_data) + serializer.is_valid(raise_exception=True) + serializer.save() + + profile = Profile.objects.get(user_id=followed.pk) + response = { + "username": followed.username, + "bio": profile.bio, + "image": profile.image, + "following": self.is_following(follower, followed) + } + return Response(response, status=status.HTTP_201_CREATED) + + def delete(self, request, *args, **kwargs): + follower = JWTAuthentication().authenticate(request)[0] + username = self.kwargs['username'] + try: + followed = User.objects.get(username=username) + following = Follow.objects.get( + followed=followed.pk, follower=follower.pk) + following.delete() + except Follow.DoesNotExist: + FollowDoesNotExist.not_following_user(username) + raise FollowDoesNotExist + + profile = Profile.objects.get(user_id=followed.pk) + response = { + "username": followed.username, + "bio": profile.bio, + "image": profile.image, + "following": self.is_following(follower, followed) + } + + return Response(response, status=status.HTTP_202_ACCEPTED) + + +class FollowingAPIView(GenericAPIView): + permission_classes = (IsAuthenticated,) + renderer_classes = (ProfileJSONRenderer,) + serializer_class = FollowingSerializer + + def get(self, request, *args, **kwargs): + """ + this method returns a list of all users/authors that a + user (usually the current user) is following + """ + follower = JWTAuthentication().authenticate(request)[0] + serializer_data = {"follower": follower.pk} + serializer = self.serializer_class(data=serializer_data) + serializer.is_valid(raise_exception=True) + + followed_by_self = Follow.objects.filter(follower=follower) + if followed_by_self.count() == 0: + raise NoFollowingException + profiles = [] + for follow_object in followed_by_self: + profile = Profile.objects.get(user_id=follow_object.followed_id) + user = User.objects.get(id=follow_object.followed_id) + profiles.append({ + 'username': user.username, + 'bio': profile.bio, + 'image': profile.image, + 'following': True + }) + res = {"following": profiles} + return Response(res, status=status.HTTP_200_OK) + + +class FollowersAPIView(GenericAPIView): + permission_classes = (IsAuthenticated,) + renderer_classes = (ProfileJSONRenderer,) + serializer_class = FollowingSerializer + + def is_following(self, follower, followed): + """ + This method returns a boolean after checking if 'follower' is following the 'followed' + """ + try: + Follow.objects.get( + followed=followed, follower=follower) + return True + except Follow.DoesNotExist: + return False + + def get(self, request, *args, **kwargs): + """ + this method returns a list of all users/authors that follow a specific user + (usually the current user) + """ + followed = JWTAuthentication().authenticate(request)[0] + serializer_data = {"follower": followed.pk} + serializer = self.serializer_class(data=serializer_data) + serializer.is_valid(raise_exception=True) + + following_self = Follow.objects.filter(followed=followed) + if following_self.count() == 0: + raise NoFollowersException + + profiles = [] + for follow_object in following_self: + profile = Profile.objects.get(user_id=follow_object.follower_id) + user = User.objects.get(id=follow_object.follower_id) + profiles.append({ + 'username': user.username, + 'bio': profile.bio, + 'image': profile.image, + 'following': self.is_following(follow_object.follower_id, followed) + }) + res = {"followers": profiles} + + return Response(res, status=status.HTTP_200_OK) diff --git a/requirements.txt b/requirements.txt index bdd2b80..a3f2f43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,10 @@ astroid==2.1.0 -certifi==2018.11.29 -chardet==3.0.4 -coreapi==2.3.3 -coreschema==0.0.4 autopep8==1.4.3 cachetools==3.0.0 certifi==2018.11.29 chardet==3.0.4 +coreapi==2.3.3 +coreschema==0.0.4 coverage==4.5.2 certifi==2018.11.29 chardet==3.0.4 @@ -46,21 +44,22 @@ pyasn1-modules==0.2.2 pycodestyle==2.4.0 PyJWT==1.6.4 pylint==2.2.1 +pylint-django==2.0.4 +pylint-plugin-utils==0.4 python-dotenv==0.9.1 python-twitter==3.5 pytz==2018.7 -ruamel.yaml==0.15.80 query-string==2018.11.20 request==2018.11.20 requests==2.20.1 requests-oauthlib==1.0.0 rope==0.11.0 rsa==4.0 +ruamel.yaml==0.15.80 six==1.11.0 typed-ast==1.1.0 -uritemplate==3.0.0 -urllib3==1.24.1 upgrade-requirements==1.7.0 +uritemplate==3.0.0 urllib3==1.24.1 whitenoise==4.1.2 wrapt==1.10.11