An error occurred while loading the file. Please try again.
-
Michael Terry authored
This is an unused-as-of-yet utility function to generate a bunch of ics files for a user's course schedule. Will be used as part of the calendar_sync feature package. AA-37
4c241e57
courses.py 26.79 KiB
"""
Functions for accessing and displaying courses within the
courseware.
"""
import logging
from collections import defaultdict, namedtuple
from datetime import datetime
import pytz
import six
from crum import get_current_request
from django.conf import settings
from django.db.models import Prefetch
from django.http import Http404, QueryDict
from django.urls import reverse
from django.utils.translation import ugettext as _
from edx_django_utils.monitoring import function_trace
from edx_when.api import get_dates_for_course
from fs.errors import ResourceNotFound
from opaque_keys.edx.keys import UsageKey
from path import Path as path
from six import text_type
import branding
from course_modes.models import CourseMode
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.access_response import MilestoneAccessError, StartDateError
from lms.djangoapps.courseware.date_summary import (
CertificateAvailableDate,
CourseAssignmentDate,
CourseEndDate,
CourseExpiredDate,
CourseStartDate,
TodaysDate,
VerificationDeadlineDate,
VerifiedUpgradeDeadlineDate
)
from lms.djangoapps.courseware.masquerade import check_content_start_date_for_masquerade_user
from lms.djangoapps.courseware.model_data import FieldDataCache
from lms.djangoapps.courseware.module_render import get_module
from edxmako.shortcuts import render_to_string
from lms.djangoapps.certificates import api as certs_api
from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.enrollments.api import get_course_enrollment_details
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.lib.api.view_utils import LazySequence
from openedx.features.course_duration_limits.access import AuditExpiredError
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, RELATIVE_DATES_FLAG
from static_replace import replace_static_urls
from student.models import CourseEnrollment
from survey.utils import is_survey_required_and_unanswered
from util.date_utils import strftime_localized
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.x_module import STUDENT_VIEW
import lms.djangoapps.course_blocks.api as course_blocks_api
from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID
log = logging.getLogger(__name__)
# Used by get_course_assignments below. You shouldn't need to use this type directly.
_Assignment = namedtuple('Assignment', ['block_key', 'title', 'url', 'date', 'requires_full_access'])
def get_course(course_id, depth=0):
"""
Given a course id, return the corresponding course descriptor.
If the course does not exist, raises a ValueError. This is appropriate
for internal use.
depth: The number of levels of children for the modulestore to cache.
None means infinite depth. Default is to fetch no children.
"""
course = modulestore().get_course(course_id, depth=depth)
if course is None:
raise ValueError(u"Course not found: {0}".format(course_id))
return course
def get_course_by_id(course_key, depth=0):
"""
Given a course id, return the corresponding course descriptor.
If such a course does not exist, raises a 404.
depth: The number of levels of children for the modulestore to cache. None means infinite depth
"""
with modulestore().bulk_operations(course_key):
course = modulestore().get_course(course_key, depth=depth)
if course:
return course
else:
raise Http404(u"Course not found: {}.".format(six.text_type(course_key)))
def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=False, check_survey_complete=True):
"""
Given a course_key, look up the corresponding course descriptor,
check that the user has the access to perform the specified action
on the course, and return the descriptor.
Raises a 404 if the course_key is invalid, or the user doesn't have access.
depth: The number of levels of children for the modulestore to cache. None means infinite depth
check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
or has staff access.
check_survey_complete: If true, additionally verifies that the user has either completed the course survey
or has staff access.
Note: We do not want to continually add these optional booleans. Ideally,
these special cases could not only be handled inside has_access, but could
be plugged in as additional callback checks for different actions.
"""
course = get_course_by_id(course_key, depth)
check_course_access(course, user, action, check_if_enrolled, check_survey_complete)
return course
def get_course_overview_with_access(user, action, course_key, check_if_enrolled=False):
"""
Given a course_key, look up the corresponding course overview,
check that the user has the access to perform the specified action
on the course, and return the course overview.
Raises a 404 if the course_key is invalid, or the user doesn't have access.
check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
or has staff access.
"""
try:
course_overview = CourseOverview.get_from_id(course_key)
except CourseOverview.DoesNotExist:
raise Http404("Course not found.")
check_course_access(course_overview, user, action, check_if_enrolled)
return course_overview
def check_course_access(course, user, action, check_if_enrolled=False, check_survey_complete=True):
"""
Check that the user has the access to perform the specified action
on the course (CourseDescriptor|CourseOverview).
check_if_enrolled: If true, additionally verifies that the user is enrolled.
check_survey_complete: If true, additionally verifies that the user has completed the survey.
"""
# Allow staff full access to the course even if not enrolled
if has_access(user, 'staff', course.id):
return
request = get_current_request()
check_content_start_date_for_masquerade_user(course.id, user, request, course.start)
access_response = has_access(user, action, course, course.id)
if not access_response:
# Redirect if StartDateError
if isinstance(access_response, StartDateError):
start_date = strftime_localized(course.start, 'SHORT_DATE')
params = QueryDict(mutable=True)
params['notlive'] = start_date
raise CourseAccessRedirect('{dashboard_url}?{params}'.format(
dashboard_url=reverse('dashboard'),
params=params.urlencode()
), access_response)
# Redirect if AuditExpiredError
if isinstance(access_response, AuditExpiredError):
params = QueryDict(mutable=True)
params['access_response_error'] = access_response.additional_context_user_message
raise CourseAccessRedirect('{dashboard_url}?{params}'.format(
dashboard_url=reverse('dashboard'),
params=params.urlencode()
), access_response)
# Redirect if the user must answer a survey before entering the course.
if isinstance(access_response, MilestoneAccessError):
raise CourseAccessRedirect('{dashboard_url}'.format(
dashboard_url=reverse('dashboard'),
), access_response)
# Deliberately return a non-specific error message to avoid
# leaking info about access control settings
raise CoursewareAccessException(access_response)
if check_if_enrolled:
# If the user is not enrolled, redirect them to the about page
if not CourseEnrollment.is_enrolled(user, course.id):
raise CourseAccessRedirect(reverse('about_course', args=[six.text_type(course.id)]))
# Redirect if the user must answer a survey before entering the course.
if check_survey_complete and action == 'load':
if is_survey_required_and_unanswered(user, course):
raise CourseAccessRedirect(reverse('course_survey', args=[six.text_type(course.id)]))
def can_self_enroll_in_course(course_key):
"""
Returns True if the user can enroll themselves in a course.
Note: an example of a course that a user cannot enroll in directly
is a CCX course. For such courses, a user can only be enrolled by
a CCX coach.
"""
if hasattr(course_key, 'ccx'):
return False
return True
def course_open_for_self_enrollment(course_key):
"""
For a given course_key, determine if the course is available for enrollment
"""
# Check to see if learners can enroll themselves.
if not can_self_enroll_in_course(course_key):
return False
# Check the enrollment start and end dates.
course_details = get_course_enrollment_details(six.text_type(course_key))
now = datetime.now().replace(tzinfo=pytz.UTC)
start = course_details['enrollment_start']
end = course_details['enrollment_end']
start = start if start is not None else now
end = end if end is not None else now
# If we are not within the start and end date for enrollment.
if now < start or end < now:
return False
return True
def find_file(filesystem, dirs, filename):
"""
Looks for a filename in a list of dirs on a filesystem, in the specified order.
filesystem: an OSFS filesystem
dirs: a list of path objects
filename: a string
Returns d / filename if found in dir d, else raises ResourceNotFound.
"""
for directory in dirs:
filepath = path(directory) / filename
if filesystem.exists(filepath):
return filepath
raise ResourceNotFound(u"Could not find {0}".format(filename))
def get_course_about_section(request, course, section_key):
"""
This returns the snippet of html to be rendered on the course about page,
given the key for the section.
Valid keys:
- overview
- about_sidebar_html
- short_description
- description
- key_dates (includes start, end, exams, etc)
- video
- course_staff_short
- course_staff_extended
- requirements
- syllabus
- textbook
- faq
- effort
- more_info
- ocw_links
"""
# Many of these are stored as html files instead of some semantic
# markup. This can change without effecting this interface when we find a
# good format for defining so many snippets of text/html.
html_sections = {
'short_description',
'description',
'key_dates',
'video',
'course_staff_short',
'course_staff_extended',
'requirements',
'syllabus',
'textbook',
'faq',
'more_info',
'overview',
'effort',
'end_date',
'prerequisites',
'about_sidebar_html',
'ocw_links'
}
if section_key in html_sections:
try:
loc = course.location.replace(category='about', name=section_key)
# Use an empty cache
field_data_cache = FieldDataCache([], course.id, request.user)
about_module = get_module(
request.user,
request,
loc,
field_data_cache,
log_if_not_found=False,
wrap_xmodule_display=False,
static_asset_path=course.static_asset_path,
course=course
)
html = ''
if about_module is not None:
try:
html = about_module.render(STUDENT_VIEW).content
except Exception: # pylint: disable=broad-except
html = render_to_string('courseware/error-message.html', None)
log.exception(
u"Error rendering course=%s, section_key=%s",
course, section_key
)
return html
except ItemNotFoundError:
log.warning(
u"Missing about section %s in course %s",
section_key, text_type(course.location)
)
return None
raise KeyError("Invalid about key " + str(section_key))
def get_course_info_usage_key(course, section_key):
"""
Returns the usage key for the specified section's course info module.
"""
return course.id.make_usage_key('course_info', section_key)
def get_course_info_section_module(request, user, course, section_key):
"""
This returns the course info module for a given section_key.
Valid keys:
- handouts
- guest_handouts
- updates
- guest_updates
"""
usage_key = get_course_info_usage_key(course, section_key)
# Use an empty cache
field_data_cache = FieldDataCache([], course.id, user)
return get_module(
user,
request,
usage_key,
field_data_cache,
log_if_not_found=False,
wrap_xmodule_display=False,
static_asset_path=course.static_asset_path,
course=course
)
def get_course_info_section(request, user, course, section_key):
"""
This returns the snippet of html to be rendered on the course info page,
given the key for the section.
Valid keys:
- handouts
- guest_handouts
- updates
- guest_updates
"""
info_module = get_course_info_section_module(request, user, course, section_key)
html = ''
if info_module is not None:
try:
html = info_module.render(STUDENT_VIEW).content.strip()
except Exception: # pylint: disable=broad-except
html = render_to_string('courseware/error-message.html', None)
log.exception(
u"Error rendering course_id=%s, section_key=%s",
six.text_type(course.id), section_key
)
return html
def get_course_date_blocks(course, user, request=None, include_access=False,
include_past_dates=False, num_assignments=None):
"""
Return the list of blocks to display on the course info page,
sorted by date.
"""
block_classes = [
CourseEndDate,
CourseStartDate,
TodaysDate,
VerificationDeadlineDate,
VerifiedUpgradeDeadlineDate,
]
if certs_api.get_active_web_certificate(course):
block_classes.insert(0, CertificateAvailableDate)
blocks = [cls(course, user) for cls in block_classes]
if RELATIVE_DATES_FLAG.is_enabled(course.id):
blocks.append(CourseExpiredDate(course, user))
blocks.extend(get_course_assignment_date_blocks(
course, user, request, num_return=num_assignments,
include_access=include_access, include_past_dates=include_past_dates,
))
return sorted((b for b in blocks if b.date and (b.is_enabled or include_past_dates)), key=date_block_key_fn)
def date_block_key_fn(block):
"""
If the block's date is None, return the maximum datetime in order
to force it to the end of the list of displayed blocks.
"""
return block.date or datetime.max.replace(tzinfo=pytz.UTC)
def get_course_assignment_date_blocks(course, user, request, num_return=None,
include_past_dates=False, include_access=False):
"""
Returns a list of assignment (at the subsection/sequential level) due date
blocks for the given course. Will return num_return results or all results
if num_return is None in date increasing order.
"""
date_blocks = []
for assignment in get_course_assignments(course.id, user, request, include_access=include_access):
date_block = CourseAssignmentDate(course, user)
date_block.date = assignment.date
date_block.requires_full_access = assignment.requires_full_access
date_block.set_title(assignment.title, link=assignment.url)
date_blocks.append(date_block)
date_blocks = sorted((b for b in date_blocks if b.is_enabled or include_past_dates), key=date_block_key_fn)
if num_return:
return date_blocks[:num_return]
return date_blocks
def get_course_assignments(course_key, user, request, include_access=False):
"""
Returns a list of assignment (at the subsection/sequential level) due dates for the given course.
Each returned object is a namedtuple with fields: block_key, title, url, date, requires_full_access
"""
store = modulestore()
all_course_dates = get_dates_for_course(course_key, user)
assignments = []
for (block_key, date_type), date in all_course_dates.items():
if date_type != 'due' or block_key.block_type != 'sequential':
continue
try:
item = store.get_item(block_key)
except ItemNotFoundError:
continue
if not item.graded:
continue
requires_full_access = include_access and _requires_full_access(store, user, block_key)
title = item.display_name or _('Assignment')
url = None
assignment_released = not item.start or item.start < datetime.now(pytz.UTC)
if assignment_released:
url = reverse('jump_to', args=[course_key, block_key])
url = request and request.build_absolute_uri(url)
assignments.append(_Assignment(block_key, title, url, date, requires_full_access))
return assignments
def _requires_full_access(store, user, block_key):
"""
Returns a boolean if any child of the block_key specified has a group_access array consisting of just full_access
"""
child_block_keys = course_blocks_api.get_course_blocks(user, block_key)
for child_block_key in child_block_keys:
child_block = store.get_item(child_block_key)
# If group_access is set on the block, and the content gating is
# only full access, set the value on the CourseAssignmentDate object
if(child_block.group_access and child_block.group_access.get(CONTENT_GATING_PARTITION_ID) == [
settings.CONTENT_TYPE_GATE_GROUP_IDS['full_access']
]):
return True
return False
# TODO: Fix this such that these are pulled in as extra course-specific tabs.
# arjun will address this by the end of October if no one does so prior to
# then.
def get_course_syllabus_section(course, section_key):
"""
This returns the snippet of html to be rendered on the syllabus page,
given the key for the section.
Valid keys:
- syllabus
- guest_syllabus
"""
# Many of these are stored as html files instead of some semantic
# markup. This can change without effecting this interface when we find a
# good format for defining so many snippets of text/html.
if section_key in ['syllabus', 'guest_syllabus']:
try:
filesys = course.system.resources_fs
# first look for a run-specific version
dirs = [path("syllabus") / course.url_name, path("syllabus")]
filepath = find_file(filesys, dirs, section_key + ".html")
with filesys.open(filepath) as html_file:
return replace_static_urls(
html_file.read().decode('utf-8'),
getattr(course, 'data_dir', None),
course_id=course.id,
static_asset_path=course.static_asset_path,
)
except ResourceNotFound:
log.exception(
u"Missing syllabus section %s in course %s",
section_key, text_type(course.location)
)
return "! Syllabus missing !"
raise KeyError("Invalid about key " + str(section_key))
@function_trace('get_courses')
def get_courses(user, org=None, filter_=None):
"""
Return a LazySequence of courses available, optionally filtered by org code (case-insensitive).
"""
courses = branding.get_visible_courses(
org=org,
filter_=filter_,
).prefetch_related(
Prefetch(
'modes',
queryset=CourseMode.objects.exclude(mode_slug__in=CourseMode.CREDIT_MODES),
to_attr='selectable_modes',
),
).select_related(
'image_set'
)
permission_name = configuration_helpers.get_value(
'COURSE_CATALOG_VISIBILITY_PERMISSION',
settings.COURSE_CATALOG_VISIBILITY_PERMISSION
)
return LazySequence(
(c for c in courses if has_access(user, permission_name, c)),
est_len=courses.count()
)
def get_permission_for_course_about():
"""
Returns the CourseOverview object for the course after checking for access.
"""
return configuration_helpers.get_value(
'COURSE_ABOUT_VISIBILITY_PERMISSION',
settings.COURSE_ABOUT_VISIBILITY_PERMISSION
)
def sort_by_announcement(courses):
"""
Sorts a list of courses by their announcement date. If the date is
not available, sort them by their start date.
"""
# Sort courses by how far are they from they start day
key = lambda course: course.sorting_score
courses = sorted(courses, key=key)
return courses
def sort_by_start_date(courses):
"""
Returns a list of courses sorted by their start date, latest first.
"""
courses = sorted(
courses,
key=lambda course: (course.has_ended(), course.start is None, course.start),
reverse=False
)
return courses
def get_cms_course_link(course, page='course'):
"""
Returns a link to course_index for editing the course in cms,
assuming that the course is actually cms-backed.
"""
# This is fragile, but unfortunately the problem is that within the LMS we
# can't use the reverse calls from the CMS
return u"//{}/{}/{}".format(settings.CMS_BASE, page, six.text_type(course.id))
def get_cms_block_link(block, page):
"""
Returns a link to block_index for editing the course in cms,
assuming that the block is actually cms-backed.
"""
# This is fragile, but unfortunately the problem is that within the LMS we
# can't use the reverse calls from the CMS
return u"//{}/{}/{}".format(settings.CMS_BASE, page, block.location)
def get_studio_url(course, page):
"""
Get the Studio URL of the page that is passed in.
Args:
course (CourseDescriptor)
"""
studio_link = None
if course.course_edit_method == "Studio":
studio_link = get_cms_course_link(course, page)
return studio_link
def get_problems_in_section(section):
"""
This returns a dict having problems in a section.
Returning dict has problem location as keys and problem
descriptor as values.
"""
problem_descriptors = defaultdict()
if not isinstance(section, UsageKey):
section_key = UsageKey.from_string(section)
else:
section_key = section
# it will be a Mongo performance boost, if you pass in a depth=3 argument here
# as it will optimize round trips to the database to fetch all children for the current node
section_descriptor = modulestore().get_item(section_key, depth=3)
# iterate over section, sub-section, vertical
for subsection in section_descriptor.get_children():
for vertical in subsection.get_children():
for component in vertical.get_children():
if component.location.block_type == 'problem' and getattr(component, 'has_score', False):
problem_descriptors[six.text_type(component.location)] = component
return problem_descriptors
def get_current_child(xmodule, min_depth=None, requested_child=None):
"""
Get the xmodule.position's display item of an xmodule that has a position and
children. If xmodule has no position or is out of bounds, return the first
child with children of min_depth.
For example, if chapter_one has no position set, with two child sections,
section-A having no children and section-B having a discussion unit,
`get_current_child(chapter, min_depth=1)` will return section-B.
Returns None only if there are no children at all.
"""
# TODO: convert this method to use the Course Blocks API
def _get_child(children):
"""
Returns either the first or last child based on the value of
the requested_child parameter. If requested_child is None,
returns the first child.
"""
if requested_child == 'first':
return children[0]
elif requested_child == 'last':
return children[-1]
else:
return children[0]
def _get_default_child_module(child_modules):
"""Returns the first child of xmodule, subject to min_depth."""
if min_depth is None or min_depth <= 0:
return _get_child(child_modules)
else:
content_children = [
child for child in child_modules
if child.has_children_at_depth(min_depth - 1) and child.get_display_items()
]
return _get_child(content_children) if content_children else None
child = None
try:
# In python 3, hasattr() catches AttributeErrors only then returns False.
# All other exceptions bubble up the call stack.
has_position = hasattr(xmodule, 'position') # This conditions returns AssertionError from xblock.fields lib.
except AssertionError:
return child
if has_position:
children = xmodule.get_display_items()
if len(children) > 0:
if xmodule.position is not None and not requested_child:
pos = int(xmodule.position) - 1 # position is 1-indexed
if 0 <= pos < len(children):
child = children[pos]
if min_depth is not None and (min_depth > 0 and not child.has_children_at_depth(min_depth - 1)):
child = None
if child is None:
child = _get_default_child_module(children)
return child
def get_course_chapter_ids(course_key):
"""
Extracts the chapter block keys from a course structure.
Arguments:
course_key (CourseLocator): The course key
Returns:
list (string): The list of string representations of the chapter block keys in the course.
"""
try:
chapter_keys = modulestore().get_course(course_key).children
except Exception: # pylint: disable=broad-except
log.exception('Failed to retrieve course from modulestore.')
return []
return [six.text_type(chapter_key) for chapter_key in chapter_keys if chapter_key.block_type == 'chapter']
def allow_public_access(course, visibilities):
"""
This checks if the unenrolled access waffle flag for the course is set
and the course visibility matches any of the input visibilities.
"""
unenrolled_access_flag = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course.id)
allow_access = unenrolled_access_flag and course.course_visibility in visibilities
return allow_access