These changes unify four different approaches to JWT creation, moving the core of the AccessTokenView to a general-purpose JwtBuilder class. This utility class defaults to using the system's JWT configuration, but it will allow overriding of the signing key and audience claim to support those clients which still require this. Part of ECOM-4566.
447 lines
15 KiB
Python
447 lines
15 KiB
Python
"""
|
|
Helper methods related to EdxNotes.
|
|
"""
|
|
import json
|
|
import logging
|
|
from json import JSONEncoder
|
|
from uuid import uuid4
|
|
import urlparse
|
|
from urllib import urlencode
|
|
|
|
import requests
|
|
from datetime import datetime
|
|
from dateutil.parser import parse as dateutil_parse
|
|
from opaque_keys.edx.keys import UsageKey
|
|
from requests.exceptions import RequestException
|
|
|
|
from django.conf import settings
|
|
from django.core.exceptions import ImproperlyConfigured
|
|
from django.core.urlresolvers import reverse
|
|
from django.utils.translation import ugettext as _
|
|
from provider.oauth2.models import Client
|
|
|
|
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
|
|
from edxnotes.plugins import EdxNotesTab
|
|
from courseware.views.views import get_current_child
|
|
from courseware.access import has_access
|
|
from openedx.core.lib.token_utils import JwtBuilder
|
|
from student.models import anonymous_id_for_user
|
|
from util.date_utils import get_default_time_display
|
|
from xmodule.modulestore.django import modulestore
|
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
# OAuth2 Client name for edxnotes
|
|
CLIENT_NAME = "edx-notes"
|
|
DEFAULT_PAGE = 1
|
|
DEFAULT_PAGE_SIZE = 25
|
|
|
|
|
|
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)
|
|
|
|
|
|
def get_edxnotes_id_token(user):
|
|
"""
|
|
Returns generated ID Token for edxnotes.
|
|
"""
|
|
# 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
|
|
|
|
|
|
def get_token_url(course_id):
|
|
"""
|
|
Returns token url for the course.
|
|
"""
|
|
return reverse("get_token", kwargs={
|
|
"course_id": unicode(course_id),
|
|
})
|
|
|
|
|
|
def send_request(user, course_id, page, page_size, path="", text=None):
|
|
"""
|
|
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
|
|
"""
|
|
url = get_internal_endpoint(path)
|
|
params = {
|
|
"user": anonymous_id_for_user(user, None),
|
|
"course_id": unicode(course_id).encode("utf-8"),
|
|
"page": page,
|
|
"page_size": page_size,
|
|
}
|
|
|
|
if text:
|
|
params.update({
|
|
"text": text,
|
|
"highlight": True
|
|
})
|
|
|
|
try:
|
|
response = requests.get(
|
|
url,
|
|
headers={
|
|
"x-annotator-auth-token": get_edxnotes_id_token(user)
|
|
},
|
|
params=params,
|
|
timeout=(settings.EDXNOTES_CONNECT_TIMEOUT, settings.EDXNOTES_READ_TIMEOUT)
|
|
)
|
|
except RequestException:
|
|
log.error("Failed to connect to edx-notes-api: url=%s, params=%s", url, str(params))
|
|
raise EdxNotesServiceUnavailable(_("EdxNotes Service is unavailable. Please try again in a few minutes."))
|
|
|
|
return response
|
|
|
|
|
|
def get_parent_unit(xblock):
|
|
"""
|
|
Find vertical that is a unit, not just some container.
|
|
"""
|
|
while xblock:
|
|
xblock = xblock.get_parent()
|
|
if xblock is None:
|
|
return None
|
|
parent = xblock.get_parent()
|
|
if parent is None:
|
|
return None
|
|
if parent.category == 'sequential':
|
|
return xblock
|
|
|
|
|
|
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 = {}
|
|
include_path_info = ('course_structure' not in settings.NOTES_DISABLED_TABS)
|
|
with store.bulk_operations(course.id):
|
|
for model in collection:
|
|
update = {
|
|
u"updated": dateutil_parse(model["updated"]),
|
|
}
|
|
|
|
model.update(update)
|
|
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
|
|
|
|
if include_path_info:
|
|
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
|
|
|
|
usage_context = {
|
|
"unit": get_module_context(course, unit),
|
|
"section": get_module_context(course, section) if include_path_info else {},
|
|
"chapter": get_module_context(course, chapter) if include_path_info else {},
|
|
}
|
|
model.update(usage_context)
|
|
if include_path_info:
|
|
cache[section] = cache[chapter] = usage_context
|
|
|
|
cache[usage_id] = cache[unit] = usage_context
|
|
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),
|
|
'display_name': item.display_name_with_default_escaped,
|
|
}
|
|
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()
|
|
item_dict['index'] = get_index(item_dict['location'], course.children)
|
|
elif item.category == 'vertical':
|
|
section = item.get_parent()
|
|
chapter = section.get_parent()
|
|
# 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)
|
|
|
|
|
|
def construct_pagination_urls(request, course_id, api_next_url, api_previous_url):
|
|
"""
|
|
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
|
|
"""
|
|
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)
|
|
|
|
base_url = reverse("notes", kwargs={"course_id": course_id})
|
|
next_url = lms_url(api_next_url)
|
|
previous_url = lms_url(api_previous_url)
|
|
|
|
return next_url, previous_url
|
|
|
|
|
|
def get_notes(request, course, page=DEFAULT_PAGE, page_size=DEFAULT_PAGE_SIZE, text=None):
|
|
"""
|
|
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
|
|
"""
|
|
path = 'search' if text else 'annotations'
|
|
response = send_request(request.user, course.id, page, page_size, path, text)
|
|
|
|
try:
|
|
collection = json.loads(response.content)
|
|
except ValueError:
|
|
log.error("Invalid JSON response received from notes api: response_content=%s", response.content)
|
|
raise EdxNotesParseError(_("Invalid JSON response received from notes api."))
|
|
|
|
# Verify response dict structure
|
|
expected_keys = ['total', 'rows', 'num_pages', 'start', 'next', 'previous', 'current_page']
|
|
keys = collection.keys()
|
|
if not keys or not all(key in expected_keys for key in keys):
|
|
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']
|
|
collection['results'] = filtered_results
|
|
del collection['rows']
|
|
|
|
collection['next'], collection['previous'] = construct_pagination_urls(
|
|
request,
|
|
course.id,
|
|
collection['next'],
|
|
collection['previous']
|
|
)
|
|
|
|
return collection
|
|
|
|
|
|
def get_endpoint(api_url, path=""):
|
|
"""
|
|
Returns edx-notes-api endpoint.
|
|
|
|
Arguments:
|
|
api_url (str): base url to the notes api
|
|
path (str): path to the resource
|
|
Returns:
|
|
str: full endpoint to the notes api
|
|
"""
|
|
try:
|
|
if not api_url.endswith("/"):
|
|
api_url += "/"
|
|
|
|
if path:
|
|
if path.startswith("/"):
|
|
path = path.lstrip("/")
|
|
if not path.endswith("/"):
|
|
path += "/"
|
|
|
|
return api_url + path
|
|
except (AttributeError, KeyError):
|
|
raise ImproperlyConfigured(_("No endpoint was provided for EdxNotes."))
|
|
|
|
|
|
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)
|
|
|
|
|
|
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 {
|
|
'display_name': chapter.display_name_with_default_escaped,
|
|
'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 {
|
|
'display_name': section.display_name_with_default_escaped,
|
|
'url': reverse('courseware_section', kwargs=urlargs)
|
|
}
|
|
|
|
|
|
def generate_uid():
|
|
"""
|
|
Generates unique id.
|
|
"""
|
|
return uuid4().int # pylint: disable=no-member
|
|
|
|
|
|
def is_feature_enabled(course):
|
|
"""
|
|
Returns True if Student Notes feature is enabled for the course, False otherwise.
|
|
"""
|
|
return EdxNotesTab.is_enabled(course)
|