utils.py 15.5 KB
Newer Older
Arjun Singh committed
1
from collections import defaultdict
2 3 4
import logging
import time
import urllib
5
from datetime import datetime
6

7 8 9
from courseware.module_render import get_module
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
10
from xmodule.modulestore.search import path_to_location
Brian Wilson committed
11 12 13
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.db import connection
Rocky Duan committed
14 15
from django.http import HttpResponse
from django.utils import simplejson
16
from django_comment_client.models import Role
Brian Wilson committed
17
from django_comment_client.permissions import check_permissions_by_view
18 19
from xmodule.modulestore.exceptions import NoPathToItem

Rocky Duan committed
20
from mitxmako import middleware
21
import pystache_custom as pystache
22

Brian Wilson committed
23 24
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
25

26
log = logging.getLogger(__name__)
27

28
# TODO these should be cached via django's caching rather than in-memory globals
29
_FULLMODULES = None
30
_DISCUSSIONINFO = defaultdict(dict)
31

Calen Pennington committed
32

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

Calen Pennington committed
36

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

Calen Pennington committed
40

Rocky Duan committed
41 42 43 44
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
45

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


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

Calen Pennington committed
52

53 54 55 56 57
def get_role_ids(course_id):
    roles = Role.objects.filter(course_id=course_id)
    staff = list(User.objects.filter(is_staff=True).values_list('id', flat=True))
    roles_with_ids = {'Staff': staff}
    for role in roles:
Brian Wilson committed
58
        roles_with_ids[role.name] = list(role.users.values_list('id', flat=True))
59 60
    return roles_with_ids

Calen Pennington committed
61

Brian Wilson committed
62 63 64 65 66 67 68
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
69

70 71 72
def get_full_modules():
    global _FULLMODULES
    if not _FULLMODULES:
Arjun Singh committed
73
        _FULLMODULES = modulestore().modules
74 75
    return _FULLMODULES

Calen Pennington committed
76

Arjun Singh committed
77
def get_discussion_id_map(course):
78 79 80 81
    """
        return a dict of the form {category: modules}
    """
    global _DISCUSSIONINFO
82
    initialize_discussion_info(course)
83
    return _DISCUSSIONINFO[course.id]['id_map']
84

Calen Pennington committed
85

Arjun Singh committed
86
def get_discussion_title(course, discussion_id):
87
    global _DISCUSSIONINFO
88
    initialize_discussion_info(course)
89
    title = _DISCUSSIONINFO[course.id]['id_map'].get(discussion_id, {}).get('title', '(no title)')
90 91
    return title

Calen Pennington committed
92

Arjun Singh committed
93 94 95
def get_discussion_category_map(course):

    global _DISCUSSIONINFO
96
    initialize_discussion_info(course)
97
    return filter_unstarted_categories(_DISCUSSIONINFO[course.id]['category_map'])
98

Calen Pennington committed
99

100 101 102 103 104 105 106
def filter_unstarted_categories(category_map):

    now = time.gmtime()

    result_map = {}

    unfiltered_queue = [category_map]
107
    filtered_queue   = [result_map]
108 109 110 111

    while len(unfiltered_queue) > 0:

        unfiltered_map = unfiltered_queue.pop()
112
        filtered_map   = filtered_queue.pop()
113 114 115 116 117 118 119

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

        for child in unfiltered_map["children"]:
            if child in unfiltered_map["entries"]:
120
                if unfiltered_map["entries"][child]["start_date"] <= now:
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
                    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:
                    print "filtering %s" % child, unfiltered_map["entries"][child]["start_date"]
            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
136

Calen Pennington committed
137

Arjun Singh committed
138 139 140 141 142 143 144 145 146
def sort_map_entries(category_map):
    things = []
    for title, entry in category_map["entries"].items():
        things.append((title, entry))
    for title, category in category_map["subcategories"].items():
        things.append((title, category))
        sort_map_entries(category_map["subcategories"][title])
    category_map["children"] = [x[0] for x in sorted(things, key=lambda x: x[1]["sort_key"])]

147

Arjun Singh committed
148
def initialize_discussion_info(course):
149
    global _DISCUSSIONINFO
150

151
    course_id = course.id
Arjun Singh committed
152 153 154

    discussion_id_map = {}
    unexpanded_category_map = defaultdict(list)
Arjun Singh committed
155

156
    # get all discussion models within this course_id
157 158
    all_modules = modulestore().get_items(['i4x', course.location.org, course.location.course,
        'discussion', None], course_id=course_id)
159 160 161

    for module in all_modules:
        skip_module = False
162 163
        for key in ('discussion_id', 'discussion_category', 'discussion_target'):
            if getattr(module, key) is None:
164 165 166 167 168 169
                log.warning("Required key '%s' not in discussion %s, leaving out of category map" % (key, module.location))
                skip_module = True

        if skip_module:
            continue

170 171 172 173
        id = module.discussion_id
        category = module.discussion_category
        title = module.discussion_target
        sort_key = module.sort_key
174 175
        category = " / ".join([x.strip() for x in category.split("/")])
        last_category = category.split("/")[-1]
176
        discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title}
177 178
        unexpanded_category_map[category].append({"title": title, "id": id,
            "sort_key": sort_key, "start_date": module.lms.start})
Arjun Singh committed
179

180
    category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
Arjun Singh committed
181 182 183
    for category_path, entries in unexpanded_category_map.items():
        node = category_map["subcategories"]
        path = [x.strip() for x in category_path.split("/")]
184 185 186 187 188 189 190

        # 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
191 192
        for level in path[:-1]:
            if level not in node:
193
                node[level] = {"subcategories": defaultdict(dict),
Arjun Singh committed
194
                               "entries": defaultdict(dict),
195 196 197 198 199
                               "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
200 201 202 203
            node = node[level]["subcategories"]

        level = path[-1]
        if level not in node:
204
            node[level] = {"subcategories": defaultdict(dict),
205 206 207
                            "entries": defaultdict(dict),
                            "sort_key": level,
                            "start_date": category_start_date}
208 209 210 211
        else:
            if node[level]["start_date"] > category_start_date:
                node[level]["start_date"] = category_start_date

Arjun Singh committed
212
        for entry in entries:
213
            node[level]["entries"][entry["title"]] = {"id": entry["id"],
214 215
                                                      "sort_key": entry["sort_key"],
                                                      "start_date": entry["start_date"]}
Arjun Singh committed
216

217 218 219
    # 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.
220
    for topic, entry in course.discussion_topics.items():
Arjun Singh committed
221
        category_map['entries'][topic] = {"id": entry["id"],
222 223
                                          "sort_key": entry.get("sort_key", topic),
                                          "start_date": time.gmtime()}
Arjun Singh committed
224
    sort_map_entries(category_map)
225

226 227
    _DISCUSSIONINFO[course.id]['id_map'] = discussion_id_map
    _DISCUSSIONINFO[course.id]['category_map'] = category_map
228
    _DISCUSSIONINFO[course.id]['timestamp'] = datetime.now()
229 230


Rocky Duan committed
231 232
class JsonResponse(HttpResponse):
    def __init__(self, data=None):
Rocky Duan committed
233
        content = simplejson.dumps(data)
Rocky Duan committed
234
        super(JsonResponse, self).__init__(content,
235
                                           mimetype='application/json; charset=utf-8')
Rocky Duan committed
236

Calen Pennington committed
237

Rocky Duan committed
238
class JsonError(HttpResponse):
239
    def __init__(self, error_messages=[], status=400):
Rocky Duan committed
240 241 242
        if isinstance(error_messages, str):
            error_messages = [error_messages]
        content = simplejson.dumps({'errors': error_messages},
Rocky Duan committed
243 244 245
                                   indent=2,
                                   ensure_ascii=False)
        super(JsonError, self).__init__(content,
246
                                        mimetype='application/json; charset=utf-8', status=status)
247

Calen Pennington committed
248

249 250 251
class HtmlResponse(HttpResponse):
    def __init__(self, html=''):
        super(HtmlResponse, self).__init__(html, content_type='text/plain')
252

Calen Pennington committed
253

254 255
class ViewNameMiddleware(object):
    def process_view(self, request, view_func, view_args, view_kwargs):
256
        request.view_name = view_func.__name__
257

Calen Pennington committed
258

259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280
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)

281
            log.info('%s queries run, total %s seconds' % (len(connection.queries), total_time))
282 283
        return response

Calen Pennington committed
284

285 286
def get_ability(course_id, content, user):
    return {
287 288 289 290 291 292
            '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_endorse': check_permissions_by_view(user, course_id, content, "endorse_comment") if content['type'] == 'comment' else False,
            '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"),
293 294
    }

295
#TODO: RENAME
Calen Pennington committed
296 297


298
def get_annotated_content_info(course_id, content, user, user_info):
299 300 301
    """
    Get metadata for an individual content (thread or comment)
    """
302 303 304 305 306
    voted = ''
    if content['id'] in user_info['upvoted_ids']:
        voted = 'up'
    elif content['id'] in user_info['downvoted_ids']:
        voted = 'down'
307
    return {
308 309
        'voted': voted,
        'subscribed': content['id'] in user_info['subscribed_thread_ids'],
310
        'ability': get_ability(course_id, content, user),
311 312
    }

313
#TODO: RENAME
Calen Pennington committed
314 315


316
def get_annotated_content_infos(course_id, thread, user, user_info):
317 318 319
    """
    Get metadata for a thread and its children
    """
320
    infos = {}
321 322
    def annotate(content):
        infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
323
        for child in content.get('children', []):
324 325
            annotate(child)
    annotate(thread)
326
    return infos
Rocky Duan committed
327

Calen Pennington committed
328

329 330 331 332 333 334 335
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

336
# put this method in utils.py to avoid circular import dependency between helpers and mustache_helpers
Calen Pennington committed
337 338


339 340 341
def url_for_tags(course_id, tags):
    return reverse('django_comment_client.forum.views.forum_form_discussion', args=[course_id]) + '?' + urllib.urlencode({'tags': tags})

Calen Pennington committed
342

Rocky Duan committed
343 344 345
def render_mustache(template_name, dictionary, *args, **kwargs):
    template = middleware.lookup['main'].get_template(template_name).source
    return pystache.render(template, dictionary)
346

Calen Pennington committed
347

348 349 350 351 352 353 354 355
def permalink(content):
    if content['type'] == 'thread':
        return reverse('django_comment_client.forum.views.single_thread',
                       args=[content['course_id'], content['commentable_id'], content['id']])
    else:
        return reverse('django_comment_client.forum.views.single_thread',
                       args=[content['course_id'], content['commentable_id'], content['thread_id']]) + '#' + content['id']

Calen Pennington committed
356

357
def extend_content(content):
358 359
    roles = {}
    if content.get('user_id'):
360 361 362 363
        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']))
        except user.DoesNotExist:
364
            log.error('User ID {0} in comment content {1} but not in our DB.'.format(content.get('user_id'), content.get('id')))
365

366 367 368 369 370
    content_info = {
        'displayed_title': content.get('highlighted_title') or content.get('title', ''),
        'displayed_body': content.get('highlighted_body') or content.get('body', ''),
        'raw_tags': ','.join(content.get('tags', [])),
        'permalink': permalink(content),
371
        'roles': roles,
Calen Pennington committed
372
        'updated': content['created_at'] != content['updated_at'],
373 374
    }
    return merge_dict(content, content_info)
375

Calen Pennington committed
376

377 378
def get_courseware_context(content, course):
    id_map = get_discussion_id_map(course)
379
    id = content['commentable_id']
380 381 382 383
    content_info = None
    if id in id_map:
        location = id_map[id]["location"].url()
        title = id_map[id]["title"]
384

385 386
        url = reverse('jump_to', kwargs={"course_id":course.location.course_id, 
                                                    "location": location})
387

388
        content_info = {"courseware_url": url, "courseware_title": title}
389 390
    return content_info

Calen Pennington committed
391

392 393
def safe_content(content):
    fields = [
394 395 396 397
        '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',
Rocky Duan committed
398
        'courseware_title', 'courseware_url', 'tags', 'unread_comments_count',
399
        'read', 'group_id', 'group_name', 'group_string', 'pinned'
400 401
    ]

402
    if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):
403 404
        fields += ['username', 'user_id']

Arjun Singh committed
405 406 407 408
    if 'children' in content:
        safe_children = [safe_content(child) for child in content['children']]
        content['children'] = safe_children

409
    return strip_none(extract(content, fields))