Commit 1cac8216 by Bill Filler

Conditionally display gated content in courseware

Display gated sections in course outline, navigation and in the course
when user has met prerequiste condition.

WL-1273
parent dd4d13af
...@@ -158,7 +158,7 @@ class ProctoringFields(object): ...@@ -158,7 +158,7 @@ class ProctoringFields(object):
@XBlock.wants('proctoring') @XBlock.wants('proctoring')
@XBlock.wants('verification') @XBlock.wants('verification')
@XBlock.wants('milestones') @XBlock.wants('gating')
@XBlock.wants('credit') @XBlock.wants('credit')
@XBlock.needs('user') @XBlock.needs('user')
@XBlock.needs('bookmarks') @XBlock.needs('bookmarks')
...@@ -230,8 +230,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -230,8 +230,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
banner_text, special_html = special_html_view banner_text, special_html = special_html_view
if special_html and not masquerading_as_specific_student: if special_html and not masquerading_as_specific_student:
return Fragment(special_html) return Fragment(special_html)
else:
banner_text = self._gated_content_staff_banner()
return self._student_view(context, banner_text) return self._student_view(context, banner_text)
def _special_exam_student_view(self): def _special_exam_student_view(self):
...@@ -269,20 +267,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -269,20 +267,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
return banner_text, hidden_content_html return banner_text, hidden_content_html
def _gated_content_staff_banner(self):
"""
Checks whether the content is gated for learners. If so,
returns a banner_text depending on whether user is staff.
"""
milestones_service = self.runtime.service(self, 'milestones')
if milestones_service:
content_milestones = milestones_service.get_course_content_milestones(
self.course_id, self.location, 'requires'
)
banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.')
if content_milestones and self.runtime.user_is_staff:
return banner_text
def _can_user_view_content(self, course): def _can_user_view_content(self, course):
""" """
Returns whether the runtime user can view the content Returns whether the runtime user can view the content
...@@ -306,10 +290,18 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -306,10 +290,18 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
""" """
display_items = self.get_display_items() display_items = self.get_display_items()
self._update_position(context, len(display_items)) self._update_position(context, len(display_items))
prereq_met = True
if self._find_gating_milestone():
if self.runtime.user_is_staff:
banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.')
else:
# check if prerequiste has been met
prereq_met, prereq_meta_info = self._is_prereq_met(True)
fragment = Fragment() fragment = Fragment()
params = { params = {
'items': self._render_student_view_for_items(context, display_items, fragment), 'items': self._render_student_view_for_items(context, display_items, fragment) if prereq_met else {},
'element_id': self.location.html_id(), 'element_id': self.location.html_id(),
'item_id': self.location.to_deprecated_string(), 'item_id': self.location.to_deprecated_string(),
'position': self.position, 'position': self.position,
...@@ -319,6 +311,10 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -319,6 +311,10 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
'prev_url': context.get('prev_url'), 'prev_url': context.get('prev_url'),
'banner_text': banner_text, 'banner_text': banner_text,
'disable_navigation': not self.is_user_authenticated(context), 'disable_navigation': not self.is_user_authenticated(context),
'gate_content': not prereq_met,
'prereq_url': prereq_meta_info['url'] if not prereq_met else None,
'prereq_section_name': prereq_meta_info['display_name'] if not prereq_met else None,
'gated_section_name': self.display_name
} }
fragment.add_content(self.system.render_template("seq_module.html", params)) fragment.add_content(self.system.render_template("seq_module.html", params))
...@@ -327,6 +323,36 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -327,6 +323,36 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
return fragment return fragment
def _find_gating_milestone(self):
"""
Checks whether a gating milestone exists for this Section
"""
gating_service = self.runtime.service(self, 'gating')
if gating_service:
milestone = gating_service.get_gating_milestone(
self.course_id, self.location, 'requires'
)
return milestone
return False
def _is_prereq_met(self, recalc_on_unmet):
"""
Evaluate if the user has completed the prerequiste
Arguments:
recalc_on_unmet: Recalculate the subsection grade if prereq has not yet been met
Returns:
tuple: True|False,
prereq_meta_info = { 'url': prereq_url, 'display_name': prereq_name}
"""
gating_service = self.runtime.service(self, 'gating')
if gating_service:
return gating_service.is_prereq_met(self.location, self.runtime.user_id, recalc_on_unmet)
return False, {}
def _update_position(self, context, number_of_display_items): def _update_position(self, context, number_of_display_items):
""" """
Update the user's sequential position given the context and the Update the user's sequential position given the context and the
......
...@@ -66,8 +66,6 @@ class MilestonesAndSpecialExamsTransformer(BlockStructureTransformer): ...@@ -66,8 +66,6 @@ class MilestonesAndSpecialExamsTransformer(BlockStructureTransformer):
if usage_info.has_staff_access: if usage_info.has_staff_access:
return False return False
elif self.has_pending_milestones_for_user(block_key, usage_info):
return True
elif self.gated_by_required_content(block_key, block_structure, required_content): elif self.gated_by_required_content(block_key, block_structure, required_content):
return True return True
elif (settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and elif (settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and
......
...@@ -493,7 +493,6 @@ def _has_access_descriptor(user, action, descriptor, course_key=None): ...@@ -493,7 +493,6 @@ def _has_access_descriptor(user, action, descriptor, course_key=None):
return ( return (
_visible_to_nonstaff_users(descriptor) and _visible_to_nonstaff_users(descriptor) and
_can_access_descriptor_with_milestones(user, descriptor, course_key) and
( (
_has_detached_class_tag(descriptor) or _has_detached_class_tag(descriptor) or
check_start_date(user, descriptor.days_early_for_beta, descriptor.start, course_key) check_start_date(user, descriptor.days_early_for_beta, descriptor.start, course_key)
......
...@@ -51,6 +51,7 @@ from openedx.core.djangoapps.crawlers.models import CrawlersConfig ...@@ -51,6 +51,7 @@ from openedx.core.djangoapps.crawlers.models import CrawlersConfig
from openedx.core.djangoapps.credit.services import CreditService from openedx.core.djangoapps.credit.services import CreditService
from openedx.core.djangoapps.monitoring_utils import set_custom_metrics_for_course_key, set_monitoring_transaction_name from openedx.core.djangoapps.monitoring_utils import set_custom_metrics_for_course_key, set_monitoring_transaction_name
from openedx.core.djangoapps.util.user_utils import SystemUser from openedx.core.djangoapps.util.user_utils import SystemUser
from openedx.core.lib.gating.services import GatingService
from openedx.core.lib.license import wrap_with_license from openedx.core.lib.license import wrap_with_license
from openedx.core.lib.url_utils import quote_slashes, unquote_slashes from openedx.core.lib.url_utils import quote_slashes, unquote_slashes
from openedx.core.lib.xblock_utils import request_token as xblock_request_token from openedx.core.lib.xblock_utils import request_token as xblock_request_token
...@@ -755,6 +756,7 @@ def get_module_system_for_user( ...@@ -755,6 +756,7 @@ def get_module_system_for_user(
'milestones': milestones_helpers.get_service(), 'milestones': milestones_helpers.get_service(),
'credit': CreditService(), 'credit': CreditService(),
'bookmarks': BookmarksService(user=user), 'bookmarks': BookmarksService(user=user),
'gating': GatingService(),
}, },
get_user_role=lambda: get_user_role(user, course_id), get_user_role=lambda: get_user_role(user, course_id),
descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access
......
...@@ -31,44 +31,7 @@ def evaluate_prerequisite(course, subsection_grade, user): ...@@ -31,44 +31,7 @@ def evaluate_prerequisite(course, subsection_grade, user):
gated_content = gated_content_milestones.get(prereq_milestone['id']) gated_content = gated_content_milestones.get(prereq_milestone['id'])
if gated_content: if gated_content:
for milestone in gated_content: for milestone in gated_content:
min_percentage = _get_minimum_required_percentage(milestone) gating_api.update_milestone(milestone, subsection_grade, prereq_milestone, user.id)
subsection_percentage = _get_subsection_percentage(subsection_grade)
if subsection_percentage >= min_percentage:
milestones_helpers.add_user_milestone({'id': user.id}, prereq_milestone)
else:
milestones_helpers.remove_user_milestone({'id': user.id}, prereq_milestone)
def _get_minimum_required_percentage(milestone):
"""
Returns the minimum percentage requirement for the given milestone.
"""
# Default minimum score to 100
min_score = 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)
)
return min_score
def _get_subsection_percentage(subsection_grade):
"""
Returns the percentage value of the given subsection_grade.
"""
return _calculate_ratio(subsection_grade.graded_total.earned, subsection_grade.graded_total.possible) * 100.0
def _calculate_ratio(earned, possible):
"""
Returns the percentage of the given earned and possible values.
"""
return float(earned) / float(possible) if possible else 0.0
def evaluate_entrance_exam(course_grade, user): def evaluate_entrance_exam(course_grade, user):
......
<%page args="prereq_url, prereq_section_name, gated_section_name" expression_filter="h"/>
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import Text
%>
<div>
<h2 class="hd hd-2 unit-title">
<span class="icon fa fa-lock" aria-hidden="true">&nbsp</span>${gated_section_name}
</h2>
<h2>
${_("Content Locked")}
</h2>
<p>
${Text(_(
"You must complete the section '{prereq_section_name}' with the required score before you are able to view this content."
)).format(prereq_section_name=prereq_section_name)}
<p>
<button><a href="${prereq_url}">${_("Go to Prerequiste Section")}</a></button>
</p>
</p>
</div>
...@@ -23,6 +23,13 @@ ...@@ -23,6 +23,13 @@
</button> </button>
<nav class="sequence-list-wrapper" aria-label="${_('Sequence')}"> <nav class="sequence-list-wrapper" aria-label="${_('Sequence')}">
<ol id="sequence-list" role="tablist"> <ol id="sequence-list" role="tablist">
% if gate_content:
<li>
<button class="active nav-item tab" aria-hidden="true" disabled>
<span class="icon fa fa-lock" aria-hidden="true"></span>
</button>
</li>
% else:
% for idx, item in enumerate(items): % for idx, item in enumerate(items):
<li role="presentation"> <li role="presentation">
<button class="seq_${item['type']} inactive nav-item tab" <button class="seq_${item['type']} inactive nav-item tab"
...@@ -44,10 +51,14 @@ ...@@ -44,10 +51,14 @@
</button> </button>
</li> </li>
% endfor % endfor
% endif
</ol> </ol>
</nav> </nav>
</div> </div>
% if gate_content:
<%include file="_gated_content.html" args="prereq_url=prereq_url, prereq_section_name=prereq_section_name, gated_section_name=gated_section_name"/>
% else:
<div class="sr-is-focusable" tabindex="-1"></div> <div class="sr-is-focusable" tabindex="-1"></div>
% for idx, item in enumerate(items): % for idx, item in enumerate(items):
...@@ -59,6 +70,7 @@ ...@@ -59,6 +70,7 @@
</div> </div>
% endfor % endfor
<div id="seq_content" role="tabpanel"></div> <div id="seq_content" role="tabpanel"></div>
% endif
<nav class="sequence-bottom" aria-label="${_('Section')}"> <nav class="sequence-bottom" aria-label="${_('Section')}">
<button class="sequence-nav-button button-previous"> <button class="sequence-nav-button button-previous">
......
...@@ -112,5 +112,9 @@ if __name__ == "__main__": ...@@ -112,5 +112,9 @@ if __name__ == "__main__":
startup.run() startup.run()
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
import ptvsd
try:
ptvsd.enable_attach("my_secret", address = ('0.0.0.0', 21000))
except:
pass
execute_from_command_line([sys.argv[0]] + django_args) execute_from_command_line([sys.argv[0]] + django_args)
...@@ -3,12 +3,17 @@ API for the gating djangoapp ...@@ -3,12 +3,17 @@ API for the gating djangoapp
""" """
import logging import logging
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
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 milestones import api as milestones_api
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import BlockUsageLocator
from lms.djangoapps.courseware.access import _has_access_to_course
from openedx.core.lib.gating.exceptions import GatingValidationError from openedx.core.lib.gating.exceptions import GatingValidationError
from util import milestones_helpers
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -299,3 +304,105 @@ def get_gated_content(course, user): ...@@ -299,3 +304,105 @@ def get_gated_content(course, user):
{'id': user.id} {'id': user.id}
) )
] ]
def is_prereq_met(content_id, user_id, recalc_on_unmet=False):
"""
Returns true if the prequiste has been met for a given milestone
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, 'display_name': prereq_name}
"""
course_id = content_id.course_key
# if unfullfilled milestones exist it means prereq has not been met
unfulfilled_milestones = milestones_helpers.get_course_content_milestones(
course_id,
content_id,
'requires',
user_id
)
prereq_met = not unfulfilled_milestones
if prereq_met or not recalc_on_unmet:
return prereq_met, {}
milestone = unfulfilled_milestones[0]
student = User.objects.get(id=user_id)
store = modulestore()
with store.bulk_operations(course_id):
course_structure = get_course_blocks(student, store.make_course_usage_key(course_id))
course = store.get_course(course_id, depth=0)
subsection_grade_factory = SubsectionGradeFactory(student, course, course_structure)
subsection_usage_key = UsageKey.from_string(milestone['namespace'].replace(GATING_NAMESPACE_QUALIFIER, ''))
if subsection_usage_key in course_structure:
# this will force a recalcuation of the subsection grade
subsection_grade = subsection_grade_factory.update(course_structure[subsection_usage_key])
prereq_met = update_milestone(milestone, subsection_grade, milestone, user_id)
prereq_meta_info = {
'url': reverse('jump_to', kwargs={'course_id': course_id, 'location': subsection_usage_key}),
'display_name': store.get_item(subsection_usage_key).display_name
}
return prereq_met, prereq_meta_info
def update_milestone(milestone, subsection_grade, prereq_milestone, user_id):
"""
Updates the milestone record based on evaluation of prerequiste met.
Arguments:
milestone: The gated milestone being evaluated
subsection_grade: The grade of the prerequiste subsection
prerequiste_milestone: The gating milestone
user_id: The id of the user
Returns:
True if prerequiste has been met, False if not
"""
min_percentage = _get_minimum_required_percentage(milestone)
subsection_percentage = _get_subsection_percentage(subsection_grade)
if subsection_percentage >= min_percentage:
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_minimum_required_percentage(milestone):
"""
Returns the minimum percentage requirement for the given milestone.
"""
# Default minimum score to 100
min_score = 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)
)
return min_score
def _get_subsection_percentage(subsection_grade):
"""
Returns the percentage value of the given subsection_grade.
"""
return _calculate_ratio(subsection_grade.graded_total.earned, subsection_grade.graded_total.possible) * 100.0
def _calculate_ratio(earned, possible):
"""
Returns the percentage of the given earned and possible values.
"""
return float(earned) / float(possible) if possible else 0.0
"""
A wrapper class around requested methods exposed in api.py
"""
import types
from . import api as gating_api
class GatingService(object): # pylint: disable=too-few-public-methods
"""
An xBlock service for xBlocks to talk to the Gating api.
NOTE: This is a Singleton class. We should only have one instance of it!
"""
_instance = None
REQUESTED_FUNCTIONS = [
'get_gating_milestone_meta_info',
'get_gating_milestone',
'is_prereq_met'
]
def __new__(cls, *args, **kwargs):
"""
This is the class factory to make sure this is a Singleton
"""
if not cls._instance:
cls._instance = super(GatingService, cls).__new__(cls, *args, **kwargs)
return cls._instance
def __init__(self):
"""
Class initializer, which just inspects the libraries and exposes the same functions
listed in REQUESTED_FUNCTIONS
"""
self._bind_to_requested_functions()
def _bind_to_requested_functions(self):
"""
bind module functions. Since we use underscores to mean private methods, let's exclude those.
"""
for attr_name in self.REQUESTED_FUNCTIONS:
attr = getattr(gating_api, attr_name, None)
if isinstance(attr, types.FunctionType) and not attr_name.startswith('_'):
if not hasattr(self, attr_name):
setattr(self, attr_name, attr)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment