445 lines
16 KiB
Python
445 lines
16 KiB
Python
"""
|
|
Discussion API serializers
|
|
"""
|
|
from urllib import urlencode
|
|
from urlparse import urlunparse
|
|
|
|
from django.contrib.auth.models import User as DjangoUser
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.urlresolvers import reverse
|
|
from rest_framework import serializers
|
|
|
|
from discussion_api.permissions import NON_UPDATABLE_COMMENT_FIELDS, NON_UPDATABLE_THREAD_FIELDS, get_editable_fields
|
|
from discussion_api.render import render_body
|
|
from django_comment_client.utils import is_comment_too_deep
|
|
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, Role
|
|
from django_comment_common.utils import get_course_discussion_settings
|
|
from lms.djangoapps.django_comment_client.utils import course_discussion_division_enabled, get_group_names_by_id
|
|
from lms.lib.comment_client.comment import Comment
|
|
from lms.lib.comment_client.thread import Thread
|
|
from lms.lib.comment_client.user import User as CommentClientUser
|
|
from lms.lib.comment_client.utils import CommentClientRequestError
|
|
|
|
|
|
def get_context(course, request, thread=None):
|
|
"""
|
|
Returns a context appropriate for use with ThreadSerializer or
|
|
(if thread is provided) CommentSerializer.
|
|
"""
|
|
# TODO: cache staff_user_ids and ta_user_ids if we need to improve perf
|
|
staff_user_ids = {
|
|
user.id
|
|
for role in Role.objects.filter(
|
|
name__in=[FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR],
|
|
course_id=course.id
|
|
)
|
|
for user in role.users.all()
|
|
}
|
|
ta_user_ids = {
|
|
user.id
|
|
for role in Role.objects.filter(name=FORUM_ROLE_COMMUNITY_TA, course_id=course.id)
|
|
for user in role.users.all()
|
|
}
|
|
requester = request.user
|
|
cc_requester = CommentClientUser.from_django_user(requester).retrieve()
|
|
cc_requester["course_id"] = course.id
|
|
course_discussion_settings = get_course_discussion_settings(course.id)
|
|
return {
|
|
"course": course,
|
|
"request": request,
|
|
"thread": thread,
|
|
"discussion_division_enabled": course_discussion_division_enabled(course_discussion_settings),
|
|
"group_ids_to_names": get_group_names_by_id(course_discussion_settings),
|
|
"is_requester_privileged": requester.id in staff_user_ids or requester.id in ta_user_ids,
|
|
"staff_user_ids": staff_user_ids,
|
|
"ta_user_ids": ta_user_ids,
|
|
"cc_requester": cc_requester,
|
|
}
|
|
|
|
|
|
def validate_not_blank(value):
|
|
"""
|
|
Validate that a value is not an empty string or whitespace.
|
|
|
|
Raises: ValidationError
|
|
"""
|
|
if not value.strip():
|
|
raise ValidationError("This field may not be blank.")
|
|
|
|
|
|
class _ContentSerializer(serializers.Serializer):
|
|
"""
|
|
A base class for thread and comment serializers.
|
|
"""
|
|
id = serializers.CharField(read_only=True) # pylint: disable=invalid-name
|
|
author = serializers.SerializerMethodField()
|
|
author_label = serializers.SerializerMethodField()
|
|
created_at = serializers.CharField(read_only=True)
|
|
updated_at = serializers.CharField(read_only=True)
|
|
raw_body = serializers.CharField(source="body", validators=[validate_not_blank])
|
|
rendered_body = serializers.SerializerMethodField()
|
|
abuse_flagged = serializers.SerializerMethodField()
|
|
voted = serializers.SerializerMethodField()
|
|
vote_count = serializers.SerializerMethodField()
|
|
editable_fields = serializers.SerializerMethodField()
|
|
|
|
non_updatable_fields = set()
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(_ContentSerializer, self).__init__(*args, **kwargs)
|
|
|
|
for field in self.non_updatable_fields:
|
|
setattr(self, "validate_{}".format(field), self._validate_non_updatable)
|
|
|
|
def _validate_non_updatable(self, value):
|
|
"""Ensure that a field is not edited in an update operation."""
|
|
if self.instance:
|
|
raise ValidationError("This field is not allowed in an update.")
|
|
return value
|
|
|
|
def _is_user_privileged(self, user_id):
|
|
"""
|
|
Returns a boolean indicating whether the given user_id identifies a
|
|
privileged user.
|
|
"""
|
|
return user_id in self.context["staff_user_ids"] or user_id in self.context["ta_user_ids"]
|
|
|
|
def _is_anonymous(self, obj):
|
|
"""
|
|
Returns a boolean indicating whether the content should be anonymous to
|
|
the requester.
|
|
"""
|
|
return (
|
|
obj["anonymous"] or
|
|
obj["anonymous_to_peers"] and not self.context["is_requester_privileged"]
|
|
)
|
|
|
|
def get_author(self, obj):
|
|
"""
|
|
Returns the author's username, or None if the content is anonymous.
|
|
"""
|
|
return None if self._is_anonymous(obj) else obj["username"]
|
|
|
|
def _get_user_label(self, user_id):
|
|
"""
|
|
Returns the role label (i.e. "Staff" or "Community TA") for the user
|
|
with the given id.
|
|
"""
|
|
return (
|
|
"Staff" if user_id in self.context["staff_user_ids"] else
|
|
"Community TA" if user_id in self.context["ta_user_ids"] else
|
|
None
|
|
)
|
|
|
|
def get_author_label(self, obj):
|
|
"""
|
|
Returns the role label for the content author.
|
|
"""
|
|
if self._is_anonymous(obj) or obj["user_id"] is None:
|
|
return None
|
|
else:
|
|
user_id = int(obj["user_id"])
|
|
return self._get_user_label(user_id)
|
|
|
|
def get_rendered_body(self, obj):
|
|
"""
|
|
Returns the rendered body content.
|
|
"""
|
|
return render_body(obj["body"])
|
|
|
|
def get_abuse_flagged(self, obj):
|
|
"""
|
|
Returns a boolean indicating whether the requester has flagged the
|
|
content as abusive.
|
|
"""
|
|
return self.context["cc_requester"]["id"] in obj.get("abuse_flaggers", [])
|
|
|
|
def get_voted(self, obj):
|
|
"""
|
|
Returns a boolean indicating whether the requester has voted for the
|
|
content.
|
|
"""
|
|
return obj["id"] in self.context["cc_requester"]["upvoted_ids"]
|
|
|
|
def get_vote_count(self, obj):
|
|
"""
|
|
Returns the number of votes for the content.
|
|
"""
|
|
return obj.get("votes", {}).get("up_count", 0)
|
|
|
|
def get_editable_fields(self, obj):
|
|
"""
|
|
Return the list of the fields the requester can edit
|
|
"""
|
|
return sorted(get_editable_fields(obj, self.context))
|
|
|
|
|
|
class ThreadSerializer(_ContentSerializer):
|
|
"""
|
|
A serializer for thread data.
|
|
|
|
N.B. This should not be used with a comment_client Thread object that has
|
|
not had retrieve() called, because of the interaction between DRF's attempts
|
|
at introspection and Thread's __getattr__.
|
|
"""
|
|
course_id = serializers.CharField()
|
|
topic_id = serializers.CharField(source="commentable_id", validators=[validate_not_blank])
|
|
group_id = serializers.IntegerField(required=False, allow_null=True)
|
|
group_name = serializers.SerializerMethodField()
|
|
type = serializers.ChoiceField(
|
|
source="thread_type",
|
|
choices=[(val, val) for val in ["discussion", "question"]]
|
|
)
|
|
title = serializers.CharField(validators=[validate_not_blank])
|
|
pinned = serializers.SerializerMethodField(read_only=True)
|
|
closed = serializers.BooleanField(read_only=True)
|
|
following = serializers.SerializerMethodField()
|
|
comment_count = serializers.SerializerMethodField(read_only=True)
|
|
unread_comment_count = serializers.SerializerMethodField(read_only=True)
|
|
comment_list_url = serializers.SerializerMethodField()
|
|
endorsed_comment_list_url = serializers.SerializerMethodField()
|
|
non_endorsed_comment_list_url = serializers.SerializerMethodField()
|
|
read = serializers.BooleanField(required=False)
|
|
has_endorsed = serializers.BooleanField(source="endorsed", read_only=True)
|
|
response_count = serializers.IntegerField(source="resp_total", read_only=True, required=False)
|
|
|
|
non_updatable_fields = NON_UPDATABLE_THREAD_FIELDS
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(ThreadSerializer, self).__init__(*args, **kwargs)
|
|
# Compensate for the fact that some threads in the comments service do
|
|
# not have the pinned field set
|
|
if self.instance and self.instance.get("pinned") is None:
|
|
self.instance["pinned"] = False
|
|
|
|
def get_pinned(self, obj):
|
|
"""
|
|
Compensate for the fact that some threads in the comments service do
|
|
not have the pinned field set.
|
|
"""
|
|
return bool(obj["pinned"])
|
|
|
|
def get_group_name(self, obj):
|
|
"""
|
|
Returns the name of the group identified by the thread's group_id.
|
|
"""
|
|
return self.context["group_ids_to_names"].get(obj["group_id"])
|
|
|
|
def get_following(self, obj):
|
|
"""
|
|
Returns a boolean indicating whether the requester is following the
|
|
thread.
|
|
"""
|
|
return obj["id"] in self.context["cc_requester"]["subscribed_thread_ids"]
|
|
|
|
def get_comment_list_url(self, obj, endorsed=None):
|
|
"""
|
|
Returns the URL to retrieve the thread's comments, optionally including
|
|
the endorsed query parameter.
|
|
"""
|
|
if (
|
|
(obj["thread_type"] == "question" and endorsed is None) or
|
|
(obj["thread_type"] == "discussion" and endorsed is not None)
|
|
):
|
|
return None
|
|
path = reverse("comment-list")
|
|
query_dict = {"thread_id": obj["id"]}
|
|
if endorsed is not None:
|
|
query_dict["endorsed"] = endorsed
|
|
return self.context["request"].build_absolute_uri(
|
|
urlunparse(("", "", path, "", urlencode(query_dict), ""))
|
|
)
|
|
|
|
def get_endorsed_comment_list_url(self, obj):
|
|
"""
|
|
Returns the URL to retrieve the thread's endorsed comments.
|
|
"""
|
|
return self.get_comment_list_url(obj, endorsed=True)
|
|
|
|
def get_non_endorsed_comment_list_url(self, obj):
|
|
"""
|
|
Returns the URL to retrieve the thread's non-endorsed comments.
|
|
"""
|
|
return self.get_comment_list_url(obj, endorsed=False)
|
|
|
|
def get_comment_count(self, obj):
|
|
"""
|
|
Increments comment count to include post and returns total count of
|
|
contributions (i.e. post + responses + comments) for the thread
|
|
"""
|
|
return obj["comments_count"] + 1
|
|
|
|
def get_unread_comment_count(self, obj):
|
|
"""
|
|
Returns the number of unread comments. If the thread has never been read,
|
|
this additionally includes 1 for the post itself, in addition to its responses and
|
|
comments.
|
|
"""
|
|
if not obj["read"] and obj["comments_count"] == obj["unread_comments_count"]:
|
|
return obj["unread_comments_count"] + 1
|
|
return obj["unread_comments_count"]
|
|
|
|
def create(self, validated_data):
|
|
thread = Thread(user_id=self.context["cc_requester"]["id"], **validated_data)
|
|
thread.save()
|
|
return thread
|
|
|
|
def update(self, instance, validated_data):
|
|
for key, val in validated_data.items():
|
|
instance[key] = val
|
|
instance.save()
|
|
return instance
|
|
|
|
|
|
class CommentSerializer(_ContentSerializer):
|
|
"""
|
|
A serializer for comment data.
|
|
|
|
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
|
|
at introspection and Comment's __getattr__.
|
|
"""
|
|
thread_id = serializers.CharField()
|
|
parent_id = serializers.CharField(required=False, allow_null=True)
|
|
endorsed = serializers.BooleanField(required=False)
|
|
endorsed_by = serializers.SerializerMethodField()
|
|
endorsed_by_label = serializers.SerializerMethodField()
|
|
endorsed_at = serializers.SerializerMethodField()
|
|
child_count = serializers.IntegerField(read_only=True)
|
|
children = serializers.SerializerMethodField(required=False)
|
|
|
|
non_updatable_fields = NON_UPDATABLE_COMMENT_FIELDS
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
remove_fields = kwargs.pop('remove_fields', None)
|
|
super(CommentSerializer, self).__init__(*args, **kwargs)
|
|
|
|
if remove_fields:
|
|
# for multiple fields in a list
|
|
for field_name in remove_fields:
|
|
self.fields.pop(field_name)
|
|
|
|
def get_endorsed_by(self, obj):
|
|
"""
|
|
Returns the username of the endorsing user, if the information is
|
|
available and would not identify the author of an anonymous thread.
|
|
"""
|
|
endorsement = obj.get("endorsement")
|
|
if endorsement:
|
|
endorser_id = int(endorsement["user_id"])
|
|
# Avoid revealing the identity of an anonymous non-staff question
|
|
# author who has endorsed a comment in the thread
|
|
if not (
|
|
self._is_anonymous(self.context["thread"]) and
|
|
not self._is_user_privileged(endorser_id)
|
|
):
|
|
return DjangoUser.objects.get(id=endorser_id).username
|
|
return None
|
|
|
|
def get_endorsed_by_label(self, obj):
|
|
"""
|
|
Returns the role label (i.e. "Staff" or "Community TA") for the
|
|
endorsing user
|
|
"""
|
|
endorsement = obj.get("endorsement")
|
|
if endorsement:
|
|
return self._get_user_label(int(endorsement["user_id"]))
|
|
else:
|
|
return None
|
|
|
|
def get_endorsed_at(self, obj):
|
|
"""
|
|
Returns the timestamp for the endorsement, if available.
|
|
"""
|
|
endorsement = obj.get("endorsement")
|
|
return endorsement["time"] if endorsement else None
|
|
|
|
def get_children(self, obj):
|
|
return [
|
|
CommentSerializer(child, context=self.context).data
|
|
for child in obj.get("children", [])
|
|
]
|
|
|
|
def to_representation(self, data):
|
|
data = super(CommentSerializer, self).to_representation(data)
|
|
|
|
# Django Rest Framework v3 no longer includes None values
|
|
# in the representation. To maintain the previous behavior,
|
|
# we do this manually instead.
|
|
if 'parent_id' not in data:
|
|
data["parent_id"] = None
|
|
|
|
return data
|
|
|
|
def validate(self, attrs):
|
|
"""
|
|
Ensure that parent_id identifies a comment that is actually in the
|
|
thread identified by thread_id and does not violate the configured
|
|
maximum depth.
|
|
"""
|
|
parent = None
|
|
parent_id = attrs.get("parent_id")
|
|
if parent_id:
|
|
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."
|
|
)
|
|
if is_comment_too_deep(parent):
|
|
raise ValidationError({"parent_id": ["Comment level is too deep."]})
|
|
return attrs
|
|
|
|
def create(self, validated_data):
|
|
comment = Comment(
|
|
course_id=self.context["thread"]["course_id"],
|
|
user_id=self.context["cc_requester"]["id"],
|
|
**validated_data
|
|
)
|
|
comment.save()
|
|
return comment
|
|
|
|
def update(self, instance, validated_data):
|
|
for key, val in validated_data.items():
|
|
instance[key] = val
|
|
# TODO: The comments service doesn't populate the endorsement
|
|
# field on comment creation, so we only provide
|
|
# endorsement_user_id on update
|
|
if key == "endorsed":
|
|
instance["endorsement_user_id"] = self.context["cc_requester"]["id"]
|
|
|
|
instance.save()
|
|
return instance
|
|
|
|
|
|
class DiscussionTopicSerializer(serializers.Serializer):
|
|
"""
|
|
Serializer for DiscussionTopic
|
|
"""
|
|
id = serializers.CharField(read_only=True) # pylint: disable=invalid-name
|
|
name = serializers.CharField(read_only=True)
|
|
thread_list_url = serializers.CharField(read_only=True)
|
|
children = serializers.SerializerMethodField()
|
|
|
|
def get_children(self, obj):
|
|
"""
|
|
Returns a list of children of DiscussionTopicSerializer type
|
|
"""
|
|
if not obj.children:
|
|
return []
|
|
return [DiscussionTopicSerializer(child).data for child in obj.children]
|
|
|
|
def create(self, validated_data):
|
|
"""
|
|
Overriden create abstract method
|
|
"""
|
|
pass
|
|
|
|
def update(self, instance, validated_data):
|
|
"""
|
|
Overriden update abstract method
|
|
"""
|
|
pass
|