utils.py 40.6 KB
Newer Older
1 2
import json
import logging
cahrens committed
3 4
from collections import defaultdict
from datetime import datetime
5

cahrens committed
6
from django.conf import settings
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 pytz import UTC
12 13 14 15 16
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import i4xEncoder

from courseware import courses
from courseware.access import has_access
17
from django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY
cahrens committed
18
from django_comment_client.permissions import check_permissions_by_view, get_team, has_permission
19
from django_comment_client.settings import MAX_COMMENT_DEPTH
cahrens committed
20
from django_comment_common.models import FORUM_ROLE_STUDENT, CourseDiscussionSettings, Role
21
from django_comment_common.utils import get_course_discussion_settings
22
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
cahrens committed
23
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_id, get_cohort_names, is_course_cohorted
24
from request_cache.middleware import request_cached
25
from student.models import get_user_by_username_or_email
26
from student.roles import GlobalStaff
cahrens committed
27 28 29
from xmodule.modulestore.django import modulestore
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
from xmodule.partitions.partitions_service import PartitionService
30

31
log = logging.getLogger(__name__)
32

Calen Pennington committed
33

Rocky Duan committed
34
def extract(dic, keys):
35 36 37
    """
    Returns a subset of keys from the provided dictionary
    """
38
    return {k: dic.get(k) for k in keys}
Rocky Duan committed
39

Calen Pennington committed
40

Rocky Duan committed
41
def strip_none(dic):
42 43 44
    """
    Returns a dictionary stripped of any keys having values of None
    """
Rocky Duan committed
45 46
    return dict([(k, v) for k, v in dic.iteritems() if v is not None])

Calen Pennington committed
47

Rocky Duan committed
48
def strip_blank(dic):
49 50 51
    """
    Returns a dictionary stripped of any 'blank' (empty) keys
    """
Rocky Duan committed
52
    def _is_blank(v):
53 54 55
        """
        Determines if the provided value contains no information
        """
Rocky Duan committed
56 57
        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
58

59
# TODO should we be checking if d1 and d2 have the same keys with different values?
Calen Pennington committed
60 61


62
def merge_dict(dic1, dic2):
63 64 65
    """
    Combines the keys from the two provided dictionaries
    """
66 67
    return dict(dic1.items() + dic2.items())

Calen Pennington committed
68

69
def get_role_ids(course_id):
70 71 72
    """
    Returns a dictionary having role names as keys and a list of users as values
    """
73 74
    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])
75

Calen Pennington committed
76

77
def has_discussion_privileges(user, course_id):
78 79
    """
    Returns True if the user is privileged in teams discussions for
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
    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
98
def has_forum_access(uname, course_id, rolename):
99 100 101
    """
    Boolean operation which tests a user's role-based permissions (not actually forums-specific)
    """
Brian Wilson committed
102 103 104 105 106 107
    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
108

109
def has_required_keys(xblock):
110
    """
111
    Returns True iff xblock has the proper attributes for generating metadata
112 113
    with get_discussion_id_map_entry()
    """
114
    for key in ('discussion_id', 'discussion_category', 'discussion_target'):
115
        if getattr(xblock, key, None) is None:
116 117 118
            log.debug(
                "Required key '%s' not in discussion %s, leaving out of category map",
                key,
119
                xblock.location
120
            )
121 122 123 124
            return False
    return True


125
def get_accessible_discussion_xblocks(course, user, include_all=False):  # pylint: disable=invalid-name
126
    """
127
    Return a list of all valid discussion xblocks in this course that
128
    are accessible to the given user.
129
    """
130 131 132 133 134 135 136 137 138
    return get_accessible_discussion_xblocks_by_course_id(course.id, user, include_all=include_all)


def get_accessible_discussion_xblocks_by_course_id(course_id, user, include_all=False):  # pylint: disable=invalid-name
    """
    Return a list of all valid discussion xblocks in this course that
    are accessible to the given user.
    """
    all_xblocks = modulestore().get_items(course_id, qualifiers={'category': 'discussion'}, include_orphans=False)
139

140
    return [
141
        xblock for xblock in all_xblocks
142
        if has_required_keys(xblock) and (include_all or has_access(user, 'load', xblock, course_id))
143
    ]
144 145


146
def get_discussion_id_map_entry(xblock):
147 148 149 150
    """
    Returns a tuple of (discussion_id, metadata) suitable for inclusion in the results of get_discussion_id_map().
    """
    return (
151
        xblock.discussion_id,
152
        {
153
            "location": xblock.location,
154
            "title": xblock.discussion_category.split("/")[-1].strip() + (" / " + xblock.discussion_target if xblock.discussion_target else "")
155 156 157 158 159 160 161 162 163
        }
    )


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


164 165
@request_cached
def get_cached_discussion_key(course_id, discussion_id):
166
    """
167
    Returns the usage key of the discussion xblock associated with discussion_id if it is cached. If the discussion id
168 169 170 171
    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:
172 173
        mapping = CourseStructure.objects.get(course_id=course_id).discussion_id_map
        if not mapping:
174
            raise DiscussionIdMapIsNotCached()
175 176

        return mapping.get(discussion_id)
177 178 179 180
    except CourseStructure.DoesNotExist:
        raise DiscussionIdMapIsNotCached()


181
def get_cached_discussion_id_map(course, discussion_ids, user):
182
    """
183
    Returns a dict mapping discussion_ids to respective discussion xblock metadata if it is cached and visible to the
184
    user. If not, returns the result of get_discussion_id_map
185
    """
186 187 188 189 190 191 192 193
    return get_cached_discussion_id_map_by_course_id(course.id, discussion_ids, user)


def get_cached_discussion_id_map_by_course_id(course_id, discussion_ids, user):  # pylint: disable=invalid-name
    """
    Returns a dict mapping discussion_ids to respective discussion xblock metadata if it is cached and visible to the
    user. If not, returns the result of get_discussion_id_map
    """
194
    try:
195 196
        entries = []
        for discussion_id in discussion_ids:
197
            key = get_cached_discussion_key(course_id, discussion_id)
198 199
            if not key:
                continue
200
            xblock = modulestore().get_item(key)
201
            if not (has_required_keys(xblock) and has_access(user, 'load', xblock, course_id)):
202
                continue
203
            entries.append(get_discussion_id_map_entry(xblock))
204
        return dict(entries)
205
    except DiscussionIdMapIsNotCached:
206
        return get_discussion_id_map_by_course_id(course_id, user)
207 208


209
def get_discussion_id_map(course, user):
210
    """
211
    Transform the list of this course's discussion xblocks (visible to a given user) into a dictionary of metadata keyed
212
    by discussion_id.
213
    """
214 215 216 217 218 219 220 221 222 223
    return get_discussion_id_map_by_course_id(course.id, user)


def get_discussion_id_map_by_course_id(course_id, user):  # pylint: disable=invalid-name
    """
    Transform the list of this course's discussion xblocks (visible to a given user) into a dictionary of metadata keyed
    by discussion_id.
    """
    xblocks = get_accessible_discussion_xblocks_by_course_id(course_id, user)
    return dict(map(get_discussion_id_map_entry, xblocks))
224

Calen Pennington committed
225

226
def _filter_unstarted_categories(category_map, course):
227 228 229 230
    """
    Returns a subset of categories from the provided map which have not yet met the start date
    Includes information about category children, subcategories (different), and entries
    """
231
    now = datetime.now(UTC)
232 233 234 235

    result_map = {}

    unfiltered_queue = [category_map]
236
    filtered_queue = [result_map]
237

polesye committed
238
    while unfiltered_queue:
239 240

        unfiltered_map = unfiltered_queue.pop()
241
        filtered_map = filtered_queue.pop()
242 243 244 245 246

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

247 248
        for child, c_type in unfiltered_map["children"]:
            if child in unfiltered_map["entries"] and c_type == TYPE_ENTRY:
249
                if course.self_paced or unfiltered_map["entries"][child]["start_date"] <= now:
250
                    filtered_map["children"].append((child, c_type))
251 252 253 254 255
                    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:
256
                    log.debug(u"Filtering out:%s with start_date: %s", child, unfiltered_map["entries"][child]["start_date"])
257
            else:
258
                if course.self_paced or unfiltered_map["subcategories"][child]["start_date"] < now:
259
                    filtered_map["children"].append((child, c_type))
260 261 262 263 264
                    filtered_map["subcategories"][child] = {}
                    unfiltered_queue.append(unfiltered_map["subcategories"][child])
                    filtered_queue.append(filtered_map["subcategories"][child])

    return result_map
Arjun Singh committed
265

David Baumgold committed
266

267
def _sort_map_entries(category_map, sort_alpha):
268 269 270
    """
    Internal helper method to list category entries according to the provided sort order
    """
Arjun Singh committed
271 272
    things = []
    for title, entry in category_map["entries"].items():
stv committed
273
        if entry["sort_key"] is None and sort_alpha:
274
            entry["sort_key"] = title
275
        things.append((title, entry, TYPE_ENTRY))
Arjun Singh committed
276
    for title, category in category_map["subcategories"].items():
277
        things.append((title, category, TYPE_SUBCATEGORY))
278
        _sort_map_entries(category_map["subcategories"][title], sort_alpha)
279
    category_map["children"] = [(x[0], x[2]) for x in sorted(things, key=lambda x: x[1]["sort_key"])]
Arjun Singh committed
280

281

282
def get_discussion_category_map(course, user, divided_only_if_explicit=False, exclude_unstarted=True):
283
    """
284
    Transform the list of this course's discussion xblocks into a recursive dictionary structure.  This is used
285
    to render the discussion category map in the discussion tab sidebar for a given user.
286 287 288 289

    Args:
        course: Course for which to get the ids.
        user:  User to check for access.
290
        divided_only_if_explicit (bool): If True, inline topics are marked is_divided only if they are
291
            explicitly listed in CourseDiscussionSettings.discussion_topics.
292 293 294 295 296 297

    Example:
        >>> example = {
        >>>               "entries": {
        >>>                   "General": {
        >>>                       "sort_key": "General",
298
        >>>                       "is_divided": True,
299 300 301
        >>>                       "id": "i4x-edx-eiorguegnru-course-foobarbaz"
        >>>                   }
        >>>               },
302 303 304 305
        >>>               "children": [
        >>>                     ["General", "entry"],
        >>>                     ["Getting Started", "subcategory"]
        >>>               ],
306 307 308 309
        >>>               "subcategories": {
        >>>                   "Getting Started": {
        >>>                       "subcategories": {},
        >>>                       "children": [
310 311
        >>>                           ["Working with Videos", "entry"],
        >>>                           ["Videos on edX", "entry"]
312 313 314 315
        >>>                       ],
        >>>                       "entries": {
        >>>                           "Working with Videos": {
        >>>                               "sort_key": None,
316
        >>>                               "is_divided": False,
317 318 319 320
        >>>                               "id": "d9f970a42067413cbb633f81cfb12604"
        >>>                           },
        >>>                           "Videos on edX": {
        >>>                               "sort_key": None,
321
        >>>                               "is_divided": False,
322 323 324 325 326 327 328
        >>>                               "id": "98d8feb5971041a085512ae22b398613"
        >>>                           }
        >>>                       }
        >>>                   }
        >>>               }
        >>>          }

329
    """
Arjun Singh committed
330
    unexpanded_category_map = defaultdict(list)
Arjun Singh committed
331

332
    xblocks = get_accessible_discussion_xblocks(course, user)
333

334 335 336
    discussion_settings = get_course_discussion_settings(course.id)
    discussion_division_enabled = course_discussion_division_enabled(discussion_settings)
    divided_discussion_ids = discussion_settings.divided_discussions
337

338 339 340 341 342 343
    for xblock in xblocks:
        discussion_id = xblock.discussion_id
        title = xblock.discussion_target
        sort_key = xblock.sort_key
        category = " / ".join([x.strip() for x in xblock.discussion_category.split("/")])
        # Handle case where xblock.start is None
344
        entry_start_date = xblock.start if xblock.start else datetime.max.replace(tzinfo=UTC)
345 346 347 348
        unexpanded_category_map[category].append({"title": title,
                                                  "id": discussion_id,
                                                  "sort_key": sort_key,
                                                  "start_date": entry_start_date})
Arjun Singh committed
349

350
    category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
Arjun Singh committed
351 352 353
    for category_path, entries in unexpanded_category_map.items():
        node = category_map["subcategories"]
        path = [x.strip() for x in category_path.split("/")]
354 355 356 357 358 359 360

        # 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
361 362
        for level in path[:-1]:
            if level not in node:
363
                node[level] = {"subcategories": defaultdict(dict),
Arjun Singh committed
364
                               "entries": defaultdict(dict),
365 366 367 368 369
                               "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
370 371 372 373
            node = node[level]["subcategories"]

        level = path[-1]
        if level not in node:
374
            node[level] = {"subcategories": defaultdict(dict),
375 376 377
                           "entries": defaultdict(dict),
                           "sort_key": level,
                           "start_date": category_start_date}
378 379 380 381
        else:
            if node[level]["start_date"] > category_start_date:
                node[level]["start_date"] = category_start_date

382
        divide_all_inline_discussions = (  # pylint: disable=invalid-name
383
            not divided_only_if_explicit and discussion_settings.always_divide_inline_discussions
384
        )
385
        dupe_counters = defaultdict(lambda: 0)  # counts the number of times we see each title
Arjun Singh committed
386
        for entry in entries:
387
            is_entry_divided = (
388 389
                discussion_division_enabled and (
                    divide_all_inline_discussions or entry["id"] in divided_discussion_ids
390 391 392
                )
            )

393 394 395 396 397 398 399 400 401
            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"],
402
                                             "is_divided": is_entry_divided}
Arjun Singh committed
403

404 405 406
    # 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.
407
    for topic, entry in course.discussion_topics.items():
408 409 410
        category_map['entries'][topic] = {
            "id": entry["id"],
            "sort_key": entry.get("sort_key", topic),
411
            "start_date": datetime.now(UTC),
412
            "is_divided": (
413
                discussion_division_enabled and entry["id"] in divided_discussion_ids
414
            )
415
        }
416

417
    _sort_map_entries(category_map, course.discussion_sort_alpha)
418

419
    return _filter_unstarted_categories(category_map, course) if exclude_unstarted else category_map
420 421


422
def discussion_category_id_access(course, user, discussion_id, xblock=None):
423
    """
Ben McMorran committed
424
    Returns True iff the given discussion_id is accessible for user in course.
425
    Assumes that the commentable identified by discussion_id has a null or 'course' context.
Ben McMorran committed
426 427
    Uses the discussion id cache if available, falling back to
    get_discussion_categories_ids if there is no cache.
428 429 430 431
    """
    if discussion_id in course.top_level_discussion_topic_ids:
        return True
    try:
432
        if not xblock:
433
            key = get_cached_discussion_key(course.id, discussion_id)
434 435
            if not key:
                return False
436 437
            xblock = modulestore().get_item(key)
        return has_required_keys(xblock) and has_access(user, 'load', xblock, course.id)
438 439 440 441
    except DiscussionIdMapIsNotCached:
        return discussion_id in get_discussion_categories_ids(course, user)


442
def get_discussion_categories_ids(course, user, include_all=False):
polesye committed
443
    """
444 445
    Returns a list of available ids of categories for the course that
    are accessible to the given user.
446 447 448 449 450 451

    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
452
    """
453
    accessible_discussion_ids = [
454
        xblock.discussion_id for xblock in get_accessible_discussion_xblocks(course, user, include_all=include_all)
455 456
    ]
    return course.top_level_discussion_topic_ids + accessible_discussion_ids
polesye committed
457 458


Rocky Duan committed
459
class JsonResponse(HttpResponse):
460 461 462
    """
    Django response object delivering JSON representations
    """
Rocky Duan committed
463
    def __init__(self, data=None):
464 465 466
        """
        Object constructor, converts data (if provided) to JSON
        """
467
        content = json.dumps(data, cls=i4xEncoder)
Rocky Duan committed
468
        super(JsonResponse, self).__init__(content,
469
                                           content_type='application/json; charset=utf-8')
Rocky Duan committed
470

Calen Pennington committed
471

Rocky Duan committed
472
class JsonError(HttpResponse):
473 474 475
    """
    Django response object delivering JSON exceptions
    """
476
    def __init__(self, error_messages=[], status=400):
477 478 479
        """
        Object constructor, returns an error response containing the provided exception messages
        """
480
        if isinstance(error_messages, basestring):
Rocky Duan committed
481
            error_messages = [error_messages]
482
        content = json.dumps({'errors': error_messages}, indent=2, ensure_ascii=False)
Rocky Duan committed
483
        super(JsonError, self).__init__(content,
484
                                        content_type='application/json; charset=utf-8', status=status)
485

Calen Pennington committed
486

487
class HtmlResponse(HttpResponse):
488 489 490
    """
    Django response object delivering HTML representations
    """
491
    def __init__(self, html=''):
492 493 494
        """
        Object constructor, brokers provided HTML to caller
        """
495
        super(HtmlResponse, self).__init__(html, content_type='text/plain')
496

Calen Pennington committed
497

498
class ViewNameMiddleware(object):
499 500 501
    """
    Django middleware object to inject view name into request context
    """
502
    def process_view(self, request, view_func, view_args, view_kwargs):
503 504 505
        """
        Injects the view name value into the request context
        """
506
        request.view_name = view_func.__name__
507

Calen Pennington committed
508

509 510 511 512 513 514 515 516
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):
517 518 519
        """
        Log information for 200 OK responses as part of the outbound pipeline
        """
520 521 522 523 524 525 526 527 528 529 530 531 532 533
        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)

534
            log.info(u'%s queries run, total %s seconds', len(connection.queries), total_time)
535 536
        return response

Calen Pennington committed
537

538
def get_ability(course_id, content, user):
539 540 541
    """
    Return a dictionary of forums-oriented actions and the user's permission to perform them
    """
542
    (user_group_id, content_user_group_id) = get_user_group_ids(course_id, content, user)
543
    return {
544 545 546 547 548 549 550 551
        'editable': check_permissions_by_view(
            user,
            course_id,
            content,
            "update_thread" if content['type'] == 'thread' else "update_comment",
            user_group_id,
            content_user_group_id
        ),
552
        'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"),
553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568
        'can_delete': check_permissions_by_view(
            user,
            course_id,
            content,
            "delete_thread" if content['type'] == 'thread' else "delete_comment",
            user_group_id,
            content_user_group_id
        ),
        'can_openclose': check_permissions_by_view(
            user,
            course_id,
            content,
            "openclose_thread" if content['type'] == 'thread' else False,
            user_group_id,
            content_user_group_id
        ),
569 570 571 572 573 574
        'can_vote': not is_content_authored_by(content, user) and check_permissions_by_view(
            user,
            course_id,
            content,
            "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"
        ),
575
        'can_report': not is_content_authored_by(content, user) and (check_permissions_by_view(
576 577 578 579
            user,
            course_id,
            content,
            "flag_abuse_for_thread" if content['type'] == 'thread' else "flag_abuse_for_comment"
580
        ) or GlobalStaff().has_user(user))
581 582
    }

583
# TODO: RENAME
Calen Pennington committed
584 585


586 587 588 589 590 591 592 593
def get_user_group_ids(course_id, content, user=None):
    """
    Given a user, course ID, and the content of the thread or comment, returns the group ID for the current user
    and the user that posted the thread/comment.
    """
    content_user_group_id = None
    user_group_id = None
    if course_id is not None:
Sofiya Semenova committed
594
        course_discussion_settings = get_course_discussion_settings(course_id)
595 596 597
        if content.get('username'):
            try:
                content_user = get_user_by_username_or_email(content.get('username'))
Sofiya Semenova committed
598
                content_user_group_id = get_group_id_for_user(content_user, course_discussion_settings)
599 600 601
            except User.DoesNotExist:
                content_user_group_id = None

Sofiya Semenova committed
602
        user_group_id = get_group_id_for_user(user, course_discussion_settings) if user else None
603 604 605
    return user_group_id, content_user_group_id


606
def get_annotated_content_info(course_id, content, user, user_info):
607 608 609
    """
    Get metadata for an individual content (thread or comment)
    """
610 611 612 613 614
    voted = ''
    if content['id'] in user_info['upvoted_ids']:
        voted = 'up'
    elif content['id'] in user_info['downvoted_ids']:
        voted = 'down'
615
    return {
616 617
        'voted': voted,
        'subscribed': content['id'] in user_info['subscribed_thread_ids'],
618
        'ability': get_ability(course_id, content, user),
619 620
    }

621
# TODO: RENAME
Calen Pennington committed
622 623


624
def get_annotated_content_infos(course_id, thread, user, user_info):
625 626 627
    """
    Get metadata for a thread and its children
    """
628
    infos = {}
629

630 631
    def annotate(content):
        infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
632 633 634 635 636
        for child in (
                content.get('children', []) +
                content.get('endorsed_responses', []) +
                content.get('non_endorsed_responses', [])
        ):
637 638
            annotate(child)
    annotate(thread)
639
    return infos
Rocky Duan committed
640

Calen Pennington committed
641

642
def get_metadata_for_threads(course_id, threads, user, user_info):
643 644 645 646
    """
    Returns annotated content information for the specified course, threads, and user information
    """

647 648 649 650 651 652
    def infogetter(thread):
        return get_annotated_content_infos(course_id, thread, user, user_info)

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

Calen Pennington committed
653

654
def permalink(content):
655
    if isinstance(content['course_id'], CourseKey):
656 657 658
        course_id = content['course_id'].to_deprecated_string()
    else:
        course_id = content['course_id']
659
    if content['type'] == 'thread':
660
        return reverse('discussion.views.single_thread',
661
                       args=[course_id, content['commentable_id'], content['id']])
662
    else:
663
        return reverse('discussion.views.single_thread',
664
                       args=[course_id, content['commentable_id'], content['thread_id']]) + '#' + content['id']
665

Calen Pennington committed
666

667
def extend_content(content):
668 669
    roles = {}
    if content.get('user_id'):
670 671 672
        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']))
673
        except User.DoesNotExist:
674 675 676 677 678
            log.error(
                'User ID %s in comment content %s but not in our DB.',
                content.get('user_id'),
                content.get('id')
            )
679

680 681 682 683
    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),
684
        'roles': roles,
Calen Pennington committed
685
        'updated': content['created_at'] != content['updated_at'],
686 687
    }
    return merge_dict(content, content_info)
688

Calen Pennington committed
689

690
def add_courseware_context(content_list, course, user, id_map=None):
691
    """
692
    Decorates `content_list` with courseware metadata using the discussion id map cache if available.
693
    """
694
    if id_map is None:
695 696 697 698 699
        id_map = get_cached_discussion_id_map(
            course,
            [content['commentable_id'] for content in content_list],
            user
        )
700

701 702 703
    for content in content_list:
        commentable_id = content['commentable_id']
        if commentable_id in id_map:
704
            location = id_map[commentable_id]["location"].to_deprecated_string()
705
            title = id_map[commentable_id]["title"]
706

707
            url = reverse('jump_to', kwargs={"course_id": course.id.to_deprecated_string(),
708 709 710
                          "location": location})

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

Calen Pennington committed
712

713
def prepare_content(content, course_key, is_staff=False, discussion_division_enabled=None):
714 715 716 717 718 719 720 721
    """
    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.
722 723 724 725 726

    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.
727 728 729
        discussion_division_enabled (bool): Whether division of course discussions is enabled.
           Note that callers of this method do not need to provide this value (it defaults to None)--
           it is calculated and then passed to recursive calls of this method.
730
    """
731
    fields = [
732 733 734 735
        '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',
736
        'courseware_title', 'courseware_url', 'unread_comments_count',
737
        'read', 'group_id', 'group_name', 'pinned', 'abuse_flaggers',
738 739
        'stats', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type',
        'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total',
740
        'endorsement', 'context', 'last_activity_at'
741 742
    ]

743
    if (content.get('anonymous') is False) and ((content.get('anonymous_to_peers') is False) or is_staff):
744 745
        fields += ['username', 'user_id']

746 747 748 749 750 751 752 753 754
    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:
755 756
                log.error(
                    "User ID %s in endorsement for comment %s but not in our DB.",
757
                    content.get('user_id'),
758
                    content.get('id')
759 760 761 762
                )

        # Only reveal endorser if requester can see author or if endorser is staff
        if (
763 764
                endorser and
                ("username" in fields or has_permission(endorser, "endorse_comment", course_key))
765 766 767 768 769
        ):
            endorsement["username"] = endorser.username
        else:
            del endorsement["user_id"]

770 771
    if discussion_division_enabled is None:
        discussion_division_enabled = course_discussion_division_enabled(get_course_discussion_settings(course_key))
772

773 774
    for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]:
        if child_content_key in content:
775
            children = [
776
                prepare_content(child, course_key, is_staff, discussion_division_enabled=discussion_division_enabled)
777
                for child in content[child_content_key]
778
            ]
779
            content[child_content_key] = children
780

781
    if discussion_division_enabled:
782 783
        # Augment the specified thread info to include the group name if a group id is present.
        if content.get('group_id') is not None:
784 785 786 787 788
            course_discussion_settings = get_course_discussion_settings(course_key)
            content['group_name'] = get_group_name(content.get('group_id'), course_discussion_settings)
            content['is_commentable_divided'] = is_commentable_divided(
                course_key, content['commentable_id'], course_discussion_settings
            )
789
    else:
790
        # Remove any group information that might remain if the course had previously been divided.
791
        content.pop('group_id', None)
792

793
    return content
794 795 796 797 798 799 800 801 802 803 804 805 806 807


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
    """
808 809
    course_discussion_settings = get_course_discussion_settings(course_key)
    if commentable_id is None or is_commentable_divided(course_key, commentable_id, course_discussion_settings):
810 811 812 813
        if request.method == "GET":
            requested_group_id = request.GET.get('group_id')
        elif request.method == "POST":
            requested_group_id = request.POST.get('group_id')
814
        if has_permission(request.user, "see_all_cohorts", course_key):
815 816
            if not requested_group_id:
                return None
817 818
            group_id = int(requested_group_id)
            _verify_group_exists(group_id, course_discussion_settings)
819 820
        else:
            # regular users always query with their own id.
821
            group_id = get_group_id_for_user(request.user, course_discussion_settings)
822 823
        return group_id
    else:
824
        # Never pass a group_id to the comments service for a non-divided
825 826
        # commentable
        return None
827 828


829
def get_group_id_for_user(user, course_discussion_settings):
830
    """
831 832 833
    Given a user, return the group_id for that user according to the course_discussion_settings.
    If discussions are not divided, this method will return None.
    It will also return None if the user is in no group within the specified division_scheme.
834
    """
835 836 837 838 839 840
    division_scheme = _get_course_division_scheme(course_discussion_settings)
    if division_scheme == CourseDiscussionSettings.COHORT:
        return get_cohort_id(user, course_discussion_settings.course_id)
    elif division_scheme == CourseDiscussionSettings.ENROLLMENT_TRACK:
        partition_service = PartitionService(course_discussion_settings.course_id)
        group_id = partition_service.get_user_group_id_for_partition(user, ENROLLMENT_TRACK_PARTITION_ID)
Sofiya Semenova committed
841
        # We negate the group_ids from dynamic partitions so that they will not conflict
842 843 844 845
        # with cohort IDs (which are an auto-incrementing integer field, starting at 1).
        return -1 * group_id if group_id is not None else None
    else:
        return None
846 847


848 849 850 851 852 853 854 855 856 857 858 859
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)
        )
    )
860 861


862
def is_commentable_divided(course_key, commentable_id, course_discussion_settings=None):
863 864 865 866
    """
    Args:
        course_key: CourseKey
        commentable_id: string
867 868
        course_discussion_settings: CourseDiscussionSettings model instance (optional). If not
            supplied, it will be retrieved via the course_key.
869 870

    Returns:
871 872
        Bool: is this commentable divided, meaning that learners are divided into
        groups (either Cohorts or Enrollment Tracks) and only see posts within their group?
873 874 875 876

    Raises:
        Http404 if the course doesn't exist.
    """
877 878 879
    if not course_discussion_settings:
        course_discussion_settings = get_course_discussion_settings(course_key)

880 881
    course = courses.get_course_by_id(course_key)

882
    if not course_discussion_division_enabled(course_discussion_settings) or get_team(commentable_id):
883 884 885
        # this is the easy case :)
        ans = False
    elif (
886 887
        commentable_id in course.top_level_discussion_topic_ids or
        course_discussion_settings.always_divide_inline_discussions is False
888
    ):
889
        # top level discussions have to be manually configured as divided
890 891
        # (default is not).
        # Same thing for inline discussions if the default is explicitly set to False in settings
892
        ans = commentable_id in course_discussion_settings.divided_discussions
893
    else:
894
        # inline discussions are divided by default
895 896
        ans = True

897
    log.debug(u"is_commentable_divided(%s, %s) = {%s}", course_key, commentable_id, ans)
898
    return ans
899 900


901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916
def course_discussion_division_enabled(course_discussion_settings):
    """
    Are discussions divided for the course represented by this instance of
    course_discussion_settings? This method looks both at
    course_discussion_settings.division_scheme, and information about the course
    state itself (For example, are cohorts enabled? And are there multiple
    enrollment tracks?).

    Args:
        course_discussion_settings: CourseDiscussionSettings model instance

    Returns: True if discussion division is enabled for the course, else False
    """
    return _get_course_division_scheme(course_discussion_settings) != CourseDiscussionSettings.NONE


917 918 919 920 921 922 923 924 925 926 927 928 929
def available_division_schemes(course_key):
    """
    Returns a list of possible discussion division schemes for this course.
    This takes into account if cohorts are enabled and if there are multiple
    enrollment tracks. If no schemes are available, returns an empty list.
    Args:
        course_key: CourseKey

    Returns: list of possible division schemes (for example, CourseDiscussionSettings.COHORT)
    """
    available_schemes = []
    if is_course_cohorted(course_key):
        available_schemes.append(CourseDiscussionSettings.COHORT)
930
    if enrollment_track_group_count(course_key) > 1:
931 932 933 934
        available_schemes.append(CourseDiscussionSettings.ENROLLMENT_TRACK)
    return available_schemes


935 936 937 938 939 940 941 942 943 944 945
def enrollment_track_group_count(course_key):
    """
    Returns the count of possible enrollment track division schemes for this course.
    Args:
        course_key: CourseKey
    Returns:
        Count of enrollment track division scheme
    """
    return len(_get_enrollment_track_groups(course_key))


946 947 948 949 950 951 952 953 954
def _get_course_division_scheme(course_discussion_settings):
    division_scheme = course_discussion_settings.division_scheme
    if (
        division_scheme == CourseDiscussionSettings.COHORT and
        not is_course_cohorted(course_discussion_settings.course_id)
    ):
        division_scheme = CourseDiscussionSettings.NONE
    elif (
        division_scheme == CourseDiscussionSettings.ENROLLMENT_TRACK and
955
        enrollment_track_group_count(course_discussion_settings.course_id) <= 1
956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017
    ):
        division_scheme = CourseDiscussionSettings.NONE
    return division_scheme


def get_group_name(group_id, course_discussion_settings):
    """
    Given a specified comments_service group_id, returns the learner-facing
    name of the Group. If no such Group exists for the specified group_id
    (taking into account the division_scheme and course specified by course_discussion_settings),
    returns None.
    Args:
        group_id: the group_id as used by the comments_service code
        course_discussion_settings: CourseDiscussionSettings model instance

    Returns: learner-facing name of the Group, or None if no such group exists
    """
    group_names_by_id = get_group_names_by_id(course_discussion_settings)
    return group_names_by_id[group_id] if group_id in group_names_by_id else None


def get_group_names_by_id(course_discussion_settings):
    """
    Creates of a dict of group_id to learner-facing group names, for the division_scheme
    in use as specified by course_discussion_settings.
    Args:
        course_discussion_settings: CourseDiscussionSettings model instance

    Returns: dict of group_id to learner-facing group names. If no division_scheme
    is in use, returns an empty dict.
    """
    division_scheme = _get_course_division_scheme(course_discussion_settings)
    course_key = course_discussion_settings.course_id
    if division_scheme == CourseDiscussionSettings.COHORT:
        return get_cohort_names(courses.get_course_by_id(course_key))
    elif division_scheme == CourseDiscussionSettings.ENROLLMENT_TRACK:
        # We negate the group_ids from dynamic partitions so that they will not conflict
        # with cohort IDs (which are an auto-incrementing integer field, starting at 1).
        return {-1 * group.id: group.name for group in _get_enrollment_track_groups(course_key)}
    else:
        return {}


def _get_enrollment_track_groups(course_key):
    """
    Helper method that returns an array of the Groups in the EnrollmentTrackUserPartition for the given course.
    If no such partition exists on the course, an empty array is returned.
    """
    partition_service = PartitionService(course_key)
    partition = partition_service.get_user_partition(ENROLLMENT_TRACK_PARTITION_ID)
    return partition.groups if partition else []


def _verify_group_exists(group_id, course_discussion_settings):
    """
    Helper method that verifies the given group_id corresponds to a Group in the
    division scheme being used. If it does not, a ValueError will be raised.
    """
    if get_group_name(group_id, course_discussion_settings) is None:
        raise ValueError


1018 1019
def is_discussion_enabled(course_id):
    """
1020
    Return True if discussions are enabled; else False
1021 1022
    """
    return settings.FEATURES.get('ENABLE_DISCUSSION_SERVICE')
1023 1024 1025 1026 1027 1028 1029 1030 1031 1032


def is_content_authored_by(content, user):
    """
    Return True if the author is this content is the passed user, else False
    """
    try:
        return int(content.get('user_id')) == user.id
    except (ValueError, TypeError):
        return False