Files

409 lines
15 KiB
Python

"""
Course API
"""
import logging
from collections import defaultdict
import search
from django.conf import settings
from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user
from django.urls import reverse
from edx_django_utils.monitoring import function_trace
from edx_when.api import get_dates_for_course
from opaque_keys.edx.django.models import CourseKeyField
from opaque_keys.edx.keys import CourseKey
from rest_framework.exceptions import PermissionDenied
from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment
from common.djangoapps.student.roles import GlobalStaff
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.courses import (
get_course_overview_with_access,
get_courses,
get_permission_for_course_about
)
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline
from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData
from openedx.core.lib.api.view_utils import LazySequence
from openedx.features.course_experience import course_home_url
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
from .exceptions import OverEnrollmentLimitException
from .permissions import can_view_courses_for_username
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
UNKNOWN_BLOCK_DISPLAY_NAME = 'UNKNOWN'
def get_effective_user(requesting_user, target_username):
"""
Get the user we want to view information on behalf of.
"""
if target_username == requesting_user.username:
return requesting_user
elif target_username == '':
return AnonymousUser()
elif can_view_courses_for_username(requesting_user, target_username):
return User.objects.get(username=target_username)
else:
raise PermissionDenied()
def course_detail(request, username, course_key):
"""
Return a single course identified by `course_key`.
The course must be visible to the user identified by `username` and the
logged-in user should have permission to view courses available to that
user.
Arguments:
request (HTTPRequest):
Used to identify the logged-in user and to instantiate the course
module to retrieve the course about description
username (string):
The name of the user `requesting_user would like to be identified as.
course_key (CourseKey): Identifies the course of interest
Return value:
`CourseOverview` object representing the requested course
"""
user = get_effective_user(request.user, username)
overview = get_course_overview_with_access(
user,
get_permission_for_course_about(),
course_key,
)
overview.effective_user = user
return overview
def _filter_by_search(course_queryset, search_term, mobile_search=False):
"""
Filters a course queryset by the specified search term.
"""
if not settings.FEATURES['ENABLE_COURSEWARE_SEARCH'] or not search_term:
return course_queryset
# Return all the results, 10K is the maximum allowed value for ElasticSearch.
# We should use 0 after upgrading to 1.1+:
# - https://github.com/elastic/elasticsearch/commit/8b0a863d427b4ebcbcfb1dcd69c996c52e7ae05e
results_size_infinity = 10000
search_courses = search.api.course_discovery_search(
search_term,
size=results_size_infinity,
)
search_courses_ids = {course['data']['id'] for course in search_courses['results']}
if mobile_search is True:
course_limit = getattr(settings, 'MOBILE_SEARCH_COURSE_LIMIT', 100)
courses = [course for course in course_queryset[:course_limit] if str(course.id) in search_courses_ids]
return LazySequence(
iter(courses),
est_len=len(courses)
)
return LazySequence(
(
course for course in course_queryset
if str(course.id) in search_courses_ids
),
est_len=len(course_queryset)
)
def list_courses(request,
username,
org=None,
filter_=None,
search_term=None,
permissions=None,
active_only=False,
course_keys=None,
mobile_search=False):
"""
Yield all available courses.
The courses returned are all be visible to the user identified by
`username` and the logged in user should have permission to view courses
available to that user.
Arguments:
request (HTTPRequest):
Used to identify the logged-in user and to instantiate the course
module to retrieve the course about description
username (string):
The name of the user the logged-in user would like to be
identified as
Keyword Arguments:
org (string):
If specified, visible `CourseOverview` objects are filtered
such that only those belonging to the organization with the provided
org code (e.g., "HarvardX") are returned. Case-insensitive.
filter_ (dict):
If specified, visible `CourseOverview` objects are filtered
by the given key-value pairs.
search_term (string):
Search term to filter courses (used by ElasticSearch).
permissions (list[str]):
If specified, it filters visible `CourseOverview` objects by
checking if each permission specified is granted for the username.
active_only (bool): Optional parameter that enables fetching active courses only.
course_keys (list[str]):
If specified, it filters visible `CourseOverview` objects by
the course keys (ids) provided
mobile_search (bool):
Optional parameter that limits the number of returned courses
to MOBILE_SEARCH_COURSE_LIMIT.
Return value:
Yield `CourseOverview` objects representing the collection of courses.
"""
user = get_effective_user(request.user, username)
course_qs = get_courses(
user, org=org, filter_=filter_, permissions=permissions, active_only=active_only, course_keys=course_keys
)
course_qs = _filter_by_search(course_qs, search_term, mobile_search)
return course_qs
@function_trace('list_course_keys')
def list_course_keys(request, username, role):
"""
Yield all available CourseKeys for the user having the given role.
The courses returned include those for which the user identified by
`username` has the given role. Additionally, the logged in user
should have permission to view courses available to that user.
Note: This function does not use branding to determine courses.
Arguments:
request (HTTPRequest):
Used to identify the logged-in user and to instantiate the course
module to retrieve the course about description
username (string):
The name of the user the logged-in user would like to be
identified as
Keyword Arguments:
role (string):
Course keys are filtered such that only those for which the
user has the specified role are returned.
Return value:
Yield `CourseKey` objects representing the collection of courses.
"""
user = get_effective_user(request.user, username)
all_course_keys = CourseOverview.get_all_course_keys()
# Global staff have access to all courses. Filter courses for non-global staff.
if GlobalStaff().has_user(user):
return all_course_keys
if role == 'staff':
# This short-circuit implementation bypasses has_access() which we think is too slow for some users when
# evaluating staff-level course access for Insights. Various tickets have context on this issue: CR-2487,
# TNL-7448, DESUPPORT-416, and probably more.
#
# This is a simplified implementation that does not consider org-level access grants (e.g. when course_id is
# empty).
filtered_course_keys = (
CourseAccessRole.objects.filter(
user=user,
# Having the instructor role implies staff access.
role__in=['staff', 'instructor'],
)
# We need to check against CourseOverview so that we don't return any Libraries.
.extra(tables=['course_overviews_courseoverview'], where=['course_id = course_overviews_courseoverview.id'])
# For good measure, make sure we don't return empty course IDs.
.exclude(course_id=CourseKeyField.Empty)
.order_by('course_id')
.values_list('course_id', flat=True)
.distinct()
)
else:
# This is the original implementation which still covers the case where role = "instructor":
filtered_course_keys = LazySequence(
(
course_key for course_key in all_course_keys
if has_access(user, role, course_key)
),
est_len=len(all_course_keys)
)
return filtered_course_keys
def get_due_dates(request, course_key, user):
"""
Get due date information for a user for blocks in a course.
Arguments:
request: the request object
course_key (CourseKey): the CourseKey for the course
user: the user object for which we want due date information
Returns:
due_dates (list): a list of dictionaries containing due date information
keys:
name: the display name of the block
url: the deep link to the block
date: the due date for the block
"""
try:
outline = get_course_outline(course_key)
except (ValueError, CourseOutlineData.DoesNotExist):
# Either this course is Old Mongo-backed or doesn't have a generated course outline.
course_version = None
else:
course_version = outline.published_version
dates = get_dates_for_course(
course_key,
user,
published_version=course_version
)
store = modulestore()
due_dates = []
for (block_key, date_type), date in dates.items():
if date_type == 'due':
try:
block_display_name = store.get_item(block_key).display_name
except ItemNotFoundError:
logger.exception(f'Failed to get block for due date item with key: {block_key}')
block_display_name = UNKNOWN_BLOCK_DISPLAY_NAME
# get url to the block in the course
block_url = reverse('jump_to', args=[course_key, block_key])
block_url = request.build_absolute_uri(block_url)
due_dates.append({
'name': block_display_name,
'url': block_url,
'date': date,
})
return due_dates
def get_course_run_url(request, course_id):
"""
Get the URL to a course run.
Arguments:
request: the request object
course_id (string): the course id of the course
Returns:
(string): the URL to the course run associated with course_id
"""
course_key = CourseKey.from_string(str(course_id))
return request.build_absolute_uri(course_home_url(course_key))
def get_course_members(course_key):
"""
Returns a dict containing all users with access to a course through CourseEnrollment
and CourseAccessRole models.
User information includes id, email, username, name, enrollment mode and role list.
This API is limited and will only work for courses with less than a configurable number
of active enrollments (managed through `settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT`,
and the default value is 1000). More than that and the method will raise a
`OverEnrollmentLimitException` exception.
This method works by querying the `CourseEnrollment` and `CourseAccessRole` models,
prefetching user information and *then joining results in Python using dictionaries*.
This approach was choosen to avoid database heavy queries (such as DISTINCT and COUNT) in
the `CourseEnrollment` table, which would take too long to complete in a request lifecycle.
The main concern with this approach is the dataset size and resource usage since this method
returns all enrollments without pagination. We're using a conservative number on the
`COURSE_MEMBER_API_ENROLLMENT_LIMIT` setting to avoid any issues.
Examples:
- Get all course members:
get_course_members(course_key)
Arguments:
course_key (CourseKey): CourseKey to retrieve student data.
Returns:
dict: A dictionary with the following format:
{
"user_id": {
"id": 12,
"username": "jonh5000",
"email": "jonh@example.com",
"name": "Jonh Doe",
"enrollment_mode": "verified",
"roles": [
"student",
"instructor",
"staff",
]
}
}
"""
def make_user_info_dict(user, enrollment_mode=None):
"""
Utility function to extract user information from model.
"""
return {
"id": user.id,
"username": user.username,
"email": user.email,
"name": user.profile.name,
"enrollment_mode": enrollment_mode,
}
# Raise error if trying to retrieve user list from a course with more than
# settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT active enrollments. The fastest way
# to do this is to query for the 1st item after `COURSE_MEMBER_API_ENROLLMENT_LIMIT`.
over_limit = CourseEnrollment.get_active_enrollments_in_course(
course_key
)[settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT:][:1]
if over_limit.exists():
raise OverEnrollmentLimitException(
f"Can't retrieve course members for {course_key} since it has more than "
f"{settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT} active enrollments. "
f"This limit is stored on `settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT`."
)
# Python dicts where we're going to manually combine the data from the two querysets
user_roles = defaultdict(list)
user_info = {}
# Retrieve all active enrollments in course and prefetch user information
enrollments = CourseEnrollment.get_active_enrollments_in_course(course_key)
# Retrieve all course access roles and prefetch user information
access_roles = CourseAccessRole.access_roles_in_course(course_key)
# Evaluates querysets and parses data from the two querysets
# into `user_info` and `user_roles` dictionaries.
for enrollment in enrollments:
user_roles[enrollment.user_id].append('student')
user_info[enrollment.user_id] = make_user_info_dict(enrollment.user, enrollment.mode)
for access_role in access_roles:
user_roles[access_role.user_id].append(access_role.role)
if access_role.user_id not in user_info:
user_info[access_role.user_id] = make_user_info_dict(access_role.user)
# Merge user role information with `user_info`
for user_id in user_info:
user_info[user_id]['roles'] = user_roles[user_id]
return user_info