Skip to content

Commit

Permalink
Add option to comment anonymously
Browse files Browse the repository at this point in the history
- unauthenticated users can comment anonymously by giving their email.
- user can verify the comment by click on the link sent to their email.
- the email can be sent in both html and text format.
- comment only hits the database when it is authenticated.

Add email field to the database -> change is backward compatible for previous comments.

Closes #33
  • Loading branch information
abhiabhi94 authored and Radi85 committed Aug 23, 2020
1 parent 5fa0818 commit 646e93b
Show file tree
Hide file tree
Showing 47 changed files with 1,339 additions and 370 deletions.
2 changes: 1 addition & 1 deletion comment/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


class CommentModelAdmin(admin.ModelAdmin):
list_display = ('__str__', 'posted', 'edited', 'content_type', 'user', 'urlhash')
list_display = ('__str__', 'posted', 'edited', 'content_type', 'user', 'email', 'urlhash')
search_fields = ('content',)

class Meta:
Expand Down
61 changes: 47 additions & 14 deletions comment/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from django.utils import timezone

from rest_framework import serializers

from comment.conf import settings
from comment.models import Comment, Flag, Reaction
from comment.utils import get_model_obj
from comment.utils import get_model_obj, process_anonymous_commenting, get_user_for_request


def get_profile_model():
Expand Down Expand Up @@ -99,31 +101,62 @@ def get_reactions(obj):


class CommentCreateSerializer(BaseCommentSerializer):

class Meta:
model = Comment
fields = ('id', 'user', 'content', 'parent', 'posted', 'edited', 'reply_count', 'replies', 'urlhash')
fields = ('id', 'user', 'email', 'content', 'parent', 'posted', 'edited', 'reply_count', 'replies', 'urlhash')

def __init__(self, *args, **kwargs):
self.model_name = kwargs['context'].get('model_name')
self.app_name = kwargs['context'].get('app_name')
self.model_id = kwargs['context'].get('model_id')
self.user = kwargs['context'].get('user')
self.parent_id = kwargs['context'].get('parent_id')
if kwargs['context']['request'].user.is_authenticated or not settings.COMMENT_ALLOW_ANONYMOUS:
del self.fields['email']

super().__init__(*args, **kwargs)

def get_parent_object(self):
if not self.parent_id or self.parent_id == '0':
return None
return Comment.objects.get(id=self.parent_id)
def validate_email(self, value):
if (not value):
raise serializers.ValidationError(
_('Email is required for posting anonymous comments.'),
code='required'
)
return value.strip().lower()

def create(self, validated_data):
return Comment.objects.create(
user=self.user,
content_object=get_model_obj(self.app_name, self.model_name, self.model_id),
parent=self.get_parent_object(),
content=validated_data.get("content")
)
request = self.context['request']
user = get_user_for_request(request)
content = validated_data.get('content')
email = validated_data.get('email')
parent_id = self.parent_id
time_posted = timezone.now()
parent_comment = Comment.objects.get_parent_comment(parent_id)
model_object = get_model_obj(self.app_name, self.model_name, self.model_id)

comment = Comment(
content_object=model_object,
content=content,
user=user,
parent=parent_comment,
email=email,
posted=time_posted
)
dict_comment = {
'user': user,
'content': content,
'email': email,
'posted': str(time_posted),
'app_name': self.app_name,
'model_name': self.model_name,
'model_id': self.model_id,
'parent': parent_id
}
if settings.COMMENT_ALLOW_ANONYMOUS and (not user):
process_anonymous_commenting(request, comment, dict_comment, api=True)
else:
comment.save()
return comment


class CommentSerializer(BaseCommentSerializer):
Expand All @@ -134,7 +167,7 @@ class CommentSerializer(BaseCommentSerializer):
class Meta:
model = Comment
fields = (
'id', 'user', 'content', 'parent', 'posted', 'edited', 'reply_count', 'replies', 'reactions',
'id', 'user', 'email', 'content', 'parent', 'posted', 'edited', 'reply_count', 'replies', 'reactions',
'is_flagged', 'flags', 'urlhash'
)

Expand Down
3 changes: 2 additions & 1 deletion comment/api/urls.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.urls import path
from django.urls import path, re_path
from rest_framework.urlpatterns import format_suffix_patterns

from comment.api import views
Expand All @@ -14,6 +14,7 @@
views.CommentDetailForFlagStateChange.as_view(),
name='comments-flag-state-change'
),
re_path(r'^comments/confirm/(?P<key>[^/]+)/$', views.ConfirmComment.as_view(), name='confirm-comment'),
]

urlpatterns = format_suffix_patterns(urlpatterns)
24 changes: 21 additions & 3 deletions comment/api/views.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
from django.core.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from rest_framework import generics, permissions, status
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from rest_framework.views import APIView

from comment.api.serializers import CommentSerializer, CommentCreateSerializer
from comment.api.permissions import (
IsOwnerOrReadOnly, ContentTypePermission, ParentIdPermission, FlagEnabledPermission, CanChangeFlaggedCommentState
)
from comment.models import Comment, Reaction, ReactionInstance, Flag, FlagInstance
from comment.utils import get_comment_from_key, CommentFailReason


class CommentCreate(generics.CreateAPIView):
serializer_class = CommentCreateSerializer
permission_classes = (permissions.IsAuthenticated, ContentTypePermission, ParentIdPermission)
permission_classes = (ContentTypePermission, ParentIdPermission)

def get_serializer_context(self):
context = super().get_serializer_context()
Expand All @@ -23,6 +26,7 @@ def get_serializer_context(self):
context['app_name'] = self.request.GET.get("app_name")
context['model_id'] = self.request.GET.get("model_id")
context['parent_id'] = self.request.GET.get("parent_id")
context['email'] = self.request.GET.get('email', None)
return context


Expand Down Expand Up @@ -111,13 +115,13 @@ def post(self, request, *args, **kwargs):
comment = get_object_or_404(Comment, id=kwargs.get('pk'))
flag = Flag.objects.get_for_comment(comment)
if not comment.is_flagged:
raise PermissionDenied(detail='You do not have permission to perform this action.')
raise PermissionDenied(detail=_('You do not have permission to perform this action.'))
state = request.data.get('state') or request.POST.get('state')
try:
state = flag.get_clean_state(state)
if not comment.is_edited and state == flag.RESOLVED:
return Response(
{'error': 'The comment must be edited before resolving the flag'},
{'error': _('The comment must be edited before resolving the flag')},
status=status.HTTP_400_BAD_REQUEST
)
flag.toggle_state(state, request.user)
Expand All @@ -126,3 +130,17 @@ def post(self, request, *args, **kwargs):

serializer = self.get_serializer(comment)
return Response(serializer.data, status=status.HTTP_200_OK)


class ConfirmComment(APIView):
def get(self, request, *args, **kwargs):
key = kwargs.get('key', None)
comment = get_comment_from_key(key)

if comment.why_invalid == CommentFailReason.BAD:
return Response({'error': _('Bad Signature, Comment discarded')}, status=status.HTTP_400_BAD_REQUEST)

if comment.why_invalid == CommentFailReason.EXISTS:
return Response({'error': _('Comment already verified')}, status=status.HTTP_200_OK)

return Response(CommentSerializer(comment.obj).data, status=status.HTTP_201_CREATED)
11 changes: 6 additions & 5 deletions comment/apps.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.apps import AppConfig
from django.db.models.signals import post_migrate
from django.utils.translation import gettext_lazy as _


def create_permission_groups(sender, **kwargs):
Expand All @@ -8,19 +9,19 @@ def create_permission_groups(sender, **kwargs):
from comment.models import Comment

comment_ct = ContentType.objects.get_for_model(Comment)
delete_comment_perm, _ = Permission.objects.get_or_create(
delete_comment_perm, __ = Permission.objects.get_or_create(
codename='delete_comment',
name='Can delete comment',
content_type=comment_ct
)
admin_group, _ = Group.objects.get_or_create(name='comment_admin')
admin_group, __ = Group.objects.get_or_create(name='comment_admin')
admin_group.permissions.add(delete_comment_perm)
delete_flagged_comment_perm, _ = Permission.objects.get_or_create(
delete_flagged_comment_perm, __ = Permission.objects.get_or_create(
codename='delete_flagged_comment',
name='Can delete flagged comment',
content_type=comment_ct
)
moderator_group, _ = Group.objects.get_or_create(name='comment_moderator')
moderator_group, __ = Group.objects.get_or_create(name='comment_moderator')
moderator_group.permissions.add(delete_flagged_comment_perm)
admin_group.permissions.add(delete_flagged_comment_perm)

Expand All @@ -33,7 +34,7 @@ def adjust_flagged_comments(sender, **kwargs):

class CommentConfig(AppConfig):
name = 'comment'
verbose_name = 'comment'
verbose_name = _('comment')

def ready(self):
post_migrate.connect(create_permission_groups, sender=self)
Expand Down
8 changes: 8 additions & 0 deletions comment/conf/defaults.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.utils.translation import gettext_lazy as _
from django.conf import settings

PROFILE_APP_NAME = None
PROFILE_MODEL_NAME = None
Expand All @@ -13,3 +14,10 @@
COMMENT_URL_PREFIX = 'comment-'
COMMENT_URL_SUFFIX = ''
COMMENT_URL_ID_LENGTH = 8
COMMENT_PER_PAGE = 10
COMMENT_ALLOW_ANONYMOUS = False
if getattr(settings, 'COMMENT_ALLOW_ANONYMOUS', COMMENT_ALLOW_ANONYMOUS):
COMMENT_FROM_EMAIL = settings.EMAIL_HOST_USER # used for sending confirmation emails
COMMENT_CONTACT_EMAIL = COMMENT_FROM_EMAIL # used for contact address in confirmation emails
COMMENT_SEND_HTML_EMAIL = True
COMMENT_ANONYMOUS_USERNAME = 'Anonymous User'
21 changes: 20 additions & 1 deletion comment/forms.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
from django import forms
from django.utils.translation import gettext_lazy as _

from comment.models import Comment
from comment.conf import settings


class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ('content', )
fields = ('content',)
widgets = {'content': forms.Textarea(attrs={'rows': 1})}

def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
if self.request.user.is_anonymous and settings.COMMENT_ALLOW_ANONYMOUS:
self.fields['email'] = forms.EmailField(
label=_("email"),
widget=forms.EmailInput(attrs={
'placeholder': _('email address, this will be used for verification.'),
'title': _("email address, it will be used for verification.")
})
)

def clean_email(self):
"""this will only be executed when email field is present for unauthenticated users"""
email = self.cleaned_data['email']
return email.strip().lower()
11 changes: 11 additions & 0 deletions comment/managers/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,14 @@ def generate_urlhash(self):
len_id=settings.COMMENT_URL_ID_LENGTH,
suffix=settings.COMMENT_URL_SUFFIX
)

def get_parent_comment(self, parent_id):
parent_comment = None
if parent_id or parent_id == '0':
parent_qs = self.filter(id=parent_id)
if parent_qs.exists():
parent_comment = parent_qs.first()
return parent_comment

def comment_exists(self, comment):
return self.model.objects.filter(email=comment.email, posted=comment.posted).count() > 0
43 changes: 43 additions & 0 deletions comment/migrations/0009_auto_20200811_1945.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


def set_default_email(apps, schema_editor):
comment_model = apps.get_model('comment', 'Comment')
for comment in comment_model.objects.all():
comment.email = comment.user.email
comment.save(update_fields=['email'])


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('comment', '0008_comment_urlhash'),
]

operations = [
migrations.AddField(
model_name='comment',
name='email',
field=models.EmailField(default=None, max_length=254, null=True),
),
migrations.RunPython(set_default_email, migrations.RunPython.noop),
migrations.AlterField(
model_name='comment',
name='email',
field=models.EmailField(max_length=254, blank=True),
),
migrations.AlterField(
model_name='comment',
name='posted',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='comment',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]
15 changes: 15 additions & 0 deletions comment/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseBadRequest
from django.utils.translation import gettext_lazy as _


# This willl be expanded to provide checks for validating the form fields
class BaseCommentMixin(LoginRequiredMixin):
pass


class AJAXRequiredMixin:
def dispatch(self, request, *args, **kwargs):
if not request.is_ajax():
return HttpResponseBadRequest(_('Only AJAX request are allowed'))
return super().dispatch(request, *args, **kwargs)
Loading

0 comments on commit 646e93b

Please sign in to comment.