api.py 29.6 KB
Newer Older
Greg Price committed
1 2 3
"""
Discussion API internal interface
"""
4
from collections import defaultdict
5 6 7
from urllib import urlencode
from urlparse import urlunparse

8
from django.core.exceptions import ValidationError
9
from django.core.urlresolvers import reverse
10
from django.http import Http404
11
import itertools
12

13 14
from rest_framework.exceptions import PermissionDenied

15
from opaque_keys import InvalidKeyError
16
from opaque_keys.edx.locator import CourseKey
17
from courseware.courses import get_course_with_access
18

19
from discussion_api.exceptions import ThreadNotFoundError, CommentNotFoundError, DiscussionDisabledError
20
from discussion_api.forms import CommentActionsForm, ThreadActionsForm
21 22 23 24 25 26
from discussion_api.permissions import (
    can_delete,
    get_editable_fields,
    get_initializable_comment_fields,
    get_initializable_thread_fields,
)
27
from discussion_api.serializers import CommentSerializer, ThreadSerializer, get_context
28 29 30 31 32
from django_comment_client.base.views import (
    track_comment_created_event,
    track_thread_created_event,
    track_voted_event,
)
33 34 35 36 37 38 39 40
from django_comment_common.signals import (
    thread_created,
    thread_edited,
    thread_deleted,
    thread_voted,
    comment_created,
    comment_edited,
    comment_voted,
41
    comment_deleted,
42
)
43
from django_comment_client.utils import get_accessible_discussion_modules, is_commentable_cohorted
44
from lms.djangoapps.discussion_api.pagination import DiscussionAPIPagination
45
from lms.lib.comment_client.comment import Comment
46
from lms.lib.comment_client.thread import Thread
47
from lms.lib.comment_client.utils import CommentClientRequestError
48
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_id
49
from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError
Greg Price committed
50 51


52
def _get_course(course_key, user):
53
    """
54 55 56
    Get the course descriptor, raising CourseNotFoundError if the course is not found or
    the user cannot access forums for the course, and DiscussionDisabledError if the
    discussion tab is disabled for the course.
57
    """
58 59 60 61
    try:
        course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True)
    except Http404:
        raise CourseNotFoundError("Course not found.")
62
    if not any([tab.type == 'discussion' and tab.is_enabled(course, user) for tab in course.tabs]):
63
        raise DiscussionDisabledError("Discussion is disabled for the course.")
64 65 66
    return course


67
def _get_thread_and_context(request, thread_id, retrieve_kwargs=None):
68 69 70 71
    """
    Retrieve the given thread and build a serializer context for it, returning
    both. This function also enforces access control for the thread (checking
    both the user's access to the course and to the thread's cohort if
72 73
    applicable). Raises ThreadNotFoundError if the thread does not exist or the
    user cannot access it.
74 75 76 77 78 79 80
    """
    retrieve_kwargs = retrieve_kwargs or {}
    try:
        if "mark_as_read" not in retrieve_kwargs:
            retrieve_kwargs["mark_as_read"] = False
        cc_thread = Thread(id=thread_id).retrieve(**retrieve_kwargs)
        course_key = CourseKey.from_string(cc_thread["course_id"])
81
        course = _get_course(course_key, request.user)
82
        context = get_context(course, request, cc_thread)
83 84 85 86 87 88 89
        if (
                not context["is_requester_privileged"] and
                cc_thread["group_id"] and
                is_commentable_cohorted(course.id, cc_thread["commentable_id"])
        ):
            requester_cohort = get_cohort_id(request.user, course.id)
            if requester_cohort is not None and cc_thread["group_id"] != requester_cohort:
90
                raise ThreadNotFoundError("Thread not found.")
91 92 93 94
        return cc_thread, context
    except CommentClientRequestError:
        # params are validated at a higher level, so the only possible request
        # error is if the thread doesn't exist
95
        raise ThreadNotFoundError("Thread not found.")
96 97


98
def _get_comment_and_context(request, comment_id):
99
    """
100 101 102
    Retrieve the given comment and build a serializer context for it, returning
    both. This function also enforces access control for the comment (checking
    both the user's access to the course and to the comment's thread's cohort if
103 104
    applicable). Raises CommentNotFoundError if the comment does not exist or the
    user cannot access it.
105 106 107
    """
    try:
        cc_comment = Comment(id=comment_id).retrieve()
108
        _, context = _get_thread_and_context(request, cc_comment["thread_id"])
109 110
        return cc_comment, context
    except CommentClientRequestError:
111
        raise CommentNotFoundError("Comment not found.")
112 113


114 115 116 117 118 119 120 121 122 123 124 125 126 127
def _is_user_author_or_privileged(cc_content, context):
    """
    Check if the user is the author of a content object or a privileged user.

    Returns:
        Boolean
    """
    return (
        context["is_requester_privileged"] or
        context["cc_requester"]["id"] == cc_content["user_id"]
    )


def get_thread_list_url(request, course_key, topic_id_list=None, following=False):
128 129 130 131
    """
    Returns the URL for the thread_list_url field, given a list of topic_ids
    """
    path = reverse("thread-list")
132 133
    query_list = (
        [("course_id", unicode(course_key))] +
134 135
        [("topic_id", topic_id) for topic_id in topic_id_list or []] +
        ([("following", following)] if following else [])
136
    )
137 138 139
    return request.build_absolute_uri(urlunparse(("", "", path, "", urlencode(query_list), "")))


140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157
def get_course(request, course_key):
    """
    Return general discussion information for the course.

    Parameters:

        request: The django request object used for build_absolute_uri and
          determining the requesting user.

        course_key: The key of the course to get information for

    Returns:

        The course information; see discussion_api.views.CourseView for more
        detail.

    Raises:

158 159
        CourseNotFoundError: if the course does not exist or is not accessible
        to the requesting user
160
    """
161
    course = _get_course(course_key, request.user)
162 163 164 165 166 167
    return {
        "id": unicode(course_key),
        "blackouts": [
            {"start": blackout["start"].isoformat(), "end": blackout["end"].isoformat()}
            for blackout in course.get_discussion_blackout_datetimes()
        ],
168 169
        "thread_list_url": get_thread_list_url(request, course_key),
        "following_thread_list_url": get_thread_list_url(request, course_key, following=True),
170 171 172 173 174 175
        "topics_url": request.build_absolute_uri(
            reverse("course_topics", kwargs={"course_id": course_key})
        )
    }


176
def get_course_topics(request, course_key):
Greg Price committed
177 178 179 180 181
    """
    Return the course topic listing for the given course and user.

    Parameters:

182
    course_key: The key of the course to get topics for
Greg Price committed
183 184 185 186 187 188 189 190 191 192 193 194 195
    user: The requesting user, for access control

    Returns:

    A course topic listing dictionary; see discussion_api.views.CourseTopicViews
    for more detail.
    """
    def get_module_sort_key(module):
        """
        Get the sort key for the module (falling back to the discussion_target
        setting if absent)
        """
        return module.sort_key or module.discussion_target
196
    course = _get_course(course_key, request.user)
197
    discussion_modules = get_accessible_discussion_modules(course, request.user)
Greg Price committed
198 199 200
    modules_by_category = defaultdict(list)
    for module in discussion_modules:
        modules_by_category[module.discussion_category].append(module)
201 202 203 204 205

    def get_sorted_modules(category):
        """Returns key sorted modules by category"""
        return sorted(modules_by_category[category], key=get_module_sort_key)

Greg Price committed
206 207 208 209
    courseware_topics = [
        {
            "id": None,
            "name": category,
210 211 212 213 214
            "thread_list_url": get_thread_list_url(
                request,
                course_key,
                [item.discussion_id for item in get_sorted_modules(category)]
            ),
Greg Price committed
215 216 217 218
            "children": [
                {
                    "id": module.discussion_id,
                    "name": module.discussion_target,
219
                    "thread_list_url": get_thread_list_url(request, course_key, [module.discussion_id]),
Greg Price committed
220 221
                    "children": [],
                }
222
                for module in get_sorted_modules(category)
Greg Price committed
223 224 225 226 227 228 229 230 231
            ],
        }
        for category in sorted(modules_by_category.keys())
    ]

    non_courseware_topics = [
        {
            "id": entry["id"],
            "name": name,
232
            "thread_list_url": get_thread_list_url(request, course_key, [entry["id"]]),
Greg Price committed
233 234 235 236 237 238 239 240 241 242 243 244
            "children": [],
        }
        for name, entry in sorted(
            course.discussion_topics.items(),
            key=lambda item: item[1].get("sort_key", item[0])
        )
    ]

    return {
        "courseware_topics": courseware_topics,
        "non_courseware_topics": non_courseware_topics,
    }
245 246


247 248 249 250 251 252 253 254
def get_thread_list(
        request,
        course_key,
        page,
        page_size,
        topic_id_list=None,
        text_search=None,
        following=False,
255 256 257 258
        view=None,
        order_by="last_activity_at",
        order_direction="desc",
):
259 260 261 262 263 264
    """
    Return the list of all discussion threads pertaining to the given course

    Parameters:

    request: The django request objects used for build_absolute_uri
265
    course_key: The key of the course to get discussion threads for
266 267
    page: The page number (1-indexed) to retrieve
    page_size: The number of threads to retrieve per page
268
    topic_id_list: The list of topic_ids to get the discussion threads for
269
    text_search A text search query string to match
270
    following: If true, retrieve only threads the requester is following
271
    view: filters for either "unread" or "unanswered" threads
272 273 274 275 276
    order_by: The key in which to sort the threads by. The only values are
        "last_activity_at", "comment_count", and "vote_count". The default is
        "last_activity_at".
    order_direction: The direction in which to sort the threads by. The only
        values are "asc" or "desc". The default is "desc".
277

278
    Note that topic_id_list, text_search, and following are mutually exclusive.
279 280 281 282 283

    Returns:

    A paginated result containing a list of threads; see
    discussion_api.views.ThreadViewSet for more detail.
284 285 286

    Raises:

287
    ValidationError: if an invalid value is passed for a field.
288 289
    ValueError: if more than one of the mutually exclusive parameters is
      provided
290 291
    CourseNotFoundError: if the requesting user does not have access to the requested course
    PageNotFoundError: if page requested is beyond the last
292
    """
293
    exclusive_param_count = sum(1 for param in [topic_id_list, text_search, following] if param)
294 295 296
    if exclusive_param_count > 1:  # pragma: no cover
        raise ValueError("More than one mutually exclusive param passed to get_thread_list")

297
    cc_map = {"last_activity_at": "activity", "comment_count": "comments", "vote_count": "votes"}
298 299 300 301 302 303 304 305 306 307
    if order_by not in cc_map:
        raise ValidationError({
            "order_by":
                ["Invalid value. '{}' must be 'last_activity_at', 'comment_count', or 'vote_count'".format(order_by)]
        })
    if order_direction not in ["asc", "desc"]:
        raise ValidationError({
            "order_direction": ["Invalid value. '{}' must be 'asc' or 'desc'".format(order_direction)]
        })

308
    course = _get_course(course_key, request.user)
309
    context = get_context(course, request)
310

311
    query_params = {
312
        "user_id": unicode(request.user.id),
313 314 315 316
        "group_id": (
            None if context["is_requester_privileged"] else
            get_cohort_id(request.user, course.id)
        ),
317
        "page": page,
318
        "per_page": page_size,
319
        "text": text_search,
320 321
        "sort_key": cc_map.get(order_by),
        "sort_order": order_direction,
322
    }
323

324
    text_search_rewrite = None
325 326 327 328 329 330 331 332 333

    if view:
        if view in ["unread", "unanswered"]:
            query_params[view] = "true"
        else:
            ValidationError({
                "view": ["Invalid value. '{}' must be 'unread' or 'unanswered'".format(view)]
            })

334
    if following:
335
        paginated_results = context["cc_requester"].subscribed_threads(query_params)
336 337 338 339
    else:
        query_params["course_id"] = unicode(course.id)
        query_params["commentable_ids"] = ",".join(topic_id_list) if topic_id_list else None
        query_params["text"] = text_search
340
        paginated_results = Thread.search(query_params)
341 342
    # The comments service returns the last page of results if the requested
    # page is beyond the last page, but we want be consistent with DRF's general
343
    # behavior and return a PageNotFoundError in that case
344
    if paginated_results.page != page:
345
        raise PageNotFoundError("Page not found (No results on this page).")
346

347
    results = [ThreadSerializer(thread, context=context).data for thread in paginated_results.collection]
348

349 350 351 352 353 354
    paginator = DiscussionAPIPagination(
        request,
        paginated_results.page,
        paginated_results.num_pages,
        paginated_results.thread_count
    )
355 356
    return paginator.get_paginated_response({
        "results": results,
357
        "text_search_rewrite": paginated_results.corrected_text,
358
    })
359 360


361
def get_comment_list(request, thread_id, endorsed, page, page_size):
362 363 364
    """
    Return the list of comments in the given thread.

365
    Arguments:
366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385

        request: The django request object used for build_absolute_uri and
          determining the requesting user.

        thread_id: The id of the thread to get comments for.

        endorsed: Boolean indicating whether to get endorsed or non-endorsed
          comments (or None for all comments). Must be None for a discussion
          thread and non-None for a question thread.

        page: The page number (1-indexed) to retrieve

        page_size: The number of comments to retrieve per page

    Returns:

        A paginated result containing a list of comments; see
        discussion_api.views.CommentViewSet for more detail.
    """
    response_skip = page_size * (page - 1)
386 387 388 389
    cc_thread, context = _get_thread_and_context(
        request,
        thread_id,
        retrieve_kwargs={
390
            "recursive": False,
391 392 393 394 395
            "user_id": request.user.id,
            "response_skip": response_skip,
            "response_limit": page_size,
        }
    )
396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420

    # Responses to discussion threads cannot be separated by endorsed, but
    # responses to question threads must be separated by endorsed due to the
    # existing comments service interface
    if cc_thread["thread_type"] == "question":
        if endorsed is None:
            raise ValidationError({"endorsed": ["This field is required for question threads."]})
        elif endorsed:
            # CS does not apply resp_skip and resp_limit to endorsed responses
            # of a question post
            responses = cc_thread["endorsed_responses"][response_skip:(response_skip + page_size)]
            resp_total = len(cc_thread["endorsed_responses"])
        else:
            responses = cc_thread["non_endorsed_responses"]
            resp_total = cc_thread["non_endorsed_resp_total"]
    else:
        if endorsed is not None:
            raise ValidationError(
                {"endorsed": ["This field may not be specified for discussion threads."]}
            )
        responses = cc_thread["children"]
        resp_total = cc_thread["resp_total"]

    # The comments service returns the last page of results if the requested
    # page is beyond the last page, but we want be consistent with DRF's general
421
    # behavior and return a PageNotFoundError in that case
422
    if not responses and page != 1:
423
        raise PageNotFoundError("Page not found (No results on this page).")
424 425 426
    num_pages = (resp_total + page_size - 1) / page_size if resp_total else 1

    results = [CommentSerializer(response, context=context).data for response in responses]
427
    paginator = DiscussionAPIPagination(request, page, num_pages, resp_total)
428
    return paginator.get_paginated_response(results)
429 430


431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490
def _check_fields(allowed_fields, data, message):
    """
    Checks that the keys given in data is in allowed_fields

    Arguments:
        allowed_fields (set): A set of allowed fields
        data (dict): The data to compare the allowed_fields against
        message (str): The message to return if there are any invalid fields

    Raises:
        ValidationError if the given data contains a key that is not in
            allowed_fields
    """
    non_allowed_fields = {field: [message] for field in data.keys() if field not in allowed_fields}
    if non_allowed_fields:
        raise ValidationError(non_allowed_fields)


def _check_initializable_thread_fields(data, context):  # pylint: disable=invalid-name
    """
    Checks if the given data contains a thread field that is not initializable
    by the requesting user

    Arguments:
        data (dict): The data to compare the allowed_fields against
        context (dict): The context appropriate for use with the thread which
            includes the requesting user

    Raises:
        ValidationError if the given data contains a thread field that is not
            initializable by the requesting user
    """
    _check_fields(
        get_initializable_thread_fields(context),
        data,
        "This field is not initializable."
    )


def _check_initializable_comment_fields(data, context):  # pylint: disable=invalid-name
    """
    Checks if the given data contains a comment field that is not initializable
    by the requesting user

    Arguments:
        data (dict): The data to compare the allowed_fields against
        context (dict): The context appropriate for use with the comment which
            includes the requesting user

    Raises:
        ValidationError if the given data contains a comment field that is not
            initializable by the requesting user
    """
    _check_fields(
        get_initializable_comment_fields(context),
        data,
        "This field is not initializable."
    )


491 492 493
def _check_editable_fields(cc_content, data, context):
    """
    Raise ValidationError if the given update data contains a field that is not
494
    editable by the requesting user
495
    """
496 497 498 499 500
    _check_fields(
        get_editable_fields(cc_content, context),
        data,
        "This field is not editable."
    )
501 502


503
def _do_extra_actions(api_content, cc_content, request_fields, actions_form, context, request):
504
    """
505
    Perform any necessary additional actions related to content creation or
506 507
    update that require a separate comments service request.
    """
508
    for field, form_value in actions_form.cleaned_data.items():
509 510
        if field in request_fields and form_value != api_content[field]:
            api_content[field] = form_value
511
            if field == "following":
512
                _handle_following_field(form_value, context["cc_requester"], cc_content)
513
            elif field == "abuse_flagged":
514 515 516
                _handle_abuse_flagged_field(form_value, context["cc_requester"], cc_content)
            elif field == "voted":
                _handle_voted_field(form_value, cc_content, api_content, request, context)
517 518
            else:
                raise ValidationError({field: ["Invalid Key"]})
519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549


def _handle_following_field(form_value, user, cc_content):
    """follow/unfollow thread for the user"""
    if form_value:
        user.follow(cc_content)
    else:
        user.unfollow(cc_content)


def _handle_abuse_flagged_field(form_value, user, cc_content):
    """mark or unmark thread/comment as abused"""
    if form_value:
        cc_content.flagAbuse(user, cc_content)
    else:
        cc_content.unFlagAbuse(user, cc_content, removeAll=False)


def _handle_voted_field(form_value, cc_content, api_content, request, context):
    """vote or undo vote on thread/comment"""
    signal = thread_voted if cc_content.type == 'thread' else comment_voted
    signal.send(sender=None, user=context["request"].user, post=cc_content)
    if form_value:
        context["cc_requester"].vote(cc_content, "up")
        api_content["vote_count"] += 1
    else:
        context["cc_requester"].unvote(cc_content)
        api_content["vote_count"] -= 1
    track_voted_event(
        request, context["course"], cc_content, vote_value="up", undo_vote=False if form_value else True
    )
550 551


552 553 554 555
def create_thread(request, thread_data):
    """
    Create a thread.

556
    Arguments:
557 558 559 560 561 562 563 564 565 566 567 568

        request: The django request object used for build_absolute_uri and
          determining the requesting user.

        thread_data: The data for the created thread.

    Returns:

        The created thread; see discussion_api.views.ThreadViewSet for more
        detail.
    """
    course_id = thread_data.get("course_id")
569
    user = request.user
570 571 572
    if not course_id:
        raise ValidationError({"course_id": ["This field is required."]})
    try:
573
        course_key = CourseKey.from_string(course_id)
574 575
        course = _get_course(course_key, user)
    except InvalidKeyError:
576 577 578
        raise ValidationError({"course_id": ["Invalid value."]})

    context = get_context(course, request)
579 580 581 582 583 584
    _check_initializable_thread_fields(thread_data, context)
    if (
            "group_id" not in thread_data and
            is_commentable_cohorted(course_key, thread_data.get("topic_id"))
    ):
        thread_data = thread_data.copy()
585
        thread_data["group_id"] = get_cohort_id(user, course_key)
586
    serializer = ThreadSerializer(data=thread_data, context=context)
587 588 589
    actions_form = ThreadActionsForm(thread_data)
    if not (serializer.is_valid() and actions_form.is_valid()):
        raise ValidationError(dict(serializer.errors.items() + actions_form.errors.items()))
590
    serializer.save()
591
    cc_thread = serializer.instance
592
    thread_created.send(sender=None, user=user, post=cc_thread)
593
    api_thread = serializer.data
594
    _do_extra_actions(api_thread, cc_thread, thread_data.keys(), actions_form, context, request)
595

596
    track_thread_created_event(request, course, cc_thread, actions_form.cleaned_data["following"])
597

598
    return api_thread
599 600 601 602 603 604


def create_comment(request, comment_data):
    """
    Create a comment.

605
    Arguments:
606 607 608 609 610 611 612 613 614 615 616 617 618 619

        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."]})
620
    cc_thread, context = _get_thread_and_context(request, thread_id)
621

622 623 624 625
    # if a thread is closed; no new comments could be made to it
    if cc_thread['closed']:
        raise PermissionDenied

626
    _check_initializable_comment_fields(comment_data, context)
627
    serializer = CommentSerializer(data=comment_data, context=context)
628 629 630
    actions_form = CommentActionsForm(comment_data)
    if not (serializer.is_valid() and actions_form.is_valid()):
        raise ValidationError(dict(serializer.errors.items() + actions_form.errors.items()))
631
    serializer.save()
632
    cc_comment = serializer.instance
633
    comment_created.send(sender=None, user=request.user, post=cc_comment)
634
    api_comment = serializer.data
635
    _do_extra_actions(api_comment, cc_comment, comment_data.keys(), actions_form, context, request)
636

637
    track_comment_created_event(request, context["course"], cc_comment, cc_thread["commentable_id"], followed=False)
638

639
    return api_comment
640 641


642 643 644 645
def update_thread(request, thread_id, update_data):
    """
    Update a thread.

646
    Arguments:
647 648 649 650 651 652 653 654 655 656 657 658 659 660

        request: The django request object used for build_absolute_uri and
          determining the requesting user.

        thread_id: The id for the thread to update.

        update_data: The data to update in the thread.

    Returns:

        The updated thread; see discussion_api.views.ThreadViewSet for more
        detail.
    """
    cc_thread, context = _get_thread_and_context(request, thread_id)
661
    _check_editable_fields(cc_thread, update_data, context)
662
    serializer = ThreadSerializer(cc_thread, data=update_data, partial=True, context=context)
663 664 665 666 667 668
    actions_form = ThreadActionsForm(update_data)
    if not (serializer.is_valid() and actions_form.is_valid()):
        raise ValidationError(dict(serializer.errors.items() + actions_form.errors.items()))
    # Only save thread object if some of the edited fields are in the thread data, not extra actions
    if set(update_data) - set(actions_form.fields):
        serializer.save()
669
        # signal to update Teams when a user edits a thread
670
        thread_edited.send(sender=None, user=request.user, post=cc_thread)
671
    api_thread = serializer.data
672
    _do_extra_actions(api_thread, cc_thread, update_data.keys(), actions_form, context, request)
673
    return api_thread
674 675


676 677 678 679
def update_comment(request, comment_id, update_data):
    """
    Update a comment.

680
    Arguments:
681 682 683 684 685 686 687 688 689 690 691 692 693 694 695

        request: The django request object used for build_absolute_uri and
          determining the requesting user.

        comment_id: The id for the comment to update.

        update_data: The data to update in the comment.

    Returns:

        The updated comment; see discussion_api.views.CommentViewSet for more
        detail.

    Raises:

696 697
        CommentNotFoundError: if the comment does not exist or is not accessible
        to the requesting user
698 699 700 701 702 703 704 705

        PermissionDenied: if the comment is accessible to but not editable by
          the requesting user

        ValidationError: if there is an error applying the update (e.g. raw_body
          is empty or thread_id is included)
    """
    cc_comment, context = _get_comment_and_context(request, comment_id)
706
    _check_editable_fields(cc_comment, update_data, context)
707
    serializer = CommentSerializer(cc_comment, data=update_data, partial=True, context=context)
708 709 710
    actions_form = CommentActionsForm(update_data)
    if not (serializer.is_valid() and actions_form.is_valid()):
        raise ValidationError(dict(serializer.errors.items() + actions_form.errors.items()))
711
    # Only save comment object if some of the edited fields are in the comment data, not extra actions
712
    if set(update_data) - set(actions_form.fields):
713
        serializer.save()
714
        comment_edited.send(sender=None, user=request.user, post=cc_comment)
715
    api_comment = serializer.data
716
    _do_extra_actions(api_comment, cc_comment, update_data.keys(), actions_form, context, request)
717
    return api_comment
718 719


720 721 722 723 724 725 726 727 728 729 730 731
def get_thread(request, thread_id):
    """
    Retrieve a thread.

    Arguments:

        request: The django request object used for build_absolute_uri and
          determining the requesting user.

        thread_id: The id for the thread to retrieve

    """
732 733 734 735 736
    cc_thread, context = _get_thread_and_context(
        request,
        thread_id,
        retrieve_kwargs={"user_id": unicode(request.user.id)}
    )
737 738 739 740
    serializer = ThreadSerializer(cc_thread, context=context)
    return serializer.data


741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762
def get_response_comments(request, comment_id, page, page_size):
    """
    Return the list of comments for the given thread response.

    Arguments:

        request: The django request object used for build_absolute_uri and
          determining the requesting user.

        comment_id: The id of the comment/response to get child comments for.

        page: The page number (1-indexed) to retrieve

        page_size: The number of comments to retrieve per page

    Returns:

        A paginated result containing a list of comments

    """
    try:
        cc_comment = Comment(id=comment_id).retrieve()
763 764 765 766 767 768 769
        cc_thread, context = _get_thread_and_context(
            request,
            cc_comment["thread_id"],
            retrieve_kwargs={
                "recursive": True,
            }
        )
770
        if cc_thread["thread_type"] == "question":
771
            thread_responses = itertools.chain(cc_thread["endorsed_responses"], cc_thread["non_endorsed_responses"])
772 773 774 775 776 777 778 779 780 781
        else:
            thread_responses = cc_thread["children"]
        response_comments = []
        for response in thread_responses:
            if response["id"] == comment_id:
                response_comments = response["children"]
                break

        response_skip = page_size * (page - 1)
        paged_response_comments = response_comments[response_skip:(response_skip + page_size)]
782 783 784
        if len(paged_response_comments) == 0 and page != 1:
            raise PageNotFoundError("Page not found (No results on this page).")

785 786 787 788
        results = [CommentSerializer(comment, context=context).data for comment in paged_response_comments]

        comments_count = len(response_comments)
        num_pages = (comments_count + page_size - 1) / page_size if comments_count else 1
789
        paginator = DiscussionAPIPagination(request, page, num_pages, comments_count)
790
        return paginator.get_paginated_response(results)
791
    except CommentClientRequestError:
792
        raise CommentNotFoundError("Comment not found")
793 794


795 796 797 798
def delete_thread(request, thread_id):
    """
    Delete a thread.

799
    Arguments:
800 801 802 803 804 805 806 807 808 809 810 811

        request: The django request object used for build_absolute_uri and
          determining the requesting user.

        thread_id: The id for the thread to delete

    Raises:

        PermissionDenied: if user does not have permission to delete thread

    """
    cc_thread, context = _get_thread_and_context(request, thread_id)
812
    if can_delete(cc_thread, context):
813
        cc_thread.delete()
814
        thread_deleted.send(sender=None, user=request.user, post=cc_thread)
815 816
    else:
        raise PermissionDenied
817 818 819 820 821 822


def delete_comment(request, comment_id):
    """
    Delete a comment.

823
    Arguments:
824 825 826 827 828 829 830 831 832 833 834 835

        request: The django request object used for build_absolute_uri and
          determining the requesting user.

        comment_id: The id of the comment to delete

    Raises:

        PermissionDenied: if user does not have permission to delete thread

    """
    cc_comment, context = _get_comment_and_context(request, comment_id)
836
    if can_delete(cc_comment, context):
837
        cc_comment.delete()
838
        comment_deleted.send(sender=None, user=request.user, post=cc_comment)
839 840
    else:
        raise PermissionDenied