This will remove imports from __future__ that are no longer needed. https://docs.python.org/3.5/library/2to3.html#2to3fixer-future
460 lines
16 KiB
Python
460 lines
16 KiB
Python
"""
|
|
Helper methods related to EdxNotes.
|
|
"""
|
|
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from json import JSONEncoder
|
|
from uuid import uuid4
|
|
|
|
import requests
|
|
import six
|
|
from six.moves.urllib.parse import urlencode, urlparse, parse_qs # pylint: disable=import-error
|
|
from dateutil.parser import parse as dateutil_parse
|
|
from django.conf import settings
|
|
from django.core.exceptions import ImproperlyConfigured
|
|
from django.urls import reverse
|
|
from django.utils.translation import ugettext as _
|
|
from oauth2_provider.models import Application
|
|
from opaque_keys.edx.keys import UsageKey
|
|
from requests.exceptions import RequestException
|
|
|
|
from lms.djangoapps.courseware.access import has_access
|
|
from lms.djangoapps.courseware.courses import get_current_child
|
|
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
|
|
from edxnotes.plugins import EdxNotesTab
|
|
from lms.lib.utils import get_parent_unit
|
|
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
|
|
from openedx.core.djangolib.markup import Text
|
|
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__)
|
|
|
|
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.
|
|
"""
|
|
try:
|
|
notes_application = Application.objects.get(name=settings.EDXNOTES_CLIENT_NAME)
|
|
except Application.DoesNotExist:
|
|
raise ImproperlyConfigured(
|
|
u'OAuth2 Client with name [{}] does not exist.'.format(settings.EDXNOTES_CLIENT_NAME)
|
|
)
|
|
return create_jwt_for_user(
|
|
user, secret=notes_application.client_secret, aud=notes_application.client_id
|
|
)
|
|
|
|
|
|
def get_token_url(course_id):
|
|
"""
|
|
Returns token url for the course.
|
|
"""
|
|
return reverse("get_token", kwargs={
|
|
"course_id": six.text_type(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": six.text_type(course_id),
|
|
"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(u"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 delete_all_notes_for_user(user):
|
|
"""
|
|
helper method to delete all notes for a user, as part of GDPR compliance
|
|
|
|
:param user: The user object associated with the deleted notes
|
|
:return: response (requests) object
|
|
|
|
Raises:
|
|
EdxNotesServiceUnavailable - when notes api is not found/misconfigured.
|
|
"""
|
|
url = get_internal_endpoint('retire_annotations')
|
|
headers = {
|
|
"x-annotator-auth-token": get_edxnotes_id_token(user),
|
|
}
|
|
data = {
|
|
"user": anonymous_id_for_user(user, None)
|
|
}
|
|
try:
|
|
response = requests.post(
|
|
url=url,
|
|
headers=headers,
|
|
data=data,
|
|
timeout=(settings.EDXNOTES_CONNECT_TIMEOUT, settings.EDXNOTES_READ_TIMEOUT)
|
|
)
|
|
except RequestException:
|
|
log.error(u"Failed to connect to edx-notes-api: url=%s, params=%s", url, str(headers))
|
|
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 = {}
|
|
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 list(cache.keys()):
|
|
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(u"Module not found: %s", usage_key)
|
|
continue
|
|
|
|
if not has_access(user, "load", item, course_key=course.id):
|
|
log.debug(u"User %s does not have an access to %s", user, item)
|
|
continue
|
|
|
|
unit = get_parent_unit(item)
|
|
if unit is None:
|
|
log.debug(u"Unit not found: %s", usage_key)
|
|
continue
|
|
|
|
if include_path_info:
|
|
section = unit.get_parent()
|
|
if not section:
|
|
log.debug(u"Section not found: %s", usage_key)
|
|
continue
|
|
if section.location in list(cache.keys()):
|
|
usage_context = cache[section.location]
|
|
usage_context.update({
|
|
"unit": get_module_context(course, unit),
|
|
})
|
|
model.update(usage_context)
|
|
cache[usage_id] = cache[unit.location] = usage_context
|
|
filtered_collection.append(model)
|
|
continue
|
|
|
|
chapter = section.get_parent()
|
|
if not chapter:
|
|
log.debug(u"Chapter not found: %s", usage_key)
|
|
continue
|
|
if chapter.location in list(cache.keys()):
|
|
usage_context = cache[chapter.location]
|
|
usage_context.update({
|
|
"unit": get_module_context(course, unit),
|
|
"section": get_module_context(course, section),
|
|
})
|
|
model.update(usage_context)
|
|
cache[usage_id] = cache[unit.location] = cache[section.location] = 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.location] = cache[chapter.location] = usage_context
|
|
|
|
cache[usage_id] = cache[unit.location] = 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': six.text_type(item.location),
|
|
'display_name': Text(item.display_name_with_default),
|
|
}
|
|
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(six.text_type(item.location), section.children) + 1
|
|
item_dict['url'] = reverse('courseware_position', kwargs={
|
|
'course_id': six.text_type(course.id),
|
|
'chapter': chapter.url_name,
|
|
'section': section.url_name,
|
|
'position': position,
|
|
})
|
|
if item.category in ('chapter', 'sequential'):
|
|
item_dict['children'] = [six.text_type(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 = [six.text_type(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(url)
|
|
query_params = 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.decode('utf-8'))
|
|
except ValueError:
|
|
log.error(u"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 = list(collection.keys())
|
|
if not keys or not all(key in expected_keys for key in keys):
|
|
log.error(u"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': six.text_type(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': Text(chapter.display_name_with_default),
|
|
'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': Text(section.display_name_with_default),
|
|
'url': reverse('courseware_section', kwargs=urlargs)
|
|
}
|
|
|
|
|
|
def generate_uid():
|
|
"""
|
|
Generates unique id.
|
|
"""
|
|
return uuid4().int # pylint: disable=no-member
|
|
|
|
|
|
def is_feature_enabled(course, user):
|
|
"""
|
|
Returns True if Student Notes feature is enabled for the course, False otherwise.
|
|
"""
|
|
return EdxNotesTab.is_enabled(course, user)
|