diff --git a/api/comments/serializers.py b/api/comments/serializers.py index 67d2f9a9c0c..7866d176d53 100644 --- a/api/comments/serializers.py +++ b/api/comments/serializers.py @@ -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, @@ -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': ''}) @@ -41,6 +43,9 @@ 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({}) @@ -48,16 +53,46 @@ class CommentSerializer(JSONAPISerializer): 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): @@ -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): @@ -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 diff --git a/api/comments/views.py b/api/comments/views.py index 47ea34fe337..131d615bb2e 100644 --- a/api/comments/views.py +++ b/api/comments/views.py @@ -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 @@ -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 diff --git a/api/nodes/views.py b/api/nodes/views.py index a56a1e863f5..aa90b60dad9 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -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 diff --git a/api_tests/comments/views/test_comment_detail.py b/api_tests/comments/views/test_comment_detail.py index e9b1af0d10a..b689f5aa137 100644 --- a/api_tests/comments/views/test_comment_detail.py +++ b/api_tests/comments/views/test_comment_detail.py @@ -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): @@ -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) @@ -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) @@ -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': '', + '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) @@ -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) @@ -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']) diff --git a/api_tests/nodes/views/test_node_comments_list.py b/api_tests/nodes/views/test_node_comments_list.py index 8e6e8616ecf..f94ee1e7bc1 100644 --- a/api_tests/nodes/views/test_node_comments_list.py +++ b/api_tests/nodes/views/test_node_comments_list.py @@ -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, @@ -12,6 +13,7 @@ CommentFactory, RetractedRegistrationFactory ) +from website.util.sanitize import strip_html class TestNodeCommentsList(ApiTestCase): @@ -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': 'Cool Comment' + }, + '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') diff --git a/framework/mongo/validators.py b/framework/mongo/validators.py index 0673ad64b6a..7279bd89b9e 100644 --- a/framework/mongo/validators.py +++ b/framework/mongo/validators.py @@ -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 diff --git a/tests/factories.py b/tests/factories.py index e1dec255fb5..054f6ad1703 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -480,10 +480,12 @@ def _build(cls, target_class, *args, **kwargs): node = kwargs.pop('node', None) or NodeFactory() user = kwargs.pop('user', None) or node.creator target = kwargs.pop('target', None) or node + content = kwargs.pop('content', None) or 'Test comment.' instance = target_class( node=node, user=user, target=target, + content=content, *args, **kwargs ) return instance @@ -493,10 +495,12 @@ def _create(cls, target_class, *args, **kwargs): node = kwargs.pop('node', None) or NodeFactory() user = kwargs.pop('user', None) or node.creator target = kwargs.pop('target', None) or node + content = kwargs.pop('content', None) or 'Test comment.' instance = target_class( node=node, user=user, target=target, + content=content, *args, **kwargs ) instance.save() diff --git a/tests/test_models.py b/tests/test_models.py index 90f3a4fd61c..b7a0937bf56 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -30,7 +30,7 @@ from website import filters, language, settings, mailchimp_utils from website.exceptions import NodeStateError from website.profile.utils import serialize_user -from website.project.signals import contributor_added +from website.project.signals import contributor_added, comment_added from website.project.model import ( Comment, Node, NodeLog, Pointer, ensure_schemas, has_anonymous_link, get_pointer_parent, Embargo, MetaSchema, DraftRegistration @@ -4119,6 +4119,7 @@ def test_create(self): node=self.comment.node, target=self.comment.target, is_public=True, + content='This is a comment.' ) assert_equal(comment.user, self.comment.user) assert_equal(comment.node, self.comment.node) @@ -4126,6 +4127,65 @@ def test_create(self): assert_equal(len(comment.node.logs), 2) assert_equal(comment.node.logs[-1].action, NodeLog.COMMENT_ADDED) + def test_create_comment_content_cannot_exceed_max_length(self): + with assert_raises(ValidationValueError): + comment = Comment.create( + auth=self.auth, + user=self.comment.user, + node=self.comment.node, + target=self.comment.target, + is_public=True, + content=''.join(['c' for c in range(settings.COMMENT_MAXLENGTH + 1)]) + ) + + def test_create_comment_content_cannot_be_none(self): + with assert_raises(ValidationError) as error: + comment = Comment.create( + auth=self.auth, + user=self.comment.user, + node=self.comment.node, + target=self.comment.target, + is_public=True, + content=None + ) + assert_equal(error.exception.message, 'Value is required.') + + def test_create_comment_content_cannot_be_empty(self): + with assert_raises(ValidationValueError) as error: + comment = Comment.create( + auth=self.auth, + user=self.comment.user, + node=self.comment.node, + target=self.comment.target, + is_public=True, + content='' + ) + assert_equal(error.exception.message, 'Value must not be empty.') + + def test_create_comment_content_cannot_be_whitespace(self): + with assert_raises(ValidationValueError) as error: + comment = Comment.create( + auth=self.auth, + user=self.comment.user, + node=self.comment.node, + target=self.comment.target, + is_public=True, + content=' ' + ) + assert_equal(error.exception.message, 'Value must not be empty.') + + def test_create_sends_comment_added_signal(self): + with capture_signals() as mock_signals: + comment = Comment.create( + auth=self.auth, + user=self.comment.user, + node=self.comment.node, + target=self.comment.target, + is_public=True, + content='This is a comment.' + ) + assert_equal(mock_signals.signals_sent(), set([comment_added])) + def test_edit(self): self.comment.edit( auth=self.auth, @@ -4231,6 +4291,61 @@ def test_get_content_private_project_throws_permissions_error_for_logged_out_use with assert_raises(PermissionsError): comment.get_content(auth=None) + def test_find_unread_is_zero_when_no_comments(self): + n_unread = Comment.find_unread(user=UserFactory(), node=ProjectFactory()) + assert_equal(n_unread, 0) + + def test_find_unread_new_comments(self): + project = ProjectFactory() + user = UserFactory() + project.add_contributor(user) + project.save() + comment = CommentFactory(node=project, user=project.creator) + n_unread = Comment.find_unread(user=user, node=project) + assert_equal(n_unread, 1) + + def test_find_unread_includes_comment_replies(self): + project = ProjectFactory() + user = UserFactory() + project.add_contributor(user) + project.save() + comment = CommentFactory(node=project, user=user) + reply = CommentFactory(node=project, target=comment, user=project.creator) + n_unread = Comment.find_unread(user=user, node=project) + assert_equal(n_unread, 1) + + # Regression test for https://openscience.atlassian.net/browse/OSF-5193 + def test_find_unread_includes_edited_comments(self): + project = ProjectFactory() + user = AuthUserFactory() + project.add_contributor(user) + project.save() + comment = CommentFactory(node=project, user=project.creator) + + url = project.api_url_for('update_comments_timestamp') + res = self.app.put_json(url, auth=user.auth) + user.reload() + n_unread = Comment.find_unread(user=user, node=project) + assert_equal(n_unread, 0) + + # Edit previously read comment + comment.edit( + auth=Auth(project.creator), + content='edited', + save=True + ) + n_unread = Comment.find_unread(user=user, node=project) + assert_equal(n_unread, 1) + + def test_find_unread_does_not_include_deleted_comments(self): + project = ProjectFactory() + user = AuthUserFactory() + project.add_contributor(user) + project.save() + comment = CommentFactory(node=project, user=project.creator, is_deleted=True) + n_unread = Comment.find_unread(user=user, node=project) + assert_equal(n_unread, 0) + class TestPrivateLink(OsfTestCase): diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 25d02f651d2..3cc19a39f03 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -19,7 +19,7 @@ from website.notifications.model import NotificationSubscription from website.notifications import emails from website.notifications import utils -from website.project.model import Node +from website.project.model import Node, Comment from website import mails from website.util import api_url_for from website.util import web_url_for @@ -1089,21 +1089,18 @@ def test_check_user_comment_reply_subscription_if_email_not_sent_to_target_user( # user is not subscribed to project comment notifications project = factories.ProjectFactory() - # reply to user + # user comments on project target = factories.CommentFactory(node=project, user=user) content = 'hammer to fall' - # auth=project.creator.auth - url = project.api_url + 'comment/' - self.app.post_json( - url, - { - 'content': content, - 'isPublic': 'public', - 'target': target._id - - }, - auth=project.creator.auth + # reply to user (note: notify is called from Comment.create) + reply = Comment.create( + auth=Auth(project.creator), + user=project.creator, + node=project, + content=content, + target=target, + is_public=True, ) assert_true(mock_notify.called) assert_equal(mock_notify.call_count, 2) diff --git a/tests/test_views.py b/tests/test_views.py index cd96ce27d15..f4d7bfe0cd4 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -16,7 +16,6 @@ from modularodm import Q from modularodm.exceptions import ValidationError -from dateutil.parser import parse as parse_date from framework import auth from framework.exceptions import HTTPError @@ -43,7 +42,6 @@ from website import mails, settings from website.util import rubeus from website.project.views.node import _view_project, abbrev_authors, _should_show_wiki_widget -from website.project.views.comment import serialize_comment from website.project.decorators import check_can_access from website.project.signals import contributor_added from website.addons.github.model import AddonGitHubOauthSettings @@ -3542,323 +3540,6 @@ def _configure_project(self, project, comment_level): project.comment_level = comment_level project.save() - def _add_comment(self, project, content=None, **kwargs): - - content = content if content is not None else 'hammer to fall' - url = project.api_url + 'comment/' - return self.app.post_json( - url, - { - 'content': content, - 'isPublic': 'public', - }, - **kwargs - ) - - def test_add_comment_public_contributor(self): - - self._configure_project(self.project, 'public') - res = self._add_comment( - self.project, auth=self.project.creator.auth, - ) - - self.project.reload() - - res_comment = res.json['comment'] - date_created = parse_date(str(res_comment.pop('dateCreated'))) - date_modified = parse_date(str(res_comment.pop('dateModified'))) - - serialized_comment = serialize_comment(self.project.commented[0], self.consolidated_auth) - date_created2 = parse_date(serialized_comment.pop('dateCreated')) - date_modified2 = parse_date(serialized_comment.pop('dateModified')) - - assert_datetime_equal(date_created, date_created2) - assert_datetime_equal(date_modified, date_modified2) - - assert_equal(len(self.project.commented), 1) - assert_equal(res_comment, serialized_comment) - - def test_add_comment_public_non_contributor(self): - - self._configure_project(self.project, 'public') - res = self._add_comment( - self.project, auth=self.non_contributor.auth, - ) - - self.project.reload() - - res_comment = res.json['comment'] - date_created = parse_date(res_comment.pop('dateCreated')) - date_modified = parse_date(res_comment.pop('dateModified')) - - serialized_comment = serialize_comment(self.project.commented[0], Auth(user=self.non_contributor)) - date_created2 = parse_date(serialized_comment.pop('dateCreated')) - date_modified2 = parse_date(serialized_comment.pop('dateModified')) - - assert_datetime_equal(date_created, date_created2) - assert_datetime_equal(date_modified, date_modified2) - - assert_equal(len(self.project.commented), 1) - assert_equal(res_comment, serialized_comment) - - def test_add_comment_private_contributor(self): - - self._configure_project(self.project, 'private') - res = self._add_comment( - self.project, auth=self.project.creator.auth, - ) - - self.project.reload() - - res_comment = res.json['comment'] - date_created = parse_date(str(res_comment.pop('dateCreated'))) - date_modified = parse_date(str(res_comment.pop('dateModified'))) - - serialized_comment = serialize_comment(self.project.commented[0], self.consolidated_auth) - date_created2 = parse_date(serialized_comment.pop('dateCreated')) - date_modified2 = parse_date(serialized_comment.pop('dateModified')) - - assert_datetime_equal(date_created, date_created2) - assert_datetime_equal(date_modified, date_modified2) - - assert_equal(len(self.project.commented), 1) - assert_equal(res_comment, serialized_comment) - - def test_add_comment_private_non_contributor(self): - - self._configure_project(self.project, 'private') - res = self._add_comment( - self.project, auth=self.non_contributor.auth, expect_errors=True, - ) - - assert_equal(res.status_code, http.FORBIDDEN) - - def test_add_comment_logged_out(self): - self._configure_project(self.project, 'public') - res = self._add_comment(self.project) - - assert_equal(res.status_code, 302) - assert_in('login', res.headers.get('location')) - - def test_add_comment_off(self): - - self._configure_project(self.project, None) - res = self._add_comment( - self.project, auth=self.project.creator.auth, expect_errors=True, - ) - - assert_equal(res.status_code, http.BAD_REQUEST) - - def test_add_comment_empty(self): - self._configure_project(self.project, 'public') - res = self._add_comment( - self.project, content='', - auth=self.project.creator.auth, - expect_errors=True, - ) - assert_equal(res.status_code, http.BAD_REQUEST) - assert_false(getattr(self.project, 'commented', [])) - - def test_add_comment_toolong(self): - self._configure_project(self.project, 'public') - res = self._add_comment( - self.project, content='toolong' * 500, - auth=self.project.creator.auth, - expect_errors=True, - ) - assert_equal(res.status_code, http.BAD_REQUEST) - assert_false(getattr(self.project, 'commented', [])) - - def test_add_comment_whitespace(self): - self._configure_project(self.project, 'public') - res = self._add_comment( - self.project, content=' ', - auth=self.project.creator.auth, - expect_errors=True - ) - assert_equal(res.status_code, http.BAD_REQUEST) - assert_false(getattr(self.project, 'commented', [])) - - def test_edit_comment(self): - - self._configure_project(self.project, 'public') - comment = CommentFactory(node=self.project) - - url = self.project.api_url + 'comment/{0}/'.format(comment._id) - res = self.app.put_json( - url, - { - 'content': 'edited', - 'isPublic': 'private', - }, - auth=self.project.creator.auth, - ) - - comment.reload() - - assert_equal(res.json['content'], 'edited') - - assert_equal(comment.content, 'edited') - - def test_edit_comment_short(self): - self._configure_project(self.project, 'public') - comment = CommentFactory(node=self.project, content='short') - url = self.project.api_url + 'comment/{0}/'.format(comment._id) - res = self.app.put_json( - url, - { - 'content': '', - 'isPublic': 'private', - }, - auth=self.project.creator.auth, - expect_errors=True, - ) - comment.reload() - assert_equal(res.status_code, http.BAD_REQUEST) - assert_equal(comment.content, 'short') - - def test_edit_comment_toolong(self): - self._configure_project(self.project, 'public') - comment = CommentFactory(node=self.project, content='short') - url = self.project.api_url + 'comment/{0}/'.format(comment._id) - res = self.app.put_json( - url, - { - 'content': 'toolong' * 500, - 'isPublic': 'private', - }, - auth=self.project.creator.auth, - expect_errors=True, - ) - comment.reload() - assert_equal(res.status_code, http.BAD_REQUEST) - assert_equal(comment.content, 'short') - - def test_edit_comment_non_author(self): - "Contributors who are not the comment author cannot edit." - self._configure_project(self.project, 'public') - comment = CommentFactory(node=self.project) - non_author = AuthUserFactory() - self.project.add_contributor(non_author, auth=self.consolidated_auth) - - url = self.project.api_url + 'comment/{0}/'.format(comment._id) - res = self.app.put_json( - url, - { - 'content': 'edited', - 'isPublic': 'private', - }, - auth=non_author.auth, - expect_errors=True, - ) - - assert_equal(res.status_code, http.FORBIDDEN) - - def test_edit_comment_non_contributor(self): - "Non-contributors who are not the comment author cannot edit." - self._configure_project(self.project, 'public') - comment = CommentFactory(node=self.project) - - url = self.project.api_url + 'comment/{0}/'.format(comment._id) - res = self.app.put_json( - url, - { - 'content': 'edited', - 'isPublic': 'private', - }, - auth=self.non_contributor.auth, - expect_errors=True, - ) - - assert_equal(res.status_code, http.FORBIDDEN) - - def test_delete_comment_author(self): - - self._configure_project(self.project, 'public') - comment = CommentFactory(node=self.project) - - url = self.project.api_url + 'comment/{0}/'.format(comment._id) - self.app.delete_json( - url, - auth=self.project.creator.auth, - ) - - comment.reload() - - assert_true(comment.is_deleted) - - def test_delete_comment_non_author(self): - - self._configure_project(self.project, 'public') - comment = CommentFactory(node=self.project) - - url = self.project.api_url + 'comment/{0}/'.format(comment._id) - res = self.app.delete_json( - url, - auth=self.non_contributor.auth, - expect_errors=True, - ) - - assert_equal(res.status_code, http.FORBIDDEN) - - comment.reload() - - assert_false(comment.is_deleted) - - def test_report_abuse(self): - - self._configure_project(self.project, 'public') - comment = CommentFactory(node=self.project) - reporter = AuthUserFactory() - - url = self.project.api_url + 'comment/{0}/report/'.format(comment._id) - - self.app.post_json( - url, - { - 'category': 'spam', - 'text': 'ads', - }, - auth=reporter.auth, - ) - - comment.reload() - assert_in(reporter._id, comment.reports) - assert_equal( - comment.reports[reporter._id], - {'category': 'spam', 'text': 'ads'} - ) - - def test_can_view_private_comments_if_contributor(self): - - self._configure_project(self.project, 'public') - CommentFactory(node=self.project, user=self.project.creator, is_public=False) - - url = self.project.api_url + 'comments/' - res = self.app.get(url, auth=self.project.creator.auth) - - assert_equal(len(res.json['comments']), 1) - - def test_view_comments_with_anonymous_link(self): - self.project.save() - self.project.set_privacy('private') - self.project.reload() - user = AuthUserFactory() - link = PrivateLinkFactory(anonymous=True) - link.nodes.append(self.project) - link.save() - - CommentFactory(node=self.project, user=self.project.creator, is_public=False) - - url = self.project.api_url + 'comments/' - res = self.app.get(url, {"view_only": link.key}, auth=user.auth) - comment = res.json['comments'][0] - author = comment['author'] - assert_in('A user', author['name']) - assert_false(author['gravatarUrl']) - assert_false(author['url']) - assert_false(author['id']) - def test_discussion_recursive(self): self._configure_project(self.project, 'public') @@ -3926,45 +3607,6 @@ def test_confirm_non_contrib_viewers_dont_have_pid_in_comments_view_timestamp(se self.non_contributor.reload() assert_not_in(self.project._id, self.non_contributor.comments_viewed_timestamp) - def test_n_unread_comments_updates_when_comment_is_added(self): - self._add_comment(self.project, auth=self.project.creator.auth) - self.project.reload() - - url = self.project.api_url_for('list_comments') - res = self.app.get(url, auth=self.user.auth) - assert_equal(res.json.get('nUnread'), 1) - - url = self.project.api_url_for('update_comments_timestamp') - res = self.app.put_json(url, auth=self.user.auth) - self.user.reload() - - url = self.project.api_url_for('list_comments') - res = self.app.get(url, auth=self.user.auth) - assert_equal(res.json.get('nUnread'), 0) - - def test_n_unread_comments_updates_when_comment_reply(self): - comment = CommentFactory(node=self.project, user=self.project.creator) - reply = CommentFactory(node=self.project, user=self.user, target=comment) - self.project.reload() - - url = self.project.api_url_for('list_comments') - res = self.app.get(url, auth=self.project.creator.auth) - assert_equal(res.json.get('nUnread'), 1) - - - def test_n_unread_comments_updates_when_comment_is_edited(self): - self.test_edit_comment() - self.project.reload() - - url = self.project.api_url_for('list_comments') - res = self.app.get(url, auth=self.user.auth) - assert_equal(res.json.get('nUnread'), 1) - - def test_n_unread_comments_is_zero_when_no_comments(self): - url = self.project.api_url_for('list_comments') - res = self.app.get(url, auth=self.project.creator.auth) - assert_equal(res.json.get('nUnread'), 0) - class TestTagViews(OsfTestCase): diff --git a/website/project/model.py b/website/project/model.py index fabd2b5eb88..241d4a4afdf 100644 --- a/website/project/model.py +++ b/website/project/model.py @@ -180,12 +180,13 @@ class Comment(GuidStoredObject): modified = fields.BooleanField(default=False) is_deleted = fields.BooleanField(default=False) - content = fields.StringField() + content = fields.StringField(required=True, + validate=[MaxLengthValidator(settings.COMMENT_MAXLENGTH), validators.string_required]) # Dictionary field mapping user IDs to dictionaries of report details: # { - # 'icpnw': {'category': 'hate', 'message': 'offensive'}, - # 'cdi38': {'category': 'spam', 'message': 'godwins law'}, + # 'icpnw': {'category': 'hate', 'text': 'offensive'}, + # 'cdi38': {'category': 'spam', 'text': 'godwins law'}, # } reports = fields.DictionaryField(validate=validate_comment_reports) @@ -205,10 +206,10 @@ def get_absolute_url(self): def get_content(self, auth): """ Returns the comment content if the user is allowed to see it. Deleted comments can only be viewed by the user who created the comment.""" - if (not auth or auth.user.is_anonymous()) and not self.node.is_public: + if not auth and not self.node.is_public: raise PermissionsError - if self.is_deleted and (((not auth or auth.user.is_anonymous()) and self.node.is_public) + if self.is_deleted and ((not auth or auth.user.is_anonymous()) or (auth and not auth.user.is_anonymous() and self.user._id != auth.user._id)): return None @@ -219,16 +220,22 @@ def find_unread(cls, user, node): default_timestamp = datetime.datetime(1970, 1, 1, 12, 0, 0) n_unread = 0 if node.is_contributor(user): + if user.comments_viewed_timestamp is None: + user.comments_viewed_timestamp = {} + user.save() view_timestamp = user.comments_viewed_timestamp.get(node._id, default_timestamp) n_unread = Comment.find(Q('node', 'eq', node) & Q('user', 'ne', user) & - Q('date_created', 'gt', view_timestamp) & - Q('date_modified', 'gt', view_timestamp)).count() + Q('is_deleted', 'ne', True) & + (Q('date_created', 'gt', view_timestamp) | + Q('date_modified', 'gt', view_timestamp))).count() return n_unread @classmethod def create(cls, auth, **kwargs): comment = cls(**kwargs) + if not comment.node.can_comment(auth): + raise PermissionsError('{0!r} does not have permission to comment on this node'.format(auth.user)) comment.save() comment.node.add_log( @@ -244,10 +251,13 @@ def create(cls, auth, **kwargs): ) comment.node.save() + project_signals.comment_added.send(comment, auth=auth) return comment def edit(self, content, auth, save=False): + if not self.node.can_comment(auth) or self.user._id != auth.user._id: + raise PermissionsError('{0!r} does not have permission to edit this comment'.format(auth.user)) self.content = content self.modified = True self.node.add_log( @@ -265,6 +275,8 @@ def edit(self, content, auth, save=False): self.save() def delete(self, auth, save=False): + if not self.node.can_comment(auth) or self.user._id != auth.user._id: + raise PermissionsError('{0!r} does not have permission to comment on this node'.format(auth.user)) self.is_deleted = True self.node.add_log( NodeLog.COMMENT_REMOVED, @@ -281,6 +293,8 @@ def delete(self, auth, save=False): self.save() def undelete(self, auth, save=False): + if not self.node.can_comment(auth) or self.user._id != auth.user._id: + raise PermissionsError('{0!r} does not have permission to comment on this node'.format(auth.user)) self.is_deleted = False self.node.add_log( NodeLog.COMMENT_ADDED, diff --git a/website/project/signals.py b/website/project/signals.py index 642f2c97227..2b22e12dc96 100644 --- a/website/project/signals.py +++ b/website/project/signals.py @@ -1,6 +1,7 @@ import blinker signals = blinker.Namespace() +comment_added = signals.signal('comment-added') contributor_added = signals.signal('contributor-added') contributor_removed = signals.signal('contributor-removed') unreg_contributor_added = signals.signal('unreg-contributor-added') diff --git a/website/project/views/comment.py b/website/project/views/comment.py index 1bc3335c5db..0e8e1a10c4a 100644 --- a/website/project/views/comment.py +++ b/website/project/views/comment.py @@ -1,35 +1,20 @@ # -*- coding: utf-8 -*- import collections -import httplib as http import pytz -from flask import request -from modularodm import Q - -from framework.exceptions import HTTPError from framework.auth.decorators import must_be_logged_in from framework.auth.utils import privacy_info_handle -from framework.forms.utils import sanitize from website import settings from website.notifications.emails import notify from website.filters import gravatar -from website.models import Guid, Comment +from website.models import Comment from website.project.decorators import must_be_contributor_or_public +from website.project.signals import comment_added from datetime import datetime from website.project.model import has_anonymous_link -def resolve_target(node, guid): - - if not guid: - return node - target = Guid.load(guid) - if target is None: - raise HTTPError(http.BAD_REQUEST) - return target.referent - - def collect_discussion(target, users=None): users = users or collections.defaultdict(list) @@ -72,85 +57,14 @@ def comment_discussion(auth, node, **kwargs): ] } - -def serialize_comment(comment, auth, anonymous=False): - return { - 'id': comment._id, - 'author': { - 'id': privacy_info_handle(comment.user._id, anonymous), - 'url': privacy_info_handle(comment.user.url, anonymous), - 'name': privacy_info_handle( - comment.user.fullname, anonymous, name=True - ), - 'gravatarUrl': privacy_info_handle( - gravatar( - comment.user, use_ssl=True, - size=settings.PROFILE_IMAGE_SMALL - ), - anonymous - ), - }, - 'dateCreated': comment.date_created.isoformat(), - 'dateModified': comment.date_modified.isoformat(), - 'content': comment.content, - 'hasChildren': bool(getattr(comment, 'commented', [])), - 'canEdit': comment.user == auth.user, - 'modified': comment.modified, - 'isDeleted': comment.is_deleted, - 'isAbuse': auth.user and auth.user._id in comment.reports, - } - - -def serialize_comments(record, auth, anonymous=False): - - return [ - serialize_comment(comment, auth, anonymous) - for comment in getattr(record, 'commented', []) - ] - - -def get_comment(cid, auth, owner=False): - comment = Comment.load(cid) - if comment is None: - raise HTTPError(http.NOT_FOUND) - if owner: - if auth.user != comment.user: - raise HTTPError(http.FORBIDDEN) - return comment - - -@must_be_logged_in -@must_be_contributor_or_public -def add_comment(auth, node, **kwargs): - - if not node.comment_level: - raise HTTPError(http.BAD_REQUEST) - - if not node.can_comment(auth): - raise HTTPError(http.FORBIDDEN) - - guid = request.json.get('target') - target = resolve_target(node, guid) - - content = request.json.get('content').strip() - content = sanitize(content) - if not content: - raise HTTPError(http.BAD_REQUEST) - if len(content) > settings.COMMENT_MAXLENGTH: - raise HTTPError(http.BAD_REQUEST) - - comment = Comment.create( - auth=auth, - node=node, - target=target, - user=auth.user, - content=content, - ) - comment.save() +@comment_added.connect +def send_comment_added_notification(comment, auth): + node = comment.node + target = comment.target context = dict( gravatar_url=auth.user.profile_image_url(), - content=content, + content=comment.content, target_user=target.user if is_reply(target) else None, parent_comment=target.content if is_reply(target) else "", url=node.absolute_url @@ -174,134 +88,16 @@ def add_comment(auth, node, **kwargs): **context ) - return { - 'comment': serialize_comment(comment, auth) - }, http.CREATED - def is_reply(target): return isinstance(target, Comment) - -@must_be_contributor_or_public -def list_comments(auth, node, **kwargs): - anonymous = has_anonymous_link(node, auth) - guid = request.args.get('target') - target = resolve_target(node, guid) - serialized_comments = serialize_comments(target, auth, anonymous) - n_unread = 0 - - if node.is_contributor(auth.user): - if auth.user.comments_viewed_timestamp is None: - auth.user.comments_viewed_timestamp = {} - auth.user.save() - n_unread = n_unread_comments(target, auth.user) - return { - 'comments': serialized_comments, - 'nUnread': n_unread - } - - -def n_unread_comments(node, user): - """Return the number of unread comments on a node for a user.""" - default_timestamp = datetime(1970, 1, 1, 12, 0, 0) - view_timestamp = user.comments_viewed_timestamp.get(node._id, default_timestamp) - return Comment.find(Q('node', 'eq', node) & - Q('user', 'ne', user) & - Q('date_created', 'gt', view_timestamp) & - Q('date_modified', 'gt', view_timestamp)).count() - - -@must_be_logged_in -@must_be_contributor_or_public -def edit_comment(auth, **kwargs): - - cid = kwargs.get('cid') - comment = get_comment(cid, auth, owner=True) - - content = request.json.get('content').strip() - content = sanitize(content) - if not content: - raise HTTPError(http.BAD_REQUEST) - if len(content) > settings.COMMENT_MAXLENGTH: - raise HTTPError(http.BAD_REQUEST) - - comment.edit( - content=content, - auth=auth, - save=True - ) - - return serialize_comment(comment, auth) - - -@must_be_logged_in -@must_be_contributor_or_public -def delete_comment(auth, **kwargs): - - cid = kwargs.get('cid') - comment = get_comment(cid, auth, owner=True) - comment.delete(auth=auth, save=True) - - return {} - - -@must_be_logged_in -@must_be_contributor_or_public -def undelete_comment(auth, **kwargs): - - cid = kwargs.get('cid') - comment = get_comment(cid, auth, owner=True) - comment.undelete(auth=auth, save=True) - - return {} - - @must_be_logged_in @must_be_contributor_or_public def update_comments_timestamp(auth, node, **kwargs): if node.is_contributor(auth.user): auth.user.comments_viewed_timestamp[node._id] = datetime.utcnow() auth.user.save() - list_comments(**kwargs) return {node._id: auth.user.comments_viewed_timestamp[node._id].isoformat()} else: return {} - - -@must_be_logged_in -@must_be_contributor_or_public -def report_abuse(auth, **kwargs): - - user = auth.user - - cid = kwargs.get('cid') - comment = get_comment(cid, auth) - - category = request.json.get('category') - text = request.json.get('text', '') - if not category: - raise HTTPError(http.BAD_REQUEST) - - try: - comment.report_abuse(user, save=True, category=category, text=text) - except ValueError: - raise HTTPError(http.BAD_REQUEST) - - return {} - - -@must_be_logged_in -@must_be_contributor_or_public -def unreport_abuse(auth, **kwargs): - user = auth.user - - cid = kwargs.get('cid') - comment = get_comment(cid, auth) - - try: - comment.unreport_abuse(user, save=True) - except ValueError: - raise HTTPError(http.BAD_REQUEST) - - return {} diff --git a/website/routes.py b/website/routes.py index 5a70da9fd85..9a6a49298d1 100644 --- a/website/routes.py +++ b/website/routes.py @@ -316,16 +316,6 @@ def make_url_map(app): process_rules(app, [ - Rule( - [ - '/project//comments/', - '/project//node//comments/', - ], - 'get', - project_views.comment.list_comments, - json_renderer, - ), - Rule( [ '/project//comments/discussion/', @@ -336,46 +326,6 @@ def make_url_map(app): json_renderer, ), - Rule( - [ - '/project//comment/', - '/project//node//comment/', - ], - 'post', - project_views.comment.add_comment, - json_renderer, - ), - - Rule( - [ - '/project//comment//', - '/project//node//comment//', - ], - 'put', - project_views.comment.edit_comment, - json_renderer, - ), - - Rule( - [ - '/project//comment//', - '/project//node//comment//', - ], - 'delete', - project_views.comment.delete_comment, - json_renderer, - ), - - Rule( - [ - '/project//comment//undelete/', - '/project//node//comment//undelete/', - ], - 'put', - project_views.comment.undelete_comment, - json_renderer, - ), - Rule( [ '/project//comments/timestamps/', @@ -386,26 +336,6 @@ def make_url_map(app): json_renderer, ), - Rule( - [ - '/project//comment//report/', - '/project//node//comment//report/', - ], - 'post', - project_views.comment.report_abuse, - json_renderer, - ), - - Rule( - [ - '/project//comment//unreport/', - '/project//node//comment//unreport/', - ], - 'post', - project_views.comment.unreport_abuse, - json_renderer, - ), - Rule( [ '/project//citation/', diff --git a/website/signals.py b/website/signals.py index 3584dc6a162..d13deb431b7 100644 --- a/website/signals.py +++ b/website/signals.py @@ -8,6 +8,7 @@ ALL_SIGNALS = [ auth.contributor_removed, auth.node_deleted, + project.comment_added, project.unreg_contributor_added, project.contributor_added, project.privacy_set_public, diff --git a/website/static/css/commentpane.css b/website/static/css/commentpane.css index 6d82aae7031..c15a63a90a7 100644 --- a/website/static/css/commentpane.css +++ b/website/static/css/commentpane.css @@ -53,4 +53,8 @@ color: #FFF; position: absolute; margin-left: 40px; +} + +.comment-gravatar { + height: 20px; } \ No newline at end of file diff --git a/website/static/js/comment.js b/website/static/js/comment.js index a9795fa1710..eb2f9e433f1 100644 --- a/website/static/js/comment.js +++ b/website/static/js/comment.js @@ -11,7 +11,6 @@ var koHelpers = require('./koHelpers'); require('knockout.punches'); require('jquery-autosize'); ko.punches.enableAll(); -var Raven = require('raven-js'); var osfHelpers = require('js/osfHelpers'); var CommentPane = require('js/commentpane'); @@ -138,23 +137,51 @@ BaseComment.prototype.fetch = function() { if (self._loaded) { deferred.resolve(self.comments()); } - $.getJSON( - nodeApiUrl + 'comments/', - {target: self.id()}, - function(response) { - self.comments( - ko.utils.arrayMap(response.comments.reverse(), function(comment) { - return new CommentModel(comment, self, self.$root); - }) - ); - self.unreadComments(response.nUnread); - deferred.resolve(self.comments()); - self._loaded = true; + var hasPrivateLink = false; + + var query = 'embed=user'; + var urlParams = osfHelpers.urlParams(); + if (urlParams.view_only) { + hasPrivateLink = true; + query = 'view_only=' + urlParams.view_only; + } + var url = osfHelpers.apiV2Url('nodes/' + window.contextVars.node.id + '/comments/', {query: query}); + if (self.id() !== undefined) { + url = osfHelpers.apiV2Url('comments/' + self.id() + '/replies/', {query: query}); + } + + var request = osfHelpers.ajaxJSON( + 'GET', + url, + {'isCors': true}); + request.done(function(response) { + self.comments( + ko.utils.arrayMap(response.data, function(comment) { + return new CommentModel(comment, self, self.$root); + }) + ); + if (!hasPrivateLink) { + self.setUnreadCommentCount(); } - ); - return deferred; + deferred.resolve(self.comments()); + self._loaded = true; + }); + return deferred.promise(); +}; + +BaseComment.prototype.setUnreadCommentCount = function() { + var self = this; + var request = osfHelpers.ajaxJSON( + 'GET', + osfHelpers.apiV2Url('nodes/' + window.contextVars.node.id + '/', {query: 'related_counts=True'}), + {'isCors': true}); + request.done(function(response) { + self.unreadComments(response.data.relationships.comments.links.related.meta.unread); + }); + return request; }; + BaseComment.prototype.submitReply = function() { var self = this; if (!self.replyContent()) { @@ -166,16 +193,33 @@ BaseComment.prototype.submitReply = function() { return; } self.submittingReply(true); - osfHelpers.postJSON( - nodeApiUrl + 'comment/', + var url = osfHelpers.apiV2Url('nodes/' + window.contextVars.node.id + '/comments/', {}); + var request = osfHelpers.ajaxJSON( + 'POST', + url, { - target: self.id(), - content: self.replyContent(), - } - ).done(function(response) { + 'isCors': true, + 'data': { + 'data': { + 'type': 'comments', + 'attributes': { + 'content': self.replyContent() + }, + 'relationships': { + 'target': { + 'data': { + 'type': self.id() === undefined ? 'nodes' : 'comments', + 'id': self.id() === undefined ? window.contextVars.node.id : self.id() + } + } + } + } + } + }); + request.done(function(response) { self.cancelReply(); self.replyContent(null); - self.comments.unshift(new CommentModel(response.comment, self, self.$root)); + self.comments.unshift(new CommentModel(response.data, self, self.$root)); if (!self.hasChildren()) { self.hasChildren(true); } @@ -187,9 +231,15 @@ BaseComment.prototype.submitReply = function() { self.$root.commented(true); } self.onSubmitSuccess(response); - }).fail(function() { + }); + request.fail(function(xhr, status, error) { self.cancelReply(); self.errorMessage('Could not submit comment'); + Raven.captureMessage('Error creating comment', { + url: url, + status: status, + error: error + }); }); }; @@ -202,13 +252,38 @@ var CommentModel = function(data, $parent, $root) { self.$parent = $parent; self.$root = $root; - // Note: assigns observables: canEdit, content, dateCreated, dateModified - // hasChildren, id, isAbuse, isDeleted. Leaves out author. - $.extend(self, koHelpers.mapJStoKO(data, {exclude: ['author']})); + self.id = ko.observable(data.id); + self.content = ko.observable(data.attributes.content || ''); + self.dateCreated = ko.observable(data.attributes.date_created); + self.dateModified = ko.observable(data.attributes.date_modified); + self.isDeleted = ko.observable(data.attributes.deleted); + self.modified = ko.observable(data.attributes.modified); + self.isAbuse = ko.observable(data.attributes.is_abuse); + self.canEdit = ko.observable(data.attributes.can_edit); + self.hasChildren = ko.observable(data.attributes.has_children); + + if ('embeds' in data && 'user' in data.embeds) { + var userData = data.embeds.user.data; + self.author = { + 'id': userData.id, + 'url': userData.links.html, + 'name': userData.attributes.full_name, + 'gravatarUrl': userData.links.profile_image + }; + } else if (osfHelpers.urlParams().view_only) { + self.author = { + 'id': null, + 'url': '', + 'name': 'A User', + 'gravatarUrl': '' + }; + } else { + self.author = self.$root.author; + } self.contentDisplay = ko.observable(markdown.full.render(self.content())); - // Update contentDisplay with rednered markdown whenever content changes + // Update contentDisplay with rendered markdown whenever content changes self.content.subscribe(function(newContent) { self.contentDisplay(markdown.full.render(newContent)); }); @@ -228,7 +303,7 @@ var CommentModel = function(data, $parent, $root) { self.undeleting = ko.observable(false); self.abuseCategory = ko.observable('spam'); - self.abuseText = ko.observable(); + self.abuseText = ko.observable(''); self.editing = ko.observable(false); @@ -289,22 +364,43 @@ CommentModel.prototype.submitEdit = function(data, event) { self.errorMessage('Please enter a comment'); return; } - osfHelpers.putJSON( - nodeApiUrl + 'comment/' + self.id() + '/', - {content: self.content()} - ).done(function(response) { - self.content(response.content); - self.dateModified(response.dateModified); + var url = osfHelpers.apiV2Url('comments/' + self.id() + '/', {}); + var request = osfHelpers.ajaxJSON( + 'PUT', + url, + { + 'isCors': true, + 'data': { + 'data': { + 'id': self.id(), + 'type': 'comments', + 'attributes': { + 'content': self.content(), + 'deleted': false + } + } + } + }); + request.done(function(response) { + self.content(response.data.attributes.content); + self.dateModified(response.data.attributes.date_modified); self.editing(false); self.modified(true); self.editErrorMessage(''); self.$root.editors -= 1; // Refresh tooltip on date modified, if present $tips.tooltip('destroy').tooltip(); - }).fail(function() { + }); + request.fail(function(xhr, status, error) { self.cancelEdit(); self.errorMessage('Could not submit comment'); + Raven.captureMessage('Error editing comment', { + url: url, + status: status, + error: error + }); }); + return request; }; CommentModel.prototype.reportAbuse = function() { @@ -319,17 +415,34 @@ CommentModel.prototype.cancelAbuse = function() { CommentModel.prototype.submitAbuse = function() { var self = this; - osfHelpers.postJSON( - nodeApiUrl + 'comment/' + self.id() + '/report/', + var url = osfHelpers.apiV2Url('comments/' + self.id() + '/reports/', {}); + var request = osfHelpers.ajaxJSON( + 'POST', + url, { - category: self.abuseCategory(), - text: self.abuseText() - } - ).done(function() { + 'isCors': true, + 'data': { + 'data': { + 'type': 'comment_reports', + 'attributes': { + 'category': self.abuseCategory(), + 'message': self.abuseText() + } + } + } + }); + request.done(function() { self.isAbuse(true); - }).fail(function() { + }); + request.fail(function(xhr, status, error) { self.errorMessage('Could not report abuse.'); + Raven.captureMessage('Error reporting abuse', { + url: url, + status: status, + error: error + }); }); + return request; }; CommentModel.prototype.startDelete = function() { @@ -338,15 +451,35 @@ CommentModel.prototype.startDelete = function() { CommentModel.prototype.submitDelete = function() { var self = this; - $.ajax({ - type: 'DELETE', - url: nodeApiUrl + 'comment/' + self.id() + '/', - }).done(function() { + var url = osfHelpers.apiV2Url('comments/' + self.id() + '/', {}); + var request = osfHelpers.ajaxJSON( + 'PATCH', + url, + { + 'isCors': true, + 'data': { + 'data': { + 'id': self.id(), + 'type': 'comments', + 'attributes': { + 'deleted': true + } + } + } + }); + request.done(function() { self.isDeleted(true); self.deleting(false); - }).fail(function() { + }); + request.fail(function(xhr, status, error) { self.deleting(false); + Raven.captureMessage('Error deleting comment', { + url: url, + status: status, + error: error + }); }); + return request; }; CommentModel.prototype.cancelDelete = function() { @@ -359,14 +492,35 @@ CommentModel.prototype.startUndelete = function() { CommentModel.prototype.submitUndelete = function() { var self = this; - osfHelpers.putJSON( - nodeApiUrl + 'comment/' + self.id() + '/undelete/', - {} - ).done(function() { + var url = osfHelpers.apiV2Url('comments/' + self.id() + '/', {}); + var request = osfHelpers.ajaxJSON( + 'PATCH', + url, + { + 'isCors': true, + 'data': { + 'data': { + 'id': self.id(), + 'type': 'comments', + 'attributes': { + 'deleted': false + } + } + } + }); + request.done(function() { self.isDeleted(false); - }).fail(function() { + }); + request.fail(function(xhr, status, error) { self.undeleting(false); + Raven.captureMessage('Error undeleting comment', { + url: url, + status: status, + error: error + }); + }); + return request; }; CommentModel.prototype.cancelUndelete = function() { @@ -379,14 +533,25 @@ CommentModel.prototype.startUnreportAbuse = function() { CommentModel.prototype.submitUnreportAbuse = function() { var self = this; - osfHelpers.postJSON( - nodeApiUrl + 'comment/' + self.id() + '/unreport/', - {} - ).done(function() { + var url = osfHelpers.apiV2Url('comments/' + self.id() + '/reports/' + window.contextVars.currentUser.id + '/', {}); + var request = osfHelpers.ajaxJSON( + 'DELETE', + url, + {'isCors': true} + ); + request.done(function() { self.isAbuse(false); - }).fail(function() { + }); + request.fail(function(xhr, status, error) { self.unreporting(false); + Raven.captureMessage('Error unreporting comment', { + url: url, + status: status, + error: error + }); + }); + return request; }; CommentModel.prototype.cancelUnreportAbuse = function() { @@ -406,7 +571,7 @@ CommentModel.prototype.onSubmitSuccess = function() { /* * */ -var CommentListModel = function(userName, canComment, hasChildren) { +var CommentListModel = function(canComment, hasChildren, currentUser) { BaseComment.prototype.constructor.call(this); @@ -417,11 +582,10 @@ var CommentListModel = function(userName, canComment, hasChildren) { self.editors = 0; self.commented = ko.observable(false); - self.userName = ko.observable(userName); self.canComment = ko.observable(canComment); self.hasChildren = ko.observable(hasChildren); self.discussion = ko.observableArray(); - + self.author = currentUser; self.fetch(); self.fetchDiscussion(); @@ -463,9 +627,9 @@ var onOpen = function() { }); }; -var init = function(selector, userName, canComment, hasChildren) { +var init = function(selector, canComment, hasChildren, currentUser) { new CommentPane(selector, {onOpen: onOpen}); - var viewModel = new CommentListModel(userName, canComment, hasChildren); + var viewModel = new CommentListModel(canComment, hasChildren, currentUser); var $elm = $(selector); if (!$elm.length) { throw('No results found for selector'); diff --git a/website/static/js/pages/project-dashboard-page.js b/website/static/js/pages/project-dashboard-page.js index 76cc88d5c73..7c9fa660dc6 100644 --- a/website/static/js/pages/project-dashboard-page.js +++ b/website/static/js/pages/project-dashboard-page.js @@ -48,10 +48,15 @@ $('body').on('nodeLoad', function(event, data) { // Initialize comment pane w/ its viewmodel var $comments = $('#comments'); if ($comments.length) { - var userName = window.contextVars.currentUser.name; var canComment = window.contextVars.currentUser.canComment; var hasChildren = window.contextVars.node.hasChildren; - Comment.init('#commentPane', userName, canComment, hasChildren); + var currentUser = { + id: ctx.currentUser.id, + url: ctx.currentUser.urls.profile, + name: ctx.currentUser.fullname, + gravatarUrl: ctx.currentUser.gravatarUrl + }; + Comment.init('#commentPane', canComment, hasChildren, currentUser); } $(document).ready(function () { diff --git a/website/templates/include/comment_template.mako b/website/templates/include/comment_template.mako index 10087bf74a7..121930ffaf1 100644 --- a/website/templates/include/comment_template.mako +++ b/website/templates/include/comment_template.mako @@ -80,7 +80,7 @@
- + diff --git a/website/templates/project/project.mako b/website/templates/project/project.mako index 2a4960b88c1..9029fec43ae 100644 --- a/website/templates/project/project.mako +++ b/website/templates/project/project.mako @@ -407,7 +407,6 @@ ${parent.javascript_bottom()} // Hack to allow mako variables to be accessed to JS modules window.contextVars = $.extend(true, {}, window.contextVars, { currentUser: { - name: ${ user_full_name | sjson, n }, canComment: ${ user['can_comment'] | sjson, n }, canEdit: ${ user['can_edit'] | sjson, n } }, diff --git a/website/templates/project/project_base.mako b/website/templates/project/project_base.mako index 827315f32d6..36010ac6984 100644 --- a/website/templates/project/project_base.mako +++ b/website/templates/project/project_base.mako @@ -71,10 +71,14 @@ ## TODO: Abstract me username: ${ user['username'] | sjson, n }, id: ${ user_id | sjson, n }, - urls: {api: userApiUrl}, + urls: { + api: userApiUrl, + profile: ${user_url | sjson, n} + }, isContributor: ${ user.get('is_contributor', False) | sjson, n }, fullname: ${ user['fullname'] | sjson, n }, - isAdmin: ${ user.get('is_admin', False) | sjson, n} + isAdmin: ${ user.get('is_admin', False) | sjson, n}, + gravatarUrl: ${user_gravatar | sjson, n} }, node: { ## TODO: Abstract me