Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
736a205
Add missing info to comment serializer
samanehsan Nov 9, 2015
5e0f5b1
Use apiV2 to fetch list of node comments
samanehsan Nov 9, 2015
db0809c
Make creating comments/replies use v2 API
samanehsan Nov 9, 2015
125db9f
Use api v2 route to edit comment
samanehsan Nov 9, 2015
bf351ad
Use v2 API to delete/undelete comments
samanehsan Nov 9, 2015
0bd2354
Use v2 API to report/unreport comments
samanehsan Nov 9, 2015
5f868c8
Add max_length to comment content field
samanehsan Nov 10, 2015
515c5b1
Send signal to call notify when commenting
samanehsan Nov 10, 2015
477c1d2
Get unread comments using v2 API
samanehsan Nov 10, 2015
52a4129
Fix get_is_abuse
samanehsan Nov 11, 2015
316e860
Do not allow whitespace comments
samanehsan Nov 11, 2015
5ce7747
Remove query string in apiv2 node url
samanehsan Nov 12, 2015
2e2739b
Add tests for updating comments
samanehsan Nov 12, 2015
2e23b2f
Fix is_abuse and creating reports
samanehsan Nov 12, 2015
0b6d65e
Correct/update documentation
samanehsan Nov 12, 2015
b529574
Fix get_can_edit
samanehsan Nov 12, 2015
5f791e3
Remove v1 comment list route
samanehsan Nov 12, 2015
b171e07
Remove remaining comment CRUD routes
samanehsan Nov 12, 2015
04ea63c
Remove comment (un)report abuse routes
samanehsan Nov 12, 2015
943e352
Fix flake8 error
samanehsan Nov 13, 2015
40c9a27
Fix failing test
samanehsan Nov 13, 2015
59240d0
Fix unread comments regression
samanehsan Nov 13, 2015
348520d
Fix getting comment reply lists
samanehsan Nov 13, 2015
9d2d57c
Fix logic for creating comment
samanehsan Nov 16, 2015
54cd0bc
Merge branch 'develop' of https://github.com/CenterForOpenScience/osf…
samanehsan Nov 22, 2015
4270db0
Embed user data in api requests for comments
samanehsan Nov 22, 2015
4e1f075
Merge branch 'develop' of https://github.com/CenterForOpenScience/osf…
samanehsan Nov 23, 2015
9daab5a
Update creating comments
samanehsan Dec 4, 2015
c42e096
Merge branch 'develop' of https://github.com/CenterForOpenScience/osf…
samanehsan Dec 6, 2015
44c3424
Add author info when creating comment
samanehsan Dec 11, 2015
75a1773
Merge branch 'develop' of https://github.com/CenterForOpenScience/osf…
samanehsan Dec 15, 2015
0044469
Remove unnecessary Auth in serializer methods
samanehsan Dec 15, 2015
da0f8e3
Check for is_deleted not equal to True in find_unread
samanehsan Dec 15, 2015
d6a0423
Add error logging and return promises
samanehsan Dec 15, 2015
f3ca37e
Refactor tests
samanehsan Dec 15, 2015
7f9acec
Add help text to new comment serializer fields
samanehsan Dec 15, 2015
e222205
Pass in user info to the comment model
samanehsan Dec 16, 2015
f54790d
Validate content max_length in Comment model
samanehsan Dec 18, 2015
84d05a7
Make content required in model & serializer
samanehsan Dec 19, 2015
af8a143
Use field validator for content
samanehsan Dec 19, 2015
3a34bb9
Add Comment model content not empty validator
samanehsan Dec 20, 2015
8db0c06
Use string_required validator for content field
samanehsan Dec 21, 2015
37d6cdf
Add permissions checks to comment model methods
samanehsan Dec 21, 2015
a84919c
Handle anonymous view only links for comments
samanehsan Dec 22, 2015
3009537
Only get unread count if not priviate link
samanehsan Dec 22, 2015
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions api/comments/serializers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from rest_framework import serializers as ser
from framework.auth.core import Auth
from framework.exceptions import PermissionsError
from website.project.model import Comment, Node
from rest_framework.exceptions import ValidationError, PermissionDenied
from api.base.exceptions import InvalidModelValueError, Conflict
from api.base.utils import absolute_reverse
from api.base.settings import osf_settings
from api.base.serializers import (JSONAPISerializer,
TargetField,
RelationshipField,
Expand All @@ -29,7 +31,7 @@ class CommentSerializer(JSONAPISerializer):

id = IDField(source='_id', read_only=True)
type = TypeField()
content = AuthorizedCharField(source='get_content')
content = AuthorizedCharField(source='get_content', required=True, max_length=osf_settings.COMMENT_MAXLENGTH)

target = TargetField(link_type='related', meta={'type': 'get_target_type'})
user = RelationshipField(related_view='users:user-detail', related_view_kwargs={'user_id': '<user._id>'})
Expand All @@ -41,23 +43,56 @@ class CommentSerializer(JSONAPISerializer):
date_modified = ser.DateTimeField(read_only=True)
modified = ser.BooleanField(read_only=True, default=False)
deleted = ser.BooleanField(read_only=True, source='is_deleted', default=False)
is_abuse = ser.SerializerMethodField(help_text='Whether the current user reported this comment.')
has_children = ser.SerializerMethodField(help_text='Whether this comment has any replies.')
can_edit = ser.SerializerMethodField(help_text='Whether the current user can edit this comment.')

# LinksField.to_representation adds link to "self"
links = LinksField({})

class Meta:
type_ = 'comments'

def validate_content(self, value):
if value is None or not value.strip():
raise ValidationError('Comment cannot be empty.')
return value

def get_is_abuse(self, obj):
user = self.context['request'].user
if user.is_anonymous():
return False
return user._id in obj.reports

def get_can_edit(self, obj):
user = self.context['request'].user
if user.is_anonymous():
return False
return obj.user._id == user._id

def get_has_children(self, obj):
return bool(getattr(obj, 'commented', []))

def update(self, comment, validated_data):
assert isinstance(comment, Comment), 'comment must be a Comment'
auth = Auth(self.context['request'].user)
if validated_data:
if 'get_content' in validated_data:
comment.edit(validated_data['get_content'], auth=auth, save=True)
content = validated_data.pop('get_content')
try:
comment.edit(content, auth=auth, save=True)
except PermissionsError:
raise PermissionDenied('Not authorized to edit this comment.')
if validated_data.get('is_deleted', None) is True:
comment.delete(auth, save=True)
try:
comment.delete(auth, save=True)
except PermissionsError:
raise PermissionDenied('Not authorized to delete this comment.')
elif comment.is_deleted:
comment.undelete(auth, save=True)
try:
comment.undelete(auth, save=True)
except PermissionsError:
raise PermissionDenied('Not authorized to undelete this comment.')
return comment

def get_target_type(self, obj):
Expand All @@ -81,7 +116,7 @@ def get_validated_target_type(self, obj):
target_type = self.context['request'].data.get('target_type')
expected_target_type = self.get_target_type(target)
if target_type != expected_target_type:
raise Conflict('Invalid target type. Expected \"{0}\", got \"{1}.\"'.format(expected_target_type, target_type))
raise Conflict('Invalid target type. Expected "{0}", got "{1}."'.format(expected_target_type, target_type))
return target_type

def get_target(self, node_id, target_id):
Expand Down Expand Up @@ -112,10 +147,10 @@ def create(self, validated_data):
)
validated_data['target'] = target
validated_data['content'] = validated_data.pop('get_content')
if node and node.can_comment(auth):
try:
comment = Comment.create(auth=auth, **validated_data)
else:
raise PermissionDenied("Not authorized to comment on this project.")
except PermissionsError:
raise PermissionDenied('Not authorized to comment on this project.')
return comment


Expand Down
6 changes: 6 additions & 0 deletions api/comments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ class CommentRepliesList(JSONAPIBaseView, generics.ListAPIView, CommentMixin, OD
date_modified iso8601 timestamp timestamp when the comment was last updated
modified boolean has this comment been edited?
deleted boolean is this comment deleted?
is_abuse boolean has this comment been reported by the current user?
has_children boolean does this comment have replies?
can_edit boolean can the current user edit this comment?

##Links

Expand Down Expand Up @@ -141,6 +144,9 @@ class CommentDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView, CommentMixi
date_modified iso8601 timestamp timestamp when the comment was last updated
modified boolean has this comment been edited?
deleted boolean is this comment deleted?
is_abuse boolean has this comment been reported by the current user?
has_children boolean does this comment have replies?
can_edit boolean can the current user edit this comment?

##Relationships

Expand Down
3 changes: 3 additions & 0 deletions api/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1770,6 +1770,9 @@ class NodeCommentsList(JSONAPIBaseView, generics.ListCreateAPIView, ODMFilterMix
date_modified iso8601 timestamp timestamp when the comment was last updated
modified boolean has this comment been edited?
deleted boolean is this comment deleted?
is_abuse boolean has this comment been reported by the current user?
has_children boolean does this comment have replies?
can_edit boolean can the current user edit this comment?

##Links

Expand Down
105 changes: 104 additions & 1 deletion api_tests/comments/views/test_comment_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
from nose.tools import * # flake8: noqa

from api.base.settings.defaults import API_BASE
from api.base.settings import osf_settings
from tests.base import ApiTestCase
from tests.factories import ProjectFactory, AuthUserFactory, CommentFactory, RegistrationFactory
from tests.factories import ProjectFactory, AuthUserFactory, CommentFactory, RegistrationFactory, PrivateLinkFactory


class TestCommentDetailView(ApiTestCase):
Expand Down Expand Up @@ -67,6 +68,27 @@ def test_private_node_logged_out_user_cannot_view_comment(self):
res = self.app.get(self.private_url, expect_errors=True)
assert_equal(res.status_code, 401)

def test_private_node_user_with_private_link_can_see_comment(self):
self._set_up_private_project_with_comment()
private_link = PrivateLinkFactory(anonymous=False)
private_link.nodes.append(self.private_project)
private_link.save()
res = self.app.get('/{}comments/{}/'.format(API_BASE, self.comment._id), {'view_only': private_link.key}, expect_errors=True)
assert_equal(res.status_code, 200)
assert_equal(self.comment._id, res.json['data']['id'])
assert_equal(self.comment.content, res.json['data']['attributes']['content'])

def test_private_node_user_with_anonymous_link_cannot_see_commenter_info(self):
self._set_up_private_project_with_comment()
private_link = PrivateLinkFactory(anonymous=True)
private_link.nodes.append(self.private_project)
private_link.save()
res = self.app.get('/{}comments/{}/'.format(API_BASE, self.comment._id), {'view_only': private_link.key}, expect_errors=True)
assert_equal(res.status_code, 200)
assert_equal(self.comment._id, res.json['data']['id'])
assert_equal(self.comment.content, res.json['data']['attributes']['content'])
assert_not_in('user', res.json['data']['relationships'])

def test_public_node_logged_in_contributor_can_view_comment(self):
self._set_up_public_project_with_comment()
res = self.app.get(self.public_url, auth=self.user.auth)
Expand All @@ -88,6 +110,15 @@ def test_public_node_logged_out_user_can_view_comment(self):
assert_equal(self.public_comment._id, res.json['data']['id'])
assert_equal(self.public_comment.content, res.json['data']['attributes']['content'])

def test_public_node_user_with_private_link_can_view_comment(self):
self._set_up_public_project_with_comment()
private_link = PrivateLinkFactory(anonymous=False)
private_link.nodes.append(self.public_project)
private_link.save()
res = self.app.get('/{}comments/{}/'.format(API_BASE, self.public_comment._id), {'view_only': private_link.key}, expect_errors=True)
assert_equal(self.public_comment._id, res.json['data']['id'])
assert_equal(self.public_comment.content, res.json['data']['attributes']['content'])

def test_registration_logged_in_contributor_can_view_comment(self):
self._set_up_registration_with_comment()
res = self.app.get(self.registration_url, auth=self.user.auth)
Expand Down Expand Up @@ -193,6 +224,39 @@ def test_public_node_non_contributor_commenter_can_update_comment(self):
assert_equal(res.status_code, 200)
assert_equal(payload['data']['attributes']['content'], res.json['data']['attributes']['content'])

def test_update_comment_cannot_exceed_max_length(self):
self._set_up_private_project_with_comment()
payload = {
'data': {
'id': self.comment._id,
'type': 'comments',
'attributes': {
'content': ''.join(['c' for c in range(osf_settings.COMMENT_MAXLENGTH + 1)]),
'deleted': False
}
}
}
res = self.app.put_json_api(self.private_url, payload, auth=self.user.auth, expect_errors=True)
assert_equal(res.status_code, 400)
assert_equal(res.json['errors'][0]['detail'],
'Ensure this field has no more than {} characters.'.format(str(osf_settings.COMMENT_MAXLENGTH)))

def test_update_comment_cannot_be_empty(self):
self._set_up_private_project_with_comment()
payload = {
'data': {
'id': self.comment._id,
'type': 'comments',
'attributes': {
'content': '',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would also be useful to test content that only has whitespace characters in it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, but I see you've done that in TestNodeCommentsList#test_create_comment_trims_whitespace.

'deleted': False
}
}
}
res = self.app.put_json_api(self.private_url, payload, auth=self.user.auth, expect_errors=True)
assert_equal(res.status_code, 400)
assert_equal(res.json['errors'][0]['detail'], 'This field may not be blank.')

def test_private_node_only_logged_in_contributor_commenter_can_delete_comment(self):
self._set_up_private_project_with_comment()
comment = CommentFactory(node=self.private_project, target=self.private_project, user=self.user)
Expand Down Expand Up @@ -446,6 +510,32 @@ def test_private_node_logged_out_user_cannot_see_deleted_comment(self):
res = self.app.get(url, expect_errors=True)
assert_equal(res.status_code, 401)

def test_private_node_view_only_link_user_cannot_see_deleted_comment(self):
self._set_up_private_project_with_comment()
self.comment.is_deleted = True
self.comment.save()

private_link = PrivateLinkFactory(anonymous=False)
private_link.nodes.append(self.private_project)
private_link.save()

res= self.app.get('/{}comments/{}/'.format(API_BASE, self.comment._id), {'view_only': private_link.key}, expect_errors=True)
assert_equal(res.status_code, 200)
assert_is_none(res.json['data']['attributes']['content'])

def test_private_node_anonymous_view_only_link_user_cannot_see_deleted_comment(self):
self._set_up_private_project_with_comment()
self.comment.is_deleted = True
self.comment.save()

anonymous_link = PrivateLinkFactory(anonymous=True)
anonymous_link.nodes.append(self.private_project)
anonymous_link.save()

res= self.app.get('/{}comments/{}/'.format(API_BASE, self.comment._id), {'view_only': anonymous_link.key}, expect_errors=True)
assert_equal(res.status_code, 200)
assert_is_none(res.json['data']['attributes']['content'])

def test_public_node_only_logged_in_commenter_can_view_deleted_comment(self):
public_project = ProjectFactory(is_public=True, creator=self.user)
comment = CommentFactory(node=public_project, target=public_project, user=self.user)
Expand Down Expand Up @@ -485,3 +575,16 @@ def test_public_node_logged_out_user_cannot_view_deleted_comments(self):
res = self.app.get(url)
assert_equal(res.status_code, 200)
assert_is_none(res.json['data']['attributes']['content'])

def test_public_node_view_only_link_user_cannot_see_deleted_comment(self):
self._set_up_public_project_with_comment()
self.public_comment.is_deleted = True
self.public_comment.save()

private_link = PrivateLinkFactory(anonymous=False)
private_link.nodes.append(self.public_project)
private_link.save()

res = self.app.get('/{}comments/{}/'.format(API_BASE, self.public_comment._id), {'view_only': private_link.key}, expect_errors=True)
assert_equal(res.status_code, 200)
assert_is_none(res.json['data']['attributes']['content'])
69 changes: 69 additions & 0 deletions api_tests/nodes/views/test_node_comments_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from framework.auth import core

from api.base.settings.defaults import API_BASE
from api.base.settings import osf_settings
from tests.base import ApiTestCase
from tests.factories import (
ProjectFactory,
Expand All @@ -12,6 +13,7 @@
CommentFactory,
RetractedRegistrationFactory
)
from website.util.sanitize import strip_html


class TestNodeCommentsList(ApiTestCase):
Expand Down Expand Up @@ -406,6 +408,73 @@ def test_create_comment_no_content(self):
assert_equal(res.json['errors'][0]['detail'], 'This field may not be blank.')
assert_equal(res.json['errors'][0]['source']['pointer'], '/data/attributes/content')

def test_create_comment_trims_whitespace(self):
self._set_up_private_project()
payload = {
'data': {
'type': 'comments',
'attributes': {
'content': ' '
},
'relationships': {
'target': {
'data': {
'type': 'nodes',
'id': self.private_project._id
}
}
}
}
}
res = self.app.post_json_api(self.private_url, payload, auth=self.user.auth, expect_errors=True)
assert_equal(res.status_code, 400)
assert_equal(res.json['errors'][0]['detail'], 'Comment cannot be empty.')

def test_create_comment_sanitizes_input(self):
self._set_up_private_project()
payload = {
'data': {
'type': 'comments',
'attributes': {
'content': '<em>Cool</em> <strong>Comment</strong>'
},
'relationships': {
'target': {
'data': {
'type': 'nodes',
'id': self.private_project._id
}
}
}
}
}
res = self.app.post_json_api(self.private_url, payload, auth=self.user.auth)
assert_equal(res.status_code, 201)
assert_equal(res.json['data']['attributes']['content'], strip_html(payload['data']['attributes']['content']))

def test_create_comment_exceeds_max_length(self):
self._set_up_private_project()
payload = {
'data': {
'type': 'comments',
'attributes': {
'content': (''.join(['c' for c in range(osf_settings.COMMENT_MAXLENGTH + 1)]))
},
'relationships': {
'target': {
'data': {
'type': 'nodes',
'id': self.private_project._id
}
}
}
}
}
res = self.app.post_json_api(self.private_url, payload, auth=self.user.auth, expect_errors=True)
assert_equal(res.status_code, 400)
assert_equal(res.json['errors'][0]['detail'],
'Ensure this field has no more than {} characters.'.format(str(osf_settings.COMMENT_MAXLENGTH)))

def test_create_comment_invalid_target_node(self):
url = '/{}nodes/{}/comments/'.format(API_BASE, 'abcde')
payload = self._set_up_payload('abcde')
Expand Down
2 changes: 1 addition & 1 deletion framework/mongo/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


def string_required(value):
if value is None or value == '':
if value is None or value.strip() == '':
raise ValidationValueError('Value must not be empty.')
return True

Expand Down
Loading