From 2210574478c6c70b5000387a26fd8763948fa3df Mon Sep 17 00:00:00 2001 From: Radi85 Date: Sun, 2 May 2021 16:32:53 +0200 Subject: [PATCH] feat(#190): handle access permission This will use status code 403 for access deneid in comment mixins and handle it in JS functions. Implement blocking views and the API. --- comment/api/permissions.py | 32 ++- comment/api/serializers.py | 22 +- comment/api/urls.py | 1 + comment/api/views.py | 31 +-- comment/conf/defaults.py | 3 +- comment/managers/blocker.py | 29 ++- comment/messages.py | 16 +- comment/mixins.py | 178 +++++++--------- comment/signals/post_migrate.py | 2 +- comment/static/js/comment.js | 125 ++++++++++- .../templates/comment/block/block_modal.html | 8 +- comment/templatetags/comment_tags.py | 4 +- comment/tests/base.py | 9 +- comment/tests/test_api/test_permissions.py | 105 +++++++-- comment/tests/test_api/test_serializers.py | 22 +- comment/tests/test_api/test_views.py | 5 +- comment/tests/test_mixins.py | 200 ++++++++++++++---- comment/tests/test_models/test_blocker.py | 11 + comment/tests/test_template_tags.py | 4 +- comment/tests/test_utils.py | 9 - comment/tests/test_validators.py | 6 +- comment/tests/test_views/test_blocker.py | 96 +++++++++ comment/tests/test_views/test_comments.py | 5 +- comment/tests/test_views/test_flags.py | 7 +- comment/tests/test_views/test_reactions.py | 6 +- comment/tests/test_views/test_subscription.py | 1 + comment/urls.py | 3 +- comment/utils.py | 10 +- comment/views/__init__.py | 2 + comment/views/base.py | 54 +++++ comment/views/blocker.py | 58 +++++ comment/views/comments.py | 3 +- comment/views/flags.py | 8 +- comment/views/followers.py | 6 +- comment/views/reactions.py | 4 +- docs/source/Web API.rst | 34 ++- docs/source/introduction.rst | 2 + docs/source/settings.rst | 11 + docs/source/usage.rst | 13 +- test/settings.py | 3 + 40 files changed, 880 insertions(+), 268 deletions(-) create mode 100644 comment/tests/test_views/test_blocker.py create mode 100644 comment/views/base.py create mode 100644 comment/views/blocker.py diff --git a/comment/api/permissions.py b/comment/api/permissions.py index 497b4d1..8ed46a5 100644 --- a/comment/api/permissions.py +++ b/comment/api/permissions.py @@ -1,7 +1,9 @@ from rest_framework import permissions from comment.conf import settings -from comment.utils import is_comment_admin, is_comment_moderator +from comment.utils import is_comment_admin, is_comment_moderator, can_block_user, can_moderate_flagging +from comment.messages import BlockUserMSG +from comment.models import BlockedUser class IsOwnerOrReadOnly(permissions.BasePermission): @@ -22,6 +24,25 @@ def has_object_permission(self, request, view, obj): return obj.user == request.user +class UserPermittedOrReadOnly(permissions.BasePermission): + message = BlockUserMSG.NOT_PERMITTED + + def has_permission(self, request, view): + data = request.POST or getattr(request, 'data', {}) + return bool( + request.method in permissions.SAFE_METHODS or + not BlockedUser.objects.is_user_blocked(request.user.id, data.get('email')) + ) + + +class CanCreatePermission(permissions.BasePermission): + """ + This will check if creating comment is permitted + """ + def has_permission(self, request, view): + return request.user.is_authenticated or settings.COMMENT_ALLOW_ANONYMOUS + + class FlagEnabledPermission(permissions.BasePermission): """ This will check if the COMMENT_FLAGS_ALLOWED is enabled @@ -32,10 +53,10 @@ def has_permission(self, request, view): class CanChangeFlaggedCommentState(permissions.BasePermission): def has_permission(self, request, view): - return is_comment_admin(request.user) or is_comment_moderator(request.user) + return can_moderate_flagging(request.user) def has_object_permission(self, request, view, obj): - return obj.is_flagged and (is_comment_admin(request.user) or is_comment_moderator(request.user)) + return obj.is_flagged class SubscriptionEnabled(permissions.BasePermission): @@ -51,3 +72,8 @@ def has_permission(self, request, view): if not super().has_permission(request, view): return False return is_comment_admin(request.user) or is_comment_moderator(request.user) + + +class CanBlockUsers(permissions.BasePermission): + def has_permission(self, request, view): + return can_block_user(request.user) diff --git a/comment/api/serializers.py b/comment/api/serializers.py index 5d5b29c..bc319dd 100644 --- a/comment/api/serializers.py +++ b/comment/api/serializers.py @@ -10,7 +10,7 @@ from comment.models import Comment, Flag, Reaction from comment.utils import get_user_for_request, get_profile_instance from comment.messages import EmailError -from comment.mixins import CommentCreateMixin +from comment.views import CommentCreateMixin def get_profile_model(): @@ -99,25 +99,21 @@ class Meta: model = Comment fields = ('id', 'user', 'email', 'content', 'parent', 'posted', 'edited', 'reply_count', 'replies', 'urlhash') - def __init__(self, *args, **kwargs): - user = kwargs['context']['request'].user - self.email_service = None - if user.is_authenticated or not settings.COMMENT_ALLOW_ANONYMOUS: - del self.fields['email'] - - super().__init__(*args, **kwargs) - @staticmethod - def validate_email(value): - if not value: - raise serializers.ValidationError(EmailError.EMAIL_MISSING, code='required') - return value.strip().lower() + def validate_email(email): + if not email: + raise serializers.ValidationError( + detail={'email': [EmailError.EMAIL_REQUIRED_FOR_ANONYMOUS]}, code='required' + ) + return email.strip().lower() def create(self, validated_data): request = self.context['request'] user = get_user_for_request(request) content = validated_data.get('content') email = validated_data.get('email') + if not user: + self.validate_email(email) time_posted = timezone.now() temp_comment = Comment( diff --git a/comment/api/urls.py b/comment/api/urls.py index 4f86c7a..0fdcfde 100644 --- a/comment/api/urls.py +++ b/comment/api/urls.py @@ -19,6 +19,7 @@ re_path(r'^comments/confirm/(?P[^/]+)/$', views.ConfirmComment.as_view(), name='confirm-comment'), path('comments/toggle-subscription/', views.ToggleFollowAPI.as_view(), name='toggle-subscription'), path('comments/subscribers/', views.SubscribersAPI.as_view(), name='subscribers'), + path('comments/toggle-blocking/', views.ToggleBlockingAPI.as_view(), name='toggle-blocking'), ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/comment/api/views.py b/comment/api/views.py index b0add54..b1f80a2 100644 --- a/comment/api/views.py +++ b/comment/api/views.py @@ -7,17 +7,18 @@ from comment.validators import ValidatorMixin, ContentTypeValidator from comment.api.serializers import CommentSerializer, CommentCreateSerializer from comment.api.permissions import ( - IsOwnerOrReadOnly, FlagEnabledPermission, CanChangeFlaggedCommentState, - SubscriptionEnabled, CanGetSubscribers) + IsOwnerOrReadOnly, FlagEnabledPermission, CanChangeFlaggedCommentState, SubscriptionEnabled, + CanGetSubscribers, CanCreatePermission, UserPermittedOrReadOnly, CanBlockUsers +) from comment.models import Comment, Reaction, ReactionInstance, Flag, FlagInstance, Follower from comment.utils import get_comment_from_key, CommentFailReason from comment.messages import FlagError, EmailError -from comment.views import BaseToggleFollowView -from comment.mixins import CommentCreateMixin +from comment.views import BaseToggleFollowView, CommentCreateMixin, BaseToggleBlockingView class CommentCreate(ValidatorMixin, generics.CreateAPIView): serializer_class = CommentCreateSerializer + permission_classes = (CanCreatePermission, UserPermittedOrReadOnly) api = True def get_serializer_context(self): @@ -42,13 +43,13 @@ def get_queryset(self): class CommentDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Comment.objects.all() serializer_class = CommentSerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly) + permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly, UserPermittedOrReadOnly) class CommentDetailForReaction(generics.RetrieveAPIView): queryset = Comment.objects.all() serializer_class = CommentSerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + permission_classes = (permissions.IsAuthenticatedOrReadOnly, UserPermittedOrReadOnly) def get_serializer_context(self): context = super().get_serializer_context() @@ -76,7 +77,7 @@ def post(self, request, *args, **kwargs): class CommentDetailForFlag(generics.RetrieveAPIView): queryset = Comment.objects.all() serializer_class = CommentSerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly, FlagEnabledPermission) + permission_classes = (permissions.IsAuthenticatedOrReadOnly, FlagEnabledPermission, UserPermittedOrReadOnly) def get_serializer_context(self): context = super().get_serializer_context() @@ -100,7 +101,7 @@ def post(self, request, *args, **kwargs): class CommentDetailForFlagStateChange(generics.RetrieveAPIView): queryset = Comment.objects.all() serializer_class = CommentSerializer - permission_classes = (CanChangeFlaggedCommentState, ) + permission_classes = (CanChangeFlaggedCommentState, UserPermittedOrReadOnly) def get_serializer_context(self): context = super().get_serializer_context() @@ -108,13 +109,8 @@ def get_serializer_context(self): return context def post(self, request, *args, **kwargs): - comment = get_object_or_404(Comment, id=kwargs.get('pk')) + comment = self.get_object() flag = Flag.objects.get_for_comment(comment) - if not comment.is_flagged: - return Response( - {'detail': FlagError.REJECT_UNFLAGGED_COMMENT}, - status=status.HTTP_400_BAD_REQUEST - ) state = request.data.get('state') or request.POST.get('state') try: state = flag.get_clean_state(state) @@ -150,7 +146,7 @@ def get(self, request, *args, **kwargs): class ToggleFollowAPI(BaseToggleFollowView, APIView): api = True response_class = Response - permission_classes = (SubscriptionEnabled, permissions.IsAuthenticated) + permission_classes = (SubscriptionEnabled, permissions.IsAuthenticated, UserPermittedOrReadOnly) def post(self, request, *args, **kwargs): self.validate(request) @@ -169,3 +165,8 @@ def get(self, request, *args, **kwargs): 'model_id': self.model_obj.id, 'followers': Follower.objects.get_emails_for_model_object(self.model_obj) }) + + +class ToggleBlockingAPI(BaseToggleBlockingView, APIView): + permission_classes = (CanBlockUsers,) + response_class = Response diff --git a/comment/conf/defaults.py b/comment/conf/defaults.py index 045385a..1482d66 100644 --- a/comment/conf/defaults.py +++ b/comment/conf/defaults.py @@ -37,4 +37,5 @@ COMMENT_DEFAULT_PROFILE_PIC_LOC = '/static/img/default.png' COMMENT_ALLOW_BLOCKING_USERS = False -COMMENT_ALLOW_MODERATOR_PERFORM_BLOCKING = False +COMMENT_ALLOW_MODERATOR_TO_BLOCK = False +COMMENT_RESPONSE_FOR_BLOCKED_USER = 'You cannot perform this action at the moment! Contact the admin for more details' diff --git a/comment/managers/blocker.py b/comment/managers/blocker.py index 44786ca..a848189 100644 --- a/comment/managers/blocker.py +++ b/comment/managers/blocker.py @@ -1,15 +1,34 @@ from django.db import models +from comment.conf import settings + class BlockedUserManager(models.Manager): def is_user_blocked(self, user_id=None, email=None): + if not settings.COMMENT_ALLOW_BLOCKING_USERS: + return False + if user_id: + return self.is_user_blocked_by_id(user_id) + elif email: + return self.is_user_blocked_by_email(email) + return False + + def is_user_blocked_by_id(self, user_id): try: - user_id = int(user_id) - return self.filter(user_id=user_id, blocked=True).exists() + return self.filter(user_id=int(user_id), blocked=True).exists() except (ValueError, TypeError): - if not email: - return False - return self.filter(email=email, blocked=True).exists() + return False + + def is_user_blocked_by_email(self, email): + if not email: + return False + return self.filter(email=email, blocked=True).exists() + + def get_or_create_blocked_user_for_comment(self, comment): + user_id = comment.user.id if comment.user else None + if user_id: + return self.get_or_create_blocked_user_by_user_id(user_id) + return self.get_or_create_blocked_user_by_email(comment.email) def get_or_create_blocked_user_by_user_id(self, user_id): return self.get_or_create(user_id=user_id) diff --git a/comment/messages.py b/comment/messages.py index 94a3f8b..e6fce7c 100644 --- a/comment/messages.py +++ b/comment/messages.py @@ -1,8 +1,12 @@ from django.utils.translation import gettext_lazy as _ +from comment.conf import settings + class ErrorMessage: LOGIN_URL_MISSING = _('Comment App: LOGIN_URL is not in the settings') + LOGIN_REQUIRED = _('Comment App: You must be logged in to perform this action.') + NOT_AUTHORIZED = _('You do not have permission to perform this action.') METHOD_NOT_IMPLEMENTED = _('Your {class_name} class has not defined a {method_name} method, which is required.') NON_AJAX_REQUEST = _('Only AJAX request are allowed') INVALID_ORDER_ARGUMENT = _(( @@ -52,7 +56,7 @@ class ReactionError: class EmailError: EMAIL_INVALID = _('Enter a valid email address.') - EMAIL_MISSING = _('Email is required for posting anonymous comments.') + EMAIL_REQUIRED_FOR_ANONYMOUS = _('Email is required for posting anonymous comments.') BROKEN_VERIFICATION_LINK = _('The link seems to be broken.') USED_VERIFICATION_LINK = _('The comment has already been verified.') @@ -86,3 +90,13 @@ class FlagState: class FollowError: EMAIL_REQUIRED = _('Email is required to subscribe {model_object}') SYSTEM_NOT_ENABLED = _('Subscribe system must be enabled') + + +class BlockState: + UNBLOCKED = _('Unblocked') + BLOCKED = _('Blocked') + + +class BlockUserMSG: + NOT_PERMITTED = _(settings.COMMENT_RESPONSE_FOR_BLOCKED_USER) + INVALID = _('Invalid input data') diff --git a/comment/mixins.py b/comment/mixins.py index 7aad114..4a73934 100644 --- a/comment/mixins.py +++ b/comment/mixins.py @@ -1,162 +1,140 @@ import abc -from django.contrib.auth.mixins import LoginRequiredMixin, AccessMixin from django.core.exceptions import ImproperlyConfigured -from django.http import HttpResponseBadRequest, HttpResponseForbidden -from django.views.generic import FormView from comment.conf import settings -from comment.utils import is_comment_admin, is_comment_moderator +from comment.utils import is_comment_admin, is_comment_moderator, can_block_user, can_moderate_flagging from comment.validators import ValidatorMixin -from comment.messages import ErrorMessage, FlagError, FollowError -from comment.service.email import DABEmailService -from comment.messages import EmailInfo -from comment.forms import CommentForm -from comment.context import DABContext -from comment.responses import DABResponseData +from comment.messages import ErrorMessage, FlagError, FollowError, BlockUserMSG +from comment.responses import UTF8JsonResponse +from comment.models import BlockedUser class AJAXRequiredMixin: + status = 403 + reason = ErrorMessage.NON_AJAX_REQUEST + def dispatch(self, request, *args, **kwargs): if not request.META.get('HTTP_X_REQUESTED_WITH', None) == 'XMLHttpRequest': - return HttpResponseBadRequest(ErrorMessage.NON_AJAX_REQUEST) + data = {'status': self.status, 'reason': self.reason} + return UTF8JsonResponse(status=self.status, data=data) return super().dispatch(request, *args, **kwargs) class BasePermission(AJAXRequiredMixin): - def has_permission(self, request): - return True + reason = ErrorMessage.NOT_AUTHORIZED - def has_object_permission(self, request, obj): + def has_permission(self, request): return True - -class BaseCommentMixin(LoginRequiredMixin, BasePermission): - pass - - -class BaseCommentView(FormView, DABResponseData): - form_class = CommentForm - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['comment_form'] = context.pop('form') - # context.update(get_comment_context_data(self.request)) - context.update(DABContext(self.request)) - return context - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs['request'] = self.request - return kwargs + def dispatch(self, request, *args, **kwargs): + if not self.has_permission(request): + data = {'status': self.status, 'reason': self.reason} + return UTF8JsonResponse(status=self.status, data=data) + return super().dispatch(request, *args, **kwargs) -class CommentCreateMixin(BaseCommentView): - email_service = None +class UserPermission(BasePermission): + reason = BlockUserMSG.NOT_PERMITTED - def _initialize_email_service(self, comment, request): - self.email_service = DABEmailService(comment, request) + def has_permission(self, request): + return not BlockedUser.objects.is_user_blocked(request.user.id, request.POST.get('email')) - def _send_notification_to_followers(self, comment, request): - if settings.COMMENT_ALLOW_SUBSCRIPTION: - self._initialize_email_service(comment, request) - self.email_service.send_notification_to_followers() - def perform_save(self, comment, request): - comment.save() - self._send_notification_to_followers(comment, request) - comment.refresh_from_db() - return comment +class BaseCommentPermission(UserPermission): + def has_permission(self, request): + if not request.user.is_authenticated: + self.reason = ErrorMessage.LOGIN_REQUIRED + return False + return super().has_permission(request) - def _handle_anonymous(self, comment, request, api=False): - self._initialize_email_service(comment, request) - self.email_service.send_confirmation_request(api=api) - self.anonymous = True - self.msg = EmailInfo.CONFIRMATION_SENT - def perform_create(self, comment, request, api=False): - if settings.COMMENT_ALLOW_ANONYMOUS and not comment.user: - self._handle_anonymous(comment, request, api) - else: - comment = self.perform_save(comment, request) - return comment +class BaseCommentMixin(BaseCommentPermission): + pass -class CanCreateMixin(BasePermission, AccessMixin, ValidatorMixin): +class BaseCreatePermission(UserPermission): def has_permission(self, request): - return request.user.is_authenticated or settings.COMMENT_ALLOW_ANONYMOUS + if not settings.COMMENT_ALLOW_ANONYMOUS and not request.user.is_authenticated: + self.reason = ErrorMessage.LOGIN_REQUIRED + return False + return super().has_permission(request) - def dispatch(self, request, *args, **kwargs): - if not self.has_permission(request): - return self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) + +class CanCreateMixin(BaseCreatePermission, ValidatorMixin): + pass -class ObjectLevelMixin(BaseCommentMixin): +class ObjectLevelMixin(BaseCommentPermission): @abc.abstractmethod def get_object(self): raise ImproperlyConfigured( ErrorMessage.METHOD_NOT_IMPLEMENTED.format(class_name=self.__class__.__name__, method_name='get_object()') ) - -class CanEditMixin(ObjectLevelMixin, ValidatorMixin, abc.ABC): def has_object_permission(self, request, obj): - return request.user == obj.user + return True def dispatch(self, request, *args, **kwargs): obj = self.get_object() if not self.has_object_permission(request, obj): - return self.handle_no_permission() + self.reason = ErrorMessage.NOT_AUTHORIZED + data = {'status': self.status, 'reason': self.reason} + return UTF8JsonResponse(status=self.status, data=data) return super().dispatch(request, *args, **kwargs) -class CanDeleteMixin(ObjectLevelMixin, ValidatorMixin, abc.ABC): +class CanEditMixin(ObjectLevelMixin, ValidatorMixin, abc.ABC): def has_object_permission(self, request, obj): - return request.user == obj.user or is_comment_admin(request.user) \ - or (obj.is_flagged and is_comment_moderator(request.user)) + return request.user == obj.user - def dispatch(self, request, *args, **kwargs): - obj = self.get_object() - if not self.has_object_permission(request, obj): - return self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) + +class CanDeleteMixin(ObjectLevelMixin, ValidatorMixin, abc.ABC): + def has_object_permission(self, request, obj): + return bool( + request.user == obj.user or + is_comment_admin(request.user) or + (obj.is_flagged and is_comment_moderator(request.user)) + ) -class BaseFlagMixin(ObjectLevelMixin, abc.ABC): - def dispatch(self, request, *args, **kwargs): - if not getattr(settings, 'COMMENT_FLAGS_ALLOWED', False): - return HttpResponseForbidden(FlagError.SYSTEM_NOT_ENABLED) - return super().dispatch(request, *args, **kwargs) +class BaseFlagPermission(BasePermission): + def has_permission(self, request): + if not settings.COMMENT_FLAGS_ALLOWED: + self.reason = FlagError.SYSTEM_NOT_ENABLED + return False + return super().has_permission(request) -class CanSetFlagMixin(BaseFlagMixin, abc.ABC): +class CanSetFlagMixin(BaseFlagPermission, ObjectLevelMixin, abc.ABC): def has_object_permission(self, request, obj): """user cannot flag their own comment""" return obj.user != request.user - def dispatch(self, request, *args, **kwargs): - obj = self.get_object() - if not self.has_object_permission(request, obj): - self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) - -class CanEditFlagStateMixin(BaseFlagMixin, abc.ABC): +class CanUpdateFlagStateMixin(BaseFlagPermission, ObjectLevelMixin, abc.ABC): def has_permission(self, request): - return is_comment_admin(request.user) or is_comment_moderator(request.user) + if not can_moderate_flagging(request.user): + self.reason = ErrorMessage.NOT_AUTHORIZED + return False + return super().has_permission(request) - def dispatch(self, request, *args, **kwargs): - obj = self.get_object() + def has_object_permission(self, request, obj): if not obj.is_flagged: - return HttpResponseBadRequest(FlagError.NOT_FLAGGED_OBJECT) - if not self.has_permission(request): - return self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) + self.reason = FlagError.NOT_FLAGGED_OBJECT + return obj.is_flagged class CanSubscribeMixin(BaseCommentMixin): - def dispatch(self, request, *args, **kwargs): - if not getattr(settings, 'COMMENT_ALLOW_SUBSCRIPTION', False): - return HttpResponseForbidden(FollowError.SYSTEM_NOT_ENABLED) - return super().dispatch(request, *args, **kwargs) + def has_permission(self, request): + if not settings.COMMENT_ALLOW_SUBSCRIPTION: + self.reason = FollowError.SYSTEM_NOT_ENABLED + return False + return super().has_permission(request) + + +class CanBlockUsersMixin(BaseCommentMixin): + reason = ErrorMessage.NOT_AUTHORIZED + + def has_permission(self, request): + return can_block_user(request.user) diff --git a/comment/signals/post_migrate.py b/comment/signals/post_migrate.py index 627e269..ca179ea 100644 --- a/comment/signals/post_migrate.py +++ b/comment/signals/post_migrate.py @@ -6,7 +6,7 @@ def create_permission_groups(sender, **kwargs): - if settings.COMMENT_FLAGS_ALLOWED: + if settings.COMMENT_FLAGS_ALLOWED or settings.COMMENT_ALLOW_BLOCKING_USERS: comment_ct = ContentType.objects.get_for_model(Comment) delete_comment_perm, __ = Permission.objects.get_or_create( codename='delete_comment', diff --git a/comment/static/js/comment.js b/comment/static/js/comment.js index 00eb5e4..a7e123e 100644 --- a/comment/static/js/comment.js +++ b/comment/static/js/comment.js @@ -8,6 +8,7 @@ document.addEventListener('DOMContentLoaded', () => { let deleteModal = document.getElementById("Modal"); let flagModal = document.getElementById('flagModal'); let followModal = document.getElementById('followModal'); + let blockModal = document.getElementById('blockModal'); let headers = { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': csrfToken, @@ -128,7 +129,11 @@ document.addEventListener('DOMContentLoaded', () => { }).then(response => { return response.json(); }).then(result => { - if (result.error) { + if (result.status === 403){ + alert(result.reason); + return; + } + else if (result.error) { // alert(result.error); form.querySelector('.error').innerHTML = result.error; form.querySelector('.error').classList.remove('d-none'); @@ -165,7 +170,7 @@ document.addEventListener('DOMContentLoaded', () => { commentCount(1); // update followBtn let followButton = form.parentElement.previousElementSibling.querySelector(".js-comment-follow"); - if (followButton){ + if (followButton) { followButton.querySelector('.comment-follow-icon').classList.add('user-has-followed'); followButton.querySelector('span').setAttribute('title', 'Unfollow this thread'); } @@ -182,8 +187,9 @@ document.addEventListener('DOMContentLoaded', () => { let clean_uri = uri.substring(0, uri.indexOf("?")); window.history.replaceState({}, document.title, clean_uri); } - }).catch(() => { + }).catch((error) => { alert(gettext("Unable to post your comment!, please try again")); + console.error(error); }); }; @@ -194,6 +200,10 @@ document.addEventListener('DOMContentLoaded', () => { fetch(url, {headers: headers}).then(response => { return response.json(); }).then(result => { + if (result.status === 403){ + alert(result.reason); + return; + } let editModeElement = stringToDom(result.data, '.js-comment-update-mode'); commentContent.replaceWith(editModeElement); // set the focus on the end of text @@ -203,8 +213,9 @@ document.addEventListener('DOMContentLoaded', () => { textAreaElement.value = ''; textAreaElement.value = value; textAreaElement.setAttribute("style", "height: " + textAreaElement.scrollHeight + "px;"); - }).catch(() => { + }).catch((error) => { alert(gettext("You can't edit this comment")); + console.error(error); }); }; @@ -224,10 +235,15 @@ document.addEventListener('DOMContentLoaded', () => { }).then(response => { return response.json(); }).then(result => { + if (result.status === 403){ + alert(result.reason); + return; + } let updatedContentElement = stringToDom(result.data, '.js-updated-comment'); form.parentElement.replaceWith(updatedContentElement); - }).catch(() => { + }).catch((error) => { alert(gettext("Modification didn't take effect!, please try again")); + console.error(error); }); }; @@ -267,11 +283,16 @@ document.addEventListener('DOMContentLoaded', () => { fetch(url, {headers: headers}).then(response => { return response.json() }).then(result => { + if (result.status === 403){ + alert(result.reason); + return; + } showModal(deleteModal); let modalContent = deleteModal.querySelector('.comment-modal-content'); modalContent.innerHTML = result.data; - }).catch(() => { + }).catch((error) => { alert(gettext("Deletion cannot be performed!, please try again")); + console.error(error); }); }; @@ -293,6 +314,10 @@ document.addEventListener('DOMContentLoaded', () => { }).then(response => { return response.json(); }).then(result => { + if (result.status === 403){ + alert(result.reason); + return; + } if (isParent) { document.getElementById("comments").outerHTML = result.data; } else { @@ -312,8 +337,9 @@ document.addEventListener('DOMContentLoaded', () => { } hideModal(deleteModal); commentElement.remove(); - }).catch(() => { + }).catch((error) => { alert(gettext("Unable to delete your comment!, please try again")); + console.error(error); }); }; @@ -364,6 +390,10 @@ document.addEventListener('DOMContentLoaded', () => { }).then(response => { return response.json(); }).then(result => { + if (result.status === 403){ + alert(result.reason); + return; + } if (result.error) { alert(result.error); } else { @@ -373,8 +403,9 @@ document.addEventListener('DOMContentLoaded', () => { changeReactionCount(parentReactionEle, result.data.likes, result.data.dislikes); } } - }).catch(() => { + }).catch((error) => { alert(gettext("Reaction couldn't be processed!, please try again")); + console.error(error); }); }; @@ -392,6 +423,10 @@ document.addEventListener('DOMContentLoaded', () => { }).then(response => { return response.json(); }).then(result => { + if (result.status === 403){ + alert(result.reason); + return; + } if (result.error) { if (result.error.email_required) { followModal.querySelector('form').setAttribute('data-target-btn-id', followButton.getAttribute('id')); @@ -417,8 +452,60 @@ document.addEventListener('DOMContentLoaded', () => { hideModal(followModal); } } - }).catch(() => { + }).catch((error) => { alert(gettext("Subscription couldn't be processed!, please try again")); + console.error(error); + }); + }; + + let loadBlockModal = blockBtn => { + blockModal.querySelector('#blockedCommentId').value = blockBtn.getAttribute('data-comment_id'); + showModal(blockModal); + }; + + let updateBlockedUserComments = data => { + let commentsByBlockedUser = document.getElementsByClassName(`block-${data.blocked_user}`); + let pathElement = data.blocked ? '' : ''; + let color = data.blocked ? '#E74C3C' : '#00BC8C'; + for (let commentByBlockedUser of commentsByBlockedUser) { + commentByBlockedUser.setAttribute('stroke', color); + commentByBlockedUser.querySelector('path').outerHTML = pathElement; + } + }; + + let toggleUserBlock = form => { + let formDataQuery = null; + if (form) { + let formData = serializeObject(form); + formDataQuery = convertFormDataToURLQuery(formData); + } + let url = form.getAttribute('data-url'); + fetch(url, { + method: 'POST', + headers: headers, + body: formDataQuery, + }).then(response => { + return response.json(); + }).then(result => { + if (result.status === 403){ + alert(result.reason); + return; + } + const infoElement = document.getElementById('comments').querySelector('.js-comment'); + let msg = ''; + if (result.error) { + msg = result.error.detail; + } else if (result.data) { + let state = result.data.blocked ? 'blocked' : 'unblocked'; + msg = gettext(`User ${result.data.blocked_user} has been successfully ${state}`); + updateBlockedUserComments(result.data); + form.querySelector('textarea').value = ''; + } + createInfoElement(infoElement, 'success', msg); + hideModal(blockModal); + }).catch((error) => { + alert(gettext("Blocking this user couldn't be processed!, please try again")); + console.error(error); }); }; @@ -509,6 +596,10 @@ document.addEventListener('DOMContentLoaded', () => { }).then(response => { return response.json(); }).then(result => { + if (result.status === 403){ + alert(result.reason); + return; + } if (result.error) { alert(result.error) } else { @@ -525,8 +616,9 @@ document.addEventListener('DOMContentLoaded', () => { hideModal(flagModal); createInfoElement(flagButton.closest('.js-parent-comment'), result.data.status, result.msg); } - }).catch(() => { + }).catch((error) => { alert(gettext("Flagging couldn't be processed!, please try again")); + console.error(error); }); }; @@ -592,6 +684,10 @@ document.addEventListener('DOMContentLoaded', () => { }).then(response => { return response.json(); }).then(result => { + if (result.status === 403){ + alert(result.reason); + return; + } if (result.error) { alert(result.error); return; @@ -637,11 +733,12 @@ document.addEventListener('DOMContentLoaded', () => { document.addEventListener('click', (event) => { removeTargetElement(); if (event.target && event.target !== event.currentTarget) { - if (event.target === deleteModal || event.target === flagModal || event.target === followModal || + if (event.target === deleteModal || event.target === flagModal || event.target === followModal || event.target === blockModal || event.target.closest('.modal-close-btn') || event.target.closest('.modal-cancel-btn')) { hideModal(deleteModal); hideModal(flagModal); hideModal(followModal); + hideModal(blockModal); } else if (event.target.closest('.js-reply-link')) { event.preventDefault(); replyLink(event.target); @@ -672,6 +769,9 @@ document.addEventListener('DOMContentLoaded', () => { } else if (event.target.closest('.js-three-dots')) { event.preventDefault(); openThreeDostMenu(event.target.closest('.js-three-dots')); + } else if (event.target.closest('.js-comment-block')) { + event.preventDefault(); + loadBlockModal(event.target.closest('.js-comment-block')); } } }, false); @@ -693,6 +793,9 @@ document.addEventListener('DOMContentLoaded', () => { event.preventDefault(); let followButton = document.getElementById(event.target.getAttribute('data-target-btn-id')); toggleFollow(followButton, event.target); + } else if (event.target.classList.contains('js-comment-block-form')) { + event.preventDefault(); + toggleUserBlock(event.target); } } }, false); diff --git a/comment/templates/comment/block/block_modal.html b/comment/templates/comment/block/block_modal.html index 7fc24ad..67f5faf 100644 --- a/comment/templates/comment/block/block_modal.html +++ b/comment/templates/comment/block/block_modal.html @@ -1,11 +1,10 @@ {% load i18n %} -{% load comment_tags %}
@@ -14,7 +13,7 @@
- diff --git a/comment/templatetags/comment_tags.py b/comment/templatetags/comment_tags.py index 9086714..f85f1f0 100644 --- a/comment/templatetags/comment_tags.py +++ b/comment/templatetags/comment_tags.py @@ -128,9 +128,7 @@ def can_block_users_tag(user): @register.filter(name='is_user_blocked') def is_user_blocked(comment): user_id = comment.user.id if comment.user else None - if BlockedUser.objects.is_user_blocked(user_id, comment.email): - return True - return False + return BlockedUser.objects.is_user_blocked(user_id, comment.email) @register.simple_tag(name='include_static') diff --git a/comment/tests/base.py b/comment/tests/base.py index 7a5ffbb..4e6873c 100644 --- a/comment/tests/base.py +++ b/comment/tests/base.py @@ -410,6 +410,10 @@ def force_migrate(self, migrate_to=None): class BaseCommentMixinTest(BaseCommentTest): base_url = None + def setUp(self): + super().setUp() + self.client = Client(HTTP_X_REQUESTED_WITH='XMLHttpRequest') + @classmethod def setUpTestData(cls): super().setUpTestData() @@ -423,9 +427,10 @@ def setUpTestData(cls): } cls.comment = cls.create_comment(cls.post_1, cls.user_1) - def get_url(self, base_url=None, **kwargs): + @classmethod + def get_url(cls, base_url=None, **kwargs): if not base_url: - base_url = self.base_url + base_url = cls.base_url if kwargs: base_url += '?' for (key, val) in kwargs.items(): diff --git a/comment/tests/test_api/test_permissions.py b/comment/tests/test_api/test_permissions.py index 9e01fb9..1c9730d 100644 --- a/comment/tests/test_api/test_permissions.py +++ b/comment/tests/test_api/test_permissions.py @@ -1,11 +1,13 @@ from unittest.mock import patch +from django.contrib.auth.models import AnonymousUser from django.test import RequestFactory from comment.tests.test_api.test_views import BaseAPITest from comment.api.permissions import ( IsOwnerOrReadOnly, FlagEnabledPermission, CanChangeFlaggedCommentState, SubscriptionEnabled, - CanGetSubscribers) + CanGetSubscribers, UserPermittedOrReadOnly, CanCreatePermission, CanBlockUsers +) from comment.api.views import CommentList from comment.models import FlagInstanceManager from comment.conf import settings @@ -94,33 +96,27 @@ def setUp(self): self.request = self.factory.get('/') self.request.user = self.user_1 + @patch.object(settings, 'COMMENT_FLAGS_ALLOWED', True) def test_normal_user(self): self.assertFalse(self.permission.has_permission(self.request, self.view)) + @patch.object(settings, 'COMMENT_FLAGS_ALLOWED', True) def test_moderator(self): self.request.user = self.moderator self.assertTrue(self.permission.has_permission(self.request, self.view)) - def test_moderator_for_unflagged_comment(self): + @patch.object(settings, 'COMMENT_FLAGS_ALLOWED', False) + def test_flagging_system_disabled(self): self.request.user = self.moderator - self.assertFalse( - self.permission.has_object_permission(self.request, self.view, self.unflagged_comment) - ) + self.assertFalse(self.permission.has_permission(self.request, self.view)) - def test_moderator_for_flagged_comment(self): - self.request.user = self.moderator + def test_cannot_change_state_for_unflagged_comment(self): + self.request.user = self.admin - self.assertIs( - True, - self.permission.has_object_permission(self.request, self.view, self.flagged_comment) - ) - - def test_normal_user_for_flagged_comment(self): - self.assertIs( - False, - self.permission.has_object_permission(self.request, self.view, self.flagged_comment) + self.assertFalse( + self.permission.has_object_permission(self.request, self.view, self.unflagged_comment) ) @@ -162,3 +158,80 @@ def test_when_subscription_disabled(self): @patch.object(settings, 'COMMENT_ALLOW_SUBSCRIPTION', True) def test_when_permission(self): self.assertTrue(self.permission.has_permission(self.request, self.view)) + + +class UserPermittedOrReadOnlyTest(BaseAPIPermissionsTest): + def setUp(self): + super().setUp() + self.permission = UserPermittedOrReadOnly() + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', True) + def test_user_has_permission_for_safe_method(self, *arg): + request = self.factory.get('/') + request.user = AnonymousUser() + self.assertTrue(self.permission.has_permission(request, self.view)) + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', False) + def test_user_has_permission_when_blocking_system_not_enabled(self, *arg): + request = self.factory.post('/') + request.user = AnonymousUser() + self.assertTrue(self.permission.has_permission(request, self.view)) + + @patch('comment.managers.BlockedUserManager.is_user_blocked', return_value=True) + def test_blocked_user_has_no_permission(self, *arg): + request = self.factory.post('/') + request.user = AnonymousUser() + self.assertFalse(self.permission.has_permission(request, self.view)) + + +class CanCreatePermissionTest(BaseAPIPermissionsTest): + def setUp(self): + super().setUp() + self.request = self.factory.get('/') + self.permission = CanCreatePermission() + + @patch.object(settings, 'COMMENT_ALLOW_ANONYMOUS', False) + def test_unauthenticated_user_cannot_create_comment(self): + self.request.user = AnonymousUser() + self.assertFalse(self.permission.has_permission(self.request, self.view)) + + @patch.object(settings, 'COMMENT_ALLOW_ANONYMOUS', False) + def test_authenticated_user_can_create_comment(self): + self.request.user = self.user_1 + self.assertTrue(self.request.user.is_authenticated) + self.assertTrue(self.permission.has_permission(self.request, self.view)) + + @patch.object(settings, 'COMMENT_ALLOW_ANONYMOUS', True) + def test_anonymous_can_create_comment_when_anonymity_system_enabled(self): + self.request.user = AnonymousUser() + self.assertFalse(self.request.user.is_authenticated) + self.assertTrue(self.permission.has_permission(self.request, self.view)) + + +class CanBlockUsersTest(BaseAPIPermissionsTest): + def setUp(self): + super().setUp() + self.request = self.factory.post('/') + self.permission = CanBlockUsers() + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', False) + def test_can_block_user_when_blocking_system_disabled(self): + self.request.user = self.admin + self.assertFalse(self.permission.has_permission(self.request, self.view)) + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', True) + def test_admin_can_block_user(self): + self.request.user = self.admin + self.assertTrue(self.permission.has_permission(self.request, self.view)) + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', True) + @patch.object(settings, 'COMMENT_ALLOW_MODERATOR_TO_BLOCK', False) + def test_moderator_cannot_block_user_when_moderation_system_disabled(self): + self.request.user = self.moderator + self.assertFalse(self.permission.has_permission(self.request, self.view)) + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', True) + @patch.object(settings, 'COMMENT_ALLOW_MODERATOR_TO_BLOCK', True) + def test_moderator_can_block_user_when_moderation_system_enabled(self): + self.request.user = self.moderator + self.assertTrue(self.permission.has_permission(self.request, self.view)) diff --git a/comment/tests/test_api/test_serializers.py b/comment/tests/test_api/test_serializers.py index ccb3cc8..286c658 100644 --- a/comment/tests/test_api/test_serializers.py +++ b/comment/tests/test_api/test_serializers.py @@ -3,11 +3,14 @@ from django.core import mail from django.test import RequestFactory +from rest_framework import serializers + from comment.conf import settings from comment.models import Comment, Follower from comment.api.serializers import get_profile_model, get_user_fields, UserSerializerDAB, CommentCreateSerializer, \ CommentSerializer from comment.tests.test_api.test_views import BaseAPITest +from comment.messages import EmailError class APICommentSerializersTest(BaseAPITest): @@ -38,7 +41,7 @@ def test_create_parent_comment_serializer(self): } serializer = CommentCreateSerializer(context=data) - self.assertIsNone(serializer.fields.get('email')) + self.assertFalse(serializer.fields['email'].required) comment = serializer.create(validated_data={'content': 'test'}) self.increase_count(parent=True) self.comment_count_test() @@ -135,6 +138,23 @@ def test_create_comment_serializer_for_anonymous(self): serializer.email_service._email_thread.join() self.assertEqual(len(mail.outbox), 1) + @patch.object(settings, 'COMMENT_ALLOW_ANONYMOUS', True) + def test_create_comment_serializer_for_anonymous_missing_email(self): + from django.contrib.auth.models import AnonymousUser + factory = RequestFactory() + request = factory.get('/') + request.user = AnonymousUser() + data = { + 'model_obj': self.post_1, + 'parent_comment': None, + 'request': request + } + serializer = CommentCreateSerializer(context=data) + + with self.assertRaises(serializers.ValidationError) as e: + serializer.create(validated_data={'content': 'test'}) + self.assertEqual(e.exception.detail, {'email': [EmailError.EMAIL_REQUIRED_FOR_ANONYMOUS]}) + def test_passing_context_to_serializer(self): serializer = CommentSerializer(self.comment_1) self.assertFalse(serializer.fields['content'].read_only) diff --git a/comment/tests/test_api/test_views.py b/comment/tests/test_api/test_views.py index 00147e4..d1fdeea 100644 --- a/comment/tests/test_api/test_views.py +++ b/comment/tests/test_api/test_views.py @@ -194,8 +194,7 @@ def test_for_anonymous_with_invalid_data(self): response = self.client.post(url, data=data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()['email'], [EmailError.EMAIL_MISSING]) - self.assertTextTranslated(response.json()['email'][0], base_url) + self.assertEqual(response.json()['email'], {'email': [EmailError.EMAIL_REQUIRED_FOR_ANONYMOUS]}) @patch.object(settings, 'COMMENT_ALLOW_ANONYMOUS', True) def test_for_anonymous_with_valid_data(self): @@ -508,7 +507,7 @@ def test_when_comment_is_not_flagged(self): response = self.client.post(self.get_base_url(comment.id), data=self.data) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 403) def test_by_not_permitted_user(self): self.client.force_login(self.user_1) diff --git a/comment/tests/test_mixins.py b/comment/tests/test_mixins.py index 78d2064..b93813b 100644 --- a/comment/tests/test_mixins.py +++ b/comment/tests/test_mixins.py @@ -1,12 +1,14 @@ from unittest.mock import patch +from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ImproperlyConfigured from django.urls import reverse -from rest_framework import status from comment.conf import settings -from comment.mixins import AJAXRequiredMixin, BasePermission, ObjectLevelMixin -from comment.messages import ErrorMessage +from comment.mixins import ( + AJAXRequiredMixin, BasePermission, ObjectLevelMixin, BaseCommentPermission, BaseCreatePermission +) +from comment.messages import ErrorMessage, FlagError, FollowError from comment.tests.base import BaseCommentMixinTest, BaseCommentFlagTest @@ -16,22 +18,39 @@ def setUp(self): self.mixin = AJAXRequiredMixin() def test_non_ajax_request(self): - request = self.factory.get('/') - response = self.mixin.dispatch(request) + response = self.mixin.dispatch(self.request) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.content.decode('utf-8'), ErrorMessage.NON_AJAX_REQUEST) + self.assert_permission_denied_response(response, reason=ErrorMessage.NON_AJAX_REQUEST) class BasePermissionTest(BaseCommentMixinTest): def test_has_permission(self): - request = self.factory.get('/') - self.assertTrue(BasePermission().has_permission(request)) + self.assertTrue(BasePermission().has_permission(self.request)) - def test_has_object_permission(self): - request = self.factory.get('/') + @patch('comment.mixins.BasePermission.has_permission', return_value=False) + def test_dispatch_with_no_permission(self, _): + response = BasePermission().dispatch(self.request) + + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) + + +class BaseCommentPermissionTest(BaseCommentMixinTest): + def test_has_permission_for_anonymous_user(self): + self.request.user = AnonymousUser() + permission = BaseCommentPermission() + + self.assertFalse(permission.has_permission(self.request)) + self.assertEqual(permission.reason, ErrorMessage.LOGIN_REQUIRED) + + +class BaseCreatePermissionTest(BaseCommentMixinTest): + @patch.object(settings, 'COMMENT_ALLOW_ANONYMOUS', False) + def test_has_permission_for_anonymous_user_when_settings_not_enabled(self): + self.request.user = AnonymousUser() + permission = BaseCreatePermission() - self.assertTrue(BasePermission().has_object_permission(request, 'object')) + self.assertFalse(permission.has_permission(self.request)) + self.assertEqual(permission.reason, ErrorMessage.LOGIN_REQUIRED) class CanCreateMixinTest(BaseCommentMixinTest): @@ -41,33 +60,46 @@ def setUp(self): self.client.force_login(self.user_1) def test_logged_in_user_permission(self): - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 200) @patch.object(settings, 'COMMENT_ALLOW_ANONYMOUS', False) def test_logged_out_user_permission(self, ): self.client.logout() - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 302) - # user will be redirected to login - self.assertEqual(response.url, settings.LOGIN_URL + '?next=/comment/create/') + response = self.client.post(self.url, data=self.data) + + self.assert_permission_denied_response(response, reason=ErrorMessage.LOGIN_REQUIRED) @patch.object(settings, 'COMMENT_ALLOW_ANONYMOUS', True) def test_permission_when_anonymous_comment_allowed(self): self.client.logout() self.data['email'] = 'test@test.come' - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 200) class ObjectLevelMixinTest(BaseCommentMixinTest): + def setUp(self): + super().setUp() + self.object_mixin = ObjectLevelMixin() + def test_get_object_without_overriding(self): - object_mixin = ObjectLevelMixin() - self.assertRaises(ImproperlyConfigured, object_mixin.get_object) + self.assertRaises(ImproperlyConfigured, self.object_mixin.get_object) + + def test_has_object_permission(self): + self.assertTrue(self.object_mixin.has_object_permission(self.request, self.comment)) + + @patch('comment.mixins.ObjectLevelMixin.has_object_permission', return_value=False) + @patch('comment.mixins.ObjectLevelMixin.get_object') + def test_dispatch_with_no_object_permission(self, *args): + response = self.object_mixin.dispatch(self.request) + + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) class CanEditMixinTest(BaseCommentMixinTest): @@ -77,24 +109,25 @@ def setUp(self): self.url = reverse('comment:edit', kwargs={'pk': self.comment.id}) def test_comment_owner_can_edit(self): - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 200) def test_edit_comment_by_non_owner(self): self.assertNotEqual(self.comment.user.id, self.user_2.id) self.client.force_login(self.user_2) - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) def test_edit_comment_by_anonymous(self): - """anonymous will be redirected to login page""" self.client.logout() - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, settings.LOGIN_URL + f'?next=/comment/edit/{self.comment.id}/') + response = self.client.post(self.url, data=self.data) + + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) class CanDeleteMixinTest(BaseCommentMixinTest): @@ -105,21 +138,25 @@ def setUp(self): def test_delete_comment_by_owner(self): self.assertEqual(self.comment.user.id, self.user_1.id) - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 200) def test_delete_comment_by_non_owner(self): self.assertNotEqual(self.comment.user.id, self.user_2.id) self.client.force_login(self.user_2) - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) def test_delete_comment_by_admin(self): self.assertNotEqual(self.comment.user.id, self.admin.id) self.client.force_login(self.admin) - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 200) @@ -127,9 +164,11 @@ def test_delete_comment_by_moderator(self): """moderator cannot delete comment unless it's flagged""" self.assertNotEqual(self.comment.user.id, self.moderator.id) self.client.force_login(self.moderator) - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) @patch.object(settings, 'COMMENT_FLAGS_ALLOWED', 1) def test_delete_flagged_comment_by_moderator(self): @@ -139,8 +178,10 @@ def test_delete_flagged_comment_by_moderator(self): self.create_flag_instance(self.moderator, self.comment) self.create_flag_instance(self.admin, self.comment) self.assertEqual(self.flags, 2) + self.assertTrue(self.comment.is_flagged) - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 200) @@ -151,18 +192,20 @@ def setUp(self): self.client.force_login(self.user_1) self.url = reverse('comment:flag', kwargs={'pk': self.comment_2.id}) - @patch('comment.mixins.settings', COMMENT_FLAGS_ALLOWED=0) - def test_flag_not_enabled_permission(self, _): + @patch.object(settings, 'COMMENT_FLAGS_ALLOWED', 0) + def test_flag_not_enabled_permission(self): """permission denied when flagging not enabled""" self.client.force_login(self.user_2) - response = self.client.post(self.url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.url) self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=FlagError.SYSTEM_NOT_ENABLED) - @patch('comment.mixins.settings', COMMENT_FLAGS_ALLOWED=2) - def test_flag_enabled_permission(self, _): + @patch.object(settings, 'COMMENT_FLAGS_ALLOWED', 2) + def test_flag_enabled_permission(self): self.client.force_login(self.user_2) - response = self.client.post(self.url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response = self.client.post(self.url) self.assertEqual(response.status_code, 200) @@ -176,9 +219,11 @@ def setUp(self): def test_user_cannot_flag_their_own_comment(self): self.assertEqual(self.comment.user.id, self.user_1.id) self.client.force_login(self.user_1) - response = self.client.post(self.url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.url) self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) class CanEditFlagStateMixinTest(BaseCommentMixinTest): @@ -189,9 +234,10 @@ def setUp(self): self.data = {'state': 3} def test_change_state_of_unflagged_comment(self): - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response = self.client.post(self.url, data=self.data) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) @patch.object(settings, 'COMMENT_FLAGS_ALLOWED', 1) def test_moderator_can_change_flag_state(self): @@ -200,14 +246,16 @@ def test_moderator_can_change_flag_state(self): self.comment.flag.refresh_from_db() self.assertEqual(self.flags, 2) self.assertTrue(self.comment.is_flagged) + # normal user cannot change flag state self.client.force_login(self.user_2) - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) self.client.force_login(self.moderator) - response = self.client.post(self.url, data=self.data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response = self.client.post(self.url, data=self.data) self.assertEqual(response.status_code, 200) @@ -223,14 +271,78 @@ def setUp(self): def test_user_cannot_subscribe(self): self.client.force_login(self.user_1) self.assertFalse(settings.COMMENT_ALLOW_SUBSCRIPTION) - response = self.client.post(self.toggle_follow_url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.toggle_follow_url) self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=FollowError.SYSTEM_NOT_ENABLED) @patch.object(settings, 'COMMENT_ALLOW_SUBSCRIPTION', True) def test_user_can_subscribe(self): self.client.force_login(self.user_1) self.assertTrue(settings.COMMENT_ALLOW_SUBSCRIPTION) - response = self.client.post(self.toggle_follow_url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + response = self.client.post(self.toggle_follow_url) + + self.assertEqual(response.status_code, 200) + + +class CanBlockUsersMixinTest(BaseCommentMixinTest): + def setUp(self): + super().setUp() + self.toggle_blocking_url = self.get_url(reverse('comment:toggle-blocking')) + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', False) + def test_admin_cannot_block_users_when_system_disabled(self): + self.client.force_login(self.admin) + self.assertFalse(settings.COMMENT_ALLOW_BLOCKING_USERS) + data = {'comment_id': self.comment.id} + + response = self.client.post(self.toggle_blocking_url, data=data) + + self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', True) + def test_non_moderate_cannot_block_users_when_system_enabled(self): + self.client.force_login(self.user_1) + self.assertTrue(settings.COMMENT_ALLOW_BLOCKING_USERS) + data = {'comment_id': self.comment.id} + + response = self.client.post(self.toggle_blocking_url, data=data) + + self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', True) + def test_admin_can_block_users_when_system_enabled(self): + self.client.force_login(self.admin) + self.assertTrue(settings.COMMENT_ALLOW_BLOCKING_USERS) + data = {'comment_id': self.comment.id} + + response = self.client.post(self.toggle_blocking_url, data=data) + + self.assertEqual(response.status_code, 200) + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', True) + @patch.object(settings, 'COMMENT_ALLOW_MODERATOR_TO_BLOCK', False) + def test_moderator_cannot_block_user_when_moderation_system_disabled(self): + self.client.force_login(self.moderator) + self.assertTrue(settings.COMMENT_ALLOW_BLOCKING_USERS) + data = {'comment_id': self.comment.id} + + response = self.client.post(self.toggle_blocking_url, data=data) + + self.assertEqual(response.status_code, 403) + self.assert_permission_denied_response(response, reason=ErrorMessage.NOT_AUTHORIZED) + + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', True) + @patch.object(settings, 'COMMENT_ALLOW_MODERATOR_TO_BLOCK', True) + def test_moderator_cannot_block_user_when_moderation_system_enabled(self): + self.client.force_login(self.moderator) + self.assertTrue(settings.COMMENT_ALLOW_BLOCKING_USERS) + data = {'comment_id': self.comment.id} + + response = self.client.post(self.toggle_blocking_url, data=data) self.assertEqual(response.status_code, 200) diff --git a/comment/tests/test_models/test_blocker.py b/comment/tests/test_models/test_blocker.py index d890ce2..c4f6d86 100644 --- a/comment/tests/test_models/test_blocker.py +++ b/comment/tests/test_models/test_blocker.py @@ -2,6 +2,7 @@ from comment.models import BlockedUser from comment.tests.base import BaseBlockerManagerTest +from comment.conf import settings class BlockerModelTest(BaseBlockerManagerTest): @@ -14,6 +15,16 @@ def test_blocked_email_str(self): class BlockerManagerTest(BaseBlockerManagerTest): + @patch.object(settings, 'COMMENT_ALLOW_BLOCKING_USERS', False) + def test_is_user_blocked_when_system_disabled(self): + self.assertFalse(BlockedUser.objects.is_user_blocked(email=self.blocked_email)) + + def test_is_user_blocked_by_id_invalid_id(self): + self.assertFalse(BlockedUser.objects.is_user_blocked_by_id(None)) + + def test_is_user_blocked_by_email_for_none_value(self): + self.assertFalse(BlockedUser.objects.is_user_blocked_by_email(None)) + def test_is_user_blocked_for_blocked_user(self): self.assertTrue(BlockedUser.objects.is_user_blocked(user_id=self.blocked_user.id)) diff --git a/comment/tests/test_template_tags.py b/comment/tests/test_template_tags.py index 143a636..17e43ef 100644 --- a/comment/tests/test_template_tags.py +++ b/comment/tests/test_template_tags.py @@ -9,8 +9,8 @@ from comment.templatetags.comment_tags import ( get_model_name, get_app_name, get_comments_count, get_img_path, get_profile_url, render_comments, include_bootstrap, include_static, render_field, has_reacted, has_flagged, - render_flag_reasons, render_content, get_username_for_comment, can_block_users_tag, - is_user_blocked) + render_flag_reasons, render_content, get_username_for_comment, can_block_users_tag, is_user_blocked +) from comment.tests.base import BaseTemplateTagsTest diff --git a/comment/tests/test_utils.py b/comment/tests/test_utils.py index d487acb..ec2229b 100644 --- a/comment/tests/test_utils.py +++ b/comment/tests/test_utils.py @@ -110,15 +110,6 @@ def test_unauthenticated_user(self): self.request.user = AnonymousUser() self.assertIsNone(get_user_for_request(self.request)) - def test_user_for_request(self): - request = self.factory.get('/') - request.user = AnonymousUser() - # test unauthenticated user - self.assertIsNone(get_user_for_request(request)) - # test authenticated user - request.user = self.user_1 - self.assertEqual(get_user_for_request(request), self.user_1) - def test_authenticated_user(self): self.request.user = self.user_1 self.assertEqual(get_user_for_request(self.request), self.user_1) diff --git a/comment/tests/test_validators.py b/comment/tests/test_validators.py index d20d5d1..63f4bf0 100644 --- a/comment/tests/test_validators.py +++ b/comment/tests/test_validators.py @@ -45,7 +45,11 @@ class ValidatorMixinTest(BaseCommentMixinTest): def setUp(self): super().setUp() self.view = MockedContentTypeValidatorView() - self.base_url = '/' + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.base_url = '/' def test_missing_app_name(self): url_data = self.data.copy() diff --git a/comment/tests/test_views/test_blocker.py b/comment/tests/test_views/test_blocker.py new file mode 100644 index 0000000..480214a --- /dev/null +++ b/comment/tests/test_views/test_blocker.py @@ -0,0 +1,96 @@ +from django.urls import reverse + +from comment.tests.base import BaseCommentMixinTest +from comment.views import BaseToggleBlockingView +from comment.messages import BlockUserMSG +from comment.models import BlockedUser + + +class BaseToggleBlockingViewTest(BaseCommentMixinTest): + def setUp(self): + super().setUp() + self.view = BaseToggleBlockingView() + self.client.force_login(self.admin) + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.comment_for_blocking = cls.create_comment(cls.post_1, user=cls.user_1) + cls.anonymous_comment_for_blocking = cls.create_anonymous_comment(cls.post_1, email='test@test.com') + cls.toggle_blocking_url = cls.get_url(reverse('comment:toggle-blocking')) + + def test_assertion_error_on_missing_request_class(self): + self.assertRaises(AssertionError, self.view.get_response_class) + + def test_success_on_providing_request_class(self): + self.view.response_class = 'test' + + self.assertEqual(self.view.get_response_class(), self.view.response_class) + + def test_block_comment_user_by_passing_non_int_comment_id(self): + data = {'comment_id': 'non int'} + response = self.client.post(self.toggle_blocking_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['error']['detail'], BlockUserMSG.INVALID) + + def test_block_comment_user_with_missing_comment_id(self): + response = self.client.post(self.toggle_blocking_url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['error']['detail'], BlockUserMSG.INVALID) + + def test_block_comment_user_for_not_existing_comment(self): + data = {'comment_id': 1000} + response = self.client.post(self.toggle_blocking_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['error']['detail'], BlockUserMSG.INVALID) + + def test_block_comment_user(self): + self.assertFalse(BlockedUser.objects.is_user_blocked(user_id=self.comment_for_blocking.user.id)) + + data = {'comment_id': self.comment_for_blocking.id} + response = self.client.post(self.toggle_blocking_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['data']['blocked_user'], self.comment_for_blocking.get_username()) + self.assertTrue(response.json()['data']['blocked']) + self.assertEqual(response.json()['data']['urlhash'], self.comment_for_blocking.urlhash) + + self.assertTrue(BlockedUser.objects.is_user_blocked(user_id=self.comment_for_blocking.user.id)) + + def test_block_anonymous_comment_email(self): + self.assertFalse(BlockedUser.objects.is_user_blocked(email=self.anonymous_comment_for_blocking.email)) + + data = {'comment_id': self.anonymous_comment_for_blocking.id} + response = self.client.post(self.toggle_blocking_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['data']['blocked_user'], self.anonymous_comment_for_blocking.get_username()) + self.assertTrue(response.json()['data']['blocked']) + self.assertEqual(response.json()['data']['urlhash'], self.anonymous_comment_for_blocking.urlhash) + + self.assertTrue(BlockedUser.objects.is_user_blocked(email=self.anonymous_comment_for_blocking.email)) + + def test_toggling_blocking(self): + self.assertFalse(BlockedUser.objects.is_user_blocked(user_id=self.comment_for_blocking.user.id)) + + data = {'comment_id': self.comment_for_blocking.id} + + # first call + response = self.client.post(self.toggle_blocking_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()['data']['blocked']) + + self.assertTrue(BlockedUser.objects.is_user_blocked(user_id=self.comment_for_blocking.user.id)) + + # second call + data['reason'] = 'promise to be good boy :)' + response = self.client.post(self.toggle_blocking_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()['data']['blocked']) + + self.assertFalse(BlockedUser.objects.is_user_blocked(user_id=self.comment_for_blocking.user.id)) diff --git a/comment/tests/test_views/test_comments.py b/comment/tests/test_views/test_comments.py index b444c55..8d029f7 100644 --- a/comment/tests/test_views/test_comments.py +++ b/comment/tests/test_views/test_comments.py @@ -53,7 +53,6 @@ def test_create_parent_comment(self): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'comment/comments/base.html') - self.assertHtmlTranslated(response.content, url) parent_comment = Comment.objects.get(object_id=self.post_1.id, parent=None) @@ -78,7 +77,6 @@ def test_create_child_comment(self): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'comment/comments/child_comment.html') - self.assertHtmlTranslated(response.content, url) child_comment = Comment.objects.get(object_id=self.post_1.id, parent=parent_comment) self.assertEqual(response.context.get('comment').id, child_comment.id) @@ -101,7 +99,7 @@ def test_send_notification_to_thread_followers_on_create_comment(self): def test_create_comment_non_ajax_request(self): response = self.client_non_ajax.post(self.get_create_url(), data=self.data) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 403) @patch.object(settings, 'COMMENT_ALLOW_ANONYMOUS', True) def test_create_anonymous_comment(self): @@ -168,7 +166,6 @@ def test_edit_comment(self): self.assertEqual(response.status_code, 200) self.assertTemplateUsed('comment/comments/comment_content.html') - self.assertHtmlTranslated(response.content, post_url) comment.refresh_from_db() self.assertEqual(comment.content, data['content']) diff --git a/comment/tests/test_views/test_flags.py b/comment/tests/test_views/test_flags.py index 78c1d22..16eb1b2 100644 --- a/comment/tests/test_views/test_flags.py +++ b/comment/tests/test_views/test_flags.py @@ -93,8 +93,7 @@ def test_set_flag_for_unauthenticated_user(self): self.client.logout() response = self.client.post(url, data=self.flag_data) - self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.assertEqual(response.url, '{}?next={}'.format(settings.LOGIN_URL, url)) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_get_request(self): """Test whether GET requests are allowed or not""" @@ -108,7 +107,7 @@ def test_non_ajax_requests(self): url = self.get_url('comment:flag', self.comment.id) response = self.client_non_ajax.post(url, data=self.flag_data) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_incorrect_comment_id(self): """Test response when an incorrect comment id is passed""" @@ -145,7 +144,7 @@ def test_change_flag_state_for_unflagged_comment(self): self.assertEqual(int(self.client.session['_auth_user_id']), self.moderator.id) response = self.client.post(self.get_url('comment:flag-change-state', comment.id), data=self.data) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 403) def test_change_flag_state_by_not_permitted_user(self): self.assertTrue(self.comment_for_change_state.is_flagged) diff --git a/comment/tests/test_views/test_reactions.py b/comment/tests/test_views/test_reactions.py index 28cfba9..ddb7600 100644 --- a/comment/tests/test_views/test_reactions.py +++ b/comment/tests/test_views/test_reactions.py @@ -3,7 +3,6 @@ from comment.tests.base import BaseCommentViewTest from comment.messages import ReactionInfo -from comment.conf import settings class SetReactionViewTest(BaseCommentViewTest): @@ -66,8 +65,7 @@ def test_set_reaction_for_unauthenticated_users(self): self.client.logout() response = self.client.post(_url) - self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.assertEqual(response.url, '{}?next={}'.format(settings.LOGIN_URL, _url)) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_get_request(self): """Test whether GET requests are allowed or not""" @@ -81,7 +79,7 @@ def test_non_ajax_requests(self): _url = self.get_reaction_url(self.comment.id, 'like') response = self.client_non_ajax.post(_url) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_incorrect_comment_id(self): """Test response when an incorrect comment id is passed""" diff --git a/comment/tests/test_views/test_subscription.py b/comment/tests/test_views/test_subscription.py index 777f410..86609da 100644 --- a/comment/tests/test_views/test_subscription.py +++ b/comment/tests/test_views/test_subscription.py @@ -32,6 +32,7 @@ def test_email_required_to_follow_object(self): ) def test_invalid_email(self): + self.client.force_login(self.user_1) data = {'email': 'invalid_email'} response = self.client.post(self.toggle_follow_url, HTTP_X_REQUESTED_WITH='XMLHttpRequest', data=data) diff --git a/comment/urls.py b/comment/urls.py index 787ed46..1f8649d 100644 --- a/comment/urls.py +++ b/comment/urls.py @@ -5,7 +5,7 @@ from comment import __version__ from comment.views import ( CreateComment, UpdateComment, DeleteComment, SetReaction, SetFlag, ChangeFlagState, - ConfirmComment, ToggleFollowView + ConfirmComment, ToggleFollowView, ToggleBlockingView ) app_name = 'comment' @@ -20,6 +20,7 @@ path('/flag/state/change/', ChangeFlagState.as_view(), name='flag-change-state'), re_path(r'^confirm/(?P[^/]+)/$', ConfirmComment.as_view(), name='confirm-comment'), path('toggle-subscription/', ToggleFollowView.as_view(), name='toggle-subscription'), + path('toggle-blocking/', ToggleBlockingView.as_view(), name='toggle-blocking'), # javascript translations # The value returned by _get_version() must change when translations change. path( diff --git a/comment/utils.py b/comment/utils.py index 88619de..38d7e37 100644 --- a/comment/utils.py +++ b/comment/utils.py @@ -74,8 +74,12 @@ def has_valid_profile(): return False +def _is_moderation_enabled(): + return settings.COMMENT_FLAGS_ALLOWED or settings.COMMENT_ALLOW_BLOCKING_USERS + + def is_comment_admin(user): - if settings.COMMENT_FLAGS_ALLOWED or settings.COMMENT_ALLOW_BLOCKING_USERS: + if _is_moderation_enabled(): return user.groups.filter(name="comment_admin").exists() or ( user.has_perm("comment.delete_flagged_comment") and user.has_perm("comment.delete_comment") @@ -84,7 +88,7 @@ def is_comment_admin(user): def is_comment_moderator(user): - if settings.COMMENT_FLAGS_ALLOWED or settings.COMMENT_ALLOW_BLOCKING_USERS: + if _is_moderation_enabled(): return user.groups.filter(name="comment_moderator").exists() or user.has_perm( "comment.delete_flagged_comment" ) @@ -99,7 +103,7 @@ def can_moderate_flagging(user): def can_moderator_block_users(moderator): - return settings.COMMENT_ALLOW_MODERATOR_PERFORM_BLOCKING and is_comment_moderator(moderator) + return settings.COMMENT_ALLOW_MODERATOR_TO_BLOCK and is_comment_moderator(moderator) def can_block_user(user): diff --git a/comment/views/__init__.py b/comment/views/__init__.py index a6aeff9..f2f7762 100644 --- a/comment/views/__init__.py +++ b/comment/views/__init__.py @@ -1,5 +1,7 @@ # flake8: noqa +from comment.views.base import * from comment.views.comments import * from comment.views.reactions import * from comment.views.flags import * from comment.views.followers import * +from comment.views.blocker import * diff --git a/comment/views/base.py b/comment/views/base.py new file mode 100644 index 0000000..5b335a5 --- /dev/null +++ b/comment/views/base.py @@ -0,0 +1,54 @@ +from django.views.generic import FormView + +from comment.conf import settings +from comment.context import DABContext +from comment.responses import DABResponseData +from comment.messages import EmailInfo +from comment.service.email import DABEmailService +from comment.forms import CommentForm + + +class BaseCommentView(FormView, DABResponseData): + form_class = CommentForm + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['comment_form'] = context.pop('form') + context.update(DABContext(self.request)) + return context + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + +class CommentCreateMixin(BaseCommentView): + email_service = None + + def _initialize_email_service(self, comment, request): + self.email_service = DABEmailService(comment, request) + + def _send_notification_to_followers(self, comment, request): + if settings.COMMENT_ALLOW_SUBSCRIPTION: + self._initialize_email_service(comment, request) + self.email_service.send_notification_to_followers() + + def perform_save(self, comment, request): + comment.save() + self._send_notification_to_followers(comment, request) + comment.refresh_from_db() + return comment + + def _handle_anonymous(self, comment, request, api=False): + self._initialize_email_service(comment, request) + self.email_service.send_confirmation_request(api=api) + self.anonymous = True + self.msg = EmailInfo.CONFIRMATION_SENT + + def perform_create(self, comment, request, api=False): + if settings.COMMENT_ALLOW_ANONYMOUS and not comment.user: + self._handle_anonymous(comment, request, api) + else: + comment = self.perform_save(comment, request) + return comment diff --git a/comment/views/blocker.py b/comment/views/blocker.py new file mode 100644 index 0000000..b82d7d8 --- /dev/null +++ b/comment/views/blocker.py @@ -0,0 +1,58 @@ +from django.views import View + +from comment.models import BlockedUser, BlockedUserHistory, Comment +from comment.mixins import CanBlockUsersMixin +from comment.responses import UTF8JsonResponse, DABResponseData +from comment.messages import BlockUserMSG + + +class BaseToggleBlockingView(DABResponseData): + response_class = None + + def get_response_class(self): + assert self.response_class is not None, ( + "'%s' should either include a `response_class` attribute, " + "or override the `get_response_class()` method." + % self.__class__.__name__ + ) + return self.response_class + + def post(self, request, *args, **kwargs): + response_class = self.get_response_class() + request_data = request.POST or getattr(request, 'data', {}) + comment_id = request_data.get('comment_id', None) + try: + comment = Comment.objects.get(id=int(comment_id)) + except (Comment.DoesNotExist, ValueError, TypeError): + self.error = { + 'detail': BlockUserMSG.INVALID + } + self.status = 400 + return response_class(self.json(), status=self.status) + + blocked_user, created = BlockedUser.objects.get_or_create_blocked_user_for_comment(comment) + + if not created: + blocked_user.blocked = not blocked_user.blocked + blocked_user.save() + + reason = request_data.get('reason', None) + if blocked_user.blocked and not reason: + reason = comment.content + + BlockedUserHistory.objects.create( + blocked_user=blocked_user, + blocker=request.user, + reason=reason, + state=int(blocked_user.blocked) + ) + self.data = { + 'blocked_user': comment.get_username(), + 'blocked': blocked_user.blocked, + 'urlhash': comment.urlhash + } + return response_class(self.json()) + + +class ToggleBlockingView(CanBlockUsersMixin, BaseToggleBlockingView, View): + response_class = UTF8JsonResponse diff --git a/comment/views/comments.py b/comment/views/comments.py index f1b9b5d..ed0a43b 100644 --- a/comment/views/comments.py +++ b/comment/views/comments.py @@ -6,9 +6,10 @@ from comment.models import Comment from comment.forms import CommentForm from comment.utils import get_comment_from_key, get_user_for_request, CommentFailReason -from comment.mixins import CanCreateMixin, CanEditMixin, CanDeleteMixin, CommentCreateMixin, BaseCommentView +from comment.mixins import CanCreateMixin, CanEditMixin, CanDeleteMixin from comment.responses import UTF8JsonResponse from comment.messages import EmailError +from comment.views import CommentCreateMixin, BaseCommentView class CreateComment(CanCreateMixin, CommentCreateMixin): diff --git a/comment/views/flags.py b/comment/views/flags.py index 65a4b42..bf869f3 100644 --- a/comment/views/flags.py +++ b/comment/views/flags.py @@ -3,8 +3,8 @@ from django.views import View from comment.models import Comment, Flag, FlagInstance -from comment.mixins import CanSetFlagMixin, CanEditFlagStateMixin, DABResponseData -from comment.responses import UTF8JsonResponse +from comment.mixins import CanSetFlagMixin, CanUpdateFlagStateMixin +from comment.responses import UTF8JsonResponse, DABResponseData from comment.messages import FlagInfo, FlagError @@ -29,6 +29,7 @@ def post(self, request, *args, **kwargs): self.msg = FlagInfo.UNFLAGGED_SUCCESS self.data.update({'status': 0}) + self.status = 200 except ValidationError as e: self.error = e.message self.status = 400 @@ -36,7 +37,7 @@ def post(self, request, *args, **kwargs): return UTF8JsonResponse(self.json(), status=self.status) -class ChangeFlagState(CanEditFlagStateMixin, View, DABResponseData): +class ChangeFlagState(CanUpdateFlagStateMixin, View, DABResponseData): comment = None def get_object(self): @@ -47,6 +48,7 @@ def post(self, request, *args, **kwargs): state = request.POST.get('state') try: self.comment.flag.toggle_state(state, request.user) + self.status = 200 except ValidationError: self.error = FlagError.STATE_CHANGE_ERROR self.status = 400 diff --git a/comment/views/followers.py b/comment/views/followers.py index 1e5a303..3c2540f 100644 --- a/comment/views/followers.py +++ b/comment/views/followers.py @@ -1,8 +1,8 @@ from django.views.generic.base import View from comment.models import Follower -from comment.mixins import CanSubscribeMixin, DABResponseData -from comment.responses import UTF8JsonResponse +from comment.mixins import CanSubscribeMixin +from comment.responses import UTF8JsonResponse, DABResponseData from comment.validators import ContentTypeValidator, DABEmailValidator from comment.messages import FollowError @@ -56,5 +56,5 @@ def post(self, request, *args, **kwargs): return response_class(self.json()) -class ToggleFollowView(BaseToggleFollowView, CanSubscribeMixin, View): +class ToggleFollowView(CanSubscribeMixin, BaseToggleFollowView, View): response_class = UTF8JsonResponse diff --git a/comment/views/reactions.py b/comment/views/reactions.py index 1ee5b2c..f858628 100644 --- a/comment/views/reactions.py +++ b/comment/views/reactions.py @@ -5,8 +5,8 @@ from django.views.decorators.http import require_POST from comment.models import Comment, Reaction, ReactionInstance -from comment.mixins import BaseCommentMixin, DABResponseData -from comment.responses import UTF8JsonResponse +from comment.mixins import BaseCommentMixin +from comment.responses import UTF8JsonResponse, DABResponseData from comment.messages import ReactionInfo diff --git a/docs/source/Web API.rst b/docs/source/Web API.rst index 27f5344..5970de9 100644 --- a/docs/source/Web API.rst +++ b/docs/source/Web API.rst @@ -30,6 +30,8 @@ The available actions with permitted user as follows: 12. Retrieve a list of subscribers to a given thread/content type. (admins and moderators) + 13. Block users/emails. (admins and moderators) + These actions are explained below. Setup: @@ -174,8 +176,8 @@ This action requires the ``comment.id``. .. code:: python payload = { - 'reason': REASON, # number of the reason - 'info': '' # this is required if the reason is 100 ``Something else`` + "reason": REASON, # number of the reason + "info": str # this is required if the reason is 100 ``Something else`` } :: @@ -203,7 +205,7 @@ This action requires comment `admin` or `moderator` privilege. .. code:: python payload = { - 'state': 3 # accepted state is 3 (REJECTED) or 4 (RESOLVED) only + "state": STATE # accepted state is 3 (REJECTED) or 4 (RESOLVED) only } :: @@ -223,7 +225,8 @@ Get request accepts 3 params: Example: -:: code:: bash +:: + $ curl -X GET -H "Content-Type: application/json" $BASE_URL/api/comments/confirm/KEY/ Since the key generated for each comment is unique, it can only be used once to verify. Any tampering with the key will result in a BAD HTTP request(400). @@ -237,7 +240,8 @@ Authorization must be provided as a TOKEN or USERNAME:PASSWORD. Subscription variable ``COMMENT_ALLOW_SUBSCRIPTION`` must be enabled in ``settings.py``. -:: code:: bash +:: + $ curl -X POST -u USERNAME:PASSWORD -H "Content-Type: application/json" "$BASE_URL/api/comments/toggle-subscription/?model_name=MODEL_NAME&model_id=ID&app_name=APP_NAME" @@ -251,3 +255,23 @@ This action requires comment `admin` or `moderator` privilege. :: code:: bash $ curl -X GET -u USERNAME:PASSWORD -H "Content-Type: application/json" $BASE_URL/api/comments/subscribers/ + + +**11- Block users/emails** + +``POST`` is the allowed method to toggle blocking. + +Authorization must be provided as a TOKEN or USERNAME:PASSWORD. + +This action requires comment `admin` or `moderator` privilege. + +.. code:: python + + payload = { + "comment_id": ID, + "reason": str # optional + } + +:: + + $ curl -X POST -u USERNAME:PASSWORD -H "Content-Type: application/json" -d '{"comment_id": ID}' $BASE_URL/api/comments/toggle-blocking/ diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 5117f1d..27ce82b 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -73,6 +73,8 @@ It allows you to integrate commenting functionality with any model you have e.g. 9. Follow and unfollow thread. (authenticated users) + 10. Block users/emails (v2.7.0 admins and moderators) + - All actions are done by Fetch API since V2.0.0 - Bootstrap 4.1.1 is used in comment templates for responsive design. diff --git a/docs/source/settings.rst b/docs/source/settings.rst index ff975f4..a75f020 100644 --- a/docs/source/settings.rst +++ b/docs/source/settings.rst @@ -168,3 +168,14 @@ COMMENT_DEFAULT_PROFILE_PIC_LOC Provides an alternate location for profile picture that can be used other than default image. Defaults to '/static/img/default.png' + + +COMMENT_ALLOW_BLOCKING_USERS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Enable blocking system. This gives only **admins** the right. + +COMMENT_ALLOW_MODERATOR_TO_BLOCK +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Allow **moderators** to perform blocking action when `COMMENT_ALLOW_BLOCKING_USERS`_ is enabled. diff --git a/docs/source/usage.rst b/docs/source/usage.rst index ce55830..9c6e001 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -188,13 +188,13 @@ To further customize different attributes related to anonymous commenting, you m 5. Enable gravatar: ^^^^^^^^^^^^^^^^^^^^ -To enable using gravatar for profile pics set ``COMMENT_USE_GRAVATAR`` in `settings.py` to ``True`` +To enable using gravatar for profile pics set ``COMMENT_USE_GRAVATAR`` in ``settings.py`` to ``True`` 6. Enable subscription: ^^^^^^^^^^^^^^^^^^^^^^^^ -To enable app subscription set ``COMMENT_ALLOW_SUBSCRIPTION`` in `settings.py` to ``True`` +To enable app subscription set ``COMMENT_ALLOW_SUBSCRIPTION`` in ``settings.py`` to ``True`` This will enable the UI functionality and the API endpoint to follow and unfollow `thread`. @@ -208,3 +208,12 @@ The thread can be a `parent` comment or the `content type` (i.g. Post, Picture, An email notification will be sent to the thread's followers up on adding a new comment to the thread. PS: This feature needs the email settings to be configured similar to `4. Allow commenting by anonymous:`_ + + +7. Enable blocking system +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Blocking functionality is added in version 2.7.0. It allows moderators to block users/emails from creating/editing or reacting to a comment. + +To enable blocking system set ``COMMENT_ALLOW_BLOCKING_USERS`` in ``settings`` to ``True``. +This will grant access for the **admins** only to block users. However, in order to give the **moderators** this right, you need to add ``COMMENT_ALLOW_MODERATOR_TO_BLOCK = True`` to `settings` diff --git a/test/settings.py b/test/settings.py index 6bfd0ce..27419df 100644 --- a/test/settings.py +++ b/test/settings.py @@ -115,3 +115,6 @@ COMMENT_ALLOW_TRANSLATION = True COMMENT_ALLOW_SUBSCRIPTION = True + +COMMENT_ALLOW_BLOCKING_USERS = True +COMMENT_ALLOW_MODERATOR_TO_BLOCK = True