314 lines
12 KiB
Python
314 lines
12 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 django_comment_common.models import (
|
|
FORUM_ROLE_ADMINISTRATOR,
|
|
FORUM_ROLE_COMMUNITY_TA,
|
|
FORUM_ROLE_MODERATOR,
|
|
Role,
|
|
)
|
|
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
|
|
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_names
|
|
from openedx.core.lib.api.fields import NonEmptyCharField
|
|
|
|
|
|
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
|
|
return {
|
|
"course": course,
|
|
"request": request,
|
|
"thread": thread,
|
|
# For now, the only groups are cohorts
|
|
"group_ids_to_names": get_cohort_names(course),
|
|
"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": CommentClientUser.from_django_user(requester).retrieve(),
|
|
}
|
|
|
|
|
|
class _ContentSerializer(serializers.Serializer):
|
|
"""A base class for thread and comment serializers."""
|
|
id_ = serializers.CharField(read_only=True)
|
|
author = serializers.SerializerMethodField("get_author")
|
|
author_label = serializers.SerializerMethodField("get_author_label")
|
|
created_at = serializers.CharField(read_only=True)
|
|
updated_at = serializers.CharField(read_only=True)
|
|
raw_body = NonEmptyCharField(source="body")
|
|
abuse_flagged = serializers.SerializerMethodField("get_abuse_flagged")
|
|
voted = serializers.SerializerMethodField("get_voted")
|
|
vote_count = serializers.SerializerMethodField("get_vote_count")
|
|
|
|
non_updatable_fields = ()
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(_ContentSerializer, self).__init__(*args, **kwargs)
|
|
# id is an invalid class attribute name, so we must declare a different
|
|
# name above and modify it here
|
|
self.fields["id"] = self.fields.pop("id_")
|
|
|
|
for field in self.non_updatable_fields:
|
|
setattr(self, "validate_{}".format(field), self._validate_non_updatable)
|
|
|
|
def _validate_non_updatable(self, attrs, _source):
|
|
"""Ensure that a field is not edited in an update operation."""
|
|
if self.object:
|
|
raise ValidationError("This field is not allowed in an update.")
|
|
return attrs
|
|
|
|
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."""
|
|
return None if self._is_anonymous(obj) else self._get_user_label(int(obj["user_id"]))
|
|
|
|
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["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["votes"]["up_count"]
|
|
|
|
|
|
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 = NonEmptyCharField(source="commentable_id")
|
|
group_id = serializers.IntegerField(read_only=True)
|
|
group_name = serializers.SerializerMethodField("get_group_name")
|
|
type_ = serializers.ChoiceField(
|
|
source="thread_type",
|
|
choices=[(val, val) for val in ["discussion", "question"]]
|
|
)
|
|
title = NonEmptyCharField()
|
|
pinned = serializers.BooleanField(read_only=True)
|
|
closed = serializers.BooleanField(read_only=True)
|
|
following = serializers.SerializerMethodField("get_following")
|
|
comment_count = serializers.IntegerField(source="comments_count", read_only=True)
|
|
unread_comment_count = serializers.IntegerField(source="unread_comments_count", read_only=True)
|
|
comment_list_url = serializers.SerializerMethodField("get_comment_list_url")
|
|
endorsed_comment_list_url = serializers.SerializerMethodField("get_endorsed_comment_list_url")
|
|
non_endorsed_comment_list_url = serializers.SerializerMethodField("get_non_endorsed_comment_list_url")
|
|
|
|
non_updatable_fields = ("course_id",)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(ThreadSerializer, self).__init__(*args, **kwargs)
|
|
# type is an invalid class attribute name, so we must declare a
|
|
# different name above and modify it here
|
|
self.fields["type"] = self.fields.pop("type_")
|
|
|
|
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 restore_object(self, attrs, instance=None):
|
|
if instance:
|
|
for key, val in attrs.items():
|
|
instance[key] = val
|
|
return instance
|
|
else:
|
|
return Thread(user_id=self.context["cc_requester"]["id"], **attrs)
|
|
|
|
|
|
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)
|
|
endorsed = serializers.BooleanField(required=False)
|
|
endorsed_by = serializers.SerializerMethodField("get_endorsed_by")
|
|
endorsed_by_label = serializers.SerializerMethodField("get_endorsed_by_label")
|
|
endorsed_at = serializers.SerializerMethodField("get_endorsed_at")
|
|
children = serializers.SerializerMethodField("get_children")
|
|
|
|
non_updatable_fields = ("thread_id", "parent_id")
|
|
|
|
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 validate(self, attrs):
|
|
"""
|
|
Ensure that parent_id identifies a comment that is actually in the
|
|
thread identified by thread_id.
|
|
"""
|
|
parent_id = attrs.get("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:
|
|
for key, val in attrs.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"]
|
|
return instance
|
|
return Comment(
|
|
course_id=self.context["thread"]["course_id"],
|
|
user_id=self.context["cc_requester"]["id"],
|
|
**attrs
|
|
)
|