utils.py 28 KB
Newer Older
Arjun Singh committed
1
from collections import defaultdict
2
from datetime import datetime
3 4
import json
import logging
5

6
import pytz
Brian Wilson committed
7 8 9
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.db import connection
Rocky Duan committed
10
from django.http import HttpResponse
11
from django.utils.timezone import UTC
12 13 14 15
import pystache_custom as pystache
from opaque_keys.edx.locations import i4xEncoder
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
16

17
from django_comment_common.models import Role, FORUM_ROLE_STUDENT
18
from django_comment_client.permissions import check_permissions_by_view, has_permission, get_team
19
from django_comment_client.settings import MAX_COMMENT_DEPTH
20
from edxmako import lookup_template
21

22
from courseware import courses
23
from courseware.access import has_access
24
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
25
from openedx.core.djangoapps.course_groups.cohorts import (
26
    get_course_cohort_settings, get_cohort_by_id, get_cohort_id, is_course_cohorted
27
)
28
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
29

30

31
log = logging.getLogger(__name__)
32

Calen Pennington committed
33

Rocky Duan committed
34
def extract(dic, keys):
35
    return {k: dic.get(k) for k in keys}
Rocky Duan committed
36

Calen Pennington committed
37

Rocky Duan committed
38
def strip_none(dic):
Rocky Duan committed
39 40
    return dict([(k, v) for k, v in dic.iteritems() if v is not None])

Calen Pennington committed
41

Rocky Duan committed
42 43 44 45
def strip_blank(dic):
    def _is_blank(v):
        return isinstance(v, str) and len(v.strip()) == 0
    return dict([(k, v) for k, v in dic.iteritems() if not _is_blank(v)])
Rocky Duan committed
46

47
# TODO should we be checking if d1 and d2 have the same keys with different values?
Calen Pennington committed
48 49


50 51 52
def merge_dict(dic1, dic2):
    return dict(dic1.items() + dic2.items())

Calen Pennington committed
53

54
def get_role_ids(course_id):
55 56
    roles = Role.objects.filter(course_id=course_id).exclude(name=FORUM_ROLE_STUDENT)
    return dict([(role.name, list(role.users.values_list('id', flat=True))) for role in roles])
57

Calen Pennington committed
58

59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
def has_discussion_privileges(user, course_id):
    """Returns True if the user is privileged in teams discussions for
    this course. The user must be one of Discussion Admin, Moderator,
    or Community TA.

    Args:
      user (User): The user to check privileges for.
      course_id (CourseKey): A key for the course to check privileges for.

    Returns:
      bool
    """
    # get_role_ids returns a dictionary of only admin, moderator and community TAs.
    roles = get_role_ids(course_id)
    for role in roles:
        if user.id in roles[role]:
            return True
    return False


Brian Wilson committed
79 80 81 82 83 84 85
def has_forum_access(uname, course_id, rolename):
    try:
        role = Role.objects.get(name=rolename, course_id=course_id)
    except Role.DoesNotExist:
        return False
    return role.users.filter(username=uname).exists()

Calen Pennington committed
86

87 88 89
def has_required_keys(module):
    """Returns True iff module has the proper attributes for generating metadata with get_discussion_id_map_entry()"""
    for key in ('discussion_id', 'discussion_category', 'discussion_target'):
90
        if getattr(module, key, None) is None:
91 92 93 94 95
            log.debug("Required key '%s' not in discussion %s, leaving out of category map", key, module.location)
            return False
    return True


96
def get_accessible_discussion_modules(course, user, include_all=False):  # pylint: disable=invalid-name
97
    """
98 99
    Return a list of all valid discussion modules in this course that
    are accessible to the given user.
100
    """
101
    all_modules = modulestore().get_items(course.id, qualifiers={'category': 'discussion'})
102

103 104
    return [
        module for module in all_modules
105
        if has_required_keys(module) and (include_all or has_access(user, 'load', module, course.id))
106
    ]
107 108


109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
def get_discussion_id_map_entry(module):
    """
    Returns a tuple of (discussion_id, metadata) suitable for inclusion in the results of get_discussion_id_map().
    """
    return (
        module.discussion_id,
        {
            "location": module.location,
            "title": module.discussion_category.split("/")[-1].strip() + " / " + module.discussion_target
        }
    )


class DiscussionIdMapIsNotCached(Exception):
    """Thrown when the discussion id map is not cached for this course, but an attempt was made to access it."""
    pass


def get_cached_discussion_key(course, discussion_id):
    """
    Returns the usage key of the discussion module associated with discussion_id if it is cached. If the discussion id
    map is cached but does not contain discussion_id, returns None. If the discussion id map is not cached for course,
    raises a DiscussionIdMapIsNotCached exception.
    """
    try:
        cached_mapping = CourseStructure.objects.get(course_id=course.id).discussion_id_map
        if not cached_mapping:
            raise DiscussionIdMapIsNotCached()
        return cached_mapping.get(discussion_id)
    except CourseStructure.DoesNotExist:
        raise DiscussionIdMapIsNotCached()


142
def get_cached_discussion_id_map(course, discussion_ids, user):
143
    """
144 145
    Returns a dict mapping discussion_ids to respective discussion module metadata if it is cached and visible to the
    user. If not, returns the result of get_discussion_id_map
146 147
    """
    try:
148 149 150 151 152 153 154 155 156 157
        entries = []
        for discussion_id in discussion_ids:
            key = get_cached_discussion_key(course, discussion_id)
            if not key:
                continue
            module = modulestore().get_item(key)
            if not (has_required_keys(module) and has_access(user, 'load', module, course.id)):
                continue
            entries.append(get_discussion_id_map_entry(module))
        return dict(entries)
158 159 160 161
    except DiscussionIdMapIsNotCached:
        return get_discussion_id_map(course, user)


162
def get_discussion_id_map(course, user):
163
    """
164 165
    Transform the list of this course's discussion modules (visible to a given user) into a dictionary of metadata keyed
    by discussion_id.
166
    """
167
    return dict(map(get_discussion_id_map_entry, get_accessible_discussion_modules(course, user)))
168

Calen Pennington committed
169

170
def _filter_unstarted_categories(category_map):
171

172
    now = datetime.now(UTC())
173 174 175 176

    result_map = {}

    unfiltered_queue = [category_map]
177
    filtered_queue = [result_map]
178

polesye committed
179
    while unfiltered_queue:
180 181

        unfiltered_map = unfiltered_queue.pop()
182
        filtered_map = filtered_queue.pop()
183 184 185 186 187 188 189

        filtered_map["children"] = []
        filtered_map["entries"] = {}
        filtered_map["subcategories"] = {}

        for child in unfiltered_map["children"]:
            if child in unfiltered_map["entries"]:
190
                if unfiltered_map["entries"][child]["start_date"] <= now:
191 192 193 194 195 196
                    filtered_map["children"].append(child)
                    filtered_map["entries"][child] = {}
                    for key in unfiltered_map["entries"][child]:
                        if key != "start_date":
                            filtered_map["entries"][child][key] = unfiltered_map["entries"][child][key]
                else:
197
                    log.debug(u"Filtering out:%s with start_date: %s", child, unfiltered_map["entries"][child]["start_date"])
198 199 200 201 202 203 204 205
            else:
                if unfiltered_map["subcategories"][child]["start_date"] < now:
                    filtered_map["children"].append(child)
                    filtered_map["subcategories"][child] = {}
                    unfiltered_queue.append(unfiltered_map["subcategories"][child])
                    filtered_queue.append(filtered_map["subcategories"][child])

    return result_map
Arjun Singh committed
206

David Baumgold committed
207

208
def _sort_map_entries(category_map, sort_alpha):
Arjun Singh committed
209 210
    things = []
    for title, entry in category_map["entries"].items():
stv committed
211
        if entry["sort_key"] is None and sort_alpha:
212
            entry["sort_key"] = title
Arjun Singh committed
213 214 215
        things.append((title, entry))
    for title, category in category_map["subcategories"].items():
        things.append((title, category))
216
        _sort_map_entries(category_map["subcategories"][title], sort_alpha)
Arjun Singh committed
217 218
    category_map["children"] = [x[0] for x in sorted(things, key=lambda x: x[1]["sort_key"])]

219

220
def get_discussion_category_map(course, user, cohorted_if_in_list=False, exclude_unstarted=True):
221
    """
222
    Transform the list of this course's discussion modules into a recursive dictionary structure.  This is used
223
    to render the discussion category map in the discussion tab sidebar for a given user.
224 225 226 227 228 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 255 256 257 258 259 260 261 262 263

    Args:
        course: Course for which to get the ids.
        user:  User to check for access.
        cohorted_if_in_list (bool): If True, inline topics are marked is_cohorted only if they are
            in course_cohort_settings.discussion_topics.

    Example:
        >>> example = {
        >>>               "entries": {
        >>>                   "General": {
        >>>                       "sort_key": "General",
        >>>                       "is_cohorted": True,
        >>>                       "id": "i4x-edx-eiorguegnru-course-foobarbaz"
        >>>                   }
        >>>               },
        >>>               "children": ["General", "Getting Started"],
        >>>               "subcategories": {
        >>>                   "Getting Started": {
        >>>                       "subcategories": {},
        >>>                       "children": [
        >>>                           "Working with Videos",
        >>>                           "Videos on edX"
        >>>                       ],
        >>>                       "entries": {
        >>>                           "Working with Videos": {
        >>>                               "sort_key": None,
        >>>                               "is_cohorted": False,
        >>>                               "id": "d9f970a42067413cbb633f81cfb12604"
        >>>                           },
        >>>                           "Videos on edX": {
        >>>                               "sort_key": None,
        >>>                               "is_cohorted": False,
        >>>                               "id": "98d8feb5971041a085512ae22b398613"
        >>>                           }
        >>>                       }
        >>>                   }
        >>>               }
        >>>          }

264
    """
Arjun Singh committed
265
    unexpanded_category_map = defaultdict(list)
Arjun Singh committed
266

267
    modules = get_accessible_discussion_modules(course, user)
268

269
    course_cohort_settings = get_course_cohort_settings(course.id)
270

271
    for module in modules:
272 273 274
        id = module.discussion_id
        title = module.discussion_target
        sort_key = module.sort_key
275
        category = " / ".join([x.strip() for x in module.discussion_category.split("/")])
276
        # Handle case where module.start is None
Calen Pennington committed
277
        entry_start_date = module.start if module.start else datetime.max.replace(tzinfo=pytz.UTC)
278
        unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": entry_start_date})
Arjun Singh committed
279

280
    category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
Arjun Singh committed
281 282 283
    for category_path, entries in unexpanded_category_map.items():
        node = category_map["subcategories"]
        path = [x.strip() for x in category_path.split("/")]
284 285 286 287 288 289 290

        # Find the earliest start date for the entries in this category
        category_start_date = None
        for entry in entries:
            if category_start_date is None or entry["start_date"] < category_start_date:
                category_start_date = entry["start_date"]

Arjun Singh committed
291 292
        for level in path[:-1]:
            if level not in node:
293
                node[level] = {"subcategories": defaultdict(dict),
Arjun Singh committed
294
                               "entries": defaultdict(dict),
295 296 297 298 299
                               "sort_key": level,
                               "start_date": category_start_date}
            else:
                if node[level]["start_date"] > category_start_date:
                    node[level]["start_date"] = category_start_date
Arjun Singh committed
300 301 302 303
            node = node[level]["subcategories"]

        level = path[-1]
        if level not in node:
304
            node[level] = {"subcategories": defaultdict(dict),
305 306 307
                           "entries": defaultdict(dict),
                           "sort_key": level,
                           "start_date": category_start_date}
308 309 310 311
        else:
            if node[level]["start_date"] > category_start_date:
                node[level]["start_date"] = category_start_date

312 313 314
        always_cohort_inline_discussions = (  # pylint: disable=invalid-name
            not cohorted_if_in_list and course_cohort_settings.always_cohort_inline_discussions
        )
315
        dupe_counters = defaultdict(lambda: 0)  # counts the number of times we see each title
Arjun Singh committed
316
        for entry in entries:
317 318 319 320 321 322
            is_entry_cohorted = (
                course_cohort_settings.is_cohorted and (
                    always_cohort_inline_discussions or entry["id"] in course_cohort_settings.cohorted_discussions
                )
            )

323 324 325 326 327 328 329 330 331
            title = entry["title"]
            if node[level]["entries"][title]:
                # If we've already seen this title, append an incrementing number to disambiguate
                # the category from other categores sharing the same title in the course discussion UI.
                dupe_counters[title] += 1
                title = u"{title} ({counter})".format(title=title, counter=dupe_counters[title])
            node[level]["entries"][title] = {"id": entry["id"],
                                             "sort_key": entry["sort_key"],
                                             "start_date": entry["start_date"],
332
                                             "is_cohorted": is_entry_cohorted}
Arjun Singh committed
333

334 335 336
    # TODO.  BUG! : course location is not unique across multiple course runs!
    # (I think Kevin already noticed this)  Need to send course_id with requests, store it
    # in the backend.
337
    for topic, entry in course.discussion_topics.items():
338 339 340 341 342 343 344
        category_map['entries'][topic] = {
            "id": entry["id"],
            "sort_key": entry.get("sort_key", topic),
            "start_date": datetime.now(UTC()),
            "is_cohorted": (course_cohort_settings.is_cohorted and
                            entry["id"] in course_cohort_settings.cohorted_discussions)
        }
345

346
    _sort_map_entries(category_map, course.discussion_sort_alpha)
347

348
    return _filter_unstarted_categories(category_map) if exclude_unstarted else category_map
349 350


351 352
def discussion_category_id_access(course, user, discussion_id):
    """
Ben McMorran committed
353
    Returns True iff the given discussion_id is accessible for user in course.
354
    Assumes that the commentable identified by discussion_id has a null or 'course' context.
Ben McMorran committed
355 356
    Uses the discussion id cache if available, falling back to
    get_discussion_categories_ids if there is no cache.
357 358 359 360 361 362 363 364 365 366 367 368 369
    """
    if discussion_id in course.top_level_discussion_topic_ids:
        return True
    try:
        key = get_cached_discussion_key(course, discussion_id)
        if not key:
            return False
        module = modulestore().get_item(key)
        return has_required_keys(module) and has_access(user, 'load', module, course.id)
    except DiscussionIdMapIsNotCached:
        return discussion_id in get_discussion_categories_ids(course, user)


370
def get_discussion_categories_ids(course, user, include_all=False):
polesye committed
371
    """
372 373
    Returns a list of available ids of categories for the course that
    are accessible to the given user.
374 375 376 377 378 379

    Args:
        course: Course for which to get the ids.
        user:  User to check for access.
        include_all (bool): If True, return all ids. Used by configuration views.

polesye committed
380
    """
381
    accessible_discussion_ids = [
382
        module.discussion_id for module in get_accessible_discussion_modules(course, user, include_all=include_all)
383 384
    ]
    return course.top_level_discussion_topic_ids + accessible_discussion_ids
polesye committed
385 386


Rocky Duan committed
387 388
class JsonResponse(HttpResponse):
    def __init__(self, data=None):
389
        content = json.dumps(data, cls=i4xEncoder)
Rocky Duan committed
390
        super(JsonResponse, self).__init__(content,
391
                                           mimetype='application/json; charset=utf-8')
Rocky Duan committed
392

Calen Pennington committed
393

Rocky Duan committed
394
class JsonError(HttpResponse):
395
    def __init__(self, error_messages=[], status=400):
396
        if isinstance(error_messages, basestring):
Rocky Duan committed
397
            error_messages = [error_messages]
398
        content = json.dumps({'errors': error_messages}, indent=2, ensure_ascii=False)
Rocky Duan committed
399
        super(JsonError, self).__init__(content,
400
                                        mimetype='application/json; charset=utf-8', status=status)
401

Calen Pennington committed
402

403 404 405
class HtmlResponse(HttpResponse):
    def __init__(self, html=''):
        super(HtmlResponse, self).__init__(html, content_type='text/plain')
406

Calen Pennington committed
407

408 409
class ViewNameMiddleware(object):
    def process_view(self, request, view_func, view_args, view_kwargs):
410
        request.view_name = view_func.__name__
411

Calen Pennington committed
412

413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434
class QueryCountDebugMiddleware(object):
    """
    This middleware will log the number of queries run
    and the total time taken for each request (with a
    status code of 200). It does not currently support
    multi-db setups.
    """
    def process_response(self, request, response):
        if response.status_code == 200:
            total_time = 0

            for query in connection.queries:
                query_time = query.get('time')
                if query_time is None:
                    # django-debug-toolbar monkeypatches the connection
                    # cursor wrapper and adds extra information in each
                    # item in connection.queries. The query time is stored
                    # under the key "duration" rather than "time" and is
                    # in milliseconds, not seconds.
                    query_time = query.get('duration', 0) / 1000
                total_time += float(query_time)

435
            log.info(u'%s queries run, total %s seconds', len(connection.queries), total_time)
436 437
        return response

Calen Pennington committed
438

439 440
def get_ability(course_id, content, user):
    return {
441 442 443 444 445
        'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"),
        'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"),
        'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"),
        'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False,
        'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"),
446 447
    }

448
# TODO: RENAME
Calen Pennington committed
449 450


451
def get_annotated_content_info(course_id, content, user, user_info):
452 453 454
    """
    Get metadata for an individual content (thread or comment)
    """
455 456 457 458 459
    voted = ''
    if content['id'] in user_info['upvoted_ids']:
        voted = 'up'
    elif content['id'] in user_info['downvoted_ids']:
        voted = 'down'
460
    return {
461 462
        'voted': voted,
        'subscribed': content['id'] in user_info['subscribed_thread_ids'],
463
        'ability': get_ability(course_id, content, user),
464 465
    }

466
# TODO: RENAME
Calen Pennington committed
467 468


469
def get_annotated_content_infos(course_id, thread, user, user_info):
470 471 472
    """
    Get metadata for a thread and its children
    """
473
    infos = {}
474

475 476
    def annotate(content):
        infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
477 478 479 480 481
        for child in (
                content.get('children', []) +
                content.get('endorsed_responses', []) +
                content.get('non_endorsed_responses', [])
        ):
482 483
            annotate(child)
    annotate(thread)
484
    return infos
Rocky Duan committed
485

Calen Pennington committed
486

487 488 489 490 491 492 493
def get_metadata_for_threads(course_id, threads, user, user_info):
    def infogetter(thread):
        return get_annotated_content_infos(course_id, thread, user, user_info)

    metadata = reduce(merge_dict, map(infogetter, threads), {})
    return metadata

494
# put this method in utils.py to avoid circular import dependency between helpers and mustache_helpers
Calen Pennington committed
495 496


Rocky Duan committed
497
def render_mustache(template_name, dictionary, *args, **kwargs):
498
    template = lookup_template('main', template_name).source
Rocky Duan committed
499
    return pystache.render(template, dictionary)
500

Calen Pennington committed
501

502
def permalink(content):
503
    if isinstance(content['course_id'], CourseKey):
504 505 506
        course_id = content['course_id'].to_deprecated_string()
    else:
        course_id = content['course_id']
507 508
    if content['type'] == 'thread':
        return reverse('django_comment_client.forum.views.single_thread',
509
                       args=[course_id, content['commentable_id'], content['id']])
510 511
    else:
        return reverse('django_comment_client.forum.views.single_thread',
512
                       args=[course_id, content['commentable_id'], content['thread_id']]) + '#' + content['id']
513

Calen Pennington committed
514

515
def extend_content(content):
516 517
    roles = {}
    if content.get('user_id'):
518 519 520
        try:
            user = User.objects.get(pk=content['user_id'])
            roles = dict(('name', role.name.lower()) for role in user.roles.filter(course_id=content['course_id']))
521
        except User.DoesNotExist:
522 523 524 525 526
            log.error(
                'User ID %s in comment content %s but not in our DB.',
                content.get('user_id'),
                content.get('id')
            )
527

528 529 530 531
    content_info = {
        'displayed_title': content.get('highlighted_title') or content.get('title', ''),
        'displayed_body': content.get('highlighted_body') or content.get('body', ''),
        'permalink': permalink(content),
532
        'roles': roles,
Calen Pennington committed
533
        'updated': content['created_at'] != content['updated_at'],
534 535
    }
    return merge_dict(content, content_info)
536

Calen Pennington committed
537

538
def add_courseware_context(content_list, course, user, id_map=None):
539
    """
540
    Decorates `content_list` with courseware metadata using the discussion id map cache if available.
541
    """
542
    if id_map is None:
543 544 545 546 547
        id_map = get_cached_discussion_id_map(
            course,
            [content['commentable_id'] for content in content_list],
            user
        )
548

549 550 551
    for content in content_list:
        commentable_id = content['commentable_id']
        if commentable_id in id_map:
552
            location = id_map[commentable_id]["location"].to_deprecated_string()
553
            title = id_map[commentable_id]["title"]
554

555
            url = reverse('jump_to', kwargs={"course_id": course.id.to_deprecated_string(),
556 557 558
                          "location": location})

            content.update({"courseware_url": url, "courseware_title": title})
559

Calen Pennington committed
560

561
def prepare_content(content, course_key, is_staff=False, course_is_cohorted=None):
562 563 564 565 566 567 568 569
    """
    This function is used to pre-process thread and comment models in various
    ways before adding them to the HTTP response.  This includes fixing empty
    attribute fields, enforcing author anonymity, and enriching metadata around
    group ownership and response endorsement.

    @TODO: not all response pre-processing steps are currently integrated into
    this function.
570 571 572 573 574 575

    Arguments:
        content (dict): A thread or comment.
        course_key (CourseKey): The course key of the course.
        is_staff (bool): Whether the user is a staff member.
        course_is_cohorted (bool): Whether the course is cohorted.
576
    """
577
    fields = [
578 579 580 581
        'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers',
        'endorsed', 'parent_id', 'thread_id', 'votes', 'closed', 'created_at',
        'updated_at', 'depth', 'type', 'commentable_id', 'comments_count',
        'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
582
        'courseware_title', 'courseware_url', 'unread_comments_count',
583
        'read', 'group_id', 'group_name', 'pinned', 'abuse_flaggers',
584 585
        'stats', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type',
        'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total',
586
        'endorsement', 'context'
587 588
    ]

589
    if (content.get('anonymous') is False) and ((content.get('anonymous_to_peers') is False) or is_staff):
590 591
        fields += ['username', 'user_id']

592 593 594 595 596 597 598 599 600
    content = strip_none(extract(content, fields))

    if content.get("endorsement"):
        endorsement = content["endorsement"]
        endorser = None
        if endorsement["user_id"]:
            try:
                endorser = User.objects.get(pk=endorsement["user_id"])
            except User.DoesNotExist:
601 602
                log.error(
                    "User ID %s in endorsement for comment %s but not in our DB.",
603
                    content.get('user_id'),
604
                    content.get('id')
605 606 607 608
                )

        # Only reveal endorser if requester can see author or if endorser is staff
        if (
609 610
                endorser and
                ("username" in fields or has_permission(endorser, "endorse_comment", course_key))
611 612 613 614 615
        ):
            endorsement["username"] = endorser.username
        else:
            del endorsement["user_id"]

616 617 618
    if course_is_cohorted is None:
        course_is_cohorted = is_course_cohorted(course_key)

619 620
    for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]:
        if child_content_key in content:
621
            children = [
622 623
                prepare_content(child, course_key, is_staff, course_is_cohorted=course_is_cohorted)
                for child in content[child_content_key]
624
            ]
625
            content[child_content_key] = children
626

627
    if course_is_cohorted:
628 629 630 631 632 633
        # Augment the specified thread info to include the group name if a group id is present.
        if content.get('group_id') is not None:
            content['group_name'] = get_cohort_by_id(course_key, content.get('group_id')).name
    else:
        # Remove any cohort information that might remain if the course had previously been cohorted.
        content.pop('group_id', None)
634

635
    return content
636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654


def get_group_id_for_comments_service(request, course_key, commentable_id=None):
    """
    Given a user requesting content within a `commentable_id`, determine the
    group_id which should be passed to the comments service.

    Returns:
        int: the group_id to pass to the comments service or None if nothing
        should be passed

    Raises:
        ValueError if the requested group_id is invalid
    """
    if commentable_id is None or is_commentable_cohorted(course_key, commentable_id):
        if request.method == "GET":
            requested_group_id = request.GET.get('group_id')
        elif request.method == "POST":
            requested_group_id = request.POST.get('group_id')
655
        if has_permission(request.user, "see_all_cohorts", course_key):
656 657 658 659 660 661 662 663 664 665 666 667 668 669 670
            if not requested_group_id:
                return None
            try:
                group_id = int(requested_group_id)
                get_cohort_by_id(course_key, group_id)
            except CourseUserGroup.DoesNotExist:
                raise ValueError
        else:
            # regular users always query with their own id.
            group_id = get_cohort_id(request.user, course_key)
        return group_id
    else:
        # Never pass a group_id to the comments service for a non-cohorted
        # commentable
        return None
671 672 673 674 675 676 677 678 679 680 681 682 683 684


def is_comment_too_deep(parent):
    """
    Determine whether a comment with the given parent violates MAX_COMMENT_DEPTH

    parent can be None to determine whether root comments are allowed
    """
    return (
        MAX_COMMENT_DEPTH is not None and (
            MAX_COMMENT_DEPTH < 0 or
            (parent and parent["depth"] >= MAX_COMMENT_DEPTH)
        )
    )
685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701


def is_commentable_cohorted(course_key, commentable_id):
    """
    Args:
        course_key: CourseKey
        commentable_id: string

    Returns:
        Bool: is this commentable cohorted?

    Raises:
        Http404 if the course doesn't exist.
    """
    course = courses.get_course_by_id(course_key)
    course_cohort_settings = get_course_cohort_settings(course_key)

702
    if not course_cohort_settings.is_cohorted or get_team(commentable_id):
703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718
        # this is the easy case :)
        ans = False
    elif (
            commentable_id in course.top_level_discussion_topic_ids or
            course_cohort_settings.always_cohort_inline_discussions is False
    ):
        # top level discussions have to be manually configured as cohorted
        # (default is not).
        # Same thing for inline discussions if the default is explicitly set to False in settings
        ans = commentable_id in course_cohort_settings.cohorted_discussions
    else:
        # inline discussions are cohorted by default
        ans = True

    log.debug(u"is_commentable_cohorted(%s, %s) = {%s}", course_key, commentable_id, ans)
    return ans