diff --git a/.gitignore b/.gitignore index 1312144..aaabfaa 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ db.sqlite3 !*/migrations/__init__.py .vscode/ *.DS_Store +*migrations diff --git a/.travis.yml b/.travis.yml index 32072cf..1f6903c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,8 +16,9 @@ install: script: - python3 manage.py makemigrations authentication - python3 manage.py migrate authentication - - python3 manage.py makemigrations articles - - python3 manage.py migrate articles + - python3 manage.py makemigrations article + - python3 manage.py migrate article + - python3 manage.py makemigrations profiles - python3 manage.py makemigrations - python3 manage.py migrate - coverage run manage.py test diff --git a/article/example.jpg b/article/example.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example.jpg differ diff --git a/article/example_033kK5t.jpg b/article/example_033kK5t.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_033kK5t.jpg differ diff --git a/article/example_07w0yYH.jpg b/article/example_07w0yYH.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_07w0yYH.jpg differ diff --git a/article/example_1iXBmMR.jpg b/article/example_1iXBmMR.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_1iXBmMR.jpg differ diff --git a/article/example_4Kef3eN.jpg b/article/example_4Kef3eN.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_4Kef3eN.jpg differ diff --git a/article/example_4OcfZLi.jpg b/article/example_4OcfZLi.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_4OcfZLi.jpg differ diff --git a/article/example_7H5yc0H.jpg b/article/example_7H5yc0H.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_7H5yc0H.jpg differ diff --git a/article/example_Ditfv77.jpg b/article/example_Ditfv77.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_Ditfv77.jpg differ diff --git a/article/example_Ix08VCf.jpg b/article/example_Ix08VCf.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_Ix08VCf.jpg differ diff --git a/article/example_Jto0Zsc.jpg b/article/example_Jto0Zsc.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_Jto0Zsc.jpg differ diff --git a/article/example_M3KOuoT.jpg b/article/example_M3KOuoT.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_M3KOuoT.jpg differ diff --git a/article/example_P9RkWCa.jpg b/article/example_P9RkWCa.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_P9RkWCa.jpg differ diff --git a/article/example_UW23hBe.jpg b/article/example_UW23hBe.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_UW23hBe.jpg differ diff --git a/article/example_XVxnDlT.jpg b/article/example_XVxnDlT.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_XVxnDlT.jpg differ diff --git a/article/example_YugPnAh.jpg b/article/example_YugPnAh.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_YugPnAh.jpg differ diff --git a/article/example_dK1f8wX.jpg b/article/example_dK1f8wX.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_dK1f8wX.jpg differ diff --git a/article/example_eYQuOq9.jpg b/article/example_eYQuOq9.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_eYQuOq9.jpg differ diff --git a/article/example_fAirp4p.jpg b/article/example_fAirp4p.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_fAirp4p.jpg differ diff --git a/article/example_ityEKgZ.jpg b/article/example_ityEKgZ.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_ityEKgZ.jpg differ diff --git a/article/example_jjFAcuu.jpg b/article/example_jjFAcuu.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_jjFAcuu.jpg differ diff --git a/article/example_mHUOOtE.jpg b/article/example_mHUOOtE.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_mHUOOtE.jpg differ diff --git a/article/example_ofs9xmg.jpg b/article/example_ofs9xmg.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_ofs9xmg.jpg differ diff --git a/article/example_pnfVCgm.jpg b/article/example_pnfVCgm.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_pnfVCgm.jpg differ diff --git a/article/example_xX3GuhB.jpg b/article/example_xX3GuhB.jpg new file mode 100644 index 0000000..e7203c6 Binary files /dev/null and b/article/example_xX3GuhB.jpg differ diff --git a/authors/__init__.py b/authors/__init__.py old mode 100644 new mode 100755 diff --git a/authors/apps/__init__.py b/authors/apps/__init__.py old mode 100644 new mode 100755 diff --git a/authors/apps/articles/__init__.py b/authors/apps/article/__init__.py old mode 100644 new mode 100755 similarity index 100% rename from authors/apps/articles/__init__.py rename to authors/apps/article/__init__.py diff --git a/authors/apps/articles/apps.py b/authors/apps/article/apps.py old mode 100644 new mode 100755 similarity index 100% rename from authors/apps/articles/apps.py rename to authors/apps/article/apps.py diff --git a/authors/apps/article/models.py b/authors/apps/article/models.py new file mode 100755 index 0000000..c7cc37c --- /dev/null +++ b/authors/apps/article/models.py @@ -0,0 +1,104 @@ +# this is how the db will be structured. + +from django.db import models +from django.utils.translation import pgettext_lazy as _ +from django.contrib.auth import get_user_model +from django.core.validators import MaxValueValidator, MinValueValidator + +'''Django-autoslug is a reusable Django library +that provides an improved slug field which can automatically: +populate itself from another field and preserve + uniqueness of the value''' +from autoslug import AutoSlugField +from versatileimagefield.fields import VersatileImageField + + +class Article(models.Model): + user = models.ForeignKey( + get_user_model(), + related_name='author', + on_delete=models.CASCADE, + null=True, + blank=True, + default=None + ) + # creates a random identifier for a particular article from the title + # field. + slug = AutoSlugField( + populate_from='title', + blank=True, + null=True, + unique=True) + title = models.CharField( + _('Article field', 'title'), + unique=True, + max_length=128 + ) + description = models.TextField( + _('Article Field', 'description'), + blank=True, + null=True + ) + body = models.TextField( + _('Article Field', 'body'), + blank=True, + null=True + ) + image = VersatileImageField( + 'Image', + upload_to='article/', + width_field='width', + height_field='height', + blank=True, + null=True + ) + height = models.PositiveIntegerField( + 'Image Height', + blank=True, + null=True + ) + width = models.PositiveIntegerField( + 'Image Width', + blank=True, + null=True + ) + created_at = models.DateTimeField( + _('Article field', 'created at'), + auto_now_add=True, + editable=False + ) + updated_at = models.DateTimeField( + _('Article field', 'updated at'), + auto_now=True + ) + + class Meta: + app_label = "article" + + def __str__(self): + return self.title + + +class RateArticle(models.Model): + """ + This is the article class. It holds data for the article. + """ + rater = models.ForeignKey( + "authentication.User", + related_name="ratearticle", + on_delete=models.CASCADE) # link with the user who rated + article = models.ForeignKey( + "article.Article", + related_name="ratearticle", + on_delete=models.CASCADE) # link with the article being rated + rate = models.IntegerField(null=False, blank=False, + validators=[ + MaxValueValidator(5), + MinValueValidator(1) + ]) # rate value column + + def __str__(self): + """ + Return a human readable format + """ + return self.rate diff --git a/authors/apps/article/serializers.py b/authors/apps/article/serializers.py new file mode 100755 index 0000000..57eed70 --- /dev/null +++ b/authors/apps/article/serializers.py @@ -0,0 +1,84 @@ +'''Serializers allow complex data +such as querysets and model instances + to be converted to +native Python datatypes that can then +be easily rendered into JSON, XML or other content types.''' + +from rest_framework import serializers +from django.apps import apps +from .models import RateArticle +from authors.apps.profiles.serializers import ProfileListSerializer + + +TABLE = apps.get_model('article', 'Article') +Profile = apps.get_model('profiles', 'UserProfile') + +NAMESPACE = 'article' +fields = ('id', 'slug', 'image', 'title', 'description', 'body', 'user',) + + +class ArticleSerializer(serializers.ModelSerializer): + update_url = serializers.HyperlinkedIdentityField( + view_name=NAMESPACE + ':update', lookup_field='slug') + delete_url = serializers.HyperlinkedIdentityField( + view_name=NAMESPACE + ':delete', lookup_field='slug') + author = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = TABLE + + fields = fields + ('author', 'update_url', 'delete_url') + + def get_author(self, obj): + try: + serializer = ProfileListSerializer( + instance=Profile.objects.get(user=obj.user) + ) + return serializer.data + except BaseException: + return {} + + def update(self, instance, validated_data): + instance.title = validated_data.get('title', instance.title) + instance.description = validated_data.get( + 'description', instance.description) + instance.body = validated_data.get('body', instance.body) + if validated_data.get('image'): + instance.image = validated_data.get('image', instance.image) + + instance.save() + + return instance + + +class ArticleCreateSerializer(serializers.ModelSerializer): + class Meta: + model = TABLE + + fields = fields + + def create(self, validated_data): + instance = TABLE.objects.create(**validated_data) + validated_data['slug'] = instance.slug + + return validated_data + + +class RateArticleSerializer(serializers.ModelSerializer): + """ + validate rate article + """ + slug = serializers.SlugField() + rate = serializers.IntegerField() + + def validate(self, data): + rate = data['rate'] + if not rate > 0 or not rate <= 5: + raise serializers.ValidationError( + 'invalid rate value should be > 0 or <=5') + + return data + + class Meta: + model = RateArticle + fields = ("slug", "rate") diff --git a/authors/apps/articles/migrations/__init__.py b/authors/apps/article/templates/__init__.py old mode 100644 new mode 100755 similarity index 100% rename from authors/apps/articles/migrations/__init__.py rename to authors/apps/article/templates/__init__.py diff --git a/authors/apps/articles/tests/__init__.py b/authors/apps/article/tests/__init__.py old mode 100644 new mode 100755 similarity index 100% rename from authors/apps/articles/tests/__init__.py rename to authors/apps/article/tests/__init__.py diff --git a/authors/apps/articles/tests/templates/activate_account.html b/authors/apps/article/tests/templates/activate_account.html old mode 100644 new mode 100755 similarity index 100% rename from authors/apps/articles/tests/templates/activate_account.html rename to authors/apps/article/tests/templates/activate_account.html diff --git a/authors/apps/article/tests/test_article.py b/authors/apps/article/tests/test_article.py new file mode 100755 index 0000000..c7b681e --- /dev/null +++ b/authors/apps/article/tests/test_article.py @@ -0,0 +1,100 @@ +from django.apps import apps +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +import factory +from faker import Factory +from django.contrib.auth import get_user_model + +Article = apps.get_model('article', 'Article') +faker = Factory.create() + + +class UserFactory(factory.DjangoModelFactory): + class Meta: + model = get_user_model() + + username = factory.Sequence(lambda n: 'map%d' % n) + email = factory.Sequence(lambda n: 'example_%s@map.com' % n) + password = factory.PostGenerationMethodCall('set_password', '1234abcd') + + +class ArticleFactory(factory.DjangoModelFactory): + class Meta: + model = Article + + user = factory.SubFactory(UserFactory) + title = faker.name() + description = faker.text() + body = faker.text() + slug = factory.Sequence(lambda n: 'map-slug%d' % n) + image = factory.django.ImageField(color='blue') + + +class TestArticles(TestCase): + def setUp(self): + self.user = UserFactory() + self.article = ArticleFactory() + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.client.credentials( + HTTP_AUTHORIZATION='Bearer ' + + self.user.token()) + + self.namespace = 'article' + self.body = { + 'title': faker.name(), + 'description': faker.text(), + 'body': faker.text(), + } + self.create_url = reverse(self.namespace + ':create') + self.list_url = reverse(self.namespace + ':list') + self.update_url = reverse( + self.namespace + ':update', + kwargs={ + 'slug': self.article.slug}) + self.delete_url = reverse( + self.namespace + ':delete', + kwargs={ + 'slug': self.article.slug}) + self.retrieve_url = reverse( + self.namespace + ':detail', + kwargs={ + 'slug': self.article.slug}) + + def test_create_article_api(self): + response = self.client.post(self.create_url, self.body, format='json') + self.assertEqual(201, response.status_code) + + def test_retrieve_article_api(self): + response = self.client.get(self.retrieve_url) + self.assertContains(response, self.article) + + def test_list_article_api_with_parameters(self): + self.client.post(self.create_url, self.body, format='json') + response = self.client.get( + self.list_url + '?q=' + self.article.slug[0]) + self.assertContains(response, self.article) + + def test_listing_articles_api(self): + response = self.client.get(self.list_url) + self.assertContains(response, self.article) + + def test_update_article_api(self): + response = self.client.post(self.create_url, self.body, format='json') + self.update_url = reverse( + self.namespace + ':update', + kwargs={ + 'slug': response.data.get('slug')}) + response = self.client.put(self.update_url, self.body) + self.assertEqual(200, response.status_code) + + def test_delete_article_api(self): + response = self.client.post(self.create_url, self.body, format='json') + self.delete_url = reverse( + self.namespace + ':delete', + kwargs={ + 'slug': response.data.get('slug')}) + + response = self.client.delete(self.delete_url) + self.assertEqual(204, response.status_code) diff --git a/authors/apps/articles/tests/test_rate_article.py b/authors/apps/article/tests/test_rate_article.py old mode 100644 new mode 100755 similarity index 89% rename from authors/apps/articles/tests/test_rate_article.py rename to authors/apps/article/tests/test_rate_article.py index a793a57..6404e47 --- a/authors/apps/articles/tests/test_rate_article.py +++ b/authors/apps/article/tests/test_rate_article.py @@ -14,6 +14,14 @@ def setUp(self): """ Prepare test environment for each testcase """ + + self.article = { + "title": "How to train your dragon today", + "description": "Ever wonder how?", + "body": "You have to believe in you", + "image": "https://dummyimage.com/600x400/000/fff" + } + self.client = APIClient() self.article = Article() self.signup_url = reverse('authentication:register') @@ -53,11 +61,12 @@ def setUp(self): body = "this is a body" author = self.user article = Article( - author=author, + user=author, slug=self.slug, body=body, title=title, - description=description) + description=description + ) article.save() self.rate_details = { "user": { @@ -65,9 +74,12 @@ def setUp(self): "rate": 3 } } - self.rate_url = os.environ["URL"]+"api/article/"\ - + self.slug + "/rate/" - self.view_rates_url = os.environ["URL"]+"api/article/rate/" + self.rate_url = os.environ["URL"] + \ + "api/article/" + self.slug + "/rate/" + self.view_rates_url = os.environ["URL"] + "api/article/rate/" + + self.articles_url = os.environ["URL"] + "api/article/" + self.create_articles_url = os.environ["URL"] + "api/article/create" def test_rate_article_without_token(self): """ @@ -152,9 +164,7 @@ def test_get_rate_article(self): response = self.client.get( self.view_rates_url + str(1) + "/", format='json') - self.assertEqual( - 3.5, - response.data["rates"]) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_get_rate_article_not_found(self): diff --git a/authors/apps/article/urls.py b/authors/apps/article/urls.py new file mode 100755 index 0000000..4ca04db --- /dev/null +++ b/authors/apps/article/urls.py @@ -0,0 +1,44 @@ +from django.urls import path +from rest_framework_swagger.views import get_swagger_view +from .views import ( + ArticleCreateAPIView, + ArticleListAPIView, + ArticleDeleteAPIView, + ArticleDetailAPIView, + ArticleUpdateAPIView, + Rate, + ArticleRate +) + +schema_view = get_swagger_view(title="Articles") + +urlpatterns = [ + path( + 'article/', + ArticleListAPIView.as_view(), + name='list'), + path( + 'article/create', + ArticleCreateAPIView.as_view(), + name='create'), + path( + 'article/delete//', + ArticleDeleteAPIView.as_view(), + name='delete'), + path( + 'article/detail//', + ArticleDetailAPIView.as_view(), + name='detail'), + path( + 'article/update//', + ArticleUpdateAPIView.as_view(), + name='update'), + path( + 'article//rate/', + Rate.as_view(), + name="rate"), + path( + 'article/rate//', + ArticleRate.as_view(), + name="view_rate"), +] diff --git a/authors/apps/articles/views.py b/authors/apps/article/views.py old mode 100644 new mode 100755 similarity index 53% rename from authors/apps/articles/views.py rename to authors/apps/article/views.py index 254d0c7..69a2c8f --- a/authors/apps/articles/views.py +++ b/authors/apps/article/views.py @@ -1,15 +1,80 @@ -from rest_framework import status +from .serializers import ( + TABLE, + ArticleSerializer, + ArticleCreateSerializer, + RateArticleSerializer +) +from ..core.permissions import IsOwnerOrReadOnly from ..authentication.renderers import UserJSONRenderer -from rest_framework.generics import CreateAPIView -from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework import serializers +from rest_framework import status +from django.db.models import Q +from rest_framework.generics import ( + ListAPIView, CreateAPIView, + RetrieveUpdateAPIView, + RetrieveAPIView, + DestroyAPIView, +) +from rest_framework.permissions import ( + IsAuthenticatedOrReadOnly, IsAuthenticated +) from .models import RateArticle, Article -from .serializers import RateArticleSerializer + +LOOKUP_FIELD = 'slug' + + +class ArticleListAPIView(ListAPIView): + permission_classes = [IsAuthenticatedOrReadOnly] + serializer_class = ArticleSerializer + + def get_queryset(self, *args, **kwargs): + queryset_list = TABLE.objects.all() + + query = self.request.GET.get('q') + + if query: + queryset_list = queryset_list.filter( + Q(title__icontains=query) | + Q(slug__icontains=query) | + Q(description__icontains=query) + ) + + return queryset_list.order_by('-id') + + +class ArticleCreateAPIView(CreateAPIView): + serializer_class = ArticleCreateSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + queryset = TABLE.objects.all() + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class ArticleDetailAPIView(RetrieveAPIView): + queryset = TABLE.objects.all() + serializer_class = ArticleSerializer + lookup_field = LOOKUP_FIELD + + +class ArticleDeleteAPIView(DestroyAPIView): + queryset = TABLE.objects.all() + permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] + serializer_class = ArticleSerializer + lookup_field = LOOKUP_FIELD + + +class ArticleUpdateAPIView(RetrieveUpdateAPIView): + permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] + queryset = TABLE.objects.all() + serializer_class = ArticleSerializer + lookup_field = LOOKUP_FIELD + + def perform_update(self, serializer): + serializer.save(user=self.request.user) -class ArticleRate(APIView): +class ArticleRate(CreateAPIView): """ rate class article """ diff --git a/authors/apps/articles/models.py b/authors/apps/articles/models.py deleted file mode 100644 index 2067fc3..0000000 --- a/authors/apps/articles/models.py +++ /dev/null @@ -1,47 +0,0 @@ -from django.db import models -from django.core.validators import MaxValueValidator, MinValueValidator - - -class Article(models.Model): - """ - This is the RateArticle class. It holds data for rating an article. - """ - - slug = models.SlugField(max_length=255, unique=True, db_index=True) - title = models.CharField(max_length=255) - description = models.TextField() - body = models.TextField() - author = models.ForeignKey('authentication.User', - related_name='articles', - on_delete=models.CASCADE) - - def __str__(self): - """ - Return article with human readable format - """ - return self.title - - -class RateArticle(models.Model): - """ - This is the article class. It holds data for the article. - """ - rater = models.ForeignKey( - "authentication.User", - related_name="ratearticle", - on_delete=models.CASCADE) # link with the user who rated - article = models.ForeignKey( - "articles.Article", - related_name="ratearticle", - on_delete=models.CASCADE) # link with the article being rated - rate = models.IntegerField(null=False, blank=False, - validators=[ - MaxValueValidator(5), - MinValueValidator(1) - ]) # rate value column - - def __str__(self): - """ - Return a human readable format - """ - return self.rate diff --git a/authors/apps/articles/serializers.py b/authors/apps/articles/serializers.py deleted file mode 100644 index ef6da93..0000000 --- a/authors/apps/articles/serializers.py +++ /dev/null @@ -1,22 +0,0 @@ -from rest_framework import serializers -from .models import RateArticle - - -class RateArticleSerializer(serializers.ModelSerializer): - """ - validate rate article - """ - slug = serializers.SlugField() - rate = serializers.IntegerField() - - def validate(self, data): - rate = data['rate'] - if not rate > 0 or not rate <= 5: - raise serializers.ValidationError( - 'invalid rate value should be > 0 or <=5') - - return data - - class Meta: - model = RateArticle - fields = ("slug", "rate") diff --git a/authors/apps/articles/urls.py b/authors/apps/articles/urls.py deleted file mode 100644 index 9002156..0000000 --- a/authors/apps/articles/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.urls import path -from rest_framework_swagger.views import get_swagger_view -from .views import Rate, ArticleRate - -schema_view = get_swagger_view(title="Articles") - -urlpatterns = [ - path( - 'article//rate/', - Rate.as_view(), - name="rate"), - path( - 'article/rate//', - ArticleRate.as_view(), - name="view_rate"), -] diff --git a/authors/apps/authentication/__init__.py b/authors/apps/authentication/__init__.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/backends.py b/authors/apps/authentication/backends.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/migrations/__init__.py b/authors/apps/authentication/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/authors/apps/authentication/models.py b/authors/apps/authentication/models.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/renderers.py b/authors/apps/authentication/renderers.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/serializers.py b/authors/apps/authentication/serializers.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/signals.py b/authors/apps/authentication/signals.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/templates/activate_account.html b/authors/apps/authentication/templates/activate_account.html old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/templates/alert_reset_password.html b/authors/apps/authentication/templates/alert_reset_password.html old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/templates/email_confirm.html b/authors/apps/authentication/templates/email_confirm.html old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/tests/__init__.py b/authors/apps/authentication/tests/__init__.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/tests/test_email_verification.py b/authors/apps/authentication/tests/test_email_verification.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/tests/test_social_auth.py b/authors/apps/authentication/tests/test_social_auth.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/tests/test_user.py b/authors/apps/authentication/tests/test_user.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/urls.py b/authors/apps/authentication/urls.py old mode 100644 new mode 100755 diff --git a/authors/apps/authentication/views.py b/authors/apps/authentication/views.py old mode 100644 new mode 100755 diff --git a/authors/apps/core/__init__.py b/authors/apps/core/__init__.py old mode 100644 new mode 100755 diff --git a/authors/apps/core/exceptions.py b/authors/apps/core/exceptions.py old mode 100644 new mode 100755 diff --git a/authors/apps/core/models.py b/authors/apps/core/models.py old mode 100644 new mode 100755 diff --git a/authors/apps/core/permissions.py b/authors/apps/core/permissions.py new file mode 100755 index 0000000..442dc5d --- /dev/null +++ b/authors/apps/core/permissions.py @@ -0,0 +1,17 @@ +from rest_framework import permissions + + +class IsOwnerOrReadOnly(permissions.BasePermission): + """ + Object-level permission to only allow owners of an object to edit it. + Assumes the model instance has an `owner` attribute. + """ + + def has_object_permission(self, request, view, obj): + # Read permissions are allowed to any request, + # so we'll always allow GET, HEAD or OPTIONS requests. + if request.method in permissions.SAFE_METHODS: + return True + + # Instance must have an attribute named `owner`. + return obj.user == request.user diff --git a/authors/apps/core/renderers.py b/authors/apps/core/renderers.py old mode 100644 new mode 100755 diff --git a/authors/apps/profiles/migrations/__init__.py b/authors/apps/profiles/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/authors/apps/profiles/models.py b/authors/apps/profiles/models.py index 3ca4df5..c9d3112 100644 --- a/authors/apps/profiles/models.py +++ b/authors/apps/profiles/models.py @@ -13,5 +13,44 @@ class UserProfile(TimestampedModel): bio = models.TextField(blank=True) image = models.URLField(blank=True) + # many to many relationship, meaning both sides of the + # profile can have more than one follower + follows = models.ManyToManyField( + 'self', + related_name='followed_by', + symmetrical=False + ) + + class Meta: + app_label = "profiles" + def __str__(self): return self.user.username + + def follow(self, my_profile): + """ + follow my profile if you are not following me yet + :param my_profile: profile to follow + :return: none + """ + self.follows.add(my_profile) + + def unfollow(self, my_profile): + """ + quit following me if you are are already following me + :param my_profile: profile to unfollow + :return: none + """ + self.follows.remove(my_profile) + + def is_following(self, profile): + """ + check if am following the other profile + :param profile: the other profile + :return: True if am following the other profile else False + """ + return self.follows.filter(pk=profile.pk).exists() + + def is_followed_by(self, my_profile): + """check if the the other profile is following my profile""" + return self.followed_by.filter(pk=my_profile.pk).exists() diff --git a/authors/apps/profiles/serializers.py b/authors/apps/profiles/serializers.py index 39319a2..2222435 100644 --- a/authors/apps/profiles/serializers.py +++ b/authors/apps/profiles/serializers.py @@ -6,10 +6,11 @@ class ProfileSerializer(serializers.ModelSerializer): username = serializers.CharField(source='user.username') bio = serializers.CharField(allow_blank=True, required=False) image = serializers.SerializerMethodField() + following = serializers.SerializerMethodField() class Meta: model = UserProfile - fields = ('username', 'bio', 'image',) + fields = ('username', 'bio', 'image', 'following',) read_only_fields = ('username',) def get_image(self, obj): @@ -18,6 +19,18 @@ def get_image(self, obj): return 'null' + def get_following(self, instance): + """get the new profile instance which we want to follow""" + # get the request context which contains the user + request = self.context.get('request', None) + if request is None: + return False + if request.user.is_authenticated: + # obtain the profile instance from the request + follower = request.user.userprofile + followee = instance + return follower.is_following(followee) + class ProfileListSerializer(serializers.ModelSerializer): username = serializers.CharField(source='user.username', read_only=True) diff --git a/authors/apps/profiles/tests/test_follow_profile.py b/authors/apps/profiles/tests/test_follow_profile.py new file mode 100644 index 0000000..69b3187 --- /dev/null +++ b/authors/apps/profiles/tests/test_follow_profile.py @@ -0,0 +1,118 @@ +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework.test import APITestCase +from rest_framework import status + + +class TestFollowUserProfile(APITestCase): + """ setup class for follow profile tests""" + + def setUp(self): + """ + Prepare test environment for each testcase + """ + self.client = APIClient() + self.user_details = { + 'user': { + 'username': 'steel', + 'email': 'steel@gmail.com', + 'password': 'somepass12345', + } + } + self.follow_me = { + 'user': { + 'username': 'bond', + 'email': 'bond@gmail.com', + 'password': 'somepass12345', + } + } + + self.login_data = { + "user": { + 'email': 'steel@gmail.com', + 'password': 'somepass12345' + } + } + + self.login_url = reverse('authentication:login') + self.signup_url = reverse('authentication:register') + self.all_profiles = reverse('profile:all_profiles') + self.update_profile = reverse('authentication:update_profile') + + def register_user(self, user_details): + """ + register a new user + """ + self.client.post( + self.signup_url, + user_details, + format='json') + + def login_user(self): + """login user to get the token""" + response = self.client.post( + self.login_url, self.login_data, format='json') + return response.data['token'] + + def test_follow_another_user_profile(self): + """ + test follow another profile + """ + self.register_user(self.user_details) + self.register_user(self.follow_me) + token = self.login_user() + response = self.client.post( + reverse( + "profile:follow", + args=['bond']), + format='json', + HTTP_AUTHORIZATION='Bearer ' + + token) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('True', str(response.data)) + + def test_un_follow_another_user_profile(self): + """ + test un follow another profile + """ + self.register_user(self.user_details) + self.register_user(self.follow_me) + token = self.login_user() + response = self.client.delete( + reverse( + "profile:follow", + args=['bond']), + format='json', HTTP_AUTHORIZATION='Bearer ' + token) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('False', str(response.data)) + + def test_follow_my_self(self): + """ + test follow myself + """ + self.register_user(self.user_details) + self.register_user(self.follow_me) + token = self.login_user() + response = self.client.post( + reverse( + "profile:follow", + args=['steel']), + format='json', HTTP_AUTHORIZATION='Bearer ' + token) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertIn( + 'You can only follow others, not yourself', str(response.data)) + + def test_follow_without_authentication(self): + """ + test follow another profile + """ + self.register_user(self.user_details) + self.register_user(self.follow_me) + response = self.client.post( + reverse( + "profile:follow", + args=['steel']), + format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn( + 'Authentication credentials were not provided', str(response.data)) diff --git a/authors/apps/profiles/urls.py b/authors/apps/profiles/urls.py index 5ef61bf..afc7d56 100644 --- a/authors/apps/profiles/urls.py +++ b/authors/apps/profiles/urls.py @@ -1,6 +1,7 @@ from django.urls import path -from authors.apps.profiles.views import ProfileListAPIView +from authors.apps.profiles.views import ProfileListAPIView,\ + ProfileFollowAPIView from .views import ProfileRetrieveAPIView profiles = 'profiles/' @@ -11,4 +12,6 @@ 'profiles//', ProfileRetrieveAPIView.as_view(), name='profile'), + path('profiles//follow/', + ProfileFollowAPIView.as_view(), name='follow') ] diff --git a/authors/apps/profiles/views.py b/authors/apps/profiles/views.py index ca5fb67..f56dacd 100644 --- a/authors/apps/profiles/views.py +++ b/authors/apps/profiles/views.py @@ -33,7 +33,9 @@ def retrieve(self, request, username, *args, **kwargs): }, status=status.HTTP_404_NOT_FOUND ) - serializer = self.serializer_class(profile) + serializer = self.serializer_class(profile, context={ + 'request': request + }) return Response(serializer.data, status=status.HTTP_200_OK) @@ -75,7 +77,7 @@ def get(self, request, **kwargs): """ try: queryset = UserProfile.objects.all() - except Exception: + except UserProfile.DoesNotExist: return Response( { 'Message': 'There are no profiles found' @@ -86,3 +88,44 @@ def get(self, request, **kwargs): profiles = Response({'profiles': serializer.data}, status=status.HTTP_200_OK) return profiles + + +class ProfileFollowAPIView(APIView): + permission_classes = (IsAuthenticated,) + renderer_classes = (ProfileJSONRenderer,) + serializer_class = ProfileSerializer + + def follow_unfollow(self, username, request, check, status_): + follower = self.request.user.userprofile + try: + followee = UserProfile.objects.get(user__username=username) + except UserProfile.DoesNotExist: + return Response( + {'Message': 'No profile with this username was found'}, + status=status.HTTP_404_NOT_FOUND + ) + + if follower.pk is followee.pk: + return Response({ + 'Message': 'You can only follow others, not yourself' + }, status=status.HTTP_409_CONFLICT) + + if check: + follower.follow(followee) + if not check: + follower.unfollow(followee) + + serializer = self.serializer_class(followee, context={ + 'request': request + }) + return Response(serializer.data, status=status_) + + def post(self, request, username=None): + """follow a profile with username 'username'""" + return self.follow_unfollow( + username, request, True, status.HTTP_201_CREATED) + + def delete(self, request, username=None): + """un-follow a profile """ + return self.follow_unfollow( + username, request, False, status.HTTP_200_OK) diff --git a/authors/settings.py b/authors/settings.py old mode 100644 new mode 100755 index 7a8f16e..7764d80 --- a/authors/settings.py +++ b/authors/settings.py @@ -51,8 +51,10 @@ 'authors.apps.authentication', 'authors.apps.core', 'authors.apps.profiles', + 'social_django', - "authors.apps.articles" + "authors.apps.article" + ] MIDDLEWARE = [ diff --git a/authors/urls.py b/authors/urls.py old mode 100644 new mode 100755 index ba1c670..1333602 --- a/authors/urls.py +++ b/authors/urls.py @@ -25,6 +25,7 @@ include( ('authors.apps.authentication.urls', 'authentication'), + namespace='authentication',)), path('api/', include( @@ -33,6 +34,6 @@ namespace='profile')), path('api/', include( - ('authors.apps.articles.urls', - 'articles'), - namespace='articles'))] + ('authors.apps.article.urls', + 'article'), + namespace='article'))] diff --git a/authors/wsgi.py b/authors/wsgi.py old mode 100644 new mode 100755 diff --git a/requirements.txt b/requirements.txt index b0499e6..75f87aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,54 +1,66 @@ +asn1crypto==0.24.0 astroid==2.0.4 atomicwrites==1.2.1 attrs==18.2.0 autopep8==1.4.2 certifi==2018.10.15 +cffi==1.11.5 chardet==3.0.4 +colorama==0.3.9 coreapi==2.3.3 coreschema==0.0.4 coverage==4.5.1 +cryptography==2.3.1 defusedxml==0.5.0 dj-database-url==0.5.0 Django==2.1.2 django-allauth==0.38.0 +django-autoslug-iplweb==1.9.4 django-cors-headers==2.4.0 django-extensions==2.1.3 django-heroku==0.3.1 django-rest-swagger==2.1.0 +django-versatileimagefield==1.10 djangorestframework==3.9.0 flake8==3.6.0 +flake8-polyfill==1.0.2 +factory-boy==2.11.1 +Faker==0.9.2 gunicorn==19.9.0 idna==2.7 isort==4.3.4 itypes==1.1.0 Jinja2==2.10 lazy-object-proxy==1.3.1 +mando==0.6.4 MarkupSafe==1.0 mccabe==0.6.1 more-itertools==4.3.0 oauthlib==2.1.0 openapi-codec==1.3.2 +Pillow==5.3.0 pluggy==0.8.0 psycopg2==2.7.5 psycopg2-binary==2.7.5 py==1.7.0 -flake8==3.6.0 -mccabe==0.6.1 -psycopg2-binary==2.7.5 pycodestyle==2.4.0 +pycparser==2.19 pyflakes==2.0.0 PyJWT==1.6.4 pylint==2.1.1 pytest==3.9.3 python-dateutil==2.7.5 python-http-client==3.1.0 +python3-openid==3.1.0 pytz==2018.5 +radon==2.4.0 requests==2.20.0 requests-oauthlib==1.0.0 simplejson==3.16.0 six==1.11.0 social-auth-app-django==3.1.0 social-auth-core==2.0.0 +text-unidecode==1.2 uritemplate==3.0.0 urllib3==1.24 whitenoise==4.1