""" Functions for accessing and displaying courses within the courseware. """ import logging from collections import defaultdict 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__) 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_due_dates( 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_due_dates(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. """ store = modulestore() all_course_dates = get_dates_for_course(course.id, user) date_blocks = [] for (block_key, date_type), date in all_course_dates.items(): if date_type == 'due' and block_key.block_type == 'sequential': try: item = store.get_item(block_key) except ItemNotFoundError: continue if item.graded: date_block = CourseAssignmentDate(course, user) date_block.date = date if include_access: date_block.requires_full_access = _requires_full_access(store, user, block_key) block_url = None now = datetime.now().replace(tzinfo=pytz.UTC) assignment_released = item.start < now if item.start else None if assignment_released: block_url = reverse('jump_to', args=[course.id, block_key]) block_url = request.build_absolute_uri(block_url) if request else None assignment_title = item.display_name if item.display_name else _('Assignment') date_block.set_title(assignment_title, link=block_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 _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