Initial changes to gate section based on completion percentage code refactoring and added evaluation of completion milestone Fixed broken unit tests and added new tests Fixed broken tests and quality violations Fixed Pep8 violation Fixed eslint quality violations Test changes as suggested by reviewer changes after feedbacy from reviewer Update the docstring with suggested changes excluding chapter from the blocks Disallow empty values for min score and min completion Changes afte feedback from UX/Accessibility removed blank line
518 lines
18 KiB
Python
518 lines
18 KiB
Python
"""
|
|
API for the gating djangoapp
|
|
"""
|
|
import json
|
|
import logging
|
|
|
|
from django.contrib.auth.models import User
|
|
from django.core.urlresolvers import reverse
|
|
from django.utils.translation import ugettext as _
|
|
|
|
from completion.models import BlockCompletion
|
|
from lms.djangoapps.courseware.access import _has_access_to_course
|
|
from lms.djangoapps.course_blocks.api import get_course_blocks
|
|
from lms.djangoapps.grades.subsection_grade_factory import SubsectionGradeFactory
|
|
from milestones import api as milestones_api
|
|
from opaque_keys.edx.keys import UsageKey
|
|
from openedx.core.lib.gating.exceptions import GatingValidationError
|
|
from util import milestones_helpers
|
|
from xmodule.modulestore.django import modulestore
|
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# This is used to namespace gating-specific milestones
|
|
GATING_NAMESPACE_QUALIFIER = '.gating'
|
|
|
|
|
|
def _get_prerequisite_milestone(prereq_content_key):
|
|
"""
|
|
Get gating milestone associated with the given content usage key.
|
|
|
|
Arguments:
|
|
prereq_content_key (str|UsageKey): The content usage key
|
|
|
|
Returns:
|
|
dict: Milestone dict
|
|
"""
|
|
milestones = milestones_api.get_milestones("{usage_key}{qualifier}".format(
|
|
usage_key=prereq_content_key,
|
|
qualifier=GATING_NAMESPACE_QUALIFIER
|
|
))
|
|
|
|
if not milestones:
|
|
log.warning("Could not find gating milestone for prereq UsageKey %s", prereq_content_key)
|
|
return None
|
|
|
|
if len(milestones) > 1:
|
|
# We should only ever have one gating milestone per UsageKey
|
|
# Log a warning here and pick the first one
|
|
log.warning("Multiple gating milestones found for prereq UsageKey %s", prereq_content_key)
|
|
|
|
return milestones[0]
|
|
|
|
|
|
def _validate_min_score(min_score):
|
|
"""
|
|
Validates the minimum score entered by the Studio user.
|
|
|
|
Arguments:
|
|
min_score (str|int): The minimum score to validate
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
GatingValidationError: If the minimum score is not valid
|
|
"""
|
|
if min_score:
|
|
message = _("%(min_score)s is not a valid grade percentage") % {'min_score': min_score}
|
|
try:
|
|
min_score = int(min_score)
|
|
except ValueError:
|
|
raise GatingValidationError(message)
|
|
|
|
if min_score < 0 or min_score > 100:
|
|
raise GatingValidationError(message)
|
|
|
|
|
|
def gating_enabled(default=None):
|
|
"""
|
|
Decorator that checks the enable_subsection_gating course flag to
|
|
see if the subsection gating feature is active for a given course.
|
|
If not, calls to the decorated function return the specified default value.
|
|
|
|
Arguments:
|
|
default (ANY): The value to return if the enable_subsection_gating course flag is False
|
|
|
|
Returns:
|
|
ANY: The specified default value if the gating feature is off,
|
|
otherwise the result of the decorated function
|
|
"""
|
|
def wrap(f): # pylint: disable=missing-docstring
|
|
def function_wrapper(course, *args): # pylint: disable=missing-docstring
|
|
if not course.enable_subsection_gating:
|
|
return default
|
|
return f(course, *args)
|
|
return function_wrapper
|
|
return wrap
|
|
|
|
|
|
def find_gating_milestones(course_key, content_key=None, relationship=None, user=None):
|
|
"""
|
|
Finds gating milestone dicts related to the given supplied parameters.
|
|
|
|
Arguments:
|
|
course_key (str|CourseKey): The course key
|
|
content_key (str|UsageKey): The content usage key
|
|
relationship (str): The relationship type (e.g. 'requires')
|
|
user (dict): The user dict (e.g. {'id': 4})
|
|
|
|
Returns:
|
|
list: A list of milestone dicts
|
|
"""
|
|
return [
|
|
m for m in milestones_api.get_course_content_milestones(course_key, content_key, relationship, user)
|
|
if GATING_NAMESPACE_QUALIFIER in m.get('namespace')
|
|
]
|
|
|
|
|
|
def get_gating_milestone(course_key, content_key, relationship):
|
|
"""
|
|
Gets a single gating milestone dict related to the given supplied parameters.
|
|
|
|
Arguments:
|
|
course_key (str|CourseKey): The course key
|
|
content_key (str|UsageKey): The content usage key
|
|
relationship (str): The relationship type (e.g. 'requires')
|
|
|
|
Returns:
|
|
dict or None: The gating milestone dict or None
|
|
"""
|
|
try:
|
|
return find_gating_milestones(course_key, content_key, relationship)[0]
|
|
except IndexError:
|
|
return None
|
|
|
|
|
|
def get_prerequisites(course_key):
|
|
"""
|
|
Find all the gating milestones associated with a course and the
|
|
XBlock info associated with those gating milestones.
|
|
|
|
Arguments:
|
|
course_key (str|CourseKey): The course key
|
|
|
|
Returns:
|
|
list: A list of dicts containing the milestone and associated XBlock info
|
|
"""
|
|
course_content_milestones = find_gating_milestones(course_key)
|
|
|
|
milestones_by_block_id = {}
|
|
block_ids = []
|
|
for milestone in course_content_milestones:
|
|
prereq_content_key = _get_gating_block_id(milestone)
|
|
block_id = UsageKey.from_string(prereq_content_key).block_id
|
|
block_ids.append(block_id)
|
|
milestones_by_block_id[block_id] = milestone
|
|
|
|
result = []
|
|
for block in modulestore().get_items(course_key, qualifiers={'name': block_ids}):
|
|
milestone = milestones_by_block_id.get(block.location.block_id)
|
|
if milestone:
|
|
milestone['block_display_name'] = block.display_name
|
|
milestone['block_usage_key'] = unicode(block.location)
|
|
result.append(milestone)
|
|
|
|
return result
|
|
|
|
|
|
def add_prerequisite(course_key, prereq_content_key):
|
|
"""
|
|
Creates a new Milestone and CourseContentMilestone indicating that
|
|
the given course content fulfills a prerequisite for gating
|
|
|
|
Arguments:
|
|
course_key (str|CourseKey): The course key
|
|
prereq_content_key (str|UsageKey): The prerequisite content usage key
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
milestone = milestones_api.add_milestone(
|
|
{
|
|
'name': _('Gating milestone for {usage_key}').format(usage_key=unicode(prereq_content_key)),
|
|
'namespace': "{usage_key}{qualifier}".format(
|
|
usage_key=prereq_content_key,
|
|
qualifier=GATING_NAMESPACE_QUALIFIER
|
|
),
|
|
'description': _('System defined milestone'),
|
|
},
|
|
propagate=False
|
|
)
|
|
milestones_api.add_course_content_milestone(course_key, prereq_content_key, 'fulfills', milestone)
|
|
|
|
|
|
def remove_prerequisite(prereq_content_key):
|
|
"""
|
|
Removes the Milestone and CourseContentMilestones related to the gating
|
|
prerequisite which the given course content fulfills
|
|
|
|
Arguments:
|
|
prereq_content_key (str|UsageKey): The prerequisite content usage key
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
milestones = milestones_api.get_milestones("{usage_key}{qualifier}".format(
|
|
usage_key=prereq_content_key,
|
|
qualifier=GATING_NAMESPACE_QUALIFIER
|
|
))
|
|
for milestone in milestones:
|
|
milestones_api.remove_milestone(milestone.get('id'))
|
|
|
|
|
|
def is_prerequisite(course_key, prereq_content_key):
|
|
"""
|
|
Returns True if there is at least one CourseContentMilestone
|
|
which the given course content fulfills
|
|
|
|
Arguments:
|
|
course_key (str|CourseKey): The course key
|
|
prereq_content_key (str|UsageKey): The prerequisite content usage key
|
|
|
|
Returns:
|
|
bool: True if the course content fulfills a CourseContentMilestone, otherwise False
|
|
"""
|
|
return get_gating_milestone(
|
|
course_key,
|
|
prereq_content_key,
|
|
'fulfills'
|
|
) is not None
|
|
|
|
|
|
def set_required_content(course_key, gated_content_key, prereq_content_key, min_score='', min_completion=''):
|
|
"""
|
|
Adds a `requires` milestone relationship for the given gated_content_key if a prerequisite
|
|
prereq_content_key is provided. If prereq_content_key is None, removes the `requires`
|
|
milestone relationship.
|
|
|
|
Arguments:
|
|
course_key (str|CourseKey): The course key
|
|
gated_content_key (str|UsageKey): The gated content usage key
|
|
prereq_content_key (str|UsageKey): The prerequisite content usage key
|
|
min_score (str|int): The minimum score
|
|
min_completion (str|int): The minimum completion percentage
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
milestone = None
|
|
for gating_milestone in find_gating_milestones(course_key, gated_content_key, 'requires'):
|
|
if not prereq_content_key or prereq_content_key not in gating_milestone.get('namespace'):
|
|
milestones_api.remove_course_content_milestone(course_key, gated_content_key, gating_milestone)
|
|
else:
|
|
milestone = gating_milestone
|
|
|
|
if prereq_content_key:
|
|
_validate_min_score(min_score)
|
|
requirements = {'min_score': min_score, 'min_completion': min_completion}
|
|
if not milestone:
|
|
milestone = _get_prerequisite_milestone(prereq_content_key)
|
|
milestones_api.add_course_content_milestone(course_key, gated_content_key, 'requires', milestone, requirements)
|
|
|
|
|
|
def get_required_content(course_key, gated_content_key):
|
|
"""
|
|
Returns the prerequisite content usage key, minimum score and minimum completion percentage needed for fulfillment
|
|
of that prerequisite for the given gated_content_key.
|
|
|
|
Args:
|
|
course_key (str|CourseKey): The course key
|
|
gated_content_key (str|UsageKey): The gated content usage key
|
|
|
|
Returns:
|
|
tuple: The prerequisite content usage key, minimum score and minimum completion percentage,
|
|
(None, None, None) if the content is not gated
|
|
"""
|
|
milestone = get_gating_milestone(course_key, gated_content_key, 'requires')
|
|
if milestone:
|
|
return (
|
|
_get_gating_block_id(milestone),
|
|
milestone.get('requirements', {}).get('min_score', None),
|
|
milestone.get('requirements', {}).get('min_completion', None),
|
|
)
|
|
else:
|
|
return None, None, None
|
|
|
|
|
|
@gating_enabled(default=[])
|
|
def get_gated_content(course, user):
|
|
"""
|
|
Returns the unfulfilled gated content usage keys in the given course.
|
|
|
|
Arguments:
|
|
course (CourseDescriptor): The course
|
|
user (User): The user
|
|
|
|
Returns:
|
|
list: The list of gated content usage keys for the given course
|
|
"""
|
|
if _has_access_to_course(user, 'staff', course.id):
|
|
return []
|
|
else:
|
|
# Get the unfulfilled gating milestones for this course, for this user
|
|
return [
|
|
m['content_id'] for m in find_gating_milestones(
|
|
course.id,
|
|
None,
|
|
'requires',
|
|
{'id': user.id}
|
|
)
|
|
]
|
|
|
|
|
|
def is_gate_fulfilled(course_key, gating_content_key, user_id):
|
|
"""
|
|
Determines if a prerequisite section specified by gating_content_key
|
|
has any unfulfilled milestones.
|
|
|
|
Arguments:
|
|
course_key (CourseUsageLocator): Course locator
|
|
gating_content_key (BlockUsageLocator): The locator for the section content
|
|
user_id: The id of the user
|
|
|
|
Returns:
|
|
Returns True if section has no unfufilled milestones or is not a prerequisite.
|
|
Returns False otherwise
|
|
"""
|
|
gating_milestone = get_gating_milestone(course_key, gating_content_key, "fulfills")
|
|
if not gating_milestone:
|
|
return True
|
|
|
|
unfulfilled_milestones = [
|
|
m['content_id'] for m in find_gating_milestones(
|
|
course_key,
|
|
None,
|
|
'requires',
|
|
{'id': user_id}
|
|
) if m['namespace'] == gating_milestone['namespace']
|
|
]
|
|
return not unfulfilled_milestones
|
|
|
|
|
|
def compute_is_prereq_met(content_id, user_id, recalc_on_unmet=False):
|
|
"""
|
|
Returns true if the prequiste has been met for a given milestone.
|
|
Will recalculate the subsection grade if specified and prereq unmet
|
|
|
|
Arguments:
|
|
content_id (BlockUsageLocator): BlockUsageLocator for the content
|
|
user_id: The id of the user
|
|
recalc_on_unmet: Recalculate the grade if prereq has not yet been met
|
|
|
|
Returns:
|
|
tuple: True|False,
|
|
prereq_meta_info = { 'url': prereq_url|None, 'display_name': prereq_name|None}
|
|
"""
|
|
course_key = content_id.course_key
|
|
|
|
# if unfullfilled milestones exist it means prereq has not been met
|
|
unfulfilled_milestones = milestones_helpers.get_course_content_milestones(
|
|
course_key,
|
|
content_id,
|
|
'requires',
|
|
user_id
|
|
)
|
|
|
|
prereq_met = not unfulfilled_milestones
|
|
prereq_meta_info = {'url': None, 'display_name': None}
|
|
|
|
if prereq_met or not recalc_on_unmet:
|
|
return prereq_met, prereq_meta_info
|
|
|
|
milestone = unfulfilled_milestones[0]
|
|
student = User.objects.get(id=user_id)
|
|
store = modulestore()
|
|
|
|
with store.bulk_operations(course_key):
|
|
subsection_usage_key = UsageKey.from_string(_get_gating_block_id(milestone))
|
|
subsection = store.get_item(subsection_usage_key)
|
|
prereq_meta_info = {
|
|
'url': reverse('jump_to', kwargs={'course_id': course_key, 'location': subsection_usage_key}),
|
|
'display_name': subsection.display_name
|
|
}
|
|
prereq_met = update_milestone(milestone, subsection_usage_key, milestone, student)
|
|
|
|
return prereq_met, prereq_meta_info
|
|
|
|
|
|
def update_milestone(milestone, usage_key, prereq_milestone, user, grade_percentage=None, completion_percentage=None):
|
|
"""
|
|
Updates the milestone record based on evaluation of prerequisite met.
|
|
|
|
Arguments:
|
|
milestone: The gated milestone being evaluated
|
|
usage_key: Usage key of the prerequisite subsection
|
|
prereq_milestone: The gating milestone
|
|
user: The user who has fulfilled milestone
|
|
grade_percentage: Grade percentage of prerequisite subsection
|
|
completion_percentage: Completion percentage of prerequisite subsection
|
|
|
|
Returns:
|
|
True if prerequisite has been met, False if not
|
|
"""
|
|
min_score, min_completion = _get_minimum_required_percentage(milestone)
|
|
if not grade_percentage:
|
|
grade_percentage = get_subsection_grade_percentage(usage_key, user) if min_score > 0 else 0
|
|
if not completion_percentage:
|
|
completion_percentage = get_subsection_completion_percentage(usage_key, user) if min_completion > 0 else 0
|
|
|
|
if grade_percentage >= min_score and completion_percentage >= min_completion:
|
|
milestones_helpers.add_user_milestone({'id': user.id}, prereq_milestone)
|
|
return True
|
|
else:
|
|
milestones_helpers.remove_user_milestone({'id': user.id}, prereq_milestone)
|
|
return False
|
|
|
|
|
|
def _get_gating_block_id(milestone):
|
|
"""
|
|
Return the block id of the gating milestone
|
|
"""
|
|
return milestone.get('namespace', '').replace(GATING_NAMESPACE_QUALIFIER, '')
|
|
|
|
|
|
def get_subsection_grade_percentage(subsection_usage_key, user):
|
|
"""
|
|
Computes grade percentage for a subsection in a given course for a user
|
|
|
|
Arguments:
|
|
subsection_usage_key: key of subsection
|
|
user: The user whose grade needs to be computed
|
|
|
|
Returns:
|
|
User's grade percentage for given subsection
|
|
"""
|
|
subsection_grade_percentage = 0.0
|
|
try:
|
|
subsection_structure = get_course_blocks(user, subsection_usage_key)
|
|
if any(subsection_structure):
|
|
subsection_grade_factory = SubsectionGradeFactory(user, course_structure=subsection_structure)
|
|
if subsection_usage_key in subsection_structure:
|
|
# this will force a recalculation of the subsection grade
|
|
subsection_grade = subsection_grade_factory.update(
|
|
subsection_structure[subsection_usage_key], persist_grade=False
|
|
)
|
|
subsection_grade_percentage = subsection_grade.percent_graded * 100.0
|
|
except ItemNotFoundError as err:
|
|
log.warning("Could not find course_block for subsection=%s error=%s", subsection_usage_key, err)
|
|
return subsection_grade_percentage
|
|
|
|
|
|
def get_subsection_completion_percentage(subsection_usage_key, user):
|
|
"""
|
|
Computes completion percentage for a subsection in a given course for a user
|
|
Arguments:
|
|
subsection_usage_key: key of subsection
|
|
user: The user whose completion percentage needs to be computed
|
|
Returns:
|
|
User's completion percentage for given subsection
|
|
"""
|
|
subsection_completion_percentage = 0.0
|
|
try:
|
|
subsection_structure = get_course_blocks(user, subsection_usage_key)
|
|
if any(subsection_structure):
|
|
completable_blocks = [
|
|
block for block in subsection_structure
|
|
if block.block_type not in ['chapter', 'sequential', 'vertical']
|
|
]
|
|
if not completable_blocks:
|
|
return 0
|
|
subsection_completion_total = 0
|
|
course_block_completions = BlockCompletion.get_course_completions(user, subsection_usage_key.course_key)
|
|
for block in completable_blocks:
|
|
if course_block_completions.get(block):
|
|
subsection_completion_total += course_block_completions.get(block)
|
|
subsection_completion_percentage = min(
|
|
100 * (subsection_completion_total / float(len(completable_blocks))), 100
|
|
)
|
|
|
|
except ItemNotFoundError as err:
|
|
log.warning("Could not find course_block for subsection=%s error=%s", subsection_usage_key, err)
|
|
|
|
return subsection_completion_percentage
|
|
|
|
|
|
def _get_minimum_required_percentage(milestone):
|
|
"""
|
|
Returns the minimum score and minimum completion percentage requirement for the given milestone.
|
|
"""
|
|
# Default minimum score and minimum completion percentage to 100
|
|
min_score = 100
|
|
min_completion = 100
|
|
requirements = milestone.get('requirements')
|
|
if requirements:
|
|
try:
|
|
min_score = int(requirements.get('min_score'))
|
|
except (ValueError, TypeError):
|
|
log.warning(
|
|
u'Gating: Failed to find minimum score for gating milestone %s, defaulting to 100',
|
|
json.dumps(milestone)
|
|
)
|
|
try:
|
|
min_completion = int(requirements.get('min_completion', 0))
|
|
except (ValueError, TypeError):
|
|
log.warning(
|
|
u'Gating: Failed to find minimum completion percentage for gating milestone %s, defaulting to 100',
|
|
json.dumps(milestone)
|
|
)
|
|
return min_score, min_completion
|
|
|
|
|
|
def _get_subsection_percentage(subsection_grade):
|
|
"""
|
|
Returns the percentage value of the given subsection_grade.
|
|
"""
|
|
return subsection_grade.percent_graded * 100.0
|