utils.py 15.9 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 107 108 109 110 111 112 113 114 115 116 117 118 119
def filter_unstarted_categories(category_map):

    now = time.gmtime()

    result_map = {}

    unfiltered_queue = [category_map]
    filtered_queue   = [result_map]

    while len(unfiltered_queue) > 0:

        unfiltered_map = unfiltered_queue.pop()
        filtered_map   = filtered_queue.pop()

        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 150

    global _DISCUSSIONINFO
151 152 153 154 155 156

    # only cache in-memory discussion information for 10 minutes
    # this is because we need a short-term hack fix for
    # mongo-backed courseware whereby new discussion modules can be added
    # without LMS service restart

157
    if _DISCUSSIONINFO[course.id]:
158 159 160 161 162
        timestamp = _DISCUSSIONINFO[course.id].get('timestamp', datetime.now())
        age = datetime.now() - timestamp
        # expire every 5 minutes
        if age.seconds < 300:
            return
163 164

    course_id = course.id
Arjun Singh committed
165 166 167

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

169
    # get all discussion models within this course_id
170 171 172 173
    all_modules = modulestore().get_items(['i4x', course.location.org, course.location.course, 'discussion', None], course_id=course_id)

    for module in all_modules:
        skip_module = False
174 175
        for key in ('discussion_id', 'discussion_category', 'discussion_target'):
            if getattr(module, key) is None:
176 177 178 179 180 181
                log.warning("Required key '%s' not in discussion %s, leaving out of category map" % (key, module.location))
                skip_module = True

        if skip_module:
            continue

182 183 184 185
        id = module.discussion_id
        category = module.discussion_category
        title = module.discussion_target
        sort_key = module.sort_key
186 187
        category = " / ".join([x.strip() for x in category.split("/")])
        last_category = category.split("/")[-1]
188
        discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title}
189
        unexpanded_category_map[category].append({"title": title, "id": id,
190
            "sort_key": sort_key, "start_date": module.lms.start})
Arjun Singh committed
191

192
    category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
Arjun Singh committed
193 194 195
    for category_path, entries in unexpanded_category_map.items():
        node = category_map["subcategories"]
        path = [x.strip() for x in category_path.split("/")]
196 197 198 199 200 201 202

        # 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
203 204
        for level in path[:-1]:
            if level not in node:
205
                node[level] = {"subcategories": defaultdict(dict),
Arjun Singh committed
206
                               "entries": defaultdict(dict),
207 208 209 210 211
                               "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
212 213 214 215
            node = node[level]["subcategories"]

        level = path[-1]
        if level not in node:
216 217
            node[level] = {"subcategories": defaultdict(dict),
                            "entries": defaultdict(dict),
218 219 220 221 222 223
                            "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
224
        for entry in entries:
225
            node[level]["entries"][entry["title"]] = {"id": entry["id"],
226 227
                                                      "sort_key": entry["sort_key"],
                                                      "start_date": entry["start_date"]}
Arjun Singh committed
228

229 230 231
    # 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.
232
    for topic, entry in course.discussion_topics.items():
Arjun Singh committed
233
        category_map['entries'][topic] = {"id": entry["id"],
234 235
                                          "sort_key": entry.get("sort_key", topic),
                                          "start_date": time.gmtime()}
Arjun Singh committed
236
    sort_map_entries(category_map)
237

238 239
    _DISCUSSIONINFO[course.id]['id_map'] = discussion_id_map
    _DISCUSSIONINFO[course.id]['category_map'] = category_map
240
    _DISCUSSIONINFO[course.id]['timestamp'] = datetime.now()
241 242


Rocky Duan committed
243 244
class JsonResponse(HttpResponse):
    def __init__(self, data=None):
Rocky Duan committed
245
        content = simplejson.dumps(data)
Rocky Duan committed
246
        super(JsonResponse, self).__init__(content,
247
                                           mimetype='application/json; charset=utf-8')
Rocky Duan committed
248

Calen Pennington committed
249

Rocky Duan committed
250
class JsonError(HttpResponse):
251
    def __init__(self, error_messages=[], status=400):
Rocky Duan committed
252 253 254
        if isinstance(error_messages, str):
            error_messages = [error_messages]
        content = simplejson.dumps({'errors': error_messages},
Rocky Duan committed
255 256 257
                                   indent=2,
                                   ensure_ascii=False)
        super(JsonError, self).__init__(content,
258
                                        mimetype='application/json; charset=utf-8', status=status)
259

Calen Pennington committed
260

261 262 263
class HtmlResponse(HttpResponse):
    def __init__(self, html=''):
        super(HtmlResponse, self).__init__(html, content_type='text/plain')
264

Calen Pennington committed
265

266 267
class ViewNameMiddleware(object):
    def process_view(self, request, view_func, view_args, view_kwargs):
268
        request.view_name = view_func.__name__
269

Calen Pennington committed
270

271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292
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)

293
            log.info('%s queries run, total %s seconds' % (len(connection.queries), total_time))
294 295
        return response

Calen Pennington committed
296

297 298 299 300 301 302 303 304 305 306
def get_ability(course_id, content, user):
    return {
            '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"),
    }

307
#TODO: RENAME
Calen Pennington committed
308 309


310
def get_annotated_content_info(course_id, content, user, user_info):
311 312 313
    """
    Get metadata for an individual content (thread or comment)
    """
314 315 316 317 318
    voted = ''
    if content['id'] in user_info['upvoted_ids']:
        voted = 'up'
    elif content['id'] in user_info['downvoted_ids']:
        voted = 'down'
319
    return {
320 321
        'voted': voted,
        'subscribed': content['id'] in user_info['subscribed_thread_ids'],
322
        'ability': get_ability(course_id, content, user),
323 324
    }

325
#TODO: RENAME
Calen Pennington committed
326 327


328
def get_annotated_content_infos(course_id, thread, user, user_info):
329 330 331
    """
    Get metadata for a thread and its children
    """
332
    infos = {}
333 334
    def annotate(content):
        infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
335
        for child in content.get('children', []):
336 337
            annotate(child)
    annotate(thread)
338
    return infos
Rocky Duan committed
339

Calen Pennington committed
340

341 342 343 344 345 346 347
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

348
# put this method in utils.py to avoid circular import dependency between helpers and mustache_helpers
Calen Pennington committed
349 350


351 352 353
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
354

Rocky Duan committed
355 356 357
def render_mustache(template_name, dictionary, *args, **kwargs):
    template = middleware.lookup['main'].get_template(template_name).source
    return pystache.render(template, dictionary)
358

Calen Pennington committed
359

360 361 362 363 364 365 366 367
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
368

369
def extend_content(content):
370 371
    roles = {}
    if content.get('user_id'):
372 373 374 375
        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:
376
            log.error('User ID {0} in comment content {1} but not in our DB.'.format(content.get('user_id'), content.get('id')))
377

378 379 380 381 382
    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),
383
        'roles': roles,
Calen Pennington committed
384
        'updated': content['created_at'] != content['updated_at'],
385 386
    }
    return merge_dict(content, content_info)
387

Calen Pennington committed
388

389 390
def get_courseware_context(content, course):
    id_map = get_discussion_id_map(course)
391
    id = content['commentable_id']
392 393 394 395
    content_info = None
    if id in id_map:
        location = id_map[id]["location"].url()
        title = id_map[id]["title"]
396

397 398
        url = reverse('jump_to', kwargs={"course_id":course.location.course_id, 
                                                    "location": location})
399

400
        content_info = {"courseware_url": url, "courseware_title": title}
401 402
    return content_info

Calen Pennington committed
403

404 405
def safe_content(content):
    fields = [
406 407 408 409
        '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
410
        'courseware_title', 'courseware_url', 'tags', 'unread_comments_count',
Your Name committed
411
        'read', 'group_id', 'group_name', 'group_string', 'pinned'
412 413
    ]

414
    if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):
415 416
        fields += ['username', 'user_id']

Arjun Singh committed
417 418 419 420
    if 'children' in content:
        safe_children = [safe_content(child) for child in content['children']]
        content['children'] = safe_children

421
    return strip_none(extract(content, fields))