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

6
from course_groups.cohorts import get_cohort_by_id
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 11
from django.http import HttpResponse
from django.utils import simplejson
12
from django_comment_common.models import Role, FORUM_ROLE_STUDENT
13
from django_comment_client.permissions import check_permissions_by_view, cached_has_permission
14

15
from edxmako import lookup_template
16
import pystache_custom as pystache
17

Brian Wilson committed
18
from xmodule.modulestore.django import modulestore
19
from django.utils.timezone import UTC
20 21
from opaque_keys.edx.locations import i4xEncoder
from opaque_keys.edx.keys import CourseKey
22
import json
23

24
log = logging.getLogger(__name__)
25

Calen Pennington committed
26

Rocky Duan committed
27
def extract(dic, keys):
28
    return {k: dic.get(k) for k in keys}
Rocky Duan committed
29

Calen Pennington committed
30

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

Calen Pennington committed
34

Rocky Duan committed
35 36 37 38
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
39

40
# TODO should we be checking if d1 and d2 have the same keys with different values?
Calen Pennington committed
41 42


43 44 45
def merge_dict(dic1, dic2):
    return dict(dic1.items() + dic2.items())

Calen Pennington committed
46

47
def get_role_ids(course_id):
48 49
    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])
50

Calen Pennington committed
51

Brian Wilson committed
52 53 54 55 56 57 58
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
59

60
def _get_discussion_modules(course):
61
    all_modules = modulestore().get_items(course.id, qualifiers={'category': 'discussion'})
62

63 64 65 66 67 68
    def has_required_keys(module):
        for key in ('discussion_id', 'discussion_category', 'discussion_target'):
            if getattr(module, key) is None:
                log.warning("Required key '%s' not in discussion %s, leaving out of category map" % (key, module.location))
                return False
        return True
Calen Pennington committed
69

70 71 72 73 74 75 76 77 78 79 80
    return filter(has_required_keys, all_modules)


def _get_discussion_id_map(course):
    def get_entry(module):
        discussion_id = module.discussion_id
        title = module.discussion_target
        last_category = module.discussion_category.split("/")[-1].strip()
        return (discussion_id, {"location": module.location, "title": last_category + " / " + title})

    return dict(map(get_entry, _get_discussion_modules(course)))
81

Calen Pennington committed
82

83
def _filter_unstarted_categories(category_map):
84

85
    now = datetime.now(UTC())
86 87 88 89

    result_map = {}

    unfiltered_queue = [category_map]
90
    filtered_queue = [result_map]
91 92 93 94

    while len(unfiltered_queue) > 0:

        unfiltered_map = unfiltered_queue.pop()
95
        filtered_map = filtered_queue.pop()
96 97 98 99 100 101 102

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

        for child in unfiltered_map["children"]:
            if child in unfiltered_map["entries"]:
103
                if unfiltered_map["entries"][child]["start_date"] <= now:
104 105 106 107 108 109
                    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:
110
                    log.debug(u"Filtering out:%s with start_date: %s", child, unfiltered_map["entries"][child]["start_date"])
111 112 113 114 115 116 117 118
            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
119

David Baumgold committed
120

121
def _sort_map_entries(category_map, sort_alpha):
Arjun Singh committed
122 123
    things = []
    for title, entry in category_map["entries"].items():
124
        if entry["sort_key"] == None and sort_alpha:
125
            entry["sort_key"] = title
Arjun Singh committed
126 127 128
        things.append((title, entry))
    for title, category in category_map["subcategories"].items():
        things.append((title, category))
129
        _sort_map_entries(category_map["subcategories"][title], sort_alpha)
Arjun Singh committed
130 131
    category_map["children"] = [x[0] for x in sorted(things, key=lambda x: x[1]["sort_key"])]

132

133
def get_discussion_category_map(course):
134
    course_id = course.id
Arjun Singh committed
135 136

    unexpanded_category_map = defaultdict(list)
Arjun Singh committed
137

138
    modules = _get_discussion_modules(course)
139

140 141 142
    is_course_cohorted = course.is_cohorted
    cohorted_discussion_ids = course.cohorted_discussions

143
    for module in modules:
144 145 146
        id = module.discussion_id
        title = module.discussion_target
        sort_key = module.sort_key
147
        category = " / ".join([x.strip() for x in module.discussion_category.split("/")])
Calen Pennington committed
148 149
        #Handle case where module.start is None
        entry_start_date = module.start if module.start else datetime.max.replace(tzinfo=pytz.UTC)
150
        unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": entry_start_date})
Arjun Singh committed
151

152
    category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
Arjun Singh committed
153 154 155
    for category_path, entries in unexpanded_category_map.items():
        node = category_map["subcategories"]
        path = [x.strip() for x in category_path.split("/")]
156 157 158 159 160 161 162

        # 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
163 164
        for level in path[:-1]:
            if level not in node:
165
                node[level] = {"subcategories": defaultdict(dict),
Arjun Singh committed
166
                               "entries": defaultdict(dict),
167 168 169 170 171
                               "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
172 173 174 175
            node = node[level]["subcategories"]

        level = path[-1]
        if level not in node:
176
            node[level] = {"subcategories": defaultdict(dict),
177 178 179
                           "entries": defaultdict(dict),
                           "sort_key": level,
                           "start_date": category_start_date}
180 181 182 183
        else:
            if node[level]["start_date"] > category_start_date:
                node[level]["start_date"] = category_start_date

Arjun Singh committed
184
        for entry in entries:
185
            node[level]["entries"][entry["title"]] = {"id": entry["id"],
186
                                                      "sort_key": entry["sort_key"],
187 188
                                                      "start_date": entry["start_date"],
                                                      "is_cohorted": is_course_cohorted}
Arjun Singh committed
189

190 191 192
    # 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.
193
    for topic, entry in course.discussion_topics.items():
Arjun Singh committed
194
        category_map['entries'][topic] = {"id": entry["id"],
195
                                          "sort_key": entry.get("sort_key", topic),
196 197
                                          "start_date": datetime.now(UTC()),
                                          "is_cohorted": is_course_cohorted and entry["id"] in cohorted_discussion_ids}
198

199
    _sort_map_entries(category_map, course.discussion_sort_alpha)
200

201
    return _filter_unstarted_categories(category_map)
202 203


Rocky Duan committed
204 205
class JsonResponse(HttpResponse):
    def __init__(self, data=None):
206
        content = json.dumps(data, cls=i4xEncoder)
Rocky Duan committed
207
        super(JsonResponse, self).__init__(content,
208
                                           mimetype='application/json; charset=utf-8')
Rocky Duan committed
209

Calen Pennington committed
210

Rocky Duan committed
211
class JsonError(HttpResponse):
212
    def __init__(self, error_messages=[], status=400):
213
        if isinstance(error_messages, basestring):
Rocky Duan committed
214 215
            error_messages = [error_messages]
        content = simplejson.dumps({'errors': error_messages},
Rocky Duan committed
216 217 218
                                   indent=2,
                                   ensure_ascii=False)
        super(JsonError, self).__init__(content,
219
                                        mimetype='application/json; charset=utf-8', status=status)
220

Calen Pennington committed
221

222 223 224
class HtmlResponse(HttpResponse):
    def __init__(self, html=''):
        super(HtmlResponse, self).__init__(html, content_type='text/plain')
225

Calen Pennington committed
226

227 228
class ViewNameMiddleware(object):
    def process_view(self, request, view_func, view_args, view_kwargs):
229
        request.view_name = view_func.__name__
230

Calen Pennington committed
231

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

254
            log.info('%s queries run, total %s seconds' % (len(connection.queries), total_time))
255 256
        return response

Calen Pennington committed
257

258 259
def get_ability(course_id, content, user):
    return {
260 261 262 263 264
        '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"),
265 266
    }

267
# TODO: RENAME
Calen Pennington committed
268 269


270
def get_annotated_content_info(course_id, content, user, user_info):
271 272 273
    """
    Get metadata for an individual content (thread or comment)
    """
274 275 276 277 278
    voted = ''
    if content['id'] in user_info['upvoted_ids']:
        voted = 'up'
    elif content['id'] in user_info['downvoted_ids']:
        voted = 'down'
279
    return {
280 281
        'voted': voted,
        'subscribed': content['id'] in user_info['subscribed_thread_ids'],
282
        'ability': get_ability(course_id, content, user),
283 284
    }

285
# TODO: RENAME
Calen Pennington committed
286 287


288
def get_annotated_content_infos(course_id, thread, user, user_info):
289 290 291
    """
    Get metadata for a thread and its children
    """
292
    infos = {}
293

294 295
    def annotate(content):
        infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
296 297 298 299 300
        for child in (
                content.get('children', []) +
                content.get('endorsed_responses', []) +
                content.get('non_endorsed_responses', [])
        ):
301 302
            annotate(child)
    annotate(thread)
303
    return infos
Rocky Duan committed
304

Calen Pennington committed
305

306 307 308 309 310 311 312
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

313
# put this method in utils.py to avoid circular import dependency between helpers and mustache_helpers
Calen Pennington committed
314 315


Rocky Duan committed
316
def render_mustache(template_name, dictionary, *args, **kwargs):
317
    template = lookup_template('main', template_name).source
Rocky Duan committed
318
    return pystache.render(template, dictionary)
319

Calen Pennington committed
320

321
def permalink(content):
322
    if isinstance(content['course_id'], CourseKey):
323 324 325
        course_id = content['course_id'].to_deprecated_string()
    else:
        course_id = content['course_id']
326 327
    if content['type'] == 'thread':
        return reverse('django_comment_client.forum.views.single_thread',
328
                       args=[course_id, content['commentable_id'], content['id']])
329 330
    else:
        return reverse('django_comment_client.forum.views.single_thread',
331
                       args=[course_id, content['commentable_id'], content['thread_id']]) + '#' + content['id']
332

Calen Pennington committed
333

334
def extend_content(content):
335 336
    roles = {}
    if content.get('user_id'):
337 338 339
        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']))
340
        except User.DoesNotExist:
341
            log.error('User ID {0} in comment content {1} but not in our DB.'.format(content.get('user_id'), content.get('id')))
342

343 344 345 346
    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),
347
        'roles': roles,
Calen Pennington committed
348
        'updated': content['created_at'] != content['updated_at'],
349 350
    }
    return merge_dict(content, content_info)
351

Calen Pennington committed
352

353
def add_courseware_context(content_list, course):
354
    id_map = _get_discussion_id_map(course)
355

356 357 358
    for content in content_list:
        commentable_id = content['commentable_id']
        if commentable_id in id_map:
359
            location = id_map[commentable_id]["location"].to_deprecated_string()
360
            title = id_map[commentable_id]["title"]
361

362
            url = reverse('jump_to', kwargs={"course_id": course.id.to_deprecated_string(),
363 364 365
                          "location": location})

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

Calen Pennington committed
367

368
def safe_content(content, course_id, is_staff=False):
369
    fields = [
370 371 372 373
        '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',
374
        'courseware_title', 'courseware_url', 'unread_comments_count',
375
        'read', 'group_id', 'group_name', 'pinned', 'abuse_flaggers',
376 377
        'stats', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type',
        'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total',
378
        'endorsement',
379 380
    ]

381
    if (content.get('anonymous') is False) and ((content.get('anonymous_to_peers') is False) or is_staff):
382 383
        fields += ['username', 'user_id']

384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406
    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:
                log.error("User ID {0} in endorsement for comment {1} but not in our DB.".format(
                    content.get('user_id'),
                    content.get('id'))
                )

        # Only reveal endorser if requester can see author or if endorser is staff
        if (
            endorser and
            ("username" in fields or cached_has_permission(endorser, "endorse_comment", course_id))
        ):
            endorsement["username"] = endorser.username
        else:
            del endorsement["user_id"]

407 408
    for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]:
        if child_content_key in content:
409 410 411
            safe_children = [
                safe_content(child, course_id, is_staff) for child in content[child_content_key]
            ]
412
            content[child_content_key] = safe_children
Arjun Singh committed
413

414
    return content
415 416 417 418 419 420 421 422


def add_thread_group_name(thread_info, course_key):
    """
    Augment the specified thread info to include the group name if a group id is present.
    """
    if thread_info.get('group_id') is not None:
        thread_info['group_name'] = get_cohort_by_id(course_key, thread_info.get('group_id')).name