Skip to content

Commit

Permalink
feat: Users can follow and unfollow each other (#34)
Browse files Browse the repository at this point in the history
- Add functionality for users to follow and unfollow each other
- Add views for users to view their followings and followers
- Add follows ManyToManyField in profile model.
- Use `related_name` to retrieve a user's follows
- Use Django's `symmetrical=False` to maintain a one sided follow.
- Update `urls` files accordingly
- Refomarted for Pep8 standards

[starts #163383185]
  • Loading branch information
codjoero authored and malep2007 committed Feb 14, 2019
1 parent 5e030e5 commit 62dd1bb
Show file tree
Hide file tree
Showing 37 changed files with 794 additions and 258 deletions.
Binary file added .DS_Store
Binary file not shown.
Binary file added authors/.DS_Store
Binary file not shown.
12 changes: 9 additions & 3 deletions authors/apps/articles/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,23 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='Article',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('id', models.AutoField(auto_created=True,
primary_key=True, serialize=False,
verbose_name='ID')),
('title', models.CharField(max_length=100)),
('slug', models.SlugField(blank=True, unique=True)),
('description', models.CharField(max_length=100)),
('favoritesCount', models.IntegerField(default=0)),
('favorited', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True, null=True)),
('image', models.ImageField(blank=True, upload_to='assets/articles/images')),
('image', models.ImageField(blank=True,
upload_to='assets/articles/images')
),
('body', models.TextField()),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='profiles.Profile')),
('author', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='profiles.Profile')),
],
options={
'ordering': ['-created_at'],
Expand Down
10 changes: 8 additions & 2 deletions authors/apps/articles/migrations/0002_auto_20190207_1417.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='article',
name='disliked_by',
field=models.ManyToManyField(related_name='disliked_articles', related_query_name='disliked_article', to=settings.AUTH_USER_MODEL),
field=models.ManyToManyField(
related_name='disliked_articles',
related_query_name='disliked_article',
to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='article',
name='liked_by',
field=models.ManyToManyField(related_name='liked_articles', related_query_name='liked_article', to=settings.AUTH_USER_MODEL),
field=models.ManyToManyField(
related_name='liked_articles',
related_query_name='liked_article',
to=settings.AUTH_USER_MODEL),
),
]
4 changes: 3 additions & 1 deletion authors/apps/articles/migrations/0002_auto_20190208_0656.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='article',
name='favorited_by',
field=models.ManyToManyField(blank=True, related_name='favorited_by', to='profiles.Profile'),
field=models.ManyToManyField(
blank=True, related_name='favorited_by',
to='profiles.Profile'),
),
]
13 changes: 9 additions & 4 deletions authors/apps/articles/migrations/0002_rating.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@ class Migration(migrations.Migration):
name='Rating',
fields=[
('id', models.AutoField(auto_created=True,
primary_key=True, serialize=False, verbose_name='ID')),
primary_key=True, serialize=False,
verbose_name='ID')),
('rate_score', models.IntegerField(blank=True, null=True)),
('article', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='article_ratings', to='articles.Article')),
('article', models.ForeignKey(
blank=True, null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='article_ratings',
to='articles.Article')),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL)),
],
),
]
59 changes: 33 additions & 26 deletions authors/apps/articles/tests/test_article_api_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,13 @@ def test_update_unexisting_article_slug(self):
"""
self.create_article_and_authenticate_test_user()
article = Article.objects.all().first()
response = self.client.patch(reverse('articles:article-details',
kwargs={'slug':
test_article_data.
un_existing_slug}),
data=test_article_data.
update_article_data,
format='json')
response = self.client.patch(reverse(
'articles:article-details',
kwargs={'slug':
test_article_data.
un_existing_slug}),
data=test_article_data.update_article_data,
format='json')
expected_dict = {
'errors': 'sorry article with that slug doesnot exist'}
self.assertDictEqual(expected_dict, response.data)
Expand Down Expand Up @@ -173,13 +173,14 @@ def test_delete_an_article_when_user_is_authorized(self):
"""
self.create_article_and_authenticate_test_user()
article = Article.objects.all().first()
response = self.client.delete(reverse('articles:article-details',
kwargs={'slug':
test_article_data.
un_existing_slug}),
data=test_article_data.
update_article_data,
format='json')
response = self.client.delete(reverse(
'articles:article-details',
kwargs={'slug':
test_article_data.
un_existing_slug}),
data=test_article_data.
update_article_data,
format='json')
expected_dict = {
'errors': 'sorry article with that slug doesnot exist'}
self.assertDictEqual(expected_dict, response.data)
Expand Down Expand Up @@ -225,49 +226,55 @@ def test_user_can_dislike_an_article(self):
self.assertFalse(self.article.is_liked_by(self.user))

def test_user_can_get_like_status_for_article_they_do_not_like(self):
"""a user should get the correct like status for an article they do
not like"""
"""a user should get the correct like status for an article
they do not like
"""
response = self.client.get(
self.is_liked_article_url(self.article.slug))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["is_liked"], False)

def test_user_can_get_like_status_for_article_they_like(self):
"""a user should get the correct like status for an article they do
like"""
"""a user should get the correct like status for an article
they do like
"""
self.article.liked_by.add(self.user)
response = self.client.get(
self.is_liked_article_url(self.article.slug))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["is_liked"], True)

def test_user_can_get_dislike_status_for_article_they_do_not_dislike(self):
"""a user should get the correct like status for an article they do
not dislike"""
"""a user should get the correct like status
for an article they do not dislike
"""
response = self.client.get(
self.is_disliked_article_url(self.article.slug))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["is_disliked"], False)

def test_user_can_get_dislike_status_for_article_they_dislike(self):
"""a user should get the correct like status for an article they do
dislike"""
"""a user should get the correct like status
for an article they do dislike
"""
self.article.disliked_by.add(self.user)
response = self.client.get(
self.is_disliked_article_url(self.article.slug))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["is_disliked"], True)

def test_unauthorized_user_cannot_like_an_article(self):
"""a request without a valid token does not allow a user to like an
article"""
"""a request without a valid token does not allow a user
to like an article
"""
self.client.force_authenticate(user=None)
response = self.client.post(self.like_article_url(self.article.slug))
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_unauthorized_user_cannot_dislike_an_article(self):
"""a request without a valid token does not allow a user to dislike an
article"""
"""a request without a valid token does not allow a user
to dislike an article
"""
self.client.force_authenticate(user=None)
response = self.client.post(
self.dislike_article_url(self.article.slug))
Expand Down
16 changes: 9 additions & 7 deletions authors/apps/articles/tests/test_rate_article.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ def test_rate_article_author(self):
Test rating an article fails when author tries to rate their
own article
"""
response = self.client.post(self.url_rate_article,
data=test_rate_article_data.valid_rate_data,
format='json')
response = self.client.post(
self.url_rate_article,
data=test_rate_article_data.valid_rate_data,
format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertIn("Author can not rate their own article",
response.data.get('errors'))
Expand Down Expand Up @@ -78,17 +79,18 @@ def test_rate_article_not_author_rate_same_article(self):
self.client.post(self.url_rate_article,
data=test_rate_article_data.valid_rate_data,
format='json')
response = self.client.post(self.url_rate_article,
data=test_rate_article_data.valid_rate_data,
format='json')
response = self.client.post(
self.url_rate_article,
data=test_rate_article_data.valid_rate_data,
format='json')
self.assertEqual(response.status_code,
status.HTTP_422_UNPROCESSABLE_ENTITY)
self.assertIn("You already rated this article",
response.data.get('message'))

def test_get_rate_average(self):
"""
Test rating an article passes when it's not author rating
Test rating an article passes when it's not author rating
and returns average
"""
self.user = self.create_another_user_in_db()
Expand Down
81 changes: 46 additions & 35 deletions authors/apps/authentication/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,64 +11,75 @@

class JWTAuthentication(authentication.BaseAuthentication):
"""
This is a custom class and it handles the authentication of a
This is a custom class and it handles the authentication of a
token provided a user
It overwrites the BaseAuthentication class provided by the restframework
"""
token_header_prefix = 'Bearer'

def authenticate(self, request):
"""This method will authenticate a user on provision of a valid Bearer token
It collects the token from the request headers on ever request performed
"""This method will authenticate a user on provision of
a valid Bearer token
It collects the token from the request headers on ever
request performed
It checks whether the provided token is a Bearer token
It also checks whether a token is provided.
Then is passes the token down to another method to validate it.
Then is passes the token down to another method to validate it.
"""
request.user = None
authentication_header = authentication.get_authorization_header(request).split()
authentication_header = authentication.get_authorization_header(
request).split()

if not authentication_header:
# Returns none if there is no authentication header provided
return None
# Returns none if there is no authentication header provided
return None
if len(authentication_header) == 1:
# Raise an error if the authentication header has only a token or Bearer
msg = 'You should provide both the Bearer prefix and the token'
raise exceptions.AuthenticationFailed(msg)
# Raise an error if the authentication header has only a token or
# Bearer
msg = 'You should provide both the Bearer prefix and the token'
raise exceptions.AuthenticationFailed(msg)
if len(authentication_header) > 2:
# Raise an error if the authentication has more than a token and Bearer
msg = 'sorry you have provided a long token'
raise exceptions.AuthenticationFailed(msg)
# Raise an error if the authentication has more than a token and
# Bearer
msg = 'sorry you have provided a long token'
raise exceptions.AuthenticationFailed(msg)

# The authentication header is list which is split into a prefix and token
# The authentication header is list which is split into a prefix and
# token
prefix = authentication_header[0].decode('utf-8')
token = authentication_header[1].decode('utf-8')

# If the provided Bearer prefix is not equal to Bearer, raise an exception
# If the provided Bearer prefix is not equal to Bearer, raise an
# exception
if prefix.lower() != self.token_header_prefix.lower():
msg = 'wrong prefix, please use Bearer'
raise exceptions.AuthenticationFailed(msg)
msg = 'wrong prefix, please use Bearer'
raise exceptions.AuthenticationFailed(msg)

return self.validate_credentials (request, token)
return self.validate_credentials(request, token)

def validate_credentials(self, request, token):
"""The validate_credentials method decods to validate the sent in user token
It receives two variables a request and a token
It will first try to decode the token and generate a user payload
If this fails it will raise an exception which might include
"""
"""The validate_credentials method decods to validate
the sent in user token
It receives two variables a request and a token
It will first try to decode the token and generate a user payload
If this fails it will raise an exception which might include
"""

try:
user_payload = jwt.decode(token, settings.SECRET_KEY)
except jwt.ExpiredSignature:
msg = ('Token has expired.')
raise exceptions.AuthenticationFailed(msg)
except (jwt.DecodeError, jwt.InvalidTokenError):
msg = ('Error decoding signature. Please check the token you have provided.')
raise exceptions.AuthenticationFailed(msg)
try:
user_payload = jwt.decode(token, settings.SECRET_KEY)
except jwt.ExpiredSignature:
msg = ('Token has expired.')
raise exceptions.AuthenticationFailed(msg)
except (jwt.DecodeError, jwt.InvalidTokenError):
msg = (
'Error decoding signature. \
Please check the token you have provided.')
raise exceptions.AuthenticationFailed(msg)

try:
try:
user = User.objects.get(pk=user_payload['id'])
if user.is_active:
return (user, token)
except User.DoesNotExist:
msg = 'A user matching this token was not found.'
raise exceptions.AuthenticationFailed(msg)
except User.DoesNotExist:
msg = 'A user matching this token was not found.'
raise exceptions.AuthenticationFailed(msg)
34 changes: 26 additions & 8 deletions authors/apps/authentication/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,36 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(db_index=True, max_length=255, unique=True)),
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
('id', models.AutoField(auto_created=True,
primary_key=True,
serialize=False, verbose_name='ID')),
('password', models.CharField(
max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(
blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(
default=False,
help_text='Designates that this user has all permissions \
without explicitly assigning them.',
verbose_name='superuser status')),
('username', models.CharField(
db_index=True, max_length=255, unique=True)),
('email', models.EmailField(
db_index=True, max_length=254, unique=True)),
('is_active', models.BooleanField(default=True)),
('is_staff', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
('groups', models.ManyToManyField(
blank=True, help_text='The groups this user belongs to. \
A user will get all permissions granted to each of their groups.',
related_name='user_set', related_query_name='user',
to='auth.Group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(
blank=True,
help_text='Specific permissions for this user.',
related_name='user_set', related_query_name='user',
to='auth.Permission', verbose_name='user permissions')),
],
options={
'abstract': False,
Expand Down
Loading

0 comments on commit 62dd1bb

Please sign in to comment.