helpers.py 14.4 KB
Newer Older
1 2 3 4 5
"""
Helper methods related to EdxNotes.
"""
import json
import logging
6
import urlparse
7 8
from datetime import datetime
from json import JSONEncoder
9
from urllib import urlencode
10
from uuid import uuid4
11 12 13

import requests
from dateutil.parser import parse as dateutil_parse
14 15
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
16
from django.core.urlresolvers import reverse
17
from django.utils.translation import ugettext as _
18
from opaque_keys.edx.keys import UsageKey
19
from provider.oauth2.models import Client
20
from requests.exceptions import RequestException
21

22
from courseware.access import has_access
23
from courseware.courses import get_current_child
24 25
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
from edxnotes.plugins import EdxNotesTab
26
from lms.lib.utils import get_parent_unit
27
from openedx.core.lib.token_utils import JwtBuilder
28
from student.models import anonymous_id_for_user
29
from util.date_utils import get_default_time_display
30 31
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
32

33
log = logging.getLogger(__name__)
34 35
# OAuth2 Client name for edxnotes
CLIENT_NAME = "edx-notes"
36
DEFAULT_PAGE = 1
37
DEFAULT_PAGE_SIZE = 25
38 39 40 41 42 43 44 45 46 47 48 49 50


class NoteJSONEncoder(JSONEncoder):
    """
    Custom JSON encoder that encode datetime objects to appropriate time strings.
    """
    # pylint: disable=method-hidden
    def default(self, obj):
        if isinstance(obj, datetime):
            return get_default_time_display(obj)
        return json.JSONEncoder.default(self, obj)


51
def get_edxnotes_id_token(user):
52
    """
53
    Returns generated ID Token for edxnotes.
54
    """
55 56 57 58 59 60 61 62 63 64 65 66 67
    # TODO: Use the system's JWT_AUDIENCE and JWT_SECRET_KEY instead of client ID and name.
    try:
        client = Client.objects.get(name=CLIENT_NAME)
    except Client.DoesNotExist:
        raise ImproperlyConfigured(
            'OAuth2 Client with name [{}] does not exist.'.format(CLIENT_NAME)
        )

    scopes = ['email', 'profile']
    expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
    jwt = JwtBuilder(user, secret=client.client_secret).build_token(scopes, expires_in, aud=client.client_id)

    return jwt
68 69 70 71 72 73 74 75 76 77 78


def get_token_url(course_id):
    """
    Returns token url for the course.
    """
    return reverse("get_token", kwargs={
        "course_id": unicode(course_id),
    })


79
def send_request(user, course_id, page, page_size, path="", text=None):
80
    """
81 82 83 84 85 86 87 88 89 90 91 92
    Sends a request to notes api with appropriate parameters and headers.

    Arguments:
        user: Current logged in user
        course_id: Course id
        page: requested or default page number
        page_size: requested or default page size
        path: `search` or `annotations`. This is used to calculate notes api endpoint.
        text: text to search.

    Returns:
        Response received from notes api
93
    """
94
    url = get_internal_endpoint(path)
95 96 97
    params = {
        "user": anonymous_id_for_user(user, None),
        "course_id": unicode(course_id).encode("utf-8"),
98 99
        "page": page,
        "page_size": page_size,
100 101
    }

102
    if text:
103
        params.update({
104
            "text": text,
105
            "highlight": True
106 107 108 109 110 111
        })

    try:
        response = requests.get(
            url,
            headers={
112
                "x-annotator-auth-token": get_edxnotes_id_token(user)
113
            },
114 115
            params=params,
            timeout=(settings.EDXNOTES_CONNECT_TIMEOUT, settings.EDXNOTES_READ_TIMEOUT)
116 117
        )
    except RequestException:
118
        log.error("Failed to connect to edx-notes-api: url=%s, params=%s", url, str(params))
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138
        raise EdxNotesServiceUnavailable(_("EdxNotes Service is unavailable. Please try again in a few minutes."))

    return response


def preprocess_collection(user, course, collection):
    """
    Prepare `collection(notes_list)` provided by edx-notes-api
    for rendering in a template:
       add information about ancestor blocks,
       convert "updated" to date

    Raises:
        ItemNotFoundError - when appropriate module is not found.
    """
    # pylint: disable=too-many-statements

    store = modulestore()
    filtered_collection = list()
    cache = {}
139
    include_path_info = ('course_structure' not in settings.NOTES_DISABLED_TABS)
140 141
    with store.bulk_operations(course.id):
        for model in collection:
142
            update = {
143
                u"updated": dateutil_parse(model["updated"]),
144
            }
145

146
            model.update(update)
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171
            usage_id = model["usage_id"]
            if usage_id in cache:
                model.update(cache[usage_id])
                filtered_collection.append(model)
                continue

            usage_key = UsageKey.from_string(usage_id)
            # Add a course run if necessary.
            usage_key = usage_key.replace(course_key=store.fill_in_run(usage_key.course_key))

            try:
                item = store.get_item(usage_key)
            except ItemNotFoundError:
                log.debug("Module not found: %s", usage_key)
                continue

            if not has_access(user, "load", item, course_key=course.id):
                log.debug("User %s does not have an access to %s", user, item)
                continue

            unit = get_parent_unit(item)
            if unit is None:
                log.debug("Unit not found: %s", usage_key)
                continue

172
            if include_path_info:
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
                section = unit.get_parent()
                if not section:
                    log.debug("Section not found: %s", usage_key)
                    continue
                if section in cache:
                    usage_context = cache[section]
                    usage_context.update({
                        "unit": get_module_context(course, unit),
                    })
                    model.update(usage_context)
                    cache[usage_id] = cache[unit] = usage_context
                    filtered_collection.append(model)
                    continue

                chapter = section.get_parent()
                if not chapter:
                    log.debug("Chapter not found: %s", usage_key)
                    continue
                if chapter in cache:
                    usage_context = cache[chapter]
                    usage_context.update({
                        "unit": get_module_context(course, unit),
                        "section": get_module_context(course, section),
                    })
                    model.update(usage_context)
                    cache[usage_id] = cache[unit] = cache[section] = usage_context
                    filtered_collection.append(model)
                    continue
201 202 203

            usage_context = {
                "unit": get_module_context(course, unit),
204 205
                "section": get_module_context(course, section) if include_path_info else {},
                "chapter": get_module_context(course, chapter) if include_path_info else {},
206 207
            }
            model.update(usage_context)
208
            if include_path_info:
209 210 211
                cache[section] = cache[chapter] = usage_context

            cache[usage_id] = cache[unit] = usage_context
212 213 214 215 216 217 218 219 220 221 222
            filtered_collection.append(model)

    return filtered_collection


def get_module_context(course, item):
    """
    Returns dispay_name and url for the parent module.
    """
    item_dict = {
        'location': unicode(item.location),
223
        'display_name': item.display_name_with_default_escaped,
224
    }
225 226 227 228
    if item.category == 'chapter' and item.get_parent():
        # course is a locator w/o branch and version
        # so for uniformity we replace it with one that has them
        course = item.get_parent()
229 230
        item_dict['index'] = get_index(item_dict['location'], course.children)
    elif item.category == 'vertical':
231 232
        section = item.get_parent()
        chapter = section.get_parent()
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254
        # Position starts from 1, that's why we add 1.
        position = get_index(unicode(item.location), section.children) + 1
        item_dict['url'] = reverse('courseware_position', kwargs={
            'course_id': unicode(course.id),
            'chapter': chapter.url_name,
            'section': section.url_name,
            'position': position,
        })
    if item.category in ('chapter', 'sequential'):
        item_dict['children'] = [unicode(child) for child in item.children]

    return item_dict


def get_index(usage_key, children):
    """
    Returns an index of the child with `usage_key`.
    """
    children = [unicode(child) for child in children]
    return children.index(usage_key)


255
def construct_pagination_urls(request, course_id, api_next_url, api_previous_url):
256
    """
257 258 259 260 261 262 263 264 265 266 267 268 269
    Construct next and previous urls for LMS. `api_next_url` and `api_previous_url`
    are returned from notes api but we need to transform them according to LMS notes
    views by removing and replacing extra information.

    Arguments:
        request: HTTP request object
        course_id: course id
        api_next_url: notes api next url
        api_previous_url: notes api previous url

    Returns:
        next_url: lms notes next url
        previous_url: lms notes previous url
270
    """
271 272 273 274 275 276 277 278 279 280 281 282 283
    def lms_url(url):
        """
        Create lms url from api url.
        """
        if url is None:
            return None

        keys = ('page', 'page_size', 'text')
        parsed = urlparse.urlparse(url)
        query_params = urlparse.parse_qs(parsed.query)

        encoded_query_params = urlencode({key: query_params.get(key)[0] for key in keys if key in query_params})
        return "{}?{}".format(request.build_absolute_uri(base_url), encoded_query_params)
284

285 286 287
    base_url = reverse("notes", kwargs={"course_id": course_id})
    next_url = lms_url(api_next_url)
    previous_url = lms_url(api_previous_url)
288

289
    return next_url, previous_url
290

291 292

def get_notes(request, course, page=DEFAULT_PAGE, page_size=DEFAULT_PAGE_SIZE, text=None):
293
    """
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311
    Returns paginated list of notes for the user.

    Arguments:
        request: HTTP request object
        course: Course descriptor
        page: requested or default page number
        page_size: requested or default page size
        text: text to search. If None then return all results for the current logged in user.

    Returns:
        Paginated dictionary with these key:
            start: start of the current page
            current_page: current page number
            next: url for next page
            previous: url for previous page
            count: total number of notes available for the sent query
            num_pages: number of pages available
            results: list with notes info dictionary. each item in this list will be a dict
312
    """
313 314 315
    path = 'search' if text else 'annotations'
    response = send_request(request.user, course.id, page, page_size, path, text)

316 317 318
    try:
        collection = json.loads(response.content)
    except ValueError:
319 320
        log.error("Invalid JSON response received from notes api: response_content=%s", response.content)
        raise EdxNotesParseError(_("Invalid JSON response received from notes api."))
321 322

    # Verify response dict structure
323
    expected_keys = ['total', 'rows', 'num_pages', 'start', 'next', 'previous', 'current_page']
324 325
    keys = collection.keys()
    if not keys or not all(key in expected_keys for key in keys):
326 327 328 329 330 331 332 333 334
        log.error("Incorrect data received from notes api: collection_data=%s", str(collection))
        raise EdxNotesParseError(_("Incorrect data received from notes api."))

    filtered_results = preprocess_collection(request.user, course, collection['rows'])
    # Notes API is called from:
    # 1. The annotatorjs in courseware. It expects these attributes to be named "total" and "rows".
    # 2. The Notes tab Javascript proxied through LMS. It expects these attributes to be called "count" and "results".
    collection['count'] = collection['total']
    del collection['total']
335
    collection['results'] = filtered_results
336
    del collection['rows']
337

338 339 340 341 342 343
    collection['next'], collection['previous'] = construct_pagination_urls(
        request,
        course.id,
        collection['next'],
        collection['previous']
    )
344

345
    return collection
346 347


348
def get_endpoint(api_url, path=""):
349 350
    """
    Returns edx-notes-api endpoint.
351 352 353 354 355 356

    Arguments:
        api_url (str): base url to the notes api
        path (str): path to the resource
    Returns:
        str: full endpoint to the notes api
357 358
    """
    try:
359 360
        if not api_url.endswith("/"):
            api_url += "/"
361 362 363 364 365 366 367

        if path:
            if path.startswith("/"):
                path = path.lstrip("/")
            if not path.endswith("/"):
                path += "/"

368
        return api_url + path
369 370 371 372
    except (AttributeError, KeyError):
        raise ImproperlyConfigured(_("No endpoint was provided for EdxNotes."))


373 374 375 376 377 378 379 380 381 382
def get_public_endpoint(path=""):
    """Get the full path to a resource on the public notes API."""
    return get_endpoint(settings.EDXNOTES_PUBLIC_API, path)


def get_internal_endpoint(path=""):
    """Get the full path to a resource on the private notes API."""
    return get_endpoint(settings.EDXNOTES_INTERNAL_API, path)


383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401
def get_course_position(course_module):
    """
    Return the user's current place in the course.

    If this is the user's first time, leads to COURSE/CHAPTER/SECTION.
    If this isn't the users's first time, leads to COURSE/CHAPTER.

    If there is no current position in the course or chapter, then selects
    the first child.
    """
    urlargs = {'course_id': unicode(course_module.id)}
    chapter = get_current_child(course_module, min_depth=1)
    if chapter is None:
        log.debug("No chapter found when loading current position in course")
        return None

    urlargs['chapter'] = chapter.url_name
    if course_module.position is not None:
        return {
402
            'display_name': chapter.display_name_with_default_escaped,
403 404 405 406 407 408 409 410 411 412 413
            'url': reverse('courseware_chapter', kwargs=urlargs),
        }

    # Relying on default of returning first child
    section = get_current_child(chapter, min_depth=1)
    if section is None:
        log.debug("No section found when loading current position in course")
        return None

    urlargs['section'] = section.url_name
    return {
414
        'display_name': section.display_name_with_default_escaped,
415 416 417 418 419 420 421 422 423 424 425 426 427
        'url': reverse('courseware_section', kwargs=urlargs)
    }


def generate_uid():
    """
    Generates unique id.
    """
    return uuid4().int  # pylint: disable=no-member


def is_feature_enabled(course):
    """
428
    Returns True if Student Notes feature is enabled for the course, False otherwise.
429
    """
430
    return EdxNotesTab.is_enabled(course)