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

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
Brian Wilson committed
13
from django_comment_client.permissions import check_permissions_by_view
14

15
import mitxmako
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
log = logging.getLogger(__name__)
22

23
# TODO these should be cached via django's caching rather than in-memory globals
24
_FULLMODULES = None
25
_DISCUSSIONINFO = defaultdict(dict)
26

Calen Pennington committed
27

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

Calen Pennington committed
31

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

Calen Pennington committed
35

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

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


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

Calen Pennington committed
47

48 49 50 51 52
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
53
        roles_with_ids[role.name] = list(role.users.values_list('id', flat=True))
54 55
    return roles_with_ids

Calen Pennington committed
56

Brian Wilson committed
57 58 59 60 61 62 63
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
64

65 66 67
def get_full_modules():
    global _FULLMODULES
    if not _FULLMODULES:
Arjun Singh committed
68
        _FULLMODULES = modulestore().modules
69 70
    return _FULLMODULES

Calen Pennington committed
71

Arjun Singh committed
72
def get_discussion_id_map(course):
73 74 75
    """
        return a dict of the form {category: modules}
    """
76
    initialize_discussion_info(course)
77
    return _DISCUSSIONINFO[course.id]['id_map']
78

Calen Pennington committed
79

Arjun Singh committed
80
def get_discussion_title(course, discussion_id):
81
    initialize_discussion_info(course)
82
    title = _DISCUSSIONINFO[course.id]['id_map'].get(discussion_id, {}).get('title', '(no title)')
83 84
    return title

Calen Pennington committed
85

Arjun Singh committed
86
def get_discussion_category_map(course):
87
    initialize_discussion_info(course)
88
    return filter_unstarted_categories(_DISCUSSIONINFO[course.id]['category_map'])
89

Calen Pennington committed
90

91 92
def filter_unstarted_categories(category_map):

93
    now = datetime.now(UTC())
94 95 96 97

    result_map = {}

    unfiltered_queue = [category_map]
98
    filtered_queue = [result_map]
99 100 101 102

    while len(unfiltered_queue) > 0:

        unfiltered_map = unfiltered_queue.pop()
103
        filtered_map = filtered_queue.pop()
104 105 106 107 108 109 110

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

        for child in unfiltered_map["children"]:
            if child in unfiltered_map["entries"]:
111
                if unfiltered_map["entries"][child]["start_date"] <= now:
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
                    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
127

128 129
    
def sort_map_entries(category_map, sort_alpha):
Arjun Singh committed
130 131
    things = []
    for title, entry in category_map["entries"].items():
132
        if entry["sort_key"] == None and sort_alpha:
133
            entry["sort_key"] = title
Arjun Singh committed
134 135 136
        things.append((title, entry))
    for title, category in category_map["subcategories"].items():
        things.append((title, category))
137
        sort_map_entries(category_map["subcategories"][title], sort_alpha)
Arjun Singh committed
138 139
    category_map["children"] = [x[0] for x in sorted(things, key=lambda x: x[1]["sort_key"])]

140

Arjun Singh committed
141
def initialize_discussion_info(course):
142
    course_id = course.id
Arjun Singh committed
143 144 145

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

147
    # get all discussion models within this course_id
148
    all_modules = modulestore().get_items(['i4x', course.location.org, course.location.course,
149
                                          'discussion', None], course_id=course_id)
150 151 152

    for module in all_modules:
        skip_module = False
153 154
        for key in ('discussion_id', 'discussion_category', 'discussion_target'):
            if getattr(module, key) is None:
155 156 157 158 159 160
                log.warning("Required key '%s' not in discussion %s, leaving out of category map" % (key, module.location))
                skip_module = True

        if skip_module:
            continue

161 162 163 164
        id = module.discussion_id
        category = module.discussion_category
        title = module.discussion_target
        sort_key = module.sort_key
165 166
        category = " / ".join([x.strip() for x in category.split("/")])
        last_category = category.split("/")[-1]
167
        discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title}
Calen Pennington committed
168 169
        #Handle case where module.start is None
        entry_start_date = module.start if module.start else datetime.max.replace(tzinfo=pytz.UTC)
170
        unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": entry_start_date})
Arjun Singh committed
171

172
    category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
Arjun Singh committed
173 174 175
    for category_path, entries in unexpanded_category_map.items():
        node = category_map["subcategories"]
        path = [x.strip() for x in category_path.split("/")]
176 177 178 179 180 181 182

        # 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
183 184
        for level in path[:-1]:
            if level not in node:
185
                node[level] = {"subcategories": defaultdict(dict),
Arjun Singh committed
186
                               "entries": defaultdict(dict),
187 188 189 190 191
                               "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
192 193 194 195
            node = node[level]["subcategories"]

        level = path[-1]
        if level not in node:
196
            node[level] = {"subcategories": defaultdict(dict),
197 198 199
                           "entries": defaultdict(dict),
                           "sort_key": level,
                           "start_date": category_start_date}
200 201 202 203
        else:
            if node[level]["start_date"] > category_start_date:
                node[level]["start_date"] = category_start_date

Arjun Singh committed
204
        for entry in entries:
205
            node[level]["entries"][entry["title"]] = {"id": entry["id"],
206 207
                                                      "sort_key": entry["sort_key"],
                                                      "start_date": entry["start_date"]}
Arjun Singh committed
208

209 210 211
    # 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.
212
    for topic, entry in course.discussion_topics.items():
Arjun Singh committed
213
        category_map['entries'][topic] = {"id": entry["id"],
214
                                          "sort_key": entry.get("sort_key", topic),
215
                                          "start_date": datetime.now(UTC())}
216 217

    sort_map_entries(category_map, course.discussion_sort_alpha)
218

219 220
    _DISCUSSIONINFO[course.id]['id_map'] = discussion_id_map
    _DISCUSSIONINFO[course.id]['category_map'] = category_map
221
    _DISCUSSIONINFO[course.id]['timestamp'] = datetime.now(UTC())
222 223


Rocky Duan committed
224 225
class JsonResponse(HttpResponse):
    def __init__(self, data=None):
Rocky Duan committed
226
        content = simplejson.dumps(data)
Rocky Duan committed
227
        super(JsonResponse, self).__init__(content,
228
                                           mimetype='application/json; charset=utf-8')
Rocky Duan committed
229

Calen Pennington committed
230

Rocky Duan committed
231
class JsonError(HttpResponse):
232
    def __init__(self, error_messages=[], status=400):
Rocky Duan committed
233 234 235
        if isinstance(error_messages, str):
            error_messages = [error_messages]
        content = simplejson.dumps({'errors': error_messages},
Rocky Duan committed
236 237 238
                                   indent=2,
                                   ensure_ascii=False)
        super(JsonError, self).__init__(content,
239
                                        mimetype='application/json; charset=utf-8', status=status)
240

Calen Pennington committed
241

242 243 244
class HtmlResponse(HttpResponse):
    def __init__(self, html=''):
        super(HtmlResponse, self).__init__(html, content_type='text/plain')
245

Calen Pennington committed
246

247 248
class ViewNameMiddleware(object):
    def process_view(self, request, view_func, view_args, view_kwargs):
249
        request.view_name = view_func.__name__
250

Calen Pennington committed
251

252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273
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)

274
            log.info('%s queries run, total %s seconds' % (len(connection.queries), total_time))
275 276
        return response

Calen Pennington committed
277

278 279
def get_ability(course_id, content, user):
    return {
280 281 282 283 284 285
        '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"),
286 287
    }

288
# TODO: RENAME
Calen Pennington committed
289 290


291
def get_annotated_content_info(course_id, content, user, user_info):
292 293 294
    """
    Get metadata for an individual content (thread or comment)
    """
295 296 297 298 299
    voted = ''
    if content['id'] in user_info['upvoted_ids']:
        voted = 'up'
    elif content['id'] in user_info['downvoted_ids']:
        voted = 'down'
300
    return {
301 302
        'voted': voted,
        'subscribed': content['id'] in user_info['subscribed_thread_ids'],
303
        'ability': get_ability(course_id, content, user),
304 305
    }

306
# TODO: RENAME
Calen Pennington committed
307 308


309
def get_annotated_content_infos(course_id, thread, user, user_info):
310 311 312
    """
    Get metadata for a thread and its children
    """
313
    infos = {}
314

315 316
    def annotate(content):
        infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
317
        for child in content.get('children', []):
318 319
            annotate(child)
    annotate(thread)
320
    return infos
Rocky Duan committed
321

Calen Pennington committed
322

323 324 325 326 327 328 329
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

330
# put this method in utils.py to avoid circular import dependency between helpers and mustache_helpers
Calen Pennington committed
331 332


333 334 335
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
336

Rocky Duan committed
337
def render_mustache(template_name, dictionary, *args, **kwargs):
338
    template = mitxmako.lookup['main'].get_template(template_name).source
Rocky Duan committed
339
    return pystache.render(template, dictionary)
340

Calen Pennington committed
341

342 343 344 345 346 347 348 349
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
350

351
def extend_content(content):
352 353
    roles = {}
    if content.get('user_id'):
354 355 356 357
        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:
358
            log.error('User ID {0} in comment content {1} but not in our DB.'.format(content.get('user_id'), content.get('id')))
359

360 361 362 363 364
    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),
365
        'roles': roles,
Calen Pennington committed
366
        'updated': content['created_at'] != content['updated_at'],
367 368
    }
    return merge_dict(content, content_info)
369

Calen Pennington committed
370

371 372
def get_courseware_context(content, course):
    id_map = get_discussion_id_map(course)
373
    id = content['commentable_id']
374 375 376 377
    content_info = None
    if id in id_map:
        location = id_map[id]["location"].url()
        title = id_map[id]["title"]
378

379 380
        url = reverse('jump_to', kwargs={"course_id": course.location.course_id,
                      "location": location})
381

382
        content_info = {"courseware_url": url, "courseware_title": title}
383 384
    return content_info

Calen Pennington committed
385

386 387
def safe_content(content):
    fields = [
388 389 390 391
        '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
392
        'courseware_title', 'courseware_url', 'tags', 'unread_comments_count',
393 394
        'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers',
        'stats'
395

396 397
    ]

398
    if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):
399 400
        fields += ['username', 'user_id']

Arjun Singh committed
401 402 403 404
    if 'children' in content:
        safe_children = [safe_content(child) for child in content['children']]
        content['children'] = safe_children

405
    return strip_none(extract(content, fields))