serializers.py 13.1 KB
Newer Older
1 2 3
"""
Discussion API serializers
"""
4 5 6
from urllib import urlencode
from urlparse import urlunparse

7
from django.contrib.auth.models import User as DjangoUser
8
from django.core.exceptions import ValidationError
9
from django.core.urlresolvers import reverse
10

11 12
from rest_framework import serializers

13 14 15 16 17
from discussion_api.permissions import (
    NON_UPDATABLE_COMMENT_FIELDS,
    NON_UPDATABLE_THREAD_FIELDS,
    get_editable_fields,
)
18
from discussion_api.render import render_body
19
from django_comment_client.utils import is_comment_too_deep
20 21 22 23 24 25
from django_comment_common.models import (
    FORUM_ROLE_ADMINISTRATOR,
    FORUM_ROLE_COMMUNITY_TA,
    FORUM_ROLE_MODERATOR,
    Role,
)
26
from lms.lib.comment_client.comment import Comment
27
from lms.lib.comment_client.thread import Thread
28
from lms.lib.comment_client.user import User as CommentClientUser
29
from lms.lib.comment_client.utils import CommentClientRequestError
30
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_names
31
from openedx.core.lib.api.fields import NonEmptyCharField
32 33


34
def get_context(course, request, thread=None):
35 36
    """
    Returns a context appropriate for use with ThreadSerializer or
37
    (if thread is provided) CommentSerializer.
38
    """
39 40 41 42 43 44 45 46 47 48 49 50 51 52
    # 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()
    }
53
    requester = request.user
54 55
    cc_requester = CommentClientUser.from_django_user(requester).retrieve()
    cc_requester["course_id"] = course.id
56
    return {
57
        "course": course,
58
        "request": request,
59 60
        "thread": thread,
        # For now, the only groups are cohorts
61 62 63 64
        "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,
65
        "cc_requester": cc_requester,
66 67 68
    }


69 70
class _ContentSerializer(serializers.Serializer):
    """A base class for thread and comment serializers."""
71 72 73 74 75
    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)
76
    raw_body = NonEmptyCharField(source="body")
77
    rendered_body = serializers.SerializerMethodField("get_rendered_body")
78 79 80
    abuse_flagged = serializers.SerializerMethodField("get_abuse_flagged")
    voted = serializers.SerializerMethodField("get_voted")
    vote_count = serializers.SerializerMethodField("get_vote_count")
81
    editable_fields = serializers.SerializerMethodField("get_editable_fields")
82

83
    non_updatable_fields = set()
84

85
    def __init__(self, *args, **kwargs):
86 87 88
        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
89 90
        self.fields["id"] = self.fields.pop("id_")

91 92 93 94 95 96 97 98 99
        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

100 101 102 103 104 105 106
    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"]

107 108
    def _is_anonymous(self, obj):
        """
109
        Returns a boolean indicating whether the content should be anonymous to
110 111 112 113 114 115 116 117
        the requester.
        """
        return (
            obj["anonymous"] or
            obj["anonymous_to_peers"] and not self.context["is_requester_privileged"]
        )

    def get_author(self, obj):
118
        """Returns the author's username, or None if the content is anonymous."""
119 120 121 122 123 124 125 126 127 128 129 130 131 132
        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):
133
        """Returns the role label for the content author."""
134 135
        return None if self._is_anonymous(obj) else self._get_user_label(int(obj["user_id"]))

136 137 138 139
    def get_rendered_body(self, obj):
        """Returns the rendered body content."""
        return render_body(obj["body"])

140 141 142
    def get_abuse_flagged(self, obj):
        """
        Returns a boolean indicating whether the requester has flagged the
143
        content as abusive.
144 145 146 147 148 149
        """
        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
150
        content.
151 152 153 154
        """
        return obj["id"] in self.context["cc_requester"]["upvoted_ids"]

    def get_vote_count(self, obj):
155
        """Returns the number of votes for the content."""
156
        return obj["votes"]["up_count"]
157

158 159 160 161
    def get_editable_fields(self, obj):
        """Return the list of the fields the requester can edit"""
        return sorted(get_editable_fields(obj, self.context))

162 163 164 165 166 167 168 169 170 171

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()
172
    topic_id = NonEmptyCharField(source="commentable_id")
173
    group_id = serializers.IntegerField(required=False)
174
    group_name = serializers.SerializerMethodField("get_group_name")
175 176 177 178
    type_ = serializers.ChoiceField(
        source="thread_type",
        choices=[(val, val) for val in ["discussion", "question"]]
    )
179
    title = NonEmptyCharField()
180 181
    pinned = serializers.BooleanField(read_only=True)
    closed = serializers.BooleanField(read_only=True)
182
    following = serializers.SerializerMethodField("get_following")
183 184
    comment_count = serializers.IntegerField(source="comments_count", read_only=True)
    unread_comment_count = serializers.IntegerField(source="unread_comments_count", read_only=True)
185 186 187
    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")
188

189
    non_updatable_fields = NON_UPDATABLE_THREAD_FIELDS
190

191 192 193 194 195
    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_")
196 197 198 199
        # Compensate for the fact that some threads in the comments service do
        # not have the pinned field set
        if self.object and self.object.get("pinned") is None:
            self.object["pinned"] = False
200 201 202 203 204 205 206 207 208 209 210 211

    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"]

212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
    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)

238 239
    def restore_object(self, attrs, instance=None):
        if instance:
240 241 242 243 244
            for key, val in attrs.items():
                instance[key] = val
            return instance
        else:
            return Thread(user_id=self.context["cc_requester"]["id"], **attrs)
245

246 247 248 249 250 251 252 253 254 255

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()
256
    parent_id = serializers.CharField(required=False)
257
    endorsed = serializers.BooleanField(required=False)
258 259 260
    endorsed_by = serializers.SerializerMethodField("get_endorsed_by")
    endorsed_by_label = serializers.SerializerMethodField("get_endorsed_by_label")
    endorsed_at = serializers.SerializerMethodField("get_endorsed_at")
261 262
    children = serializers.SerializerMethodField("get_children")

263
    non_updatable_fields = NON_UPDATABLE_COMMENT_FIELDS
264

265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297
    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

298
    def get_children(self, obj):
299
        return [
300
            CommentSerializer(child, context=self.context).data
301 302 303 304 305 306
            for child in obj.get("children", [])
        ]

    def validate(self, attrs):
        """
        Ensure that parent_id identifies a comment that is actually in the
307 308
        thread identified by thread_id and does not violate the configured
        maximum depth.
309
        """
310
        parent = None
311
        parent_id = attrs.get("parent_id")
312 313 314 315 316 317 318 319 320
        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."
                )
321
        if is_comment_too_deep(parent):
322
            raise ValidationError({"parent_id": ["Comment level is too deep."]})
323 324 325
        return attrs

    def restore_object(self, attrs, instance=None):
326 327 328
        if instance:
            for key, val in attrs.items():
                instance[key] = val
329 330 331 332 333
                # 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"]
334
            return instance
335 336 337 338 339
        return Comment(
            course_id=self.context["thread"]["course_id"],
            user_id=self.context["cc_requester"]["id"],
            **attrs
        )