Commit d6cd09be by Greg Price

Merge pull request #8217 from edx/gprice/discussion-api-create-comment

Add comment creation to discussion API
parents ef8f3918 8fbfa239
...@@ -15,6 +15,8 @@ from discussion_api.pagination import get_paginated_data ...@@ -15,6 +15,8 @@ from discussion_api.pagination import get_paginated_data
from discussion_api.serializers import CommentSerializer, ThreadSerializer, get_context from discussion_api.serializers import CommentSerializer, ThreadSerializer, get_context
from django_comment_client.base.views import ( from django_comment_client.base.views import (
THREAD_CREATED_EVENT_NAME, THREAD_CREATED_EVENT_NAME,
get_comment_created_event_data,
get_comment_created_event_name,
get_thread_created_event_data, get_thread_created_event_data,
track_forum_event, track_forum_event,
) )
...@@ -264,4 +266,49 @@ def create_thread(request, thread_data): ...@@ -264,4 +266,49 @@ def create_thread(request, thread_data):
get_thread_created_event_data(thread, followed=following) get_thread_created_event_data(thread, followed=following)
) )
return ret
def create_comment(request, comment_data):
"""
Create a comment.
Parameters:
request: The django request object used for build_absolute_uri and
determining the requesting user.
comment_data: The data for the created comment.
Returns:
The created comment; see discussion_api.views.CommentViewSet for more
detail.
"""
thread_id = comment_data.get("thread_id")
if not thread_id:
raise ValidationError({"thread_id": ["This field is required."]})
try:
thread = Thread(id=thread_id).retrieve(mark_as_read=False)
course_key = CourseLocator.from_string(thread["course_id"])
course = _get_course_or_404(course_key, request.user)
except (Http404, CommentClientRequestError):
raise ValidationError({"thread_id": ["Invalid value."]})
parent_id = comment_data.get("parent_id")
context = get_context(course, request, thread, parent_id)
serializer = CommentSerializer(data=comment_data, context=context)
if not serializer.is_valid():
raise ValidationError(serializer.errors)
serializer.save()
comment = serializer.object
track_forum_event(
request,
get_comment_created_event_name(comment),
course,
comment,
get_comment_created_event_data(comment, thread["commentable_id"], followed=False)
)
return serializer.data return serializer.data
...@@ -5,6 +5,7 @@ from urllib import urlencode ...@@ -5,6 +5,7 @@ from urllib import urlencode
from urlparse import urlunparse from urlparse import urlunparse
from django.contrib.auth.models import User as DjangoUser from django.contrib.auth.models import User as DjangoUser
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from rest_framework import serializers from rest_framework import serializers
...@@ -15,12 +16,14 @@ from django_comment_common.models import ( ...@@ -15,12 +16,14 @@ from django_comment_common.models import (
FORUM_ROLE_MODERATOR, FORUM_ROLE_MODERATOR,
Role, Role,
) )
from lms.lib.comment_client.comment import Comment
from lms.lib.comment_client.thread import Thread from lms.lib.comment_client.thread import Thread
from lms.lib.comment_client.user import User as CommentClientUser from lms.lib.comment_client.user import User as CommentClientUser
from lms.lib.comment_client.utils import CommentClientRequestError
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_names from openedx.core.djangoapps.course_groups.cohorts import get_cohort_names
def get_context(course, request, thread=None): def get_context(course, request, thread=None, parent_id=None):
""" """
Returns a context appropriate for use with ThreadSerializer or Returns a context appropriate for use with ThreadSerializer or
(if thread is provided) CommentSerializer. (if thread is provided) CommentSerializer.
...@@ -49,6 +52,7 @@ def get_context(course, request, thread=None): ...@@ -49,6 +52,7 @@ def get_context(course, request, thread=None):
"ta_user_ids": ta_user_ids, "ta_user_ids": ta_user_ids,
"cc_requester": CommentClientUser.from_django_user(requester).retrieve(), "cc_requester": CommentClientUser.from_django_user(requester).retrieve(),
"thread": thread, "thread": thread,
"parent_id": parent_id,
} }
...@@ -204,13 +208,16 @@ class CommentSerializer(_ContentSerializer): ...@@ -204,13 +208,16 @@ class CommentSerializer(_ContentSerializer):
""" """
A serializer for comment data. A serializer for comment data.
Because it is not a field in the underlying data, parent_id must be provided
in the context for both serialization and deserialization.
N.B. This should not be used with a comment_client Comment object that has N.B. This should not be used with a comment_client Comment object that has
not had retrieve() called, because of the interaction between DRF's attempts not had retrieve() called, because of the interaction between DRF's attempts
at introspection and Comment's __getattr__. at introspection and Comment's __getattr__.
""" """
thread_id = serializers.CharField() thread_id = serializers.CharField()
parent_id = serializers.SerializerMethodField("get_parent_id") parent_id = serializers.SerializerMethodField("get_parent_id")
endorsed = serializers.BooleanField() endorsed = serializers.BooleanField(read_only=True)
endorsed_by = serializers.SerializerMethodField("get_endorsed_by") endorsed_by = serializers.SerializerMethodField("get_endorsed_by")
endorsed_by_label = serializers.SerializerMethodField("get_endorsed_by_label") endorsed_by_label = serializers.SerializerMethodField("get_endorsed_by_label")
endorsed_at = serializers.SerializerMethodField("get_endorsed_at") endorsed_at = serializers.SerializerMethodField("get_endorsed_at")
...@@ -218,7 +225,7 @@ class CommentSerializer(_ContentSerializer): ...@@ -218,7 +225,7 @@ class CommentSerializer(_ContentSerializer):
def get_parent_id(self, _obj): def get_parent_id(self, _obj):
"""Returns the comment's parent's id (taken from the context).""" """Returns the comment's parent's id (taken from the context)."""
return self.context.get("parent_id") return self.context["parent_id"]
def get_endorsed_by(self, obj): def get_endorsed_by(self, obj):
""" """
...@@ -257,4 +264,35 @@ class CommentSerializer(_ContentSerializer): ...@@ -257,4 +264,35 @@ class CommentSerializer(_ContentSerializer):
"""Returns the list of the comment's children, serialized.""" """Returns the list of the comment's children, serialized."""
child_context = dict(self.context) child_context = dict(self.context)
child_context["parent_id"] = obj["id"] child_context["parent_id"] = obj["id"]
return [CommentSerializer(child, context=child_context).data for child in obj["children"]] return [
CommentSerializer(child, context=child_context).data
for child in obj.get("children", [])
]
def validate(self, attrs):
"""
Ensure that parent_id identifies a comment that is actually in the
thread identified by thread_id.
"""
parent_id = self.context["parent_id"]
if parent_id:
parent = None
try:
parent = Comment(id=parent_id).retrieve()
except CommentClientRequestError:
pass
if not (parent and parent["thread_id"] == attrs["thread_id"]):
raise ValidationError(
"parent_id does not identify a comment in the thread identified by thread_id."
)
return attrs
def restore_object(self, attrs, instance=None):
if instance: # pragma: no cover
raise ValueError("CommentSerializer cannot be used for updates.")
return Comment(
course_id=self.context["thread"]["course_id"],
parent_id=self.context["parent_id"],
user_id=self.context["cc_requester"]["id"],
**attrs
)
...@@ -17,7 +17,13 @@ from django.test.client import RequestFactory ...@@ -17,7 +17,13 @@ from django.test.client import RequestFactory
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
from courseware.tests.factories import BetaTesterFactory, StaffFactory from courseware.tests.factories import BetaTesterFactory, StaffFactory
from discussion_api.api import create_thread, get_comment_list, get_course_topics, get_thread_list from discussion_api.api import (
create_comment,
create_thread,
get_comment_list,
get_course_topics,
get_thread_list,
)
from discussion_api.tests.utils import ( from discussion_api.tests.utils import (
CommentsServiceMockMixin, CommentsServiceMockMixin,
make_minimal_cs_comment, make_minimal_cs_comment,
...@@ -1116,3 +1122,144 @@ class CreateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC ...@@ -1116,3 +1122,144 @@ class CreateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC
data["type"] = "invalid_type" data["type"] = "invalid_type"
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
create_thread(self.request, data) create_thread(self.request, data)
@ddt.ddt
class CreateCommentTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase):
"""Tests for create_comment"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super(CreateCommentTest, self).setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.disable)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
self.course = CourseFactory.create()
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.register_get_thread_response(
make_minimal_cs_thread({
"id": "test_thread",
"course_id": unicode(self.course.id),
"commentable_id": "test_topic",
})
)
self.minimal_data = {
"thread_id": "test_thread",
"raw_body": "Test body",
}
@ddt.data(None, "test_parent")
@mock.patch("eventtracking.tracker.emit")
def test_success(self, parent_id, mock_emit):
if parent_id:
self.register_get_comment_response({"id": parent_id, "thread_id": "test_thread"})
self.register_post_comment_response(
{
"id": "test_comment",
"thread_id": "test_thread",
"username": self.user.username,
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
},
thread_id=(None if parent_id else "test_thread"),
parent_id=parent_id
)
data = self.minimal_data.copy()
if parent_id:
data["parent_id"] = parent_id
actual = create_comment(self.request, data)
expected = {
"id": "test_comment",
"thread_id": "test_thread",
"parent_id": parent_id,
"author": self.user.username,
"author_label": None,
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
"raw_body": "Test body",
"endorsed": False,
"endorsed_by": None,
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"voted": False,
"vote_count": 0,
"children": [],
}
self.assertEqual(actual, expected)
expected_url = (
"/api/v1/comments/{}".format(parent_id) if parent_id else
"/api/v1/threads/test_thread/comments"
)
self.assertEqual(
urlparse(httpretty.last_request().path).path,
expected_url
)
self.assertEqual(
httpretty.last_request().parsed_body,
{
"course_id": [unicode(self.course.id)],
"body": ["Test body"],
"user_id": [str(self.user.id)]
}
)
expected_event_name = (
"edx.forum.comment.created" if parent_id else
"edx.forum.response.created"
)
expected_event_data = {
"discussion": {"id": "test_thread"},
"commentable_id": "test_topic",
"options": {"followed": False},
"id": "test_comment",
"truncated": False,
"body": "Test body",
"url": "",
"user_forums_roles": [FORUM_ROLE_STUDENT],
"user_course_roles": [],
}
if parent_id:
expected_event_data["response"] = {"id": parent_id}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
def test_thread_id_missing(self):
with self.assertRaises(ValidationError) as assertion:
create_comment(self.request, {})
self.assertEqual(assertion.exception.message_dict, {"thread_id": ["This field is required."]})
def test_thread_id_not_found(self):
self.register_get_thread_error_response("test_thread", 404)
with self.assertRaises(ValidationError) as assertion:
create_comment(self.request, self.minimal_data)
self.assertEqual(assertion.exception.message_dict, {"thread_id": ["Invalid value."]})
def test_nonexistent_course(self):
self.register_get_thread_response(
make_minimal_cs_thread({"id": "test_thread", "course_id": "non/existent/course"})
)
with self.assertRaises(ValidationError) as assertion:
create_comment(self.request, self.minimal_data)
self.assertEqual(assertion.exception.message_dict, {"thread_id": ["Invalid value."]})
def test_not_enrolled(self):
self.request.user = UserFactory.create()
with self.assertRaises(ValidationError) as assertion:
create_comment(self.request, self.minimal_data)
self.assertEqual(assertion.exception.message_dict, {"thread_id": ["Invalid value."]})
def test_discussions_disabled(self):
_remove_discussion_tab(self.course, self.user.id)
with self.assertRaises(ValidationError) as assertion:
create_comment(self.request, self.minimal_data)
self.assertEqual(assertion.exception.message_dict, {"thread_id": ["Invalid value."]})
def test_invalid_field(self):
data = self.minimal_data.copy()
del data["raw_body"]
with self.assertRaises(ValidationError):
create_comment(self.request, data)
...@@ -440,3 +440,106 @@ class ThreadSerializerDeserializationTest(CommentsServiceMockMixin, UrlResetMixi ...@@ -440,3 +440,106 @@ class ThreadSerializerDeserializationTest(CommentsServiceMockMixin, UrlResetMixi
data["type"] = "invalid_type" data["type"] = "invalid_type"
serializer = ThreadSerializer(data=data) serializer = ThreadSerializer(data=data)
self.assertFalse(serializer.is_valid()) self.assertFalse(serializer.is_valid())
@ddt.ddt
class CommentSerializerDeserializationTest(CommentsServiceMockMixin, ModuleStoreTestCase):
"""Tests for ThreadSerializer deserialization."""
def setUp(self):
super(CommentSerializerDeserializationTest, self).setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.disable)
self.course = CourseFactory.create()
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
self.minimal_data = {
"thread_id": "test_thread",
"raw_body": "Test body",
}
def save_and_reserialize(self, data, parent_id=None):
"""
Create a serializer with the given data, ensure that it is valid, save
the result, and return the full comment data from the serializer.
"""
context = get_context(
self.course,
self.request,
make_minimal_cs_thread({"course_id": unicode(self.course.id)}),
parent_id
)
serializer = CommentSerializer(data=data, context=context)
self.assertTrue(serializer.is_valid())
serializer.save()
return serializer.data
@ddt.data(None, "test_parent")
def test_success(self, parent_id):
if parent_id:
self.register_get_comment_response({"thread_id": "test_thread", "id": parent_id})
self.register_post_comment_response(
{"id": "test_comment"},
thread_id=(None if parent_id else "test_thread"),
parent_id=parent_id
)
saved = self.save_and_reserialize(self.minimal_data, parent_id=parent_id)
expected_url = (
"/api/v1/comments/{}".format(parent_id) if parent_id else
"/api/v1/threads/test_thread/comments"
)
self.assertEqual(urlparse(httpretty.last_request().path).path, expected_url)
self.assertEqual(
httpretty.last_request().parsed_body,
{
"course_id": [unicode(self.course.id)],
"body": ["Test body"],
"user_id": [str(self.user.id)],
}
)
self.assertEqual(saved["id"], "test_comment")
self.assertEqual(saved["parent_id"], parent_id)
def test_parent_id_nonexistent(self):
self.register_get_comment_error_response("bad_parent", 404)
context = get_context(self.course, self.request, make_minimal_cs_thread(), "bad_parent")
serializer = CommentSerializer(data=self.minimal_data, context=context)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors,
{
"non_field_errors": [
"parent_id does not identify a comment in the thread identified by thread_id."
]
}
)
def test_parent_id_wrong_thread(self):
self.register_get_comment_response({"thread_id": "different_thread", "id": "test_parent"})
context = get_context(self.course, self.request, make_minimal_cs_thread(), "test_parent")
serializer = CommentSerializer(data=self.minimal_data, context=context)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors,
{
"non_field_errors": [
"parent_id does not identify a comment in the thread identified by thread_id."
]
}
)
def test_missing_field(self):
for field in self.minimal_data:
data = self.minimal_data.copy()
data.pop(field)
serializer = CommentSerializer(
data=data,
context=get_context(self.course, self.request, make_minimal_cs_thread())
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors,
{field: ["This field is required."]}
)
...@@ -3,6 +3,7 @@ Tests for Discussion API views ...@@ -3,6 +3,7 @@ Tests for Discussion API views
""" """
from datetime import datetime from datetime import datetime
import json import json
from urlparse import urlparse
import httpretty import httpretty
import mock import mock
...@@ -416,3 +417,86 @@ class CommentViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): ...@@ -416,3 +417,86 @@ class CommentViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"mark_as_read": ["True"], "mark_as_read": ["True"],
} }
) )
@httpretty.activate
class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for CommentViewSet create"""
def setUp(self):
super(CommentViewSetCreateTest, self).setUp()
self.url = reverse("comment-list")
def test_basic(self):
self.register_get_user_response(self.user)
self.register_get_thread_response(
make_minimal_cs_thread({
"id": "test_thread",
"course_id": unicode(self.course.id),
"commentable_id": "test_topic",
})
)
self.register_post_comment_response(
{
"id": "test_comment",
"thread_id": "test_thread",
"username": self.user.username,
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
},
thread_id="test_thread"
)
request_data = {
"thread_id": "test_thread",
"raw_body": "Test body",
}
expected_response_data = {
"id": "test_comment",
"thread_id": "test_thread",
"parent_id": None,
"author": self.user.username,
"author_label": None,
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
"raw_body": "Test body",
"endorsed": False,
"endorsed_by": None,
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"voted": False,
"vote_count": 0,
"children": [],
}
response = self.client.post(
self.url,
json.dumps(request_data),
content_type="application/json"
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data, expected_response_data)
self.assertEqual(
urlparse(httpretty.last_request().path).path,
"/api/v1/threads/test_thread/comments"
)
self.assertEqual(
httpretty.last_request().parsed_body,
{
"course_id": [unicode(self.course.id)],
"body": ["Test body"],
"user_id": [str(self.user.id)],
}
)
def test_error(self):
response = self.client.post(
self.url,
json.dumps({}),
content_type="application/json"
)
expected_response_data = {
"field_errors": {"thread_id": {"developer_message": "This field is required."}}
}
self.assertEqual(response.status_code, 400)
response_data = json.loads(response.content)
self.assertEqual(response_data, expected_response_data)
...@@ -61,6 +61,56 @@ class CommentsServiceMockMixin(object): ...@@ -61,6 +61,56 @@ class CommentsServiceMockMixin(object):
status=200 status=200
) )
def register_post_comment_response(self, response_overrides, thread_id=None, parent_id=None):
"""
Register a mock response for POST on the CS comments endpoint for the
given thread or parent; exactly one of thread_id and parent_id must be
specified.
"""
def callback(request, _uri, headers):
"""
Simulate the comment creation endpoint by returning the provided data
along with the data from response_overrides.
"""
response_data = make_minimal_cs_comment(
{key: val[0] for key, val in request.parsed_body.items()}
)
response_data.update(response_overrides or {})
return (200, headers, json.dumps(response_data))
if thread_id and not parent_id:
url = "http://localhost:4567/api/v1/threads/{}/comments".format(thread_id)
elif parent_id and not thread_id:
url = "http://localhost:4567/api/v1/comments/{}".format(parent_id)
else: # pragma: no cover
raise ValueError("Exactly one of thread_id and parent_id must be provided.")
httpretty.register_uri(httpretty.POST, url, body=callback)
def register_get_comment_error_response(self, comment_id, status_code):
"""
Register a mock error response for GET on the CS comment instance
endpoint.
"""
httpretty.register_uri(
httpretty.GET,
"http://localhost:4567/api/v1/comments/{id}".format(id=comment_id),
body="",
status=status_code
)
def register_get_comment_response(self, response_overrides):
"""
Register a mock response for GET on the CS comment instance endpoint.
"""
comment = make_minimal_cs_comment(response_overrides)
httpretty.register_uri(
httpretty.GET,
"http://localhost:4567/api/v1/comments/{id}".format(id=comment["id"]),
body=json.dumps(comment),
status=200
)
def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None):
"""Register a mock response for GET on the CS user instance endpoint""" """Register a mock response for GET on the CS user instance endpoint"""
httpretty.register_uri( httpretty.register_uri(
......
...@@ -11,7 +11,13 @@ from rest_framework.viewsets import ViewSet ...@@ -11,7 +11,13 @@ from rest_framework.viewsets import ViewSet
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
from discussion_api.api import create_thread, get_comment_list, get_course_topics, get_thread_list from discussion_api.api import (
create_comment,
create_thread,
get_comment_list,
get_course_topics,
get_thread_list,
)
from discussion_api.forms import CommentListGetForm, ThreadListGetForm from discussion_api.forms import CommentListGetForm, ThreadListGetForm
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
...@@ -173,6 +179,12 @@ class CommentViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet): ...@@ -173,6 +179,12 @@ class CommentViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet):
GET /api/discussion/v1/comments/?thread_id=0123456789abcdef01234567 GET /api/discussion/v1/comments/?thread_id=0123456789abcdef01234567
POST /api/discussion/v1/comments/
{
"thread_id": "0123456789abcdef01234567",
"raw_body": "Body text"
}
**GET Parameters**: **GET Parameters**:
* thread_id (required): The thread to retrieve comments for * thread_id (required): The thread to retrieve comments for
...@@ -185,55 +197,67 @@ class CommentViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet): ...@@ -185,55 +197,67 @@ class CommentViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet):
* page_size: The number of items per page (default is 10, max is 100) * page_size: The number of items per page (default is 10, max is 100)
**Response Values**: **POST Parameters**:
* results: The list of comments. Each item in the list includes: * thread_id (required): The thread to post the comment in
* id: The id of the comment * parent_id: The parent comment of the new comment. Can be null or
omitted for a comment that should be directly under the thread
* thread_id: The id of the comment's thread * raw_body: The comment's raw body text
* parent_id: The id of the comment's parent **GET Response Values**:
* author: The username of the comment's author, or None if the * results: The list of comments; each item in the list has the same
comment is anonymous fields as the POST response below
* author_label: A label indicating whether the author has a special * next: The URL of the next page (or null if first page)
role in the course, either "staff" for moderators and
administrators or "community_ta" for community TAs
* created_at: The ISO 8601 timestamp for the creation of the comment * previous: The URL of the previous page (or null if last page)
* updated_at: The ISO 8601 timestamp for the last modification of **POST Response Values**:
the comment, which may not have been an update of the body
* raw_body: The comment's raw body text without any rendering applied * id: The id of the comment
* endorsed: Boolean indicating whether the comment has been endorsed * thread_id: The id of the comment's thread
(by a privileged user or, for a question thread, the thread
author)
* endorsed_by: The username of the endorsing user, if available * parent_id: The id of the comment's parent
* endorsed_by_label: A label indicating whether the endorsing user * author: The username of the comment's author, or None if the
has a special role in the course (see author_label) comment is anonymous
* endorsed_at: The ISO 8601 timestamp for the endorsement, if * author_label: A label indicating whether the author has a special
available role in the course, either "staff" for moderators and
administrators or "community_ta" for community TAs
* abuse_flagged: Boolean indicating whether the requesting user has * created_at: The ISO 8601 timestamp for the creation of the comment
flagged the comment for abuse
* voted: Boolean indicating whether the requesting user has voted * updated_at: The ISO 8601 timestamp for the last modification of
for the comment the comment, which may not have been an update of the body
* vote_count: The number of votes for the comment * raw_body: The comment's raw body text without any rendering applied
* children: The list of child comments (with the same format) * endorsed: Boolean indicating whether the comment has been endorsed
(by a privileged user or, for a question thread, the thread
author)
* next: The URL of the next page (or null if first page) * endorsed_by: The username of the endorsing user, if available
* previous: The URL of the previous page (or null if last page) * endorsed_by_label: A label indicating whether the endorsing user
has a special role in the course (see author_label)
* endorsed_at: The ISO 8601 timestamp for the endorsement, if
available
* abuse_flagged: Boolean indicating whether the requesting user has
flagged the comment for abuse
* voted: Boolean indicating whether the requesting user has voted
for the comment
* vote_count: The number of votes for the comment
* children: The list of child comments (with the same format)
""" """
def list(self, request): def list(self, request):
""" """
...@@ -252,3 +276,10 @@ class CommentViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet): ...@@ -252,3 +276,10 @@ class CommentViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet):
form.cleaned_data["page_size"] form.cleaned_data["page_size"]
) )
) )
def create(self, request):
"""
Implements the POST method for the list endpoint as described in the
class docstring.
"""
return Response(create_comment(request, request.DATA))
...@@ -38,6 +38,8 @@ log = logging.getLogger(__name__) ...@@ -38,6 +38,8 @@ log = logging.getLogger(__name__)
TRACKING_MAX_FORUM_BODY = 2000 TRACKING_MAX_FORUM_BODY = 2000
THREAD_CREATED_EVENT_NAME = "edx.forum.thread.created" THREAD_CREATED_EVENT_NAME = "edx.forum.thread.created"
RESPONSE_CREATED_EVENT_NAME = 'edx.forum.response.created'
COMMENT_CREATED_EVENT_NAME = 'edx.forum.comment.created'
def permitted(fn): def permitted(fn):
...@@ -119,6 +121,29 @@ def get_thread_created_event_data(thread, followed): ...@@ -119,6 +121,29 @@ def get_thread_created_event_data(thread, followed):
} }
def get_comment_created_event_name(comment):
"""Get the appropriate event name for creating a response/comment"""
return COMMENT_CREATED_EVENT_NAME if comment.get("parent_id") else RESPONSE_CREATED_EVENT_NAME
def get_comment_created_event_data(comment, commentable_id, followed):
"""
Get the event data payload for comment creation (excluding fields populated
by track_forum_event)
"""
event_data = {
'discussion': {'id': comment.thread_id},
'commentable_id': commentable_id,
'options': {'followed': followed},
}
parent_id = comment.get("parent_id")
if parent_id:
event_data['response'] = {'id': parent_id}
return event_data
@require_POST @require_POST
@login_required @login_required
@permitted @permitted
...@@ -270,16 +295,8 @@ def _create_comment(request, course_key, thread_id=None, parent_id=None): ...@@ -270,16 +295,8 @@ def _create_comment(request, course_key, thread_id=None, parent_id=None):
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
user.follow(comment.thread) user.follow(comment.thread)
event_data = {'discussion': {'id': comment.thread_id}, 'options': {'followed': followed}} event_name = get_comment_created_event_name(comment)
event_data = get_comment_created_event_data(comment, comment.thread.commentable_id, followed)
if parent_id:
event_data['response'] = {'id': comment.parent_id}
event_name = 'edx.forum.comment.created'
else:
event_name = 'edx.forum.response.created'
event_data['commentable_id'] = comment.thread.commentable_id
track_forum_event(request, event_name, course, comment, event_data) track_forum_event(request, event_name, course, comment, event_data)
if request.is_ajax(): if request.is_ajax():
......
...@@ -32,10 +32,10 @@ class Comment(models.Model): ...@@ -32,10 +32,10 @@ class Comment(models.Model):
@classmethod @classmethod
def url_for_comments(cls, params={}): def url_for_comments(cls, params={}):
if params.get('thread_id'): if params.get('parent_id'):
return _url_for_thread_comments(params['thread_id'])
else:
return _url_for_comment(params['parent_id']) return _url_for_comment(params['parent_id'])
else:
return _url_for_thread_comments(params['thread_id'])
@classmethod @classmethod
def url(cls, action, params={}): def url(cls, action, params={}):
......
...@@ -40,7 +40,11 @@ class DeveloperErrorViewMixin(object): ...@@ -40,7 +40,11 @@ class DeveloperErrorViewMixin(object):
if hasattr(validation_error, "message_dict"): if hasattr(validation_error, "message_dict"):
response_obj = {} response_obj = {}
message_dict = dict(validation_error.message_dict) message_dict = dict(validation_error.message_dict)
non_field_error_list = message_dict.pop(NON_FIELD_ERRORS, None) # Extract both Django form and DRF serializer non-field errors
non_field_error_list = (
message_dict.pop(NON_FIELD_ERRORS, []) +
message_dict.pop("non_field_errors", [])
)
if non_field_error_list: if non_field_error_list:
response_obj["developer_message"] = non_field_error_list[0] response_obj["developer_message"] = non_field_error_list[0]
if message_dict: if message_dict:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment