From 405c214cba94080010f365fe68b512030c749a10 Mon Sep 17 00:00:00 2001 From: Colline Waitire Date: Fri, 21 Sep 2018 18:12:55 +0300 Subject: [PATCH] feat(Reporting): Enable reporting of an article - write unite tests - enable users to post reports - enable superuser to get all reports - enable superuser to get reports on a single article [Finishes #159952021] --- README.md | 39 +++++++ authors/apps/articles/models.py | 49 ++++++--- authors/apps/articles/serializers.py | 34 +++++- authors/apps/articles/tests/test_articles.py | 107 +++++++++++++++++++ authors/apps/articles/tests/test_data.py | 4 + authors/apps/articles/tests/test_replies.py | 4 - authors/apps/articles/urls.py | 15 ++- authors/apps/articles/views.py | 53 ++++++++- authors/apps/articles/views_extra.py | 1 - 9 files changed, 278 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d41e08c..cda92bf 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,18 @@ The preferred JSON object to be returned by the API should be structured as foll ] } ``` + +### List of reports + +```source-json +{ + "reports": [ + "user": "jake", + "article": "how-to-train-your-dragon", + "report_message": "let me report this issue" + ] +} +``` ### Errors and Status Codes If a request fails any validations, expect errors in the following format: @@ -510,6 +522,33 @@ Example request body: Authentication and super user required. returns a tag. +### Report Article + +`POST /api/articles/reports//` + +Example request body: + +```source-json +{ + "report_message": "let me report this issue" +} +``` +Required fields: `report_message`. + +Authentication required, returns a report. + +### Get all Reports of all Articles + +`GET /api/articles/reports/` + +Authentication and super user required, returns multiple reports. + +### Get all Reports on a single Article + +`GET /api/articles/reports//` + +Authentication and super user required, returns multiple reports. + ##### Steps to install the project locally. 1. Install PostgresQL on the machine. diff --git a/authors/apps/articles/models.py b/authors/apps/articles/models.py index 8dd4428..5a19e63 100644 --- a/authors/apps/articles/models.py +++ b/authors/apps/articles/models.py @@ -30,7 +30,8 @@ class Article(models.Model): slug = models.SlugField(max_length=100, unique=True) - author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles", null=True) + author = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="articles", null=True) title = models.CharField(max_length=255, null=False, blank=False, error_messages={"required": "Write a short title for your article."}) @@ -41,9 +42,11 @@ class Article(models.Model): body = models.TextField(null=False, blank=False, error_messages={"required": "You cannot submit an article without body."}) - created_at = models.DateTimeField(auto_created=True, auto_now=False, default=timezone.now) + created_at = models.DateTimeField( + auto_created=True, auto_now=False, default=timezone.now) - updated_at = models.DateTimeField(auto_created=True, auto_now=False, default=timezone.now) + updated_at = models.DateTimeField( + auto_created=True, auto_now=False, default=timezone.now) favorites_count = models.IntegerField(default=0) @@ -85,9 +88,12 @@ class Rating(models.Model): """ Model for creating article ratings or votes """ - article = models.ForeignKey(Article, related_name="scores", on_delete=models.CASCADE) - rated_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name="scores", null=True) - rated_at = models.DateTimeField(auto_created=True, default=timezone.now, auto_now=False) + article = models.ForeignKey( + Article, related_name="scores", on_delete=models.CASCADE) + rated_by = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="scores", null=True) + rated_at = models.DateTimeField( + auto_created=True, default=timezone.now, auto_now=False) score = models.DecimalField(max_digits=4, decimal_places=2) class Meta: @@ -96,13 +102,16 @@ class Meta: class Comments(models.Model): - article = models.ForeignKey(Article, related_name='comments', on_delete=models.CASCADE, blank=True, null=True) + article = models.ForeignKey( + Article, related_name='comments', on_delete=models.CASCADE, blank=True, null=True) author = models.ForeignKey(User, on_delete=models.CASCADE) - body = models.TextField(null=False, blank=False, error_messages={"required": "You cannot submit without a comment."}) + body = models.TextField(null=False, blank=False, error_messages={ + "required": "You cannot submit without a comment."}) - created_at = models.DateTimeField(auto_created=True, auto_now=False, default=timezone.now) + created_at = models.DateTimeField( + auto_created=True, auto_now=False, default=timezone.now) def __str__(self): """ @@ -117,14 +126,17 @@ class Meta: class Replies(models.Model): - comment = models.ForeignKey(Comments, related_name='replies', on_delete=models.CASCADE, blank=True, null=True) + comment = models.ForeignKey( + Comments, related_name='replies', on_delete=models.CASCADE, blank=True, null=True) - author = models.ForeignKey(User, related_name='replies', on_delete=models.CASCADE, blank=True , null=True) + author = models.ForeignKey( + User, related_name='replies', on_delete=models.CASCADE, blank=True, null=True) content = models.TextField(null=False, blank=False, - error_messages={"required": "You cannot submit without a reply."}) + error_messages={"required": "You cannot submit without a reply."}) - created_at = models.DateTimeField(auto_created=True, auto_now=False, default=timezone.now) + created_at = models.DateTimeField( + auto_created=True, auto_now=False, default=timezone.now) def __str__(self): """ @@ -136,3 +148,14 @@ class Meta: get_latest_by = 'created_at' ordering = ['-created_at'] + +class ArticleReport(models.Model): + """ + Model for creating reports made on articles + """ + article = models.ForeignKey( + Article, blank=False, null=False, on_delete=models.CASCADE) + user = models.ForeignKey( + User, blank=False, null=False, on_delete=models.CASCADE) + report_message = models.TextField(blank=True, null=True) + reported_at = models.DateTimeField(auto_now_add=True) diff --git a/authors/apps/articles/serializers.py b/authors/apps/articles/serializers.py index 4ce1b11..d80715d 100644 --- a/authors/apps/articles/serializers.py +++ b/authors/apps/articles/serializers.py @@ -4,11 +4,14 @@ from rest_framework import serializers from rest_framework.pagination import PageNumberPagination from authors.apps.articles.exceptions import NotFoundException -from authors.apps.articles.models import Article, Tag, Rating, Comments, Replies + +from authors.apps.articles.models import (Article, + Tag, Rating, ArticleReport, Comments, Replies) from authors.apps.articles.utils import get_date from authors.apps.authentication.models import User from authors.apps.profiles.models import UserProfile from authors.apps.profiles.serializers import UserProfileSerializer +from rest_framework.exceptions import NotFound class TagRelatedField(serializers.RelatedField): @@ -16,6 +19,7 @@ class TagRelatedField(serializers.RelatedField): Implements a custom relational field by overriding RelatedFied. returns a list of tag names. """ + def to_representation(self, value): return value.tag_name @@ -122,6 +126,16 @@ def validate_for_update(data: dict, user, slug): }) return article, data + @staticmethod + def get_article_object(slug): + """This method returns an instance of Article""" + article = None + try: + article = Article.objects.get(slug=slug) + except Article.DoesNotExist: + raise NotFound("An article with this slug does not exist") + return article + def to_representation(self, instance): """ formats serializer display response @@ -249,3 +263,21 @@ class behaviours model = Rating fields = ("score", "rated_by", "rated_at", "article") + +class ArticleReportSerializer(serializers.ModelSerializer): + """ + Handles serialization and deserialization of ArticleReportSerializer objects. + """ + + def create(self, validated_data): + return ArticleReport.objects.create(**validated_data) + + def to_representation(self, instance): + response = super().to_representation(instance) + response["user"] = instance.user.username + response["article"] = instance.article.slug + return response + + class Meta: + model = ArticleReport + fields = ['user', 'article', 'report_message'] diff --git a/authors/apps/articles/tests/test_articles.py b/authors/apps/articles/tests/test_articles.py index b044c17..2be6bc5 100644 --- a/authors/apps/articles/tests/test_articles.py +++ b/authors/apps/articles/tests/test_articles.py @@ -306,3 +306,110 @@ def test_tag_update_on_article(self): self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertIn("django_restful", response.json()["article"]["tagList"]) self.assertNotIn("django", response.json()["article"]["tagList"]) + + def test_reporting_an_article(self): + """Test that a user is able to report an article""" + self.client.post( + "/api/articles/", + self.post_article_with_tags, + format="json") + resp = self.client.get("/api/articles/") + slug = resp.json()["article"]["results"]["slug"] + response = self.client.post( + "/api/articles/reports/{}/".format(slug), + self.post_report, + format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual("for real I have nothing to report.", + response.json()["report_message"]) + + def test_reporting_on_non_existing_article(self): + """ + Test that an error is raised when a user tries to + report an article that does not exist. + """ + response = self.client.post( + "/api/articles/reports/wrong_slug/", + self.post_report, + format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual("An article with this slug does not exist", + response.json()["detail"]) + + def test_report_message_validation(self): + """ + Test that an error is raised when the request is + missing: {"report_message": "the report message here"} or + has a blank report message. + """ + self.client.post( + "/api/articles/", + self.post_article_with_tags, + format="json") + resp = self.client.get("/api/articles/") + slug = resp.json()["article"]["results"]["slug"] + response = self.client.post( + "/api/articles/reports/{}/".format(slug), + " ", + format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual("A report message is required", + response.json()["detail"]) + + def test_all_reports_returned(self): + """Test that all reports on all articles are returned.""" + self.user.is_superuser = True + self.user.save() + self.client.post( + "/api/articles/", + self.post_article_with_tags, + format="json") + resp = self.client.get("/api/articles/") + slug = resp.json()["article"]["results"]["slug"] + self.client.post( + "/api/articles/reports/{}/".format(slug), + self.post_report, + format="json" + ) + + response = self.client.get("/api/articles/reports/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual("for real I have nothing to report.", + response.json()["reports"][0]["report_message"]) + + def test_reports_of_a_single_article_returned(self): + """Test that all reports of a single article are returned.""" + self.user.is_superuser = True + self.user.save() + self.client.post( + "/api/articles/", + self.post_article_with_tags, + format="json") + resp = self.client.get("/api/articles/") + slug = resp.json()["article"]["results"]["slug"] + self.client.post( + "/api/articles/reports/{}/".format(slug), + self.post_report, + format="json" + ) + + response = self.client.get("/api/articles/reports/{}/".format(slug)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual("for real I have nothing to report.", + response.json()["reports"][0]["report_message"]) + + def test_non_superuser_denied_report_viewing(self): + """ + Test that an error is returned when a non super user tries to + view reports made on articles + """ + response = self.client.get("/api/articles/reports/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual("permission denied, you do not have access rights.", + response.json()["detail"]) diff --git a/authors/apps/articles/tests/test_data.py b/authors/apps/articles/tests/test_data.py index 4b20430..f9ce62c 100644 --- a/authors/apps/articles/tests/test_data.py +++ b/authors/apps/articles/tests/test_data.py @@ -59,6 +59,10 @@ class TestData: } } + post_report = { + "report_message": "for real I have nothing to report." + } + user_name = "iroq" user_email = "iroq@sims.andela" password = "teamiroq1" diff --git a/authors/apps/articles/tests/test_replies.py b/authors/apps/articles/tests/test_replies.py index c5d14fb..2c2fcd1 100644 --- a/authors/apps/articles/tests/test_replies.py +++ b/authors/apps/articles/tests/test_replies.py @@ -134,7 +134,6 @@ def test_update_reply(self): self.post_comment), content_type='application/json') self.assertEqual(201, response.status_code) self.assertIsInstance(response.json(), dict) - print('response --- > ', response) id = response.json().get('id') response = self.client.post( @@ -173,7 +172,6 @@ def test_reply_update_with_no_content(self): self.post_comment), content_type='application/json') self.assertEqual(201, response.status_code) self.assertIsInstance(response.json(), dict) - print('response --- > ', response) id = response.json().get('id') @@ -221,8 +219,6 @@ def test_delete_reply(self): self.post_comment), content_type='application/json') self.assertEqual(201, response.status_code) self.assertIsInstance(response.json(), dict) - print('response --- > ', response) - id = response.json().get('id') response = self.client.post( "/api/articles/comment/{}/replies/".format(id), data=json.dumps( diff --git a/authors/apps/articles/urls.py b/authors/apps/articles/urls.py index 19b1d7e..095af56 100644 --- a/authors/apps/articles/urls.py +++ b/authors/apps/articles/urls.py @@ -3,15 +3,17 @@ """ from django.urls import path from rest_framework.routers import DefaultRouter -from authors.apps.articles.views import ArticleViewSet, FavoriteArticlesAPIView, TagViewSet, RatingsView -from authors.apps.articles.views import CommentsView,RepliesView -from authors.apps.articles.views import ArticleViewSet, TagViewSet, RatingsView +from authors.apps.articles.views import (FavoriteArticlesAPIView, + ArticleViewSet, TagViewSet, RatingsView, ArticleReportView, + CommentsView, RepliesView) urlpatterns = [ - path('/favorite/', FavoriteArticlesAPIView.as_view(), name="favorite"), - path('/unfavorite/', FavoriteArticlesAPIView.as_view(), name="unfavorite"), + path('/favorite/', + FavoriteArticlesAPIView.as_view(), name="favorite"), + path('/unfavorite/', + FavoriteArticlesAPIView.as_view(), name="unfavorite"), path("/rate/", RatingsView.as_view()), path('/comment/', CommentsView.as_view()), path('comment//', CommentsView.as_view()), @@ -19,6 +21,9 @@ path('comment//replies/', RepliesView.as_view()), path('comment/replies//', RepliesView.as_view()), + path("/rate/", RatingsView.as_view()), + path("reports/", ArticleReportView.as_view()), + path("reports//", ArticleReportView.as_view()), ] router = DefaultRouter() diff --git a/authors/apps/articles/views.py b/authors/apps/articles/views.py index 44e0e49..5cd9e68 100644 --- a/authors/apps/articles/views.py +++ b/authors/apps/articles/views.py @@ -7,11 +7,12 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from rest_framework.viewsets import ViewSet -from authors.apps.articles.exceptions import ( InvalidQueryParameterException) -from authors.apps.articles.models import Article, Tag +from authors.apps.articles.exceptions import ( + NotFoundException, InvalidQueryParameterException) +from authors.apps.articles.models import Article, Tag, ArticleReport from authors.apps.articles.renderer import ArticleJSONRenderer, TagJSONRenderer -from authors.apps.articles.serializers import (RatingSerializer, - ArticleSerializer, PaginatedArticleSerializer, TagSerializer) +from authors.apps.articles.serializers import (RatingSerializer, ArticleReportSerializer, + ArticleSerializer, PaginatedArticleSerializer, TagSerializer) from authors.apps.articles.permissions import IsSuperuser @@ -238,3 +239,47 @@ def destroy(self, request, *args, **kwargs): from .views_extra import * +class ArticleReportView(APIView): + """ + Handles creating, reading, updating and deleting reports + made on an article + """ + permission_classes = (IsAuthenticated, ) + serializer_class = ArticleReportSerializer + + def post(self, request, slug): + """This method handles post requests when reporting an article.""" + message = None + if "report_message" in request.data and request.data["report_message"].strip(): + article = ArticleSerializer.get_article_object(slug) + user = request.user.id + message = request.data["report_message"] + data = {"user": user, "article": article.id, + "report_message": message} + serializer = self.serializer_class(data=data) + + serializer.is_valid() + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response({"detail": "A report message is required"}, + status=status.HTTP_400_BAD_REQUEST) + + def get(self, request, slug=None): + """This method returns reports made on an article.""" + article_reports = None + serializer = None + if request.user.is_superuser: + if slug: + + article = ArticleSerializer.get_article_object(slug) + article_reports = ArticleReport.objects.filter( + article=article.id) + serializer = self.serializer_class(article_reports, many=True) + return Response({"reports": serializer.data}, status=status.HTTP_200_OK) + + article_reports = ArticleReport.objects.all() + serializer = self.serializer_class(article_reports, many=True) + return Response({"reports": serializer.data}, status=status.HTTP_200_OK) + return Response({"detail": "permission denied, you do not have access rights."}, + status=status.HTTP_403_FORBIDDEN) diff --git a/authors/apps/articles/views_extra.py b/authors/apps/articles/views_extra.py index fb69fa5..2482cdc 100644 --- a/authors/apps/articles/views_extra.py +++ b/authors/apps/articles/views_extra.py @@ -36,7 +36,6 @@ def post(self, request, slug): serializer = CommentSerializer(data=content_data) serializer.is_valid(raise_exception=True) serializer.save() - print('serializer data', serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED) except Article.DoesNotExist: return Response({"message": "Sorry, this article is not found."}, status=status.HTTP_404_NOT_FOUND)