# pylint: disable=invalid-name """ Utility library for working with the edx-milestones app """ from django.conf import settings from django.utils.translation import ugettext as _ from milestones import api as milestones_api from milestones.exceptions import InvalidMilestoneRelationshipTypeException, InvalidUserException from milestones.models import MilestoneRelationshipType from milestones.services import MilestonesService from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey import request_cache from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.modulestore.django import modulestore NAMESPACE_CHOICES = { 'ENTRANCE_EXAM': 'entrance_exams' } REQUEST_CACHE_NAME = "milestones" def get_namespace_choices(): """ Return the enum to the caller """ return NAMESPACE_CHOICES def is_entrance_exams_enabled(): """ Checks to see if the Entrance Exams feature is enabled Use this operation instead of checking the feature flag all over the place """ return settings.FEATURES.get('ENTRANCE_EXAMS') def is_prerequisite_courses_enabled(): """ Returns boolean indicating prerequisite courses enabled system wide or not. """ return settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES') and settings.FEATURES.get('MILESTONES_APP') def add_prerequisite_course(course_key, prerequisite_course_key): """ It would create a milestone, then it would set newly created milestones as requirement for course referred by `course_key` and it would set newly created milestone as fulfillment milestone for course referred by `prerequisite_course_key`. """ if not is_prerequisite_courses_enabled(): return None milestone_name = _('Course {course_id} requires {prerequisite_course_id}').format( course_id=unicode(course_key), prerequisite_course_id=unicode(prerequisite_course_key) ) milestone = milestones_api.add_milestone({ 'name': milestone_name, 'namespace': unicode(prerequisite_course_key), 'description': _('System defined milestone'), }) # add requirement course milestone milestones_api.add_course_milestone(course_key, 'requires', milestone) # add fulfillment course milestone milestones_api.add_course_milestone(prerequisite_course_key, 'fulfills', milestone) def remove_prerequisite_course(course_key, milestone): """ It would remove pre-requisite course milestone for course referred by `course_key`. """ if not is_prerequisite_courses_enabled(): return None milestones_api.remove_course_milestone( course_key, milestone, ) def set_prerequisite_courses(course_key, prerequisite_course_keys): """ It would remove any existing requirement milestones for the given `course_key` and create new milestones for each pre-requisite course in `prerequisite_course_keys`. To only remove course milestones pass `course_key` and empty list or None as `prerequisite_course_keys` . """ if not is_prerequisite_courses_enabled(): return None #remove any existing requirement milestones with this pre-requisite course as requirement course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="requires") if course_milestones: for milestone in course_milestones: remove_prerequisite_course(course_key, milestone) # add milestones if pre-requisite course is selected if prerequisite_course_keys: for prerequisite_course_key_string in prerequisite_course_keys: prerequisite_course_key = CourseKey.from_string(prerequisite_course_key_string) add_prerequisite_course(course_key, prerequisite_course_key) def get_pre_requisite_courses_not_completed(user, enrolled_courses): # pylint: disable=invalid-name """ Makes a dict mapping courses to their unfulfilled milestones using the fulfillment API of the milestones app. Arguments: user (User): the user for whom we are checking prerequisites. enrolled_courses (CourseKey): a list of keys for the courses to be checked. The given user must be enrolled in all of these courses. Returns: dict[CourseKey: dict[ 'courses': list[dict['key': CourseKey, 'display': str]] ]] If a course has no incomplete prerequisites, it will be excluded from the dictionary. """ if not is_prerequisite_courses_enabled(): return {} pre_requisite_courses = {} for course_key in enrolled_courses: required_courses = [] fulfillment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id}) for __, milestone_value in fulfillment_paths.items(): for key, value in milestone_value.items(): if key == 'courses' and value: for required_course in value: required_course_key = CourseKey.from_string(required_course) required_course_overview = CourseOverview.get_from_id(required_course_key) required_courses.append({ 'key': required_course_key, 'display': get_course_display_string(required_course_overview) }) # If there are required courses, add them to the result dict. if required_courses: pre_requisite_courses[course_key] = {'courses': required_courses} return pre_requisite_courses def get_prerequisite_courses_display(course_descriptor): """ It would retrieve pre-requisite courses, make display strings and return list of dictionary with course key as 'key' field and course display name as `display` field. """ pre_requisite_courses = [] if is_prerequisite_courses_enabled() and course_descriptor.pre_requisite_courses: for course_id in course_descriptor.pre_requisite_courses: course_key = CourseKey.from_string(course_id) required_course_descriptor = modulestore().get_course(course_key) prc = { 'key': course_key, 'display': get_course_display_string(required_course_descriptor) } pre_requisite_courses.append(prc) return pre_requisite_courses def get_course_display_string(descriptor): """ Returns a string to display for a course or course overview. Arguments: descriptor (CourseDescriptor|CourseOverview): a course or course overview. """ return ' '.join([ descriptor.display_org_with_default, descriptor.display_number_with_default ]) def fulfill_course_milestone(course_key, user): """ Marks the course specified by the given course_key as complete for the given user. If any other courses require this course as a prerequisite, their milestones will be appropriately updated. """ if not settings.FEATURES.get('MILESTONES_APP'): return None try: course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="fulfills") except InvalidMilestoneRelationshipTypeException: # we have not seeded milestone relationship types seed_milestone_relationship_types() course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="fulfills") for milestone in course_milestones: milestones_api.add_user_milestone({'id': user.id}, milestone) def remove_course_milestones(course_key, user, relationship): """ Remove all user milestones for the course specified by course_key. """ if not settings.FEATURES.get('MILESTONES_APP'): return None course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship=relationship) for milestone in course_milestones: milestones_api.remove_user_milestone({'id': user.id}, milestone) def get_required_content(course_key, user): """ Queries milestones subsystem to see if the specified course is gated on one or more milestones, and if those milestones can be fulfilled via completion of a particular course content module """ required_content = [] if settings.FEATURES.get('MILESTONES_APP'): course_run_id = unicode(course_key) if user.is_authenticated(): # Get all of the outstanding milestones for this course, for this user try: milestone_paths = get_course_milestones_fulfillment_paths( course_run_id, serialize_user(user) ) except InvalidMilestoneRelationshipTypeException: return required_content # For each outstanding milestone, see if this content is one of its fulfillment paths for path_key in milestone_paths: milestone_path = milestone_paths[path_key] if milestone_path.get('content') and len(milestone_path['content']): for content in milestone_path['content']: required_content.append(content) else: if get_course_milestones(course_run_id): # NOTE (CCB): The initial version of anonymous courseware access is very simple. We avoid accidentally # exposing locked content by simply avoiding anonymous access altogether for courses runs with # milestones. raise InvalidUserException('Anonymous access is not allowed for course runs with milestones set.') return required_content def milestones_achieved_by_user(user, namespace): """ It would fetch list of milestones completed by user """ if not settings.FEATURES.get('MILESTONES_APP'): return None return milestones_api.get_user_milestones({'id': user.id}, namespace) def is_valid_course_key(key): """ validates course key. returns True if valid else False. """ try: course_key = CourseKey.from_string(key) except InvalidKeyError: course_key = key return isinstance(course_key, CourseKey) def seed_milestone_relationship_types(): """ Helper method to pre-populate MRTs so the tests can run """ if not settings.FEATURES.get('MILESTONES_APP'): return None MilestoneRelationshipType.objects.create(name='requires') MilestoneRelationshipType.objects.create(name='fulfills') def generate_milestone_namespace(namespace, course_key=None): """ Returns a specifically-formatted namespace string for the specified type """ if namespace in NAMESPACE_CHOICES.values(): if namespace == 'entrance_exams': return '{}.{}'.format(unicode(course_key), NAMESPACE_CHOICES['ENTRANCE_EXAM']) def serialize_user(user): """ Returns a milestones-friendly representation of a user object """ return { 'id': user.id, } def add_milestone(milestone_data): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return None return milestones_api.add_milestone(milestone_data) def get_milestones(namespace): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return [] return milestones_api.get_milestones(namespace) def get_milestone_relationship_types(): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return {} return milestones_api.get_milestone_relationship_types() def add_course_milestone(course_id, relationship, milestone): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return None return milestones_api.add_course_milestone(course_id, relationship, milestone) def get_course_milestones(course_id): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return [] return milestones_api.get_course_milestones(course_id) def add_course_content_milestone(course_id, content_id, relationship, milestone): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return None return milestones_api.add_course_content_milestone(course_id, content_id, relationship, milestone) def get_course_content_milestones(course_id, content_id, relationship, user_id=None): """ Client API operation adapter/wrapper Uses the request cache to store all of a user's milestones """ if not settings.FEATURES.get('MILESTONES_APP'): return [] if user_id is None: return milestones_api.get_course_content_milestones(course_id, content_id, relationship) request_cache_dict = request_cache.get_cache(REQUEST_CACHE_NAME) if user_id not in request_cache_dict: request_cache_dict[user_id] = {} if relationship not in request_cache_dict[user_id]: request_cache_dict[user_id][relationship] = milestones_api.get_course_content_milestones( course_key=course_id, relationship=relationship, user={"id": user_id} ) return [m for m in request_cache_dict[user_id][relationship] if m['content_id'] == unicode(content_id)] def remove_course_content_user_milestones(course_key, content_key, user, relationship): """ Removes the specified User-Milestone link from the system for the specified course content module. """ if not settings.FEATURES.get('MILESTONES_APP'): return [] course_content_milestones = milestones_api.get_course_content_milestones(course_key, content_key, relationship) for milestone in course_content_milestones: milestones_api.remove_user_milestone({'id': user.id}, milestone) def remove_content_references(content_id): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return None return milestones_api.remove_content_references(content_id) def any_unfulfilled_milestones(course_id, user_id): """ Returns a boolean if user has any unfulfilled milestones """ if not settings.FEATURES.get('MILESTONES_APP'): return False fulfillment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_id, {'id': user_id}) # Returns True if any of the milestones is unfulfilled. False if # values is empty or all values are. return any(fulfillment_paths.values()) def get_course_milestones_fulfillment_paths(course_id, user_id): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return None return milestones_api.get_course_milestones_fulfillment_paths( course_id, user_id ) def add_user_milestone(user, milestone): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return None return milestones_api.add_user_milestone(user, milestone) def remove_user_milestone(user, milestone): """ Client API operation adapter/wrapper """ if not settings.FEATURES.get('MILESTONES_APP'): return None return milestones_api.remove_user_milestone(user, milestone) def get_service(): """ Returns MilestonesService instance if feature flag enabled; else returns None. Note: MilestonesService only has access to the functions explicitly requested in the MilestonesServices class """ if not settings.FEATURES.get('MILESTONES_APP', False): return None return MilestonesService()