serializers.py 15.6 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 31 32
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_names


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


68 69 70 71 72 73 74 75 76 77
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.")


78 79
class _ContentSerializer(serializers.Serializer):
    """A base class for thread and comment serializers."""
80 81 82
    id = serializers.CharField(read_only=True)  # pylint: disable=invalid-name
    author = serializers.SerializerMethodField()
    author_label = serializers.SerializerMethodField()
83 84
    created_at = serializers.CharField(read_only=True)
    updated_at = serializers.CharField(read_only=True)
85 86 87 88 89 90
    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()
91

92
    non_updatable_fields = set()
93

94
    def __init__(self, *args, **kwargs):
95
        super(_ContentSerializer, self).__init__(*args, **kwargs)
96

97 98 99
        for field in self.non_updatable_fields:
            setattr(self, "validate_{}".format(field), self._validate_non_updatable)

100
    def _validate_non_updatable(self, value):
101
        """Ensure that a field is not edited in an update operation."""
102
        if self.instance:
103
            raise ValidationError("This field is not allowed in an update.")
104
        return value
105

106 107 108 109 110 111 112
    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"]

113 114
    def _is_anonymous(self, obj):
        """
115
        Returns a boolean indicating whether the content should be anonymous to
116 117 118 119 120 121 122 123
        the requester.
        """
        return (
            obj["anonymous"] or
            obj["anonymous_to_peers"] and not self.context["is_requester_privileged"]
        )

    def get_author(self, obj):
124
        """Returns the author's username, or None if the content is anonymous."""
125 126 127 128
        return None if self._is_anonymous(obj) else obj["username"]

    def _get_user_label(self, user_id):
        """
129
        Returns the role label (i.e. "Staff" or "Community TA") for the user
130 131 132
        with the given id.
        """
        return (
133 134
            "Staff" if user_id in self.context["staff_user_ids"] else
            "Community TA" if user_id in self.context["ta_user_ids"] else
135 136 137 138
            None
        )

    def get_author_label(self, obj):
139
        """Returns the role label for the content author."""
140 141 142 143 144
        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)
145

146 147 148 149
    def get_rendered_body(self, obj):
        """Returns the rendered body content."""
        return render_body(obj["body"])

150 151 152
    def get_abuse_flagged(self, obj):
        """
        Returns a boolean indicating whether the requester has flagged the
153
        content as abusive.
154
        """
155
        return self.context["cc_requester"]["id"] in obj.get("abuse_flaggers", [])
156 157 158 159

    def get_voted(self, obj):
        """
        Returns a boolean indicating whether the requester has voted for the
160
        content.
161 162 163 164
        """
        return obj["id"] in self.context["cc_requester"]["upvoted_ids"]

    def get_vote_count(self, obj):
165
        """Returns the number of votes for the content."""
166
        return obj.get("votes", {}).get("up_count", 0)
167

168 169 170 171
    def get_editable_fields(self, obj):
        """Return the list of the fields the requester can edit"""
        return sorted(get_editable_fields(obj, self.context))

172 173 174 175 176 177 178 179 180 181

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()
182 183 184 185
    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(
186 187 188
        source="thread_type",
        choices=[(val, val) for val in ["discussion", "question"]]
    )
189 190
    title = serializers.CharField(validators=[validate_not_blank])
    pinned = serializers.SerializerMethodField(read_only=True)
191
    closed = serializers.BooleanField(read_only=True)
192
    following = serializers.SerializerMethodField()
193 194
    comment_count = serializers.SerializerMethodField(read_only=True)
    unread_comment_count = serializers.SerializerMethodField(read_only=True)
195 196 197
    comment_list_url = serializers.SerializerMethodField()
    endorsed_comment_list_url = serializers.SerializerMethodField()
    non_endorsed_comment_list_url = serializers.SerializerMethodField()
198
    read = serializers.BooleanField(required=False)
199
    has_endorsed = serializers.BooleanField(read_only=True, source="endorsed")
200
    response_count = serializers.IntegerField(source="resp_total", read_only=True, required=False)
201

202
    non_updatable_fields = NON_UPDATABLE_THREAD_FIELDS
203

204 205
    def __init__(self, *args, **kwargs):
        super(ThreadSerializer, self).__init__(*args, **kwargs)
206 207
        # Compensate for the fact that some threads in the comments service do
        # not have the pinned field set
208 209
        if self.instance and self.instance.get("pinned") is None:
            self.instance["pinned"] = False
210

211 212 213 214 215 216
    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"])
217 218 219 220 221 222 223 224 225 226 227 228

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

229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254
    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)

255 256 257 258 259 260 261 262 263 264 265 266
    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):
        """Increments comment count to include post if thread is unread and returns
        total count of unread contributions (i.e. post + responses + comments) for the thread"""
        if not obj["read"]:
            return obj["unread_comments_count"] + 1
        return obj["unread_comments_count"]

267 268 269 270 271 272 273 274
    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
275
        instance.save()
276
        return instance
277

278 279 280 281 282 283 284 285 286 287

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()
288
    parent_id = serializers.CharField(required=False, allow_null=True)
289
    endorsed = serializers.BooleanField(required=False)
290 291 292
    endorsed_by = serializers.SerializerMethodField()
    endorsed_by_label = serializers.SerializerMethodField()
    endorsed_at = serializers.SerializerMethodField()
293
    child_count = serializers.IntegerField(read_only=True)
294
    children = serializers.SerializerMethodField(required=False)
295

296
    non_updatable_fields = NON_UPDATABLE_COMMENT_FIELDS
297

298 299 300 301 302 303 304 305 306
    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)

307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325
    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):
        """
326
        Returns the role label (i.e. "Staff" or "Community TA") for the
327 328 329 330 331 332 333 334 335 336 337 338 339
        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

340
    def get_children(self, obj):
341
        return [
342
            CommentSerializer(child, context=self.context).data
343 344 345
            for child in obj.get("children", [])
        ]

346 347 348 349 350 351 352 353 354 355 356
    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

357 358 359
    def validate(self, attrs):
        """
        Ensure that parent_id identifies a comment that is actually in the
360 361
        thread identified by thread_id and does not violate the configured
        maximum depth.
362
        """
363
        parent = None
364
        parent_id = attrs.get("parent_id")
365 366 367 368 369 370 371 372 373
        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."
                )
374
        if is_comment_too_deep(parent):
375
            raise ValidationError({"parent_id": ["Comment level is too deep."]})
376 377
        return attrs

378 379
    def create(self, validated_data):
        comment = Comment(
380 381
            course_id=self.context["thread"]["course_id"],
            user_id=self.context["cc_requester"]["id"],
382
            **validated_data
383
        )
384 385 386 387 388 389 390 391 392 393 394 395 396 397
        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
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427


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