526 lines
19 KiB
Python
526 lines
19 KiB
Python
"""
|
|
API for the gating djangoapp
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
|
|
from completion.models import BlockCompletion
|
|
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
|
from django.urls import reverse
|
|
from django.utils.translation import ugettext as _
|
|
from milestones import api as milestones_api
|
|
from opaque_keys.edx.keys import UsageKey
|
|
from xblock.completable import XBlockCompletionMode as CompletionMode
|
|
|
|
from lms.djangoapps.course_blocks.api import get_course_blocks
|
|
from lms.djangoapps.courseware.access import _has_access_to_course
|
|
from lms.djangoapps.grades.api import SubsectionGradeFactory
|
|
from openedx.core.lib.gating.exceptions import GatingValidationError
|
|
from common.djangoapps.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) # lint-amnesty, pylint: disable=raise-missing-from
|
|
|
|
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):
|
|
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'] = str(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=str(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 (CourseBlock): 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,
|
|
'id': str(subsection_usage_key)
|
|
}
|
|
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
|
|
"""
|
|
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:
|
|
subsection_grade = subsection_grade_factory.update(subsection_structure[subsection_usage_key])
|
|
return _get_subsection_percentage(subsection_grade)
|
|
except ItemNotFoundError as err:
|
|
log.warning("Could not find course_block for subsection=%s error=%s", subsection_usage_key, err)
|
|
return 0.0
|
|
|
|
|
|
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 = []
|
|
for block in subsection_structure:
|
|
completion_mode = subsection_structure.get_xblock_field(
|
|
block, 'completion_mode'
|
|
)
|
|
|
|
# always exclude html blocks (in addition to EXCLUDED blocks) for gating calculations
|
|
# See https://openedx.atlassian.net/browse/WL-1798
|
|
if completion_mode not in (CompletionMode.AGGREGATOR, CompletionMode.EXCLUDED) \
|
|
and not block.block_type == 'html':
|
|
completable_blocks.append(block)
|
|
|
|
if not completable_blocks:
|
|
return 100
|
|
subsection_completion_total = 0
|
|
course_key = subsection_usage_key.course_key
|
|
course_block_completions = BlockCompletion.get_learning_context_completions(user, 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(
|
|
'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(
|
|
'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
|