diff --git a/.gitignore b/.gitignore index 8229571..f9e597f 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,8 @@ ENV/ # SQLite3 db.sqlite3 + +#the coverage file +.coveragerc + +htmlcov/* diff --git a/authors/apps/articles/__init__.py b/authors/apps/articles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authors/apps/articles/admin.py b/authors/apps/articles/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/authors/apps/articles/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/authors/apps/articles/apps.py b/authors/apps/articles/apps.py new file mode 100644 index 0000000..bc12dfb --- /dev/null +++ b/authors/apps/articles/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ArticlesConfig(AppConfig): + name = 'articles' diff --git a/authors/apps/articles/fixtures/article.json b/authors/apps/articles/fixtures/article.json new file mode 100644 index 0000000..54f4daa --- /dev/null +++ b/authors/apps/articles/fixtures/article.json @@ -0,0 +1,17 @@ +[ +{ + "model": "articles.article", + "pk": 1, + "fields": { + "slug" : "this-is-andela-ftub", + "title": "this is andela", + "description": "The best tech startapp operating", + "body": "The tech startapp harder to get into than Harvard", + "created_at": "2019-04-29T14:13:25.526681Z", + "updated_at": "2019-04-29T14:13:25.526692Z", + "favorited": false, + "favorite_count": 0, + "author_id": "jeanmarcus" + } +} +] \ No newline at end of file diff --git a/authors/apps/articles/fixtures/user.json b/authors/apps/articles/fixtures/user.json new file mode 100644 index 0000000..1d2c840 --- /dev/null +++ b/authors/apps/articles/fixtures/user.json @@ -0,0 +1,13 @@ +[ + { + "model": "authentication.user", + "pk": 1, + "fields": { + "username": "jeanmarcus", + "email": "jean@gmail.com", + "password": "pbkdf2_sha256$120000$RLkaJYAyp0Dq$+2V+D1qXP5P1UuEjLxaRAorMiw2khCxKevg+/YrCAyI=", + "created_at": "2019-04-29T14:13:25.526681Z", + "updated_at": "2019-04-29T14:13:25.526692Z" + } + } +] diff --git a/authors/apps/articles/migrations/0001_initial.py b/authors/apps/articles/migrations/0001_initial.py new file mode 100644 index 0000000..c096ed5 --- /dev/null +++ b/authors/apps/articles/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 2.1 on 2019-04-25 19:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=120)), + ('slug', models.SlugField(blank=True, null=True)), + ('description', models.TextField()), + ('body', models.TextField()), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(default=django.utils.timezone.now)), + ('favorited', models.BooleanField(default=False)), + ('favorite_count', models.IntegerField(default=0)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, to_field='username')), + ], + ), + ] diff --git a/authors/apps/articles/migrations/__init__.py b/authors/apps/articles/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authors/apps/articles/models.py b/authors/apps/articles/models.py new file mode 100644 index 0000000..83ee3fd --- /dev/null +++ b/authors/apps/articles/models.py @@ -0,0 +1,39 @@ +from django.db import models +from django.db.models.signals import pre_save +from django.utils import timezone + +from authors.apps.authentication.models import User +from .utils import unique_slug_generator + + +class Article(models.Model): + """ + Model class for creating an article + """ + + title = models.CharField(max_length=120) + slug = models.SlugField(blank=True, + null=True) + description = models.TextField() + body = models.TextField() + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(default=timezone.now) + favorited = models.BooleanField(default=False) + favorite_count = models.IntegerField(default=0) + author = models.ForeignKey( + User, to_field='username', on_delete=models.CASCADE, null=False) + + def __str__(self): + """ + return string representation of the article + model class + """ + return self.title + + +def slug_generator(sender, instance, *args, **kwargs): + if not instance.slug: + instance.slug = unique_slug_generator(instance) + + +pre_save.connect(slug_generator, sender=Article) diff --git a/authors/apps/articles/permissions.py b/authors/apps/articles/permissions.py new file mode 100644 index 0000000..0bce009 --- /dev/null +++ b/authors/apps/articles/permissions.py @@ -0,0 +1,8 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsOwnerOrReadOnly(BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + return obj.author == request.user diff --git a/authors/apps/articles/renderer.py b/authors/apps/articles/renderer.py new file mode 100644 index 0000000..22efcce --- /dev/null +++ b/authors/apps/articles/renderer.py @@ -0,0 +1,44 @@ +import json + +# from rest_framework.renderers import JSONRenderer + + +# class ArticleJSONRenderer(JSONRenderer): +# charset = 'utf-8' + +# def render(self, data, media_type=None, renderer_context=None): +# """ Method to render response with article key and list of dictionaries as value """ +# return json.dumps({ +# 'article': data +# }) + +import json + +from rest_framework.renderers import JSONRenderer + + +class ArticleJSONRenderer(JSONRenderer): + charset = 'utf-8' + + def render(self, data, media_type=None, renderer_context=None): + # If the view throws an error (such as the article can't be created + # or something similar due to missing fields), `data` will contain an `errors` key. We want + # the default JSONRenderer to handle rendering errors, so we need to + # check for this case. + errors ='' + try: + errors = data.get('errors', None) + except: + pass + + + if errors: + # As mentioned about, we will let the default JSONRenderer handle + # rendering errors. + return super(ArticleJSONRenderer, self).render(data) + + + # Finally, we can render our data under the "user" namespace. + return json.dumps({ + 'article': data + }) diff --git a/authors/apps/articles/serializers.py b/authors/apps/articles/serializers.py new file mode 100644 index 0000000..696f001 --- /dev/null +++ b/authors/apps/articles/serializers.py @@ -0,0 +1,53 @@ +from rest_framework import serializers + +from .models import Article +from .validations import ValidateArticleCreation + + +class ArticleSerializer(serializers.ModelSerializer): + + title = serializers.CharField( + required=True, + error_messages={ + "required": "The title field is required", + "blank": "The title field cannot be left blank" + } + ) + description = serializers.CharField( + required=True, + error_messages={ + "required": "The description field is required", + "blank": "The description field cannot be left blank" + } + ) + body = serializers.CharField( + required=True, + error_messages={ + "required": "The body field is required", + "blank": "The body field cannot be left blank" + } + ) + + def validate(self, data): + title = data.get('title', None) + description = data.get('description', None) + body = data.get('body', None) + + validator = ValidateArticleCreation() + validator.validate_title(title) + validator.validate_title(description) + validator.validate_title(body) + + return { + 'title': title, + 'body': body, + 'description': description + } + + class Meta: + """class for returning our field.""" + + model = Article + fields = '__all__' + read_only_fields = ('created_at', 'updated_at', 'author', + 'favorited', 'favorite_count', 'slug') diff --git a/authors/apps/articles/tests/__init__.py b/authors/apps/articles/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authors/apps/articles/tests/base.py b/authors/apps/articles/tests/base.py new file mode 100644 index 0000000..908de53 --- /dev/null +++ b/authors/apps/articles/tests/base.py @@ -0,0 +1,67 @@ +from rest_framework.test import APIClient, APITestCase + +from authors.apps.authentication.models import User + +class BaseTestCase(APITestCase): + """ + Base Test class for out tests in this app + Class will also house the setup and teardown + methods for our tests + """ + def setUp(self): + # Initialize the Testclient for the tests + self.client = APIClient() + user = User.objects.get(username='jeanmarcus') + self.client.force_authenticate(user=user) + + self.create_article_data = { + "title": "Fresh kid wonders on stage at lugogo", + "description": "he wows the kids", + "body": "Fresh kid is a 5 year musician who has been on map." + } + self.create_article_with_blank_title = { + "title": "", + "description": "he wows the kids", + "body": "Fresh kid is a 5 year musician who has been on map." + } + self.create_article_with_no_title = { + "description": "he wows the kids", + "body": "Fresh kid is a 5 year musician who has been on map." + } + self.create_article_with_blank_description = { + "title": "fresh kid is a wiz", + "description": "", + "body": "Fresh kid is a 5 year musician who has been on map." + } + self.create_article_with_no_description= { + "title": "he wows the kids", + "body": "Fresh kid is a 5 year musician who has been on map." + } + self.create_article_with_blank_body = { + "title": "fresh kid is a wiz", + "description": "Fresh kid is a 5 year musician who has been on map", + "body": "" + } + self.create_article_with_no_body= { + "title": "he wows the kids", + "bdescription": "Fresh kid is a 5 year musician who has been on map." + } + + self.update_article = { + "title": "Fresh kid concert was great", + "description": "he wowed the adults too", + "body": "Fresh kid is on the map." + } + self.short_title_article = { + "title": "F", + "description": "he wowed the adults too", + "body": "Fresh kid is on the map." + } + + self.login_data = { + "user": { + "email": "jean@gmail.com", + "password": "kensanya1234" + } + } + diff --git a/authors/apps/articles/tests/test_article_crud.py b/authors/apps/articles/tests/test_article_crud.py new file mode 100644 index 0000000..90a0215 --- /dev/null +++ b/authors/apps/articles/tests/test_article_crud.py @@ -0,0 +1,183 @@ +# Django and Rest framework imports +from django.urls import reverse +from rest_framework import status +# Local imports +from authors.apps.articles.models import Article +from .base import BaseTestCase +from authors.apps.authentication.models import User + + +class ArticleCrudTest(BaseTestCase): + """ + Class Test Case for Testing user Login functionality + """ + # Initialize fixture for the class Test Case + fixtures = ['authors/apps/articles/fixtures/article.json', + 'authors/apps/articles/fixtures/user.json'] + + def test_user_create_article(self): + """ + Method tests the create article + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = reverse('articles-list-create') + response = self.client.post( + url, self.create_article_data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_user_create_article_blank_title(self): + """ + Method tests creating the article with blank title + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = reverse('articles-list-create') + response = self.client.post( + url, self.create_article_with_blank_title, format="json") + self.assertIn("The title field cannot be left blank", + str(response.data)) + + def test_user_created_article_no_title(self): + """ + Method tests creating the article with no title field + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = reverse('articles-list-create') + response = self.client.post( + url, self.create_article_with_no_title, format="json") + self.assertIn("The title field is required", str(response.data)) + + def test_user_created_article_blank_description(self): + """ + Method tests creating the article with blank description field + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = reverse('articles-list-create') + response = self.client.post( + url, self.create_article_with_blank_description, format="json") + self.assertIn("The description field cannot be left blank", + str(response.data)) + + def test_user_created_article_no_description(self): + """ + Method tests creating the article with no description field + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = reverse('articles-list-create') + response = self.client.post( + url, self.create_article_with_no_description, format="json") + self.assertIn("The description field is required", str(response.data)) + + def test_only_one_article_created(self): + """ + Method tests for only one created article + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = reverse('articles-list-create') + response = self.client.post( + url, self.create_article_data, format="json") + self.assertEqual(Article.objects.count(), 2) + + def test_get_all_articles(self): + """ + Method tests for getting all articles + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = '/api/articles/' + response = self.client.get( + url, self.create_article_data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_get_one_article(self): + """ + Method tests for getting one article + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = '/api/articles/1/' + response = self.client.get( + url, self.create_article_data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_update_article(self): + """ + Method tests for getting one article + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = '/api/articles/1/' + response = self.client.put(url, self.update_article, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_delete_article(self): + """ + Method tests for getting one article by id + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = '/api/articles/1/' + response = self.client.delete(url, format="json") + self.assertEqual(Article.objects.count(), 0) + + def test_get_delete_message(self): + """ + Method tests for getting one article by id + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = '/api/articles/1/' + response = self.client.delete(url, format="json") + self.assertIn("You have succesfully deleted the article", + str(response.data)) + + def test_get_article_wrong_id(self): + """ + Method tests for getting one article with the wrong id + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = '/api/articles/4/' + response = self.client.get( + url, self.create_article_data, format="json") + self.assertIn("That article is not found", str(response.data)) + + def test_user_create_article_blank_body(self): + """ + Method tests creating the article with blank body + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = reverse('articles-list-create') + response = self.client.post( + url, self.create_article_with_blank_body, format="json") + self.assertIn("The body field cannot be left blank", + str(response.data)) + + def test_user_created_article_no_body(self): + """ + Method tests creating the article with no body field + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = reverse('articles-list-create') + response = self.client.post( + url, self.create_article_with_no_body, format="json") + self.assertIn("The body field is required", str(response.data)) + + def test_user_created_article_short_title(self): + """ + Method tests creating the article with short title + """ + url = '/api/users/login/' + response = self.client.post(url, self.login_data, format="json") + url = reverse('articles-list-create') + response = self.client.post( + url, self.short_title_article, format="json") + self.assertIn("Title should be atleast 10 characters", + str(response.data)) diff --git a/authors/apps/articles/urls.py b/authors/apps/articles/urls.py new file mode 100644 index 0000000..3e4e9fc --- /dev/null +++ b/authors/apps/articles/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .views import ListCreateArticles, RetrieveUpdateDestroyArticle + + +urlpatterns = [ + path('articles/', ListCreateArticles.as_view(), + name="articles-list-create"), + path('articles//', RetrieveUpdateDestroyArticle.as_view(), + name="article-get-update-delete"), +] diff --git a/authors/apps/articles/utils.py b/authors/apps/articles/utils.py new file mode 100644 index 0000000..57ff157 --- /dev/null +++ b/authors/apps/articles/utils.py @@ -0,0 +1,24 @@ +import string +import random + +from django.utils.text import slugify + + +def random_string_generator(size=10, chars=string.ascii_lowercase + string.digits): + return ''.join(random.choice(chars) for _ in range(size)) + + +def unique_slug_generator(instance, new_slug=None, **kwargs): + if new_slug is not None: + slug = new_slug + slug = slugify(instance.title) + print(slug) + + Klass = instance.__class__ + qs_exists = Klass.objects.filter(slug=slug).exists() + if qs_exists: + random_string = random_string_generator(size=4) + new_slug = slug+"-"+random_string + print(new_slug) + return new_slug + return slug diff --git a/authors/apps/articles/validations.py b/authors/apps/articles/validations.py new file mode 100644 index 0000000..dc58737 --- /dev/null +++ b/authors/apps/articles/validations.py @@ -0,0 +1,8 @@ +from rest_framework.serializers import ValidationError + + +class ValidateArticleCreation: + + def validate_title(self, title): + if len(title) < 3: + raise ValidationError("Title should be atleast 10 characters") diff --git a/authors/apps/articles/views.py b/authors/apps/articles/views.py new file mode 100644 index 0000000..0a05db3 --- /dev/null +++ b/authors/apps/articles/views.py @@ -0,0 +1,64 @@ +from rest_framework import generics +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework import status + +from .renderer import ArticleJSONRenderer +from .models import Article +from .serializers import ArticleSerializer +from .permissions import IsOwnerOrReadOnly + + +class ListCreateArticles(generics.ListCreateAPIView): + """ + GET articles/ + POST articles/ + """ + queryset = Article.objects.all() + serializer_class = ArticleSerializer + permission_classes = (IsAuthenticated, ) + renderer_classes = (ArticleJSONRenderer, ) + + def perform_create(self, serializer): + + serializer.save( + author=self.request.user + ) + + # if serializer.is_valid(): + # serializer.save( + # author=self.request.user + # ) + # return Response({"article": serializer.data}, status.HTTP_201_CREATED) + + +class RetrieveUpdateDestroyArticle(generics.RetrieveUpdateDestroyAPIView): + """ + GET articles/:id/ + PUT articles/:id/ + DELETE articles/:id/ + """ + queryset = Article.objects.all() + serializer_class = ArticleSerializer + permission_classes = (IsAuthenticated, IsOwnerOrReadOnly) + renderer_classes = (ArticleJSONRenderer, ) + err_message = {"errors": "That article is not found"} + + def get(self, request, pk, **kwargs): + user = request.user + queryset = Article.objects.all().filter(id=pk) + + if queryset: + serializer = ArticleSerializer( + queryset, many=True, context={'request': request}) + # return Response({"article": serializer.data}) + return Response(serializer.data) + return Response(self.err_message, status=status.HTTP_404_NOT_FOUND) + + def destroy(self, request, pk, **kwargs): + user = request.user + queryset = Article.objects.all().filter(id=pk) + if queryset: + self.perform_destroy(queryset) + return Response("You have succesfully deleted the article") + return Response(self.err_message, status=status.HTTP_404_NOT_FOUND) diff --git a/authors/settings.py b/authors/settings.py index 34adf85..c720b26 100644 --- a/authors/settings.py +++ b/authors/settings.py @@ -48,6 +48,7 @@ 'authors.apps.authentication', 'authors.apps.core', 'authors.apps.profiles', + 'authors.apps.articles' ] MIDDLEWARE = [ @@ -154,6 +155,10 @@ 'DEFAULT_AUTHENTICATION_CLASSES': ( 'authors.apps.authentication.backends.JWTAuthentication', + ), + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer', ) } diff --git a/authors/urls.py b/authors/urls.py index d74e26b..c12456e 100644 --- a/authors/urls.py +++ b/authors/urls.py @@ -19,4 +19,5 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/', include('authors.apps.authentication.urls')), + path('api/', include('authors.apps.articles.urls')), ]